From 9a2aea038a67af1be2289a30a9257a561f288bd9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sat, 24 Jan 2026 17:45:27 +0300 Subject: [PATCH] chore: add uv package manager and ruff linter configuration - Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files --- .gitignore | 3 + .python-version | 1 + Makefile | 16 +- app/bot.py | 175 +- app/cabinet/auth/__init__.py | 21 +- app/cabinet/auth/email_verification.py | 3 +- app/cabinet/auth/jwt_handler.py | 34 +- app/cabinet/auth/password_utils.py | 9 +- app/cabinet/auth/telegram_auth.py | 44 +- app/cabinet/dependencies.py | 88 +- app/cabinet/routes/__init__.py | 54 +- app/cabinet/routes/admin_apps.py | 204 +- app/cabinet/routes/admin_ban_system.py | 924 +-- app/cabinet/routes/admin_broadcasts.py | 217 +- app/cabinet/routes/admin_campaigns.py | 246 +- app/cabinet/routes/admin_payments.py | 243 +- app/cabinet/routes/admin_promo_offers.py | 228 +- app/cabinet/routes/admin_promocodes.py | 278 +- app/cabinet/routes/admin_remnawave.py | 555 +- app/cabinet/routes/admin_servers.py | 136 +- app/cabinet/routes/admin_settings.py | 82 +- app/cabinet/routes/admin_stats.py | 630 +- app/cabinet/routes/admin_tariffs.py | 233 +- app/cabinet/routes/admin_tickets.py | 214 +- app/cabinet/routes/admin_users.py | 668 +-- app/cabinet/routes/admin_wheel.py | 118 +- app/cabinet/routes/auth.py | 245 +- app/cabinet/routes/balance.py | 731 +-- app/cabinet/routes/branding.py | 282 +- app/cabinet/routes/contests.py | 220 +- app/cabinet/routes/info.py | 107 +- app/cabinet/routes/media.py | 68 +- app/cabinet/routes/notifications.py | 68 +- app/cabinet/routes/polls.py | 98 +- app/cabinet/routes/promo.py | 133 +- app/cabinet/routes/promocode.py | 42 +- app/cabinet/routes/referral.py | 66 +- app/cabinet/routes/subscription.py | 1691 +++--- app/cabinet/routes/ticket_notifications.py | 72 +- app/cabinet/routes/tickets.py | 98 +- app/cabinet/routes/websocket.py | 370 +- app/cabinet/routes/wheel.py | 116 +- app/cabinet/schemas/__init__.py | 115 +- app/cabinet/schemas/auth.py | 76 +- app/cabinet/schemas/balance.py | 57 +- app/cabinet/schemas/ban_system.py | 174 +- app/cabinet/schemas/broadcasts.py | 57 +- app/cabinet/schemas/campaigns.py | 95 +- app/cabinet/schemas/referral.py | 20 +- app/cabinet/schemas/remnawave.py | 230 +- app/cabinet/schemas/servers.py | 56 +- app/cabinet/schemas/subscription.py | 65 +- app/cabinet/schemas/tariffs.py | 130 +- app/cabinet/schemas/tickets.py | 42 +- app/cabinet/schemas/users.py | 275 +- app/cabinet/schemas/wheel.py | 146 +- app/cabinet/services/__init__.py | 3 +- app/cabinet/services/email_service.py | 51 +- app/cabinet/services/email_templates.py | 406 +- app/config.py | 1700 +++--- app/database/__init__.py | 17 +- app/database/crud/campaign.py | 171 +- app/database/crud/cloudpayments.py | 65 +- app/database/crud/contest.py | 53 +- app/database/crud/cryptobot.py | 105 +- app/database/crud/discount_offer.py | 76 +- app/database/crud/faq.py | 40 +- app/database/crud/freekassa.py | 67 +- app/database/crud/heleket.py | 65 +- app/database/crud/kassa_ai.py | 69 +- app/database/crud/main_menu_button.py | 31 +- app/database/crud/mulenpay.py | 47 +- app/database/crud/notification.py | 15 +- app/database/crud/pal24.py | 102 +- app/database/crud/platega.py | 75 +- app/database/crud/poll.py | 56 +- app/database/crud/privacy_policy.py | 22 +- app/database/crud/promo_group.py | 115 +- app/database/crud/promo_offer_log.py | 26 +- app/database/crud/promo_offer_template.py | 207 +- app/database/crud/promocode.py | 193 +- app/database/crud/public_offer.py | 22 +- app/database/crud/referral.py | 356 +- app/database/crud/referral_contest.py | 306 +- app/database/crud/rules.py | 164 +- app/database/crud/server_squad.py | 465 +- app/database/crud/squad.py | 50 +- app/database/crud/subscription.py | 965 ++-- app/database/crud/subscription_conversion.py | 88 +- app/database/crud/subscription_event.py | 25 +- app/database/crud/system_setting.py | 15 +- app/database/crud/tariff.py | 172 +- app/database/crud/ticket.py | 308 +- app/database/crud/ticket_notification.py | 61 +- app/database/crud/transaction.py | 377 +- app/database/crud/user.py | 495 +- app/database/crud/user_message.py | 102 +- app/database/crud/user_promo_group.py | 100 +- app/database/crud/wata.py | 91 +- app/database/crud/web_api_token.py | 33 +- app/database/crud/webhook.py | 38 +- app/database/crud/welcome_text.py | 180 +- app/database/crud/wheel.py | 165 +- app/database/crud/yookassa.py | 195 +- app/database/database.py | 330 +- app/database/models.py | 1533 +++-- app/database/universal_migration.py | 4123 +++++++------- app/external/ban_system_api.py | 210 +- app/external/cryptobot.py | 150 +- app/external/heleket.py | 139 +- app/external/heleket_webhook.py | 53 +- app/external/pal24_client.py | 171 +- app/external/pal24_webhook.py | 86 +- app/external/remnawave_api.py | 755 ++- app/external/telegram_stars.py | 84 +- app/external/tribute.py | 193 +- app/external/wata_webhook.py | 146 +- app/external/webhook_server.py | 377 +- app/external/yookassa_webhook.py | 290 +- app/handlers/__init__.py | 3 +- app/handlers/admin/__init__.py | 2 +- app/handlers/admin/backup.py | 678 +-- app/handlers/admin/blacklist.py | 318 +- app/handlers/admin/bot_configuration.py | 1837 +++--- app/handlers/admin/bulk_ban.py | 171 +- app/handlers/admin/campaigns.py | 941 ++- app/handlers/admin/contests.py | 502 +- app/handlers/admin/daily_contests.py | 237 +- app/handlers/admin/faq.py | 531 +- app/handlers/admin/main.py | 438 +- app/handlers/admin/maintenance.py | 481 +- app/handlers/admin/messages.py | 1294 ++--- app/handlers/admin/monitoring.py | 1553 ++--- app/handlers/admin/payments.py | 402 +- app/handlers/admin/polls.py | 566 +- app/handlers/admin/pricing.py | 912 ++- app/handlers/admin/privacy_policy.py | 294 +- app/handlers/admin/promo_groups.py | 629 +- app/handlers/admin/promo_offers.py | 1401 ++--- app/handlers/admin/promocodes.py | 1109 ++-- app/handlers/admin/public_offer.py | 300 +- app/handlers/admin/referrals.py | 576 +- app/handlers/admin/remnawave.py | 2773 ++++----- app/handlers/admin/reports.py | 35 +- app/handlers/admin/rules.py | 407 +- app/handlers/admin/servers.py | 1134 ++-- app/handlers/admin/statistics.py | 297 +- app/handlers/admin/subscriptions.py | 408 +- app/handlers/admin/support_settings.py | 430 +- app/handlers/admin/system_logs.py | 81 +- app/handlers/admin/tariffs.py | 1755 +++--- app/handlers/admin/tickets.py | 918 ++- app/handlers/admin/trials.py | 43 +- app/handlers/admin/updates.py | 268 +- app/handlers/admin/user_messages.py | 614 +- app/handlers/admin/users.py | 5068 ++++++++--------- app/handlers/admin/welcome_text.py | 423 +- app/handlers/balance/__init__.py | 3 +- app/handlers/balance/cloudpayments.py | 193 +- app/handlers/balance/cryptobot.py | 303 +- app/handlers/balance/freekassa.py | 156 +- app/handlers/balance/heleket.py | 211 +- app/handlers/balance/kassa_ai.py | 156 +- app/handlers/balance/main.py | 817 ++- app/handlers/balance/mulenpay.py | 184 +- app/handlers/balance/pal24.py | 351 +- app/handlers/balance/platega.py | 188 +- app/handlers/balance/stars.py | 109 +- app/handlers/balance/tribute.py | 45 +- app/handlers/balance/wata.py | 162 +- app/handlers/balance/yookassa.py | 505 +- app/handlers/common.py | 108 +- app/handlers/contests.py | 99 +- app/handlers/menu.py | 667 +-- app/handlers/polls.py | 75 +- app/handlers/promocode.py | 119 +- app/handlers/referral.py | 945 ++- app/handlers/server_status.py | 89 +- app/handlers/simple_subscription.py | 1639 +++--- app/handlers/stars_payments.py | 225 +- app/handlers/start.py | 1061 ++-- app/handlers/subscription/__init__.py | 34 +- app/handlers/subscription/autopay.py | 202 +- app/handlers/subscription/common.py | 366 +- app/handlers/subscription/countries.py | 543 +- app/handlers/subscription/devices.py | 899 ++- app/handlers/subscription/happ.py | 145 +- app/handlers/subscription/links.py | 342 +- app/handlers/subscription/modem.py | 271 +- app/handlers/subscription/notifications.py | 124 +- app/handlers/subscription/pricing.py | 354 +- app/handlers/subscription/promo.py | 286 +- app/handlers/subscription/purchase.py | 2949 +++++----- app/handlers/subscription/summary.py | 21 +- app/handlers/subscription/tariff_purchase.py | 1692 +++--- app/handlers/subscription/traffic.py | 534 +- app/handlers/support.py | 24 +- app/handlers/tickets.py | 895 +-- app/handlers/webhooks.py | 96 +- app/keyboards/admin.py | 3550 ++++++------ app/keyboards/inline.py | 2752 ++++----- app/keyboards/reply.py | 119 +- app/lib/nalogo/__init__.py | 22 +- app/lib/nalogo/_http.py | 40 +- app/lib/nalogo/auth.py | 56 +- app/lib/nalogo/client.py | 34 +- app/lib/nalogo/dto/__init__.py | 38 +- app/lib/nalogo/dto/device.py | 28 +- app/lib/nalogo/dto/income.py | 120 +- app/lib/nalogo/dto/invoice.py | 30 +- app/lib/nalogo/dto/payment_type.py | 46 +- app/lib/nalogo/dto/tax.py | 10 +- app/lib/nalogo/dto/user.py | 106 +- app/lib/nalogo/exceptions.py | 24 +- app/lib/nalogo/income.py | 50 +- app/lib/nalogo/payment_type.py | 4 +- app/lib/nalogo/receipt.py | 10 +- app/lib/nalogo/tax.py | 16 +- app/lib/nalogo/user.py | 2 +- app/localization/loader.py | 112 +- app/localization/texts.py | 156 +- app/middlewares/auth.py | 250 +- app/middlewares/button_stats.py | 165 +- app/middlewares/channel_checker.py | 210 +- app/middlewares/display_name_restriction.py | 107 +- app/middlewares/global_error.py | 149 +- app/middlewares/logging.py | 43 +- app/middlewares/maintenance.py | 33 +- app/middlewares/subscription_checker.py | 30 +- app/middlewares/throttling.py | 57 +- app/services/__init__.py | 2 +- app/services/admin_notification_service.py | 1221 ++-- app/services/backup_service.py | 1039 ++-- app/services/ban_notification_service.py | 291 +- app/services/blacklist_service.py | 78 +- app/services/broadcast_service.py | 88 +- app/services/bulk_ban_service.py | 104 +- app/services/campaign_service.py | 78 +- app/services/cloudpayments_service.py | 211 +- app/services/contest_rotation_service.py | 255 +- app/services/contests/__init__.py | 17 +- app/services/contests/attempt_service.py | 58 +- app/services/contests/enums.py | 28 +- app/services/contests/games.py | 205 +- app/services/daily_subscription_service.py | 225 +- app/services/event_emitter.py | 21 +- app/services/external_admin_service.py | 62 +- app/services/faq_service.py | 48 +- app/services/freekassa_service.py | 335 +- app/services/kassa_ai_service.py | 212 +- app/services/log_rotation_service.py | 124 +- app/services/main_menu_button_service.py | 28 +- app/services/maintenance_service.py | 446 +- app/services/menu_layout/__init__.py | 27 +- app/services/menu_layout/constants.py | 1014 ++-- app/services/menu_layout/context.py | 12 +- app/services/menu_layout/history_service.py | 70 +- app/services/menu_layout/service.py | 817 ++- app/services/menu_layout/stats_service.py | 305 +- app/services/menu_layout_service.py | 29 +- app/services/modem_service.py | 149 +- app/services/monitoring_service.py | 1165 ++-- app/services/mulenpay_service.py | 169 +- app/services/nalogo_queue_service.py | 147 +- app/services/nalogo_service.py | 300 +- app/services/notification_delivery_service.py | 226 +- app/services/notification_settings_service.py | 98 +- app/services/pal24_service.py | 78 +- app/services/partner_stats_service.py | 377 +- app/services/payment/__init__.py | 39 +- app/services/payment/cloudpayments.py | 222 +- app/services/payment/common.py | 188 +- app/services/payment/cryptobot.py | 369 +- app/services/payment/freekassa.py | 238 +- app/services/payment/heleket.py | 277 +- app/services/payment/kassa_ai.py | 190 +- app/services/payment/mulenpay.py | 326 +- app/services/payment/pal24.py | 694 ++- app/services/payment/platega.py | 265 +- app/services/payment/stars.py | 201 +- app/services/payment/tribute.py | 25 +- app/services/payment/wata.py | 316 +- app/services/payment/yookassa.py | 792 ++- app/services/payment_service.py | 146 +- app/services/payment_verification_service.py | 357 +- app/services/pinned_message_service.py | 78 +- app/services/platega_service.py | 137 +- app/services/poll_service.py | 76 +- app/services/privacy_policy_service.py | 34 +- app/services/promo_group_assignment.py | 36 +- app/services/promo_offer_service.py | 71 +- app/services/promocode_service.py | 205 +- app/services/public_offer_service.py | 77 +- app/services/referral_contest_service.py | 223 +- app/services/referral_service.py | 348 +- app/services/referral_withdrawal_service.py | 485 +- app/services/remnawave_service.py | 2208 ++++--- app/services/remnawave_sync_service.py | 121 +- app/services/reporting_service.py | 265 +- app/services/server_status_service.py | 99 +- .../subscription_auto_purchase_service.py | 523 +- app/services/subscription_checkout_service.py | 19 +- app/services/subscription_purchase_service.py | 708 ++- app/services/subscription_renewal_service.py | 247 +- app/services/subscription_service.py | 664 +-- app/services/support_settings_service.py | 96 +- app/services/system_settings_service.py | 1753 +++--- app/services/traffic_monitoring_service.py | 330 +- app/services/trial_activation_service.py | 33 +- app/services/tribute_service.py | 439 +- app/services/user_cart_service.py | 39 +- app/services/user_service.py | 1011 ++-- app/services/version_service.py | 236 +- app/services/wata_service.py | 171 +- app/services/web_api_token_service.py | 21 +- app/services/webhook_service.py | 59 +- app/services/wheel_service.py | 289 +- app/services/yookassa_service.py | 439 +- app/states.py | 18 +- app/utils/__init__.py | 9 +- app/utils/cache.py | 203 +- app/utils/check_reg_process.py | 32 +- app/utils/currency_converter.py | 83 +- app/utils/decorators.py | 165 +- app/utils/formatters.py | 222 +- app/utils/log_handlers.py | 41 +- app/utils/message_patch.py | 60 +- app/utils/miniapp_buttons.py | 2 +- app/utils/pagination.py | 73 +- app/utils/payment_logger.py | 6 +- app/utils/payment_utils.py | 284 +- app/utils/photo_message.py | 42 +- app/utils/price_display.py | 82 +- app/utils/pricing_utils.py | 245 +- app/utils/promo_offer.py | 105 +- app/utils/security.py | 15 +- app/utils/startup_timeline.py | 115 +- app/utils/subscription_utils.py | 155 +- app/utils/telegram_webapp.py | 41 +- app/utils/timezone.py | 24 +- app/utils/user_utils.py | 279 +- app/utils/validators.py | 151 +- app/webapi/__init__.py | 4 +- app/webapi/app.py | 267 +- app/webapi/background/backup_tasks.py | 33 +- app/webapi/dependencies.py | 16 +- app/webapi/docs.py | 6 +- app/webapi/middleware.py | 7 +- app/webapi/routes/__init__.py | 65 +- app/webapi/routes/backups.py | 120 +- app/webapi/routes/ban_notifications.py | 48 +- app/webapi/routes/broadcasts.py | 29 +- app/webapi/routes/campaigns.py | 35 +- app/webapi/routes/config.py | 42 +- app/webapi/routes/contests.py | 191 +- app/webapi/routes/health.py | 9 +- app/webapi/routes/logs.py | 80 +- app/webapi/routes/main_menu_buttons.py | 12 +- app/webapi/routes/media.py | 49 +- app/webapi/routes/menu_layout.py | 573 +- app/webapi/routes/miniapp.py | 3605 ++++++------ app/webapi/routes/pages.py | 123 +- app/webapi/routes/partners.py | 131 +- app/webapi/routes/pinned_messages.py | 84 +- app/webapi/routes/polls.py | 115 +- app/webapi/routes/promo_groups.py | 51 +- app/webapi/routes/promo_offers.py | 129 +- app/webapi/routes/promocodes.py | 119 +- app/webapi/routes/remnawave.py | 239 +- app/webapi/routes/servers.py | 123 +- app/webapi/routes/stats.py | 350 +- app/webapi/routes/subscription_events.py | 41 +- app/webapi/routes/subscriptions.py | 75 +- app/webapi/routes/tickets.py | 92 +- app/webapi/routes/tokens.py | 19 +- app/webapi/routes/transactions.py | 21 +- app/webapi/routes/user_messages.py | 21 +- app/webapi/routes/users.py | 134 +- app/webapi/routes/webhooks.py | 53 +- app/webapi/routes/websocket.py | 67 +- app/webapi/routes/welcome_texts.py | 25 +- app/webapi/schemas/backups.py | 34 +- app/webapi/schemas/ban_notifications.py | 58 +- app/webapi/schemas/broadcasts.py | 73 +- app/webapi/schemas/campaigns.py | 137 +- app/webapi/schemas/config.py | 14 +- app/webapi/schemas/contests.py | 130 +- app/webapi/schemas/health.py | 4 +- app/webapi/schemas/logs.py | 61 +- app/webapi/schemas/main_menu_buttons.py | 42 +- app/webapi/schemas/media.py | 14 +- app/webapi/schemas/menu_layout.py | 507 +- app/webapi/schemas/miniapp.py | 825 +-- app/webapi/schemas/pages.py | 76 +- app/webapi/schemas/partners.py | 79 +- app/webapi/schemas/pinned_messages.py | 29 +- app/webapi/schemas/polls.py | 56 +- app/webapi/schemas/promo_groups.py | 39 +- app/webapi/schemas/promo_offers.py | 180 +- app/webapi/schemas/promocodes.py | 34 +- app/webapi/schemas/remnawave.py | 106 +- app/webapi/schemas/servers.py | 124 +- app/webapi/schemas/subscription_events.py | 50 +- app/webapi/schemas/subscriptions.py | 23 +- app/webapi/schemas/tickets.py | 29 +- app/webapi/schemas/tokens.py | 21 +- app/webapi/schemas/transactions.py | 9 +- app/webapi/schemas/user_messages.py | 19 +- app/webapi/schemas/users.py | 76 +- app/webapi/schemas/webhooks.py | 33 +- app/webapi/schemas/welcome_texts.py | 19 +- app/webapi/server.py | 69 +- app/webserver/__init__.py | 5 +- app/webserver/payments.py | 525 +- app/webserver/telegram.py | 77 +- app/webserver/unified_app.py | 96 +- main.py | 643 +-- pyproject.toml | 299 + tests/conftest.py | 129 +- tests/crud/test_promocode_crud.py | 31 +- tests/external/test_cryptobot_service.py | 69 +- tests/external/test_wata_webhook.py | 54 +- tests/external/test_yookassa_webhook.py | 82 +- tests/fixtures/promocode_fixtures.py | 34 +- .../test_promocode_promo_group_flow.py | 172 +- .../test_channel_checker_payload.py | 91 +- tests/services/test_menu_layout_service.py | 112 +- tests/services/test_modem_service.py | 57 +- .../services/test_mulenpay_service_adapter.py | 140 +- tests/services/test_pal24_service_adapter.py | 103 +- tests/services/test_payment_common.py | 19 +- .../test_payment_service_cryptobot.py | 61 +- .../services/test_payment_service_heleket.py | 189 +- .../test_payment_service_modularity.py | 33 +- .../services/test_payment_service_mulenpay.py | 151 +- tests/services/test_payment_service_pal24.py | 193 +- .../services/test_payment_service_platega.py | 135 +- tests/services/test_payment_service_stars.py | 148 +- .../services/test_payment_service_tribute.py | 45 +- tests/services/test_payment_service_wata.py | 203 +- .../services/test_payment_service_webhooks.py | 589 +- .../services/test_payment_service_yookassa.py | 115 +- tests/services/test_platega_service.py | 12 +- tests/services/test_promocode_service.py | 348 +- tests/services/test_referral_service.py | 31 +- tests/services/test_remnawave_service_sync.py | 46 +- ...test_subscription_auto_purchase_service.py | 322 +- .../test_system_settings_env_priority.py | 103 +- .../services/test_traffic_monitoring_redis.py | 71 +- .../services/test_yookassa_service_adapter.py | 136 +- tests/test_device_limit_resolution.py | 22 +- tests/test_menu_subscription_status.py | 6 +- tests/test_miniapp_payments.py | 113 +- tests/test_promo_group_base_discounts.py | 23 +- tests/test_subscription_cart_integration.py | 177 +- tests/test_trial_activation_paid.py | 8 +- tests/test_user_cart_service.py | 96 +- tests/utils/test_formatters_basic.py | 62 +- tests/utils/test_pricing_utils.py | 18 +- tests/utils/test_remnawave_auto_sync.py | 32 +- tests/utils/test_security.py | 20 +- tests/utils/test_validators_basic.py | 92 +- tests/webserver/test_payments.py | 253 +- tests/webserver/test_telegram.py | 144 +- tests/webserver/test_unified_app.py | 93 +- uv.lock | 1607 ++++++ 466 files changed, 63301 insertions(+), 69047 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 9f6d9e30..75160994 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ docker-compose.override.yml !app-config.json !main.py !requirements.txt +!pyproject.toml +!uv.lock +!.python-version !docs/ !docs/** !migrations/ diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Makefile b/Makefile index f9607ad1..b2438a82 100644 --- a/Makefile +++ b/Makefile @@ -25,8 +25,20 @@ reload-follow: ## Перезапустить контейнеры с логам .PHONY: test test: ## Запустить тесты - @echo "🧪 Запускаем тесты..." - pytest -v + uv run pytest -v + +.PHONY: lint +lint: ## Проверить код (ruff check) + uv run ruff check . + +.PHONY: format +format: ## Форматировать код (ruff format) + uv run ruff format . + +.PHONY: fix +fix: ## Исправить код (ruff check --fix + format) + uv run ruff check . --fix + uv run ruff format . .PHONY: help help: ## Показать список доступных команд diff --git a/app/bot.py b/app/bot.py index b323cdc7..66276db8 100644 --- a/app/bot.py +++ b/app/bot.py @@ -1,119 +1,114 @@ import logging -from aiogram import Bot, Dispatcher, types -from aiogram.fsm.storage.redis import RedisStorage -from aiogram.fsm.storage.memory import MemoryStorage + import redis.asyncio as redis +from aiogram import Bot, Dispatcher, types +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.fsm.storage.redis import RedisStorage from app.config import settings -from app.middlewares.global_error import GlobalErrorMiddleware -from app.middlewares.auth import AuthMiddleware -from app.middlewares.logging import LoggingMiddleware -from app.middlewares.throttling import ThrottlingMiddleware -from app.middlewares.subscription_checker import SubscriptionStatusMiddleware -from app.middlewares.maintenance import MaintenanceMiddleware -from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware -from app.middlewares.button_stats import ButtonStatsMiddleware -from app.services.maintenance_service import maintenance_service -from app.utils.cache import cache - from app.handlers import ( - start, - menu, - subscription, balance, + common, + contests as user_contests, + menu, + polls as user_polls, promocode, referral, - support, server_status, - common, + simple_subscription, + start, + subscription, + support, tickets, ) -from app.handlers import polls as user_polls -from app.handlers import simple_subscription from app.handlers.admin import ( - main as admin_main, + backup as admin_backup, blacklist as admin_blacklist, + bot_configuration as admin_bot_configuration, bulk_ban as admin_bulk_ban, - users as admin_users, - subscriptions as admin_subscriptions, - promocodes as admin_promocodes, - messages as admin_messages, - monitoring as admin_monitoring, - referrals as admin_referrals, - rules as admin_rules, - remnawave as admin_remnawave, - statistics as admin_statistics, - polls as admin_polls, - servers as admin_servers, - maintenance as admin_maintenance, - promo_groups as admin_promo_groups, campaigns as admin_campaigns, contests as admin_contests, daily_contests as admin_daily_contests, - promo_offers as admin_promo_offers, - user_messages as admin_user_messages, - updates as admin_updates, - backup as admin_backup, - system_logs as admin_system_logs, - welcome_text as admin_welcome_text, - tickets as admin_tickets, - reports as admin_reports, - bot_configuration as admin_bot_configuration, + faq as admin_faq, + main as admin_main, + maintenance as admin_maintenance, + messages as admin_messages, + monitoring as admin_monitoring, + payments as admin_payments, + polls as admin_polls, pricing as admin_pricing, privacy_policy as admin_privacy_policy, + promo_groups as admin_promo_groups, + promo_offers as admin_promo_offers, + promocodes as admin_promocodes, public_offer as admin_public_offer, - faq as admin_faq, - payments as admin_payments, - trials as admin_trials, + referrals as admin_referrals, + remnawave as admin_remnawave, + reports as admin_reports, + rules as admin_rules, + servers as admin_servers, + statistics as admin_statistics, + subscriptions as admin_subscriptions, + system_logs as admin_system_logs, tariffs as admin_tariffs, + tickets as admin_tickets, + trials as admin_trials, + updates as admin_updates, + user_messages as admin_user_messages, + users as admin_users, + welcome_text as admin_welcome_text, ) -from app.handlers import contests as user_contests from app.handlers.stars_payments import register_stars_handlers - +from app.middlewares.auth import AuthMiddleware +from app.middlewares.button_stats import ButtonStatsMiddleware +from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware +from app.middlewares.global_error import GlobalErrorMiddleware +from app.middlewares.logging import LoggingMiddleware +from app.middlewares.maintenance import MaintenanceMiddleware +from app.middlewares.subscription_checker import SubscriptionStatusMiddleware +from app.middlewares.throttling import ThrottlingMiddleware +from app.services.maintenance_service import maintenance_service +from app.utils.cache import cache from app.utils.message_patch import patch_message_methods + patch_message_methods() logger = logging.getLogger(__name__) async def debug_callback_handler(callback: types.CallbackQuery): - logger.info(f"🔍 DEBUG CALLBACK:") - logger.info(f" - Data: {callback.data}") - logger.info(f" - User: {callback.from_user.id}") - logger.info(f" - Username: {callback.from_user.username}") + logger.info('🔍 DEBUG CALLBACK:') + logger.info(f' - Data: {callback.data}') + logger.info(f' - User: {callback.from_user.id}') + logger.info(f' - Username: {callback.from_user.username}') async def setup_bot() -> tuple[Bot, Dispatcher]: - try: await cache.connect() - logger.info("Кеш инициализирован") + logger.info('Кеш инициализирован') except Exception as e: - logger.warning(f"Кеш не инициализирован: {e}") - + logger.warning(f'Кеш не инициализирован: {e}') + from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML) - ) - + bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + maintenance_service.set_bot(bot) - logger.info("Бот установлен в maintenance_service") - + logger.info('Бот установлен в maintenance_service') + try: redis_client = redis.from_url(settings.REDIS_URL) await redis_client.ping() storage = RedisStorage(redis_client) - logger.info("Подключено к Redis для FSM storage") + logger.info('Подключено к Redis для FSM storage') except Exception as e: - logger.warning(f"Не удалось подключиться к Redis: {e}") - logger.info("Используется MemoryStorage для FSM") + logger.warning(f'Не удалось подключиться к Redis: {e}') + logger.info('Используется MemoryStorage для FSM') storage = MemoryStorage() - - + dp = Dispatcher(storage=storage) dp.message.middleware(GlobalErrorMiddleware()) @@ -129,12 +124,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: dp.pre_checkout_query.middleware(display_name_middleware) dp.message.middleware(ThrottlingMiddleware()) dp.callback_query.middleware(ThrottlingMiddleware()) - + # Middleware для автоматического логирования кликов по кнопкам if settings.MENU_LAYOUT_ENABLED: button_stats_middleware = ButtonStatsMiddleware() dp.callback_query.middleware(button_stats_middleware) - logger.info("📊 ButtonStatsMiddleware активирован") + logger.info('📊 ButtonStatsMiddleware активирован') if settings.CHANNEL_IS_REQUIRED_SUB: from app.middlewares.channel_checker import ChannelCheckerMiddleware @@ -142,9 +137,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: channel_checker_middleware = ChannelCheckerMiddleware() dp.message.middleware(channel_checker_middleware) dp.callback_query.middleware(channel_checker_middleware) - logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован") + logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован') else: - logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован") + logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован') dp.message.middleware(AuthMiddleware()) dp.callback_query.middleware(AuthMiddleware()) dp.pre_checkout_query.middleware(AuthMiddleware()) @@ -162,7 +157,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_main.register_handlers(dp) admin_users.register_handlers(dp) admin_subscriptions.register_handlers(dp) - admin_servers.register_handlers(dp) + admin_servers.register_handlers(dp) admin_promocodes.register_handlers(dp) admin_messages.register_handlers(dp) admin_monitoring.register_handlers(dp) @@ -199,34 +194,34 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: user_contests.register_handlers(dp) user_polls.register_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) - logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") - logger.info("⚡ Зарегистрированы обработчики простой покупки") - logger.info("⚡ Зарегистрированы обработчики простой подписки") - + logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей') + logger.info('⚡ Зарегистрированы обработчики простой покупки') + logger.info('⚡ Зарегистрированы обработчики простой подписки') + if settings.is_maintenance_monitoring_enabled(): try: await maintenance_service.start_monitoring() - logger.info("Мониторинг техработ запущен") + logger.info('Мониторинг техработ запущен') except Exception as e: - logger.error(f"Ошибка запуска мониторинга техработ: {e}") + logger.error(f'Ошибка запуска мониторинга техработ: {e}') else: - logger.info("Мониторинг техработ отключен настройками") - - logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries") - logger.info("Бот успешно настроен") - + logger.info('Мониторинг техработ отключен настройками') + + logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries') + logger.info('Бот успешно настроен') + return bot, dp async def shutdown_bot(): try: await maintenance_service.stop_monitoring() - logger.info("Мониторинг техработ остановлен") + logger.info('Мониторинг техработ остановлен') except Exception as e: - logger.error(f"Ошибка остановки мониторинга: {e}") - + logger.error(f'Ошибка остановки мониторинга: {e}') + try: await cache.close() - logger.info("Соединения с кешем закрыты") + logger.info('Соединения с кешем закрыты') except Exception as e: - logger.error(f"Ошибка закрытия кеша: {e}") + logger.error(f'Ошибка закрытия кеша: {e}') diff --git a/app/cabinet/auth/__init__.py b/app/cabinet/auth/__init__.py index e14023bc..ed99c1c9 100644 --- a/app/cabinet/auth/__init__.py +++ b/app/cabinet/auth/__init__.py @@ -1,21 +1,22 @@ """Cabinet authentication module.""" -from .password_utils import hash_password, verify_password from .jwt_handler import ( create_access_token, create_refresh_token, decode_token, get_token_payload, ) -from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data +from .password_utils import hash_password, verify_password +from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget + __all__ = [ - "hash_password", - "verify_password", - "create_access_token", - "create_refresh_token", - "decode_token", - "get_token_payload", - "validate_telegram_login_widget", - "validate_telegram_init_data", + 'create_access_token', + 'create_refresh_token', + 'decode_token', + 'get_token_payload', + 'hash_password', + 'validate_telegram_init_data', + 'validate_telegram_login_widget', + 'verify_password', ] diff --git a/app/cabinet/auth/email_verification.py b/app/cabinet/auth/email_verification.py index 210a4cdf..071603a5 100644 --- a/app/cabinet/auth/email_verification.py +++ b/app/cabinet/auth/email_verification.py @@ -2,7 +2,6 @@ import secrets from datetime import datetime, timedelta -from typing import Optional from app.config import settings @@ -49,7 +48,7 @@ def get_password_reset_expires_at() -> datetime: return datetime.utcnow() + timedelta(hours=hours) -def is_token_expired(expires_at: Optional[datetime]) -> bool: +def is_token_expired(expires_at: datetime | None) -> bool: """ Check if a token has expired. diff --git a/app/cabinet/auth/jwt_handler.py b/app/cabinet/auth/jwt_handler.py index ec36d780..ef430608 100644 --- a/app/cabinet/auth/jwt_handler.py +++ b/app/cabinet/auth/jwt_handler.py @@ -1,15 +1,17 @@ """JWT token handling for cabinet authentication.""" -import jwt from datetime import datetime, timedelta -from typing import Optional, Dict, Any +from typing import Any + +import jwt from app.config import settings -JWT_ALGORITHM = "HS256" + +JWT_ALGORITHM = 'HS256' -def create_access_token(user_id: int, telegram_id: Optional[int] = None) -> str: +def create_access_token(user_id: int, telegram_id: int | None = None) -> str: """ Create a short-lived access token. @@ -24,15 +26,15 @@ def create_access_token(user_id: int, telegram_id: Optional[int] = None) -> str: expires = datetime.utcnow() + timedelta(minutes=expire_minutes) payload = { - "sub": str(user_id), - "type": "access", - "exp": expires, - "iat": datetime.utcnow(), + 'sub': str(user_id), + 'type': 'access', + 'exp': expires, + 'iat': datetime.utcnow(), } # Добавляем telegram_id только если он есть if telegram_id is not None: - payload["telegram_id"] = telegram_id + payload['telegram_id'] = telegram_id secret = settings.get_cabinet_jwt_secret() return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) @@ -52,17 +54,17 @@ def create_refresh_token(user_id: int) -> str: expires = datetime.utcnow() + timedelta(days=expire_days) payload = { - "sub": str(user_id), - "type": "refresh", - "exp": expires, - "iat": datetime.utcnow(), + 'sub': str(user_id), + 'type': 'refresh', + 'exp': expires, + 'iat': datetime.utcnow(), } secret = settings.get_cabinet_jwt_secret() return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) -def decode_token(token: str) -> Optional[Dict[str, Any]]: +def decode_token(token: str) -> dict[str, Any] | None: """ Decode and validate a JWT token. @@ -81,7 +83,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]: return None -def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]: +def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None: """ Decode token and verify its type. @@ -97,7 +99,7 @@ def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dic if not payload: return None - if payload.get("type") != expected_type: + if payload.get('type') != expected_type: return None return payload diff --git a/app/cabinet/auth/password_utils.py b/app/cabinet/auth/password_utils.py index a9bf282e..9d937f16 100644 --- a/app/cabinet/auth/password_utils.py +++ b/app/cabinet/auth/password_utils.py @@ -2,6 +2,7 @@ import bcrypt + BCRYPT_ROUNDS = 12 @@ -15,10 +16,10 @@ def hash_password(password: str) -> str: Returns: Hashed password string """ - password_bytes = password.encode("utf-8") + password_bytes = password.encode('utf-8') salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS) hashed = bcrypt.hashpw(password_bytes, salt) - return hashed.decode("utf-8") + return hashed.decode('utf-8') def verify_password(password: str, password_hash: str) -> bool: @@ -33,8 +34,8 @@ def verify_password(password: str, password_hash: str) -> bool: True if password matches, False otherwise """ try: - password_bytes = password.encode("utf-8") - hash_bytes = password_hash.encode("utf-8") + password_bytes = password.encode('utf-8') + hash_bytes = password_hash.encode('utf-8') return bcrypt.checkpw(password_bytes, hash_bytes) except (ValueError, TypeError): return False diff --git a/app/cabinet/auth/telegram_auth.py b/app/cabinet/auth/telegram_auth.py index d6950454..c9b841b5 100644 --- a/app/cabinet/auth/telegram_auth.py +++ b/app/cabinet/auth/telegram_auth.py @@ -4,13 +4,13 @@ import hashlib import hmac import json from datetime import datetime -from typing import Dict, Any, Optional +from typing import Any from urllib.parse import parse_qsl, unquote from app.config import settings -def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = 86400) -> bool: +def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool: """ Validate Telegram Login Widget data. @@ -24,13 +24,13 @@ def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = True if data is valid, False otherwise """ auth_data = data.copy() - check_hash = auth_data.pop("hash", None) + check_hash = auth_data.pop('hash', None) if not check_hash: return False # Check auth_date is not too old - auth_date = auth_data.get("auth_date") + auth_date = auth_data.get('auth_date') if auth_date: try: # Use UTC timestamp to avoid timezone issues @@ -42,24 +42,20 @@ def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int = return False # Build data-check-string (sorted key=value pairs, newline-separated) - data_check_arr = [f"{k}={v}" for k, v in sorted(auth_data.items()) if v is not None] - data_check_string = "\n".join(data_check_arr) + data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None] + data_check_string = '\n'.join(data_check_arr) # Create secret key from bot token using SHA256 bot_token = settings.BOT_TOKEN secret_key = hashlib.sha256(bot_token.encode()).digest() # Calculate expected hash - calculated_hash = hmac.new( - secret_key, - data_check_string.encode(), - hashlib.sha256 - ).hexdigest() + calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(calculated_hash, check_hash) -def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> Optional[Dict[str, Any]]: +def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None: """ Validate Telegram WebApp initData. @@ -76,12 +72,12 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> # Parse the init_data string parsed = dict(parse_qsl(init_data, keep_blank_values=True)) - received_hash = parsed.pop("hash", None) + received_hash = parsed.pop('hash', None) if not received_hash: return None # Check auth_date is not too old - auth_date = parsed.get("auth_date") + auth_date = parsed.get('auth_date') if auth_date: try: # Use UTC timestamp to avoid timezone issues @@ -93,29 +89,21 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> return None # Build data-check-string - data_check_arr = [f"{k}={v}" for k, v in sorted(parsed.items())] - data_check_string = "\n".join(data_check_arr) + data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())] + data_check_string = '\n'.join(data_check_arr) # Create secret key: HMAC_SHA256(bot_token, "WebAppData") bot_token = settings.BOT_TOKEN - secret_key = hmac.new( - b"WebAppData", - bot_token.encode(), - hashlib.sha256 - ).digest() + secret_key = hmac.new(b'WebAppData', bot_token.encode(), hashlib.sha256).digest() # Calculate expected hash - calculated_hash = hmac.new( - secret_key, - data_check_string.encode(), - hashlib.sha256 - ).hexdigest() + calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(calculated_hash, received_hash): return None # Parse user data from the validated data - user_data_str = parsed.get("user") + user_data_str = parsed.get('user') if user_data_str: user_data = json.loads(unquote(user_data_str)) return user_data @@ -126,7 +114,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> return None -def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]: +def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None: """ Extract and validate user info from Telegram WebApp initData. diff --git a/app/cabinet/dependencies.py b/app/cabinet/dependencies.py index 90489983..e3ff5c14 100644 --- a/app/cabinet/dependencies.py +++ b/app/cabinet/dependencies.py @@ -1,25 +1,27 @@ """FastAPI dependencies for cabinet module.""" import logging -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from sqlalchemy.ext.asyncio import AsyncSession -from typing import Optional -from aiogram import Bot +from aiogram import Bot +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import get_user_by_id from app.database.database import AsyncSessionLocal from app.database.models import User -from app.database.crud.user import get_user_by_id -from app.config import settings from app.services.maintenance_service import maintenance_service + from .auth.jwt_handler import get_token_payload + logger = logging.getLogger(__name__) security = HTTPBearer(auto_error=False) # Кешированный Bot для проверки подписки на канал -_channel_check_bot: Optional[Bot] = None +_channel_check_bot: Bot | None = None def _get_channel_check_bot() -> Bot: @@ -40,7 +42,7 @@ async def get_cabinet_db() -> AsyncSession: async def get_current_cabinet_user( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + credentials: HTTPAuthorizationCredentials | None = Depends(security), db: AsyncSession = Depends(get_cabinet_db), ) -> User: """ @@ -64,27 +66,27 @@ async def get_current_cabinet_user( if not credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication required", - headers={"WWW-Authenticate": "Bearer"}, + detail='Authentication required', + headers={'WWW-Authenticate': 'Bearer'}, ) token = credentials.credentials - payload = get_token_payload(token, expected_type="access") + payload = get_token_payload(token, expected_type='access') if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired token", - headers={"WWW-Authenticate": "Bearer"}, + detail='Invalid or expired token', + headers={'WWW-Authenticate': 'Bearer'}, ) try: - user_id = int(payload.get("sub")) + user_id = int(payload.get('sub')) except (TypeError, ValueError): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token payload", - headers={"WWW-Authenticate": "Bearer"}, + detail='Invalid token payload', + headers={'WWW-Authenticate': 'Bearer'}, ) user = await get_user_by_id(db, user_id) @@ -92,30 +94,27 @@ async def get_current_cabinet_user( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) - if user.status != "active": + if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User account is not active", + detail='User account is not active', ) # Check maintenance mode (allow admins to pass) if maintenance_service.is_maintenance_active(): # Проверяем админа по telegram_id ИЛИ email - is_admin = settings.is_admin( - telegram_id=user.telegram_id, - email=user.email if user.email_verified else None - ) + is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) if not is_admin: status_info = maintenance_service.get_status_info() raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ - "code": "maintenance", - "message": maintenance_service.get_maintenance_message() or "Service is under maintenance", - "reason": status_info.get("reason"), + 'code': 'maintenance', + 'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance', + 'reason': status_info.get('reason'), }, ) @@ -125,40 +124,36 @@ async def get_current_cabinet_user( if user.telegram_id is not None: # Проверяем админа по telegram_id ИЛИ email is_admin = settings.is_admin( - telegram_id=user.telegram_id, - email=user.email if user.email_verified else None + telegram_id=user.telegram_id, email=user.email if user.email_verified else None ) if not is_admin: try: bot = _get_channel_check_bot() - chat_member = await bot.get_chat_member( - chat_id=settings.CHANNEL_SUB_ID, - user_id=user.telegram_id - ) + chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id) # Не закрываем сессию - бот переиспользуется - if chat_member.status not in ["member", "administrator", "creator"]: + if chat_member.status not in ['member', 'administrator', 'creator']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ - "code": "channel_subscription_required", - "message": "Please subscribe to our channel to continue", - "channel_link": settings.CHANNEL_LINK, + 'code': 'channel_subscription_required', + 'message': 'Please subscribe to our channel to continue', + 'channel_link': settings.CHANNEL_LINK, }, ) except HTTPException: raise except Exception as e: - logger.warning(f"Failed to check channel subscription for user {user.telegram_id}: {e}") + logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}') # Don't block user if check fails return user async def get_optional_cabinet_user( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + credentials: HTTPAuthorizationCredentials | None = Depends(security), db: AsyncSession = Depends(get_cabinet_db), -) -> Optional[User]: +) -> User | None: """ Optionally get current authenticated cabinet user. @@ -168,19 +163,19 @@ async def get_optional_cabinet_user( return None token = credentials.credentials - payload = get_token_payload(token, expected_type="access") + payload = get_token_payload(token, expected_type='access') if not payload: return None try: - user_id = int(payload.get("sub")) + user_id = int(payload.get('sub')) except (TypeError, ValueError): return None user = await get_user_by_id(db, user_id) - if not user or user.status != "active": + if not user or user.status != 'active': return None return user @@ -203,14 +198,11 @@ async def get_current_admin_user( Raises: HTTPException: If user is not an admin """ - is_admin = settings.is_admin( - telegram_id=user.telegram_id, - email=user.email if user.email_verified else None - ) + is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) if not is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin access required", + detail='Admin access required', ) return user diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index bea7b005..7afe3b1f 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -2,42 +2,44 @@ from fastapi import APIRouter -from .auth import router as auth_router -from .subscription import router as subscription_router -from .balance import router as balance_router -from .referral import router as referral_router -from .tickets import router as tickets_router -from .ticket_notifications import router as ticket_notifications_router -from .ticket_notifications import admin_router as admin_ticket_notifications_router -from .admin_tickets import router as admin_tickets_router -from .admin_settings import router as admin_settings_router from .admin_apps import router as admin_apps_router -from .promocode import router as promocode_router -from .contests import router as contests_router -from .polls import router as polls_router -from .promo import router as promo_router -from .notifications import router as notifications_router -from .info import router as info_router -from .branding import router as branding_router -from .wheel import router as wheel_router -from .admin_wheel import router as admin_wheel_router -from .admin_tariffs import router as admin_tariffs_router -from .admin_servers import router as admin_servers_router -from .admin_stats import router as admin_stats_router from .admin_ban_system import router as admin_ban_system_router from .admin_broadcasts import router as admin_broadcasts_router -from .admin_promocodes import router as admin_promocodes_router -from .admin_promocodes import promo_groups_router as admin_promo_groups_router from .admin_campaigns import router as admin_campaigns_router -from .admin_users import router as admin_users_router from .admin_payments import router as admin_payments_router from .admin_promo_offers import router as admin_promo_offers_router +from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router from .admin_remnawave import router as admin_remnawave_router +from .admin_servers import router as admin_servers_router +from .admin_settings import router as admin_settings_router +from .admin_stats import router as admin_stats_router +from .admin_tariffs import router as admin_tariffs_router +from .admin_tickets import router as admin_tickets_router +from .admin_users import router as admin_users_router +from .admin_wheel import router as admin_wheel_router +from .auth import router as auth_router +from .balance import router as balance_router +from .branding import router as branding_router +from .contests import router as contests_router +from .info import router as info_router from .media import router as media_router +from .notifications import router as notifications_router +from .polls import router as polls_router +from .promo import router as promo_router +from .promocode import router as promocode_router +from .referral import router as referral_router +from .subscription import router as subscription_router +from .ticket_notifications import ( + admin_router as admin_ticket_notifications_router, + router as ticket_notifications_router, +) +from .tickets import router as tickets_router from .websocket import router as websocket_router +from .wheel import router as wheel_router + # Main cabinet router -router = APIRouter(prefix="/cabinet", tags=["Cabinet"]) +router = APIRouter(prefix='/cabinet', tags=['Cabinet']) # Include all sub-routers router.include_router(auth_router) @@ -81,4 +83,4 @@ router.include_router(admin_remnawave_router) # WebSocket route router.include_router(websocket_router) -__all__ = ["router"] +__all__ = ['router'] diff --git a/app/cabinet/routes/admin_apps.py b/app/cabinet/routes/admin_apps.py index a67dea91..10169673 100644 --- a/app/cabinet/routes/admin_apps.py +++ b/app/cabinet/routes/admin_apps.py @@ -2,70 +2,77 @@ import json import logging -from typing import List, Optional, Dict, Any from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User from app.config import settings +from app.database.models import User from app.services.remnawave_service import RemnaWaveService from app.services.system_settings_service import bot_configuration_service from ..dependencies import get_cabinet_db, get_current_admin_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/apps", tags=["Cabinet Admin Apps"]) +router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps']) # ============ Schemas ============ + class LocalizedText(BaseModel): """Localized text for multiple languages.""" - en: str = "" - ru: str = "" - zh: Optional[str] = "" - fa: Optional[str] = "" + + en: str = '' + ru: str = '' + zh: str | None = '' + fa: str | None = '' class AppButton(BaseModel): """Button with link and localized text.""" + buttonLink: str buttonText: LocalizedText class AppStep(BaseModel): """Step with description and optional buttons/title.""" + description: LocalizedText - buttons: Optional[List[AppButton]] = None - title: Optional[LocalizedText] = None + buttons: list[AppButton] | None = None + title: LocalizedText | None = None class AppDefinition(BaseModel): """VPN application definition.""" + id: str name: str isFeatured: bool = False urlScheme: str - isNeedBase64Encoding: Optional[bool] = None + isNeedBase64Encoding: bool | None = None installationStep: AppStep addSubscriptionStep: AppStep connectAndUseStep: AppStep - additionalBeforeAddSubscriptionStep: Optional[AppStep] = None - additionalAfterAddSubscriptionStep: Optional[AppStep] = None + additionalBeforeAddSubscriptionStep: AppStep | None = None + additionalAfterAddSubscriptionStep: AppStep | None = None class PlatformApps(BaseModel): """Apps for a specific platform.""" + platform: str - apps: List[AppDefinition] + apps: list[AppDefinition] class AppConfigBranding(BaseModel): """Branding configuration.""" + name: str logoUrl: str supportUrl: str @@ -73,39 +80,46 @@ class AppConfigBranding(BaseModel): class AppConfigConfig(BaseModel): """Top-level config section.""" - additionalLocales: List[str] + + additionalLocales: list[str] branding: AppConfigBranding class AppConfigResponse(BaseModel): """Full app config response.""" + config: AppConfigConfig - platforms: Dict[str, List[AppDefinition]] + platforms: dict[str, list[AppDefinition]] class CreateAppRequest(BaseModel): """Request to create a new app.""" + platform: str app: AppDefinition class UpdateAppRequest(BaseModel): """Request to update an app.""" + app: AppDefinition class ReorderAppsRequest(BaseModel): """Request to reorder apps in a platform.""" - app_ids: List[str] + + app_ids: list[str] class UpdateBrandingRequest(BaseModel): """Request to update branding.""" + branding: AppConfigBranding # ============ Helpers ============ + def _get_config_path() -> Path: """Get path to app-config.json.""" return Path(settings.get_app_config_path()) @@ -117,16 +131,16 @@ def _load_config() -> dict: if not config_path.exists(): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"App config file not found: {config_path}", + detail=f'App config file not found: {config_path}', ) try: - with open(config_path, "r", encoding="utf-8") as f: + with open(config_path, encoding='utf-8') as f: return json.load(f) except json.JSONDecodeError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to parse app config: {e}", + detail=f'Failed to parse app config: {e}', ) @@ -135,21 +149,22 @@ def _save_config(config: dict) -> None: config_path = _get_config_path() try: - with open(config_path, "w", encoding="utf-8") as f: + with open(config_path, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2, ensure_ascii=False) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to save app config: {e}", + detail=f'Failed to save app config: {e}', ) -VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"] +VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV'] # ============ Routes ============ -@router.get("", response_model=AppConfigResponse) + +@router.get('', response_model=AppConfigResponse) async def get_app_config( admin: User = Depends(get_current_admin_user), ): @@ -158,7 +173,7 @@ async def get_app_config( return config -@router.get("/platforms", response_model=List[str]) +@router.get('/platforms', response_model=list[str]) async def get_platforms( admin: User = Depends(get_current_admin_user), ): @@ -166,7 +181,7 @@ async def get_platforms( return VALID_PLATFORMS -@router.get("/platforms/{platform}", response_model=List[AppDefinition]) +@router.get('/platforms/{platform}', response_model=list[AppDefinition]) async def get_platform_apps( platform: str, admin: User = Depends(get_current_admin_user), @@ -175,15 +190,15 @@ async def get_platform_apps( if platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}", + detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) return platforms.get(platform, []) -@router.post("/platforms/{platform}", response_model=AppDefinition) +@router.post('/platforms/{platform}', response_model=AppDefinition) async def create_app( platform: str, request: CreateAppRequest, @@ -193,17 +208,17 @@ async def create_app( if platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform: {platform}", + detail=f'Invalid platform: {platform}', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) if platform not in platforms: platforms[platform] = [] # Check if app with same ID already exists - existing_ids = [app.get("id") for app in platforms[platform]] + existing_ids = [app.get('id') for app in platforms[platform]] if request.app.id in existing_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -213,7 +228,7 @@ async def create_app( # Add new app app_dict = request.app.model_dump(exclude_none=True) platforms[platform].append(app_dict) - config["platforms"] = platforms + config['platforms'] = platforms _save_config(config) logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'") @@ -221,7 +236,7 @@ async def create_app( return request.app -@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition) +@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition) async def update_app( platform: str, app_id: str, @@ -232,17 +247,17 @@ async def update_app( if platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform: {platform}", + detail=f'Invalid platform: {platform}', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) apps = platforms.get(platform, []) # Find and update app app_index = None for i, app in enumerate(apps): - if app.get("id") == app_id: + if app.get('id') == app_id: app_index = i break @@ -256,7 +271,7 @@ async def update_app( app_dict = request.app.model_dump(exclude_none=True) apps[app_index] = app_dict platforms[platform] = apps - config["platforms"] = platforms + config['platforms'] = platforms _save_config(config) logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'") @@ -264,7 +279,7 @@ async def update_app( return request.app -@router.delete("/platforms/{platform}/{app_id}") +@router.delete('/platforms/{platform}/{app_id}') async def delete_app( platform: str, app_id: str, @@ -274,16 +289,16 @@ async def delete_app( if platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform: {platform}", + detail=f'Invalid platform: {platform}', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) apps = platforms.get(platform, []) # Find and remove app original_length = len(apps) - apps = [app for app in apps if app.get("id") != app_id] + apps = [app for app in apps if app.get('id') != app_id] if len(apps) == original_length: raise HTTPException( @@ -292,15 +307,15 @@ async def delete_app( ) platforms[platform] = apps - config["platforms"] = platforms + config['platforms'] = platforms _save_config(config) logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'") - return {"status": "deleted", "app_id": app_id} + return {'status': 'deleted', 'app_id': app_id} -@router.post("/platforms/{platform}/reorder") +@router.post('/platforms/{platform}/reorder') async def reorder_apps( platform: str, request: ReorderAppsRequest, @@ -310,15 +325,15 @@ async def reorder_apps( if platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform: {platform}", + detail=f'Invalid platform: {platform}', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) apps = platforms.get(platform, []) # Create a map of apps by ID - apps_map = {app.get("id"): app for app in apps} + apps_map = {app.get('id'): app for app in apps} # Verify all IDs exist for app_id in request.app_ids: @@ -333,19 +348,19 @@ async def reorder_apps( # Add any apps that weren't in the reorder list (shouldn't happen but just in case) for app in apps: - if app.get("id") not in request.app_ids: + if app.get('id') not in request.app_ids: reordered_apps.append(app) platforms[platform] = reordered_apps - config["platforms"] = platforms + config['platforms'] = platforms _save_config(config) logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'") - return {"status": "reordered", "order": request.app_ids} + return {'status': 'reordered', 'order': request.app_ids} -@router.put("/branding", response_model=AppConfigBranding) +@router.put('/branding', response_model=AppConfigBranding) async def update_branding( request: UpdateBrandingRequest, admin: User = Depends(get_current_admin_user), @@ -353,28 +368,28 @@ async def update_branding( """Update branding configuration.""" config = _load_config() - if "config" not in config: - config["config"] = {} + if 'config' not in config: + config['config'] = {} - config["config"]["branding"] = request.branding.model_dump() + config['config']['branding'] = request.branding.model_dump() _save_config(config) - logger.info(f"Admin {admin.id} updated branding") + logger.info(f'Admin {admin.id} updated branding') return request.branding -@router.get("/branding", response_model=AppConfigBranding) +@router.get('/branding', response_model=AppConfigBranding) async def get_branding( admin: User = Depends(get_current_admin_user), ): """Get branding configuration.""" config = _load_config() - branding = config.get("config", {}).get("branding", {}) + branding = config.get('config', {}).get('branding', {}) return branding -@router.post("/platforms/{platform}/copy/{app_id}") +@router.post('/platforms/{platform}/copy/{app_id}') async def copy_app_to_platform( platform: str, app_id: str, @@ -385,17 +400,17 @@ async def copy_app_to_platform( if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid platform(s)", + detail='Invalid platform(s)', ) config = _load_config() - platforms = config.get("platforms", {}) + platforms = config.get('platforms', {}) source_apps = platforms.get(platform, []) # Find source app source_app = None for app in source_apps: - if app.get("id") == app_id: + if app.get('id') == app_id: source_app = app.copy() break @@ -407,44 +422,48 @@ async def copy_app_to_platform( # Generate new ID for copied app import time - new_id = f"{app_id}-copy-{int(time.time())}" - source_app["id"] = new_id + + new_id = f'{app_id}-copy-{int(time.time())}' + source_app['id'] = new_id # Add to target platform if target_platform not in platforms: platforms[target_platform] = [] platforms[target_platform].append(source_app) - config["platforms"] = platforms + config['platforms'] = platforms _save_config(config) logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'") - return {"status": "copied", "new_id": new_id, "target_platform": target_platform} + return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform} # ============ RemnaWave Config Routes ============ + class RemnaWaveConfigStatus(BaseModel): """Status of RemnaWave config integration.""" + enabled: bool - config_uuid: Optional[str] = None + config_uuid: str | None = None class UpdateRemnaWaveUuidRequest(BaseModel): """Request to update RemnaWave config UUID.""" - uuid: Optional[str] = None + + uuid: str | None = None -def _get_remnawave_config_uuid() -> Optional[str]: +def _get_remnawave_config_uuid() -> str | None: """Get RemnaWave config UUID from system settings or env.""" try: - return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG") + return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG') except Exception: return settings.CABINET_REMNA_SUB_CONFIG -@router.get("/remnawave/status", response_model=RemnaWaveConfigStatus) +@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus) async def get_remnawave_config_status( admin: User = Depends(get_current_admin_user), ): @@ -456,7 +475,7 @@ async def get_remnawave_config_status( ) -@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus) +@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus) async def set_remnawave_config_uuid( request: UpdateRemnaWaveUuidRequest, admin: User = Depends(get_current_admin_user), @@ -468,24 +487,23 @@ async def set_remnawave_config_uuid( # Validate UUID format if provided if uuid_value: import re - uuid_pattern = re.compile( - r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' - ) + + uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') if not uuid_pattern.match(uuid_value): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid UUID format", + detail='Invalid UUID format', ) try: - await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value) + await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value) await db.commit() logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'") except Exception as e: - logger.error(f"Error saving RemnaWave config UUID: {e}") + logger.error(f'Error saving RemnaWave config UUID: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to save configuration", + detail='Failed to save configuration', ) return RemnaWaveConfigStatus( @@ -494,7 +512,7 @@ async def set_remnawave_config_uuid( ) -@router.get("/remnawave/config") +@router.get('/remnawave/config') async def get_remnawave_subscription_config( admin: User = Depends(get_current_admin_user), ): @@ -506,7 +524,7 @@ async def get_remnawave_subscription_config( if not config_uuid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="CABINET_REMNA_SUB_CONFIG is not configured", + detail='CABINET_REMNA_SUB_CONFIG is not configured', ) try: @@ -521,22 +539,22 @@ async def get_remnawave_subscription_config( # Return the raw config data from RemnaWave return { - "uuid": config.uuid, - "name": config.name, - "view_position": config.view_position, - "config": config.config, + 'uuid': config.uuid, + 'name': config.name, + 'view_position': config.view_position, + 'config': config.config, } except HTTPException: raise except Exception as e: - logger.error(f"Error fetching RemnaWave config: {e}") + logger.error(f'Error fetching RemnaWave config: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch config from RemnaWave: {str(e)}", + detail=f'Failed to fetch config from RemnaWave: {e!s}', ) -@router.get("/remnawave/configs") +@router.get('/remnawave/configs') async def list_remnawave_subscription_configs( admin: User = Depends(get_current_admin_user), ): @@ -547,15 +565,15 @@ async def list_remnawave_subscription_configs( configs = await api.get_subscription_page_configs() return [ { - "uuid": c.uuid, - "name": c.name, - "view_position": c.view_position, + 'uuid': c.uuid, + 'name': c.name, + 'view_position': c.view_position, } for c in configs ] except Exception as e: - logger.error(f"Error listing RemnaWave configs: {e}") + logger.error(f'Error listing RemnaWave configs: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch configs from RemnaWave: {str(e)}", + detail=f'Failed to fetch configs from RemnaWave: {e!s}', ) diff --git a/app/cabinet/routes/admin_ban_system.py b/app/cabinet/routes/admin_ban_system.py index e8151dcd..16c57166 100644 --- a/app/cabinet/routes/admin_ban_system.py +++ b/app/cabinet/routes/admin_ban_system.py @@ -1,9 +1,9 @@ """Admin routes for Ban System monitoring in cabinet.""" import logging -from typing import Optional, List, Any +from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from app.config import settings from app.database.models import User @@ -11,59 +11,62 @@ from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError from ..dependencies import get_current_admin_user from ..schemas.ban_system import ( - BanSystemStatusResponse, - BanSystemStatsResponse, - BanUsersListResponse, - BanUserListItem, - BanUserDetailResponse, - BanUserIPInfo, - BanUserRequestLog, - BanPunishmentsListResponse, - BanPunishmentItem, - BanHistoryResponse, - BanUserRequest, - UnbanResponse, - BanNodesListResponse, - BanNodeItem, - BanAgentsListResponse, + BanAgentHistoryItem, + BanAgentHistoryResponse, BanAgentItem, + BanAgentsListResponse, BanAgentsSummary, - BanTrafficViolationsResponse, - BanTrafficViolationItem, - BanTrafficResponse, - BanTrafficTopItem, - BanSettingsResponse, - BanSettingDefinition, - BanWhitelistRequest, - BanReportResponse, - BanReportTopViolator, - BanHealthResponse, BanHealthComponent, BanHealthDetailedResponse, - BanAgentHistoryResponse, - BanAgentHistoryItem, + BanHealthResponse, + BanHistoryResponse, + BanNodeItem, + BanNodesListResponse, + BanPunishmentItem, + BanPunishmentsListResponse, + BanReportResponse, + BanReportTopViolator, + BanSettingDefinition, + BanSettingsResponse, + BanSystemStatsResponse, + BanSystemStatusResponse, + BanTrafficResponse, + BanTrafficTopItem, + BanTrafficViolationItem, + BanTrafficViolationsResponse, + BanUserDetailResponse, + BanUserIPInfo, + BanUserListItem, + BanUserRequest, + BanUserRequestLog, + BanUsersListResponse, + BanWhitelistRequest, + UnbanResponse, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/ban-system", tags=["Cabinet Admin Ban System"]) +router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']) def _get_ban_api() -> BanSystemAPI: """Get Ban System API instance.""" - logger.debug(f"Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}") - logger.debug(f"Ban System URL: {settings.get_ban_system_api_url()}") + logger.debug( + f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}' + ) + logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}') if not settings.is_ban_system_enabled(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Ban System integration is disabled", + detail='Ban System integration is disabled', ) if not settings.is_ban_system_configured(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Ban System is not configured", + detail='Ban System is not configured', ) return BanSystemAPI( @@ -80,22 +83,23 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any: func = getattr(api, method) return await func(*args, **kwargs) except BanSystemAPIError as e: - logger.error(f"Ban System API error: {e}") + logger.error(f'Ban System API error: {e}') raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Ban System API error: {e.message}", + detail=f'Ban System API error: {e.message}', ) except Exception as e: - logger.error(f"Ban System unexpected error: {e}") + logger.error(f'Ban System unexpected error: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal error: {str(e)}", + detail=f'Internal error: {e!s}', ) # === Status === -@router.get("/status", response_model=BanSystemStatusResponse) + +@router.get('/status', response_model=BanSystemStatusResponse) async def get_ban_system_status( admin: User = Depends(get_current_admin_user), ) -> BanSystemStatusResponse: @@ -108,17 +112,18 @@ async def get_ban_system_status( # === Stats === -@router.get("/stats/raw") + +@router.get('/stats/raw') async def get_stats_raw( admin: User = Depends(get_current_admin_user), ) -> dict: """Get raw stats from Ban System API for debugging.""" api = _get_ban_api() - data = await _api_request(api, "get_stats") - return {"raw_response": data} + data = await _api_request(api, 'get_stats') + return {'raw_response': data} -@router.get("/stats", response_model=BanSystemStatsResponse) +@router.get('/stats', response_model=BanSystemStatsResponse) async def get_stats( admin: User = Depends(get_current_admin_user), ) -> BanSystemStatsResponse: @@ -126,97 +131,102 @@ async def get_stats( from datetime import datetime api = _get_ban_api() - data = await _api_request(api, "get_stats") + data = await _api_request(api, 'get_stats') - logger.debug(f"Ban System raw stats: {data}") + logger.debug(f'Ban System raw stats: {data}') # Extract punishment stats - punishment_stats = data.get("punishment_stats") or {} + punishment_stats = data.get('punishment_stats') or {} # Extract connected nodes info - connected_nodes = data.get("connected_nodes", []) + connected_nodes = data.get('connected_nodes', []) # Count online nodes/agents - nodes_online = sum(1 for n in connected_nodes if n.get("is_online", False)) + nodes_online = sum(1 for n in connected_nodes if n.get('is_online', False)) # Extract tcp_metrics for uptime - tcp_metrics = data.get("tcp_metrics") or {} + tcp_metrics = data.get('tcp_metrics') or {} uptime_seconds = None - intake_started = tcp_metrics.get("intake_started_at") + intake_started = tcp_metrics.get('intake_started_at') if intake_started: try: - start_time = datetime.fromisoformat(intake_started.replace("Z", "+00:00")) + start_time = datetime.fromisoformat(intake_started.replace('Z', '+00:00')) uptime_seconds = int((datetime.now(start_time.tzinfo) - start_time).total_seconds()) except Exception: pass return BanSystemStatsResponse( - total_users=data.get("total_users", 0), - active_users=data.get("users_with_limit", 0), - users_over_limit=data.get("users_over_limit", 0), - total_requests=data.get("total_requests", 0), - total_punishments=punishment_stats.get("total_punishments", 0), - active_punishments=punishment_stats.get("active_punishments", 0), + total_users=data.get('total_users', 0), + active_users=data.get('users_with_limit', 0), + users_over_limit=data.get('users_over_limit', 0), + total_requests=data.get('total_requests', 0), + total_punishments=punishment_stats.get('total_punishments', 0), + active_punishments=punishment_stats.get('active_punishments', 0), nodes_online=nodes_online, nodes_total=len(connected_nodes), agents_online=nodes_online, # Agents = connected nodes with stats agents_total=len(connected_nodes), - panel_connected=data.get("panel_loaded", False), + panel_connected=data.get('panel_loaded', False), uptime_seconds=uptime_seconds, ) # === Users === -@router.get("/users", response_model=BanUsersListResponse) + +@router.get('/users', response_model=BanUsersListResponse) async def get_users( offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - status: Optional[str] = Query(None, description="Filter: over_limit, with_limit, unlimited"), + status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'), admin: User = Depends(get_current_admin_user), ) -> BanUsersListResponse: """Get list of users from Ban System.""" api = _get_ban_api() - data = await _api_request(api, "get_users", offset=offset, limit=limit, status=status) + data = await _api_request(api, 'get_users', offset=offset, limit=limit, status=status) users = [] - for user_data in data.get("users", []): - users.append(BanUserListItem( - email=user_data.get("email", ""), - unique_ip_count=user_data.get("unique_ip_count", 0), - total_requests=user_data.get("total_requests", 0), - limit=user_data.get("limit"), - is_over_limit=user_data.get("is_over_limit", False), - blocked_count=user_data.get("blocked_count", 0), - )) + for user_data in data.get('users', []): + users.append( + BanUserListItem( + email=user_data.get('email', ''), + unique_ip_count=user_data.get('unique_ip_count', 0), + total_requests=user_data.get('total_requests', 0), + limit=user_data.get('limit'), + is_over_limit=user_data.get('is_over_limit', False), + blocked_count=user_data.get('blocked_count', 0), + ) + ) return BanUsersListResponse( users=users, - total=data.get("total", len(users)), + total=data.get('total', len(users)), offset=offset, limit=limit, ) -@router.get("/users/over-limit", response_model=BanUsersListResponse) +@router.get('/users/over-limit', response_model=BanUsersListResponse) async def get_users_over_limit( limit: int = Query(50, ge=1, le=100), admin: User = Depends(get_current_admin_user), ) -> BanUsersListResponse: """Get users who exceeded their device limit.""" api = _get_ban_api() - data = await _api_request(api, "get_users_over_limit", limit=limit) + data = await _api_request(api, 'get_users_over_limit', limit=limit) users = [] - for user_data in data.get("users", []): - users.append(BanUserListItem( - email=user_data.get("email", ""), - unique_ip_count=user_data.get("unique_ip_count", 0), - total_requests=user_data.get("total_requests", 0), - limit=user_data.get("limit"), - is_over_limit=True, - blocked_count=user_data.get("blocked_count", 0), - )) + for user_data in data.get('users', []): + users.append( + BanUserListItem( + email=user_data.get('email', ''), + unique_ip_count=user_data.get('unique_ip_count', 0), + total_requests=user_data.get('total_requests', 0), + limit=user_data.get('limit'), + is_over_limit=True, + blocked_count=user_data.get('blocked_count', 0), + ) + ) return BanUsersListResponse( users=users, @@ -226,26 +236,28 @@ async def get_users_over_limit( ) -@router.get("/users/search/{query}") +@router.get('/users/search/{query}') async def search_users( query: str, admin: User = Depends(get_current_admin_user), ) -> BanUsersListResponse: """Search for users.""" api = _get_ban_api() - data = await _api_request(api, "search_users", query=query) + data = await _api_request(api, 'search_users', query=query) users = [] - users_data = data.get("users", []) if isinstance(data, dict) else data + users_data = data.get('users', []) if isinstance(data, dict) else data for user_data in users_data: - users.append(BanUserListItem( - email=user_data.get("email", ""), - unique_ip_count=user_data.get("unique_ip_count", 0), - total_requests=user_data.get("total_requests", 0), - limit=user_data.get("limit"), - is_over_limit=user_data.get("is_over_limit", False), - blocked_count=user_data.get("blocked_count", 0), - )) + users.append( + BanUserListItem( + email=user_data.get('email', ''), + unique_ip_count=user_data.get('unique_ip_count', 0), + total_requests=user_data.get('total_requests', 0), + limit=user_data.get('limit'), + is_over_limit=user_data.get('is_over_limit', False), + blocked_count=user_data.get('blocked_count', 0), + ) + ) return BanUsersListResponse( users=users, @@ -255,80 +267,87 @@ async def search_users( ) -@router.get("/users/{email}", response_model=BanUserDetailResponse) +@router.get('/users/{email}', response_model=BanUserDetailResponse) async def get_user_detail( email: str, admin: User = Depends(get_current_admin_user), ) -> BanUserDetailResponse: """Get detailed user information.""" api = _get_ban_api() - data = await _api_request(api, "get_user", email=email) + data = await _api_request(api, 'get_user', email=email) ips = [] - for ip_data in data.get("ips", {}).values() if isinstance(data.get("ips"), dict) else data.get("ips", []): - ips.append(BanUserIPInfo( - ip=ip_data.get("ip", ""), - first_seen=ip_data.get("first_seen"), - last_seen=ip_data.get("last_seen"), - node=ip_data.get("node"), - request_count=ip_data.get("request_count", 0), - country_code=ip_data.get("country_code"), - country_name=ip_data.get("country_name"), - city=ip_data.get("city"), - )) + for ip_data in data.get('ips', {}).values() if isinstance(data.get('ips'), dict) else data.get('ips', []): + ips.append( + BanUserIPInfo( + ip=ip_data.get('ip', ''), + first_seen=ip_data.get('first_seen'), + last_seen=ip_data.get('last_seen'), + node=ip_data.get('node'), + request_count=ip_data.get('request_count', 0), + country_code=ip_data.get('country_code'), + country_name=ip_data.get('country_name'), + city=ip_data.get('city'), + ) + ) recent_requests = [] - for req_data in data.get("recent_requests", []): - recent_requests.append(BanUserRequestLog( - timestamp=req_data.get("timestamp"), - source_ip=req_data.get("source_ip", ""), - destination=req_data.get("destination"), - dest_port=req_data.get("dest_port"), - protocol=req_data.get("protocol"), - action=req_data.get("action"), - node=req_data.get("node"), - )) + for req_data in data.get('recent_requests', []): + recent_requests.append( + BanUserRequestLog( + timestamp=req_data.get('timestamp'), + source_ip=req_data.get('source_ip', ''), + destination=req_data.get('destination'), + dest_port=req_data.get('dest_port'), + protocol=req_data.get('protocol'), + action=req_data.get('action'), + node=req_data.get('node'), + ) + ) return BanUserDetailResponse( - email=data.get("email", email), - unique_ip_count=data.get("unique_ip_count", 0), - total_requests=data.get("total_requests", 0), - limit=data.get("limit"), - is_over_limit=data.get("is_over_limit", False), - blocked_count=data.get("blocked_count", 0), + email=data.get('email', email), + unique_ip_count=data.get('unique_ip_count', 0), + total_requests=data.get('total_requests', 0), + limit=data.get('limit'), + is_over_limit=data.get('is_over_limit', False), + blocked_count=data.get('blocked_count', 0), ips=ips, recent_requests=recent_requests, - network_type=data.get("network_type"), + network_type=data.get('network_type'), ) # === Punishments === -@router.get("/punishments", response_model=BanPunishmentsListResponse) + +@router.get('/punishments', response_model=BanPunishmentsListResponse) async def get_punishments( admin: User = Depends(get_current_admin_user), ) -> BanPunishmentsListResponse: """Get list of active punishments (bans).""" api = _get_ban_api() - data = await _api_request(api, "get_punishments") + data = await _api_request(api, 'get_punishments') punishments = [] - punishments_data = data if isinstance(data, list) else data.get("punishments", []) + punishments_data = data if isinstance(data, list) else data.get('punishments', []) for p in punishments_data: - punishments.append(BanPunishmentItem( - id=p.get("id"), - user_id=p.get("user_id", ""), - uuid=p.get("uuid"), - username=p.get("username", ""), - reason=p.get("reason"), - punished_at=p.get("punished_at"), - enable_at=p.get("enable_at"), - ip_count=p.get("ip_count", 0), - limit=p.get("limit", 0), - enabled=p.get("enabled", False), - enabled_at=p.get("enabled_at"), - node_name=p.get("node_name"), - )) + punishments.append( + BanPunishmentItem( + id=p.get('id'), + user_id=p.get('user_id', ''), + uuid=p.get('uuid'), + username=p.get('username', ''), + reason=p.get('reason'), + punished_at=p.get('punished_at'), + enable_at=p.get('enable_at'), + ip_count=p.get('ip_count', 0), + limit=p.get('limit', 0), + enabled=p.get('enabled', False), + enabled_at=p.get('enabled_at'), + node_name=p.get('node_name'), + ) + ) return BanPunishmentsListResponse( punishments=punishments, @@ -336,7 +355,7 @@ async def get_punishments( ) -@router.post("/punishments/{user_id}/unban", response_model=UnbanResponse) +@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse) async def unban_user( user_id: str, admin: User = Depends(get_current_admin_user), @@ -344,16 +363,16 @@ async def unban_user( """Unban (enable) a user.""" api = _get_ban_api() try: - await _api_request(api, "enable_user", user_id=user_id) - logger.info(f"Admin {admin.id} unbanned user {user_id} in Ban System") - return UnbanResponse(success=True, message="User unbanned successfully") + await _api_request(api, 'enable_user', user_id=user_id) + logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System') + return UnbanResponse(success=True, message='User unbanned successfully') except HTTPException: raise except Exception as e: return UnbanResponse(success=False, message=str(e)) -@router.post("/ban", response_model=UnbanResponse) +@router.post('/ban', response_model=UnbanResponse) async def ban_user( request: BanUserRequest, admin: User = Depends(get_current_admin_user), @@ -363,20 +382,20 @@ async def ban_user( try: await _api_request( api, - "ban_user", + 'ban_user', username=request.username, minutes=request.minutes, reason=request.reason, ) - logger.info(f"Admin {admin.id} banned user {request.username}: {request.reason}") - return UnbanResponse(success=True, message="User banned successfully") + logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}') + return UnbanResponse(success=True, message='User banned successfully') except HTTPException: raise except Exception as e: return UnbanResponse(success=False, message=str(e)) -@router.get("/history/{query}", response_model=BanHistoryResponse) +@router.get('/history/{query}', response_model=BanHistoryResponse) async def get_punishment_history( query: str, limit: int = Query(20, ge=1, le=100), @@ -384,25 +403,27 @@ async def get_punishment_history( ) -> BanHistoryResponse: """Get punishment history for a user.""" api = _get_ban_api() - data = await _api_request(api, "get_punishment_history", query=query, limit=limit) + data = await _api_request(api, 'get_punishment_history', query=query, limit=limit) items = [] - history_data = data if isinstance(data, list) else data.get("items", []) + history_data = data if isinstance(data, list) else data.get('items', []) for p in history_data: - items.append(BanPunishmentItem( - id=p.get("id"), - user_id=p.get("user_id", ""), - uuid=p.get("uuid"), - username=p.get("username", ""), - reason=p.get("reason"), - punished_at=p.get("punished_at"), - enable_at=p.get("enable_at"), - ip_count=p.get("ip_count", 0), - limit=p.get("limit", 0), - enabled=p.get("enabled", False), - enabled_at=p.get("enabled_at"), - node_name=p.get("node_name"), - )) + items.append( + BanPunishmentItem( + id=p.get('id'), + user_id=p.get('user_id', ''), + uuid=p.get('uuid'), + username=p.get('username', ''), + reason=p.get('reason'), + punished_at=p.get('punished_at'), + enable_at=p.get('enable_at'), + ip_count=p.get('ip_count', 0), + limit=p.get('limit', 0), + enabled=p.get('enabled', False), + enabled_at=p.get('enabled_at'), + node_name=p.get('node_name'), + ) + ) return BanHistoryResponse( items=items, @@ -412,32 +433,35 @@ async def get_punishment_history( # === Nodes === -@router.get("/nodes", response_model=BanNodesListResponse) + +@router.get('/nodes', response_model=BanNodesListResponse) async def get_nodes( admin: User = Depends(get_current_admin_user), ) -> BanNodesListResponse: """Get list of connected nodes.""" api = _get_ban_api() - data = await _api_request(api, "get_nodes") + data = await _api_request(api, 'get_nodes') nodes = [] - nodes_data = data if isinstance(data, list) else data.get("nodes", []) + nodes_data = data if isinstance(data, list) else data.get('nodes', []) online_count = 0 for n in nodes_data: # API returns is_online, not is_connected - is_connected = n.get("is_online", n.get("is_connected", False)) + is_connected = n.get('is_online', n.get('is_connected', False)) if is_connected: online_count += 1 - nodes.append(BanNodeItem( - name=n.get("name", ""), - address=n.get("address"), - is_connected=is_connected, - # API returns last_heartbeat, not last_seen - last_seen=n.get("last_heartbeat", n.get("last_seen")), - # API returns unique_users, not users_count - users_count=n.get("unique_users", n.get("users_count", 0)), - agent_stats=n.get("agent_stats"), - )) + nodes.append( + BanNodeItem( + name=n.get('name', ''), + address=n.get('address'), + is_connected=is_connected, + # API returns last_heartbeat, not last_seen + last_seen=n.get('last_heartbeat', n.get('last_seen')), + # API returns unique_users, not users_count + users_count=n.get('unique_users', n.get('users_count', 0)), + agent_stats=n.get('agent_stats'), + ) + ) return BanNodesListResponse( nodes=nodes, @@ -448,94 +472,99 @@ async def get_nodes( # === Agents === -@router.get("/agents", response_model=BanAgentsListResponse) + +@router.get('/agents', response_model=BanAgentsListResponse) async def get_agents( - search: Optional[str] = Query(None), - health: Optional[str] = Query(None, description="healthy, warning, critical"), - agent_status: Optional[str] = Query(None, alias="status", description="online, offline"), + search: str | None = Query(None), + health: str | None = Query(None, description='healthy, warning, critical'), + agent_status: str | None = Query(None, alias='status', description='online, offline'), admin: User = Depends(get_current_admin_user), ) -> BanAgentsListResponse: """Get list of monitoring agents.""" api = _get_ban_api() data = await _api_request( api, - "get_agents", + 'get_agents', search=search, health=health, status=agent_status, ) agents = [] - agents_data = data.get("agents", {}) if isinstance(data, dict) else data + agents_data = data.get('agents', {}) if isinstance(data, dict) else data online_count = 0 # API returns agents as dict: {"node_name": {stats...}, ...} if isinstance(agents_data, dict): for node_name, agent_info in agents_data.items(): # Extract metrics from nested structure - stats = agent_info.get("stats", {}) or {} - metrics = stats.get("metrics", {}) or {} - sent_info = metrics.get("sent", {}) or {} - queue_info = metrics.get("queue", {}) or {} - conn_info = metrics.get("connection", {}) or {} + stats = agent_info.get('stats', {}) or {} + metrics = stats.get('metrics', {}) or {} + sent_info = metrics.get('sent', {}) or {} + queue_info = metrics.get('queue', {}) or {} + conn_info = metrics.get('connection', {}) or {} - is_online = agent_info.get("is_online", False) + is_online = agent_info.get('is_online', False) if is_online: online_count += 1 - agents.append(BanAgentItem( - node_name=node_name, - sent_total=sent_info.get("total", 0), - dropped_total=sent_info.get("dropped", 0), - batches_total=sent_info.get("batches", 0), - reconnects=conn_info.get("reconnects", 0), - failures=conn_info.get("failures", sent_info.get("failed", 0)), - queue_size=queue_info.get("current", 0), - queue_max=queue_info.get("high_watermark", 0), - dedup_checked=0, - dedup_skipped=0, - filter_checked=0, - filter_filtered=0, - health=agent_info.get("health", "unknown"), - is_online=is_online, - last_report=agent_info.get("updated_at"), - )) + agents.append( + BanAgentItem( + node_name=node_name, + sent_total=sent_info.get('total', 0), + dropped_total=sent_info.get('dropped', 0), + batches_total=sent_info.get('batches', 0), + reconnects=conn_info.get('reconnects', 0), + failures=conn_info.get('failures', sent_info.get('failed', 0)), + queue_size=queue_info.get('current', 0), + queue_max=queue_info.get('high_watermark', 0), + dedup_checked=0, + dedup_skipped=0, + filter_checked=0, + filter_filtered=0, + health=agent_info.get('health', 'unknown'), + is_online=is_online, + last_report=agent_info.get('updated_at'), + ) + ) else: # Fallback for list format for a in agents_data: - is_online = a.get("is_online", False) + is_online = a.get('is_online', False) if is_online: online_count += 1 - agents.append(BanAgentItem( - node_name=a.get("node_name", ""), - sent_total=a.get("sent_total", 0), - dropped_total=a.get("dropped_total", 0), - batches_total=a.get("batches_total", 0), - reconnects=a.get("reconnects", 0), - failures=a.get("failures", 0), - queue_size=a.get("queue_size", 0), - queue_max=a.get("queue_max", 0), - dedup_checked=a.get("dedup_checked", 0), - dedup_skipped=a.get("dedup_skipped", 0), - filter_checked=a.get("filter_checked", 0), - filter_filtered=a.get("filter_filtered", 0), - health=a.get("health", "unknown"), - is_online=is_online, - last_report=a.get("last_report"), - )) + agents.append( + BanAgentItem( + node_name=a.get('node_name', ''), + sent_total=a.get('sent_total', 0), + dropped_total=a.get('dropped_total', 0), + batches_total=a.get('batches_total', 0), + reconnects=a.get('reconnects', 0), + failures=a.get('failures', 0), + queue_size=a.get('queue_size', 0), + queue_max=a.get('queue_max', 0), + dedup_checked=a.get('dedup_checked', 0), + dedup_skipped=a.get('dedup_skipped', 0), + filter_checked=a.get('filter_checked', 0), + filter_filtered=a.get('filter_filtered', 0), + health=a.get('health', 'unknown'), + is_online=is_online, + last_report=a.get('last_report'), + ) + ) summary = None - if isinstance(data, dict) and "summary" in data: - s = data["summary"] + if isinstance(data, dict) and 'summary' in data: + s = data['summary'] summary = BanAgentsSummary( - total_agents=s.get("total_agents", len(agents)), - online_agents=s.get("online_agents", online_count), - total_sent=s.get("total_sent", 0), - total_dropped=s.get("total_dropped", 0), - avg_queue_size=s.get("avg_queue_size", 0.0), - healthy_count=s.get("healthy_count", 0), - warning_count=s.get("warning_count", 0), - critical_count=s.get("critical_count", 0), + total_agents=s.get('total_agents', len(agents)), + online_agents=s.get('online_agents', online_count), + total_sent=s.get('total_sent', 0), + total_dropped=s.get('total_dropped', 0), + avg_queue_size=s.get('avg_queue_size', 0.0), + healthy_count=s.get('healthy_count', 0), + warning_count=s.get('warning_count', 0), + critical_count=s.get('critical_count', 0), ) return BanAgentsListResponse( @@ -546,51 +575,54 @@ async def get_agents( ) -@router.get("/agents/summary", response_model=BanAgentsSummary) +@router.get('/agents/summary', response_model=BanAgentsSummary) async def get_agents_summary( admin: User = Depends(get_current_admin_user), ) -> BanAgentsSummary: """Get agents summary statistics.""" api = _get_ban_api() - data = await _api_request(api, "get_agents_summary") + data = await _api_request(api, 'get_agents_summary') return BanAgentsSummary( - total_agents=data.get("total_agents", 0), - online_agents=data.get("online_agents", 0), - total_sent=data.get("total_sent", 0), - total_dropped=data.get("total_dropped", 0), - avg_queue_size=data.get("avg_queue_size", 0.0), - healthy_count=data.get("healthy_count", 0), - warning_count=data.get("warning_count", 0), - critical_count=data.get("critical_count", 0), + total_agents=data.get('total_agents', 0), + online_agents=data.get('online_agents', 0), + total_sent=data.get('total_sent', 0), + total_dropped=data.get('total_dropped', 0), + avg_queue_size=data.get('avg_queue_size', 0.0), + healthy_count=data.get('healthy_count', 0), + warning_count=data.get('warning_count', 0), + critical_count=data.get('critical_count', 0), ) # === Traffic Violations === -@router.get("/traffic/violations", response_model=BanTrafficViolationsResponse) + +@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse) async def get_traffic_violations( limit: int = Query(50, ge=1, le=100), admin: User = Depends(get_current_admin_user), ) -> BanTrafficViolationsResponse: """Get list of traffic limit violations.""" api = _get_ban_api() - data = await _api_request(api, "get_traffic_violations", limit=limit) + data = await _api_request(api, 'get_traffic_violations', limit=limit) violations = [] - violations_data = data if isinstance(data, list) else data.get("violations", []) + violations_data = data if isinstance(data, list) else data.get('violations', []) for v in violations_data: - violations.append(BanTrafficViolationItem( - id=v.get("id"), - username=v.get("username", ""), - email=v.get("email"), - violation_type=v.get("violation_type", v.get("type", "")), - description=v.get("description"), - bytes_used=v.get("bytes_used", 0), - bytes_limit=v.get("bytes_limit", 0), - detected_at=v.get("detected_at"), - resolved=v.get("resolved", False), - )) + violations.append( + BanTrafficViolationItem( + id=v.get('id'), + username=v.get('username', ''), + email=v.get('email'), + violation_type=v.get('violation_type', v.get('type', '')), + description=v.get('description'), + bytes_used=v.get('bytes_used', 0), + bytes_limit=v.get('bytes_limit', 0), + detected_at=v.get('detected_at'), + resolved=v.get('resolved', False), + ) + ) return BanTrafficViolationsResponse( violations=violations, @@ -600,173 +632,184 @@ async def get_traffic_violations( # === Full Traffic Stats === -@router.get("/traffic", response_model=BanTrafficResponse) + +@router.get('/traffic', response_model=BanTrafficResponse) async def get_traffic( admin: User = Depends(get_current_admin_user), ) -> BanTrafficResponse: """Get full traffic statistics including top users.""" api = _get_ban_api() - data = await _api_request(api, "get_traffic") + data = await _api_request(api, 'get_traffic') top_users = [] - for u in data.get("top_users", []): - top_users.append(BanTrafficTopItem( - username=u.get("username", ""), - bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), - bytes_limit=u.get("bytes_limit"), - over_limit=u.get("over_limit", False), - )) + for u in data.get('top_users', []): + top_users.append( + BanTrafficTopItem( + username=u.get('username', ''), + bytes_total=u.get('bytes_total', u.get('total_bytes', 0)), + bytes_limit=u.get('bytes_limit'), + over_limit=u.get('over_limit', False), + ) + ) violations = [] - for v in data.get("recent_violations", []): - violations.append(BanTrafficViolationItem( - id=v.get("id"), - username=v.get("username", ""), - email=v.get("email"), - violation_type=v.get("violation_type", v.get("type", "")), - description=v.get("description"), - bytes_used=v.get("bytes_used", 0), - bytes_limit=v.get("bytes_limit", 0), - detected_at=v.get("detected_at"), - resolved=v.get("resolved", False), - )) + for v in data.get('recent_violations', []): + violations.append( + BanTrafficViolationItem( + id=v.get('id'), + username=v.get('username', ''), + email=v.get('email'), + violation_type=v.get('violation_type', v.get('type', '')), + description=v.get('description'), + bytes_used=v.get('bytes_used', 0), + bytes_limit=v.get('bytes_limit', 0), + detected_at=v.get('detected_at'), + resolved=v.get('resolved', False), + ) + ) return BanTrafficResponse( - enabled=data.get("enabled", False), - stats=data.get("stats"), + enabled=data.get('enabled', False), + stats=data.get('stats'), top_users=top_users, recent_violations=violations, ) -@router.get("/traffic/top") +@router.get('/traffic/top') async def get_traffic_top( limit: int = Query(20, ge=1, le=100), admin: User = Depends(get_current_admin_user), -) -> List[BanTrafficTopItem]: +) -> list[BanTrafficTopItem]: """Get top users by traffic.""" api = _get_ban_api() - data = await _api_request(api, "get_traffic_top", limit=limit) + data = await _api_request(api, 'get_traffic_top', limit=limit) top_users = [] - users_data = data if isinstance(data, list) else data.get("users", []) + users_data = data if isinstance(data, list) else data.get('users', []) for u in users_data: - top_users.append(BanTrafficTopItem( - username=u.get("username", ""), - bytes_total=u.get("bytes_total", u.get("total_bytes", 0)), - bytes_limit=u.get("bytes_limit"), - over_limit=u.get("over_limit", False), - )) + top_users.append( + BanTrafficTopItem( + username=u.get('username', ''), + bytes_total=u.get('bytes_total', u.get('total_bytes', 0)), + bytes_limit=u.get('bytes_limit'), + over_limit=u.get('over_limit', False), + ) + ) return top_users # === Settings === -def _parse_setting_response(key: str, data: Any, default_type: str = "str") -> BanSettingDefinition: + +def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> BanSettingDefinition: """Parse setting response from API.""" - if isinstance(data, dict) and "value" in data: + if isinstance(data, dict) and 'value' in data: return BanSettingDefinition( key=key, - value=data.get("value"), - type=data.get("type", default_type), - min_value=data.get("min"), - max_value=data.get("max"), - editable=data.get("editable", True), - description=data.get("description"), - category=data.get("category"), + value=data.get('value'), + type=data.get('type', default_type), + min_value=data.get('min'), + max_value=data.get('max'), + editable=data.get('editable', True), + description=data.get('description'), + category=data.get('category'), ) - else: - # Простое значение или dict без "value" - value = data.get("value", data) if isinstance(data, dict) else data - value_type = default_type - if isinstance(value, bool): - value_type = "bool" - elif isinstance(value, int): - value_type = "int" - elif isinstance(value, float): - value_type = "float" - elif isinstance(value, list): - value_type = "list" + # Простое значение или dict без "value" + value = data.get('value', data) if isinstance(data, dict) else data + value_type = default_type + if isinstance(value, bool): + value_type = 'bool' + elif isinstance(value, int): + value_type = 'int' + elif isinstance(value, float): + value_type = 'float' + elif isinstance(value, list): + value_type = 'list' - return BanSettingDefinition( - key=key, - value=value, - type=value_type, - min_value=None, - max_value=None, - editable=True, - description=None, - category=None, - ) + return BanSettingDefinition( + key=key, + value=value, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + ) -@router.get("/settings", response_model=BanSettingsResponse) +@router.get('/settings', response_model=BanSettingsResponse) async def get_settings( admin: User = Depends(get_current_admin_user), ) -> BanSettingsResponse: """Get all Ban System settings.""" api = _get_ban_api() - data = await _api_request(api, "get_settings") + data = await _api_request(api, 'get_settings') settings_list = [] - settings_data = data.get("settings", {}) if isinstance(data, dict) else {} + settings_data = data.get('settings', {}) if isinstance(data, dict) else {} for key, info in settings_data.items(): # API может возвращать настройки в двух форматах: # 1. {"key": {"value": ..., "type": ...}} - с метаданными # 2. {"key": value} - просто значение - if isinstance(info, dict) and "value" in info: + if isinstance(info, dict) and 'value' in info: # Формат с метаданными - settings_list.append(BanSettingDefinition( - key=key, - value=info.get("value"), - type=info.get("type", "str"), - min_value=info.get("min"), - max_value=info.get("max"), - editable=info.get("editable", True), - description=info.get("description"), - category=info.get("category"), - )) + settings_list.append( + BanSettingDefinition( + key=key, + value=info.get('value'), + type=info.get('type', 'str'), + min_value=info.get('min'), + max_value=info.get('max'), + editable=info.get('editable', True), + description=info.get('description'), + category=info.get('category'), + ) + ) else: # Простой формат - определяем тип по значению - value_type = "str" + value_type = 'str' if isinstance(info, bool): - value_type = "bool" + value_type = 'bool' elif isinstance(info, int): - value_type = "int" + value_type = 'int' elif isinstance(info, float): - value_type = "float" + value_type = 'float' elif isinstance(info, list): - value_type = "list" + value_type = 'list' - settings_list.append(BanSettingDefinition( - key=key, - value=info, - type=value_type, - min_value=None, - max_value=None, - editable=True, - description=None, - category=None, - )) + settings_list.append( + BanSettingDefinition( + key=key, + value=info, + type=value_type, + min_value=None, + max_value=None, + editable=True, + description=None, + category=None, + ) + ) return BanSettingsResponse(settings=settings_list) -@router.get("/settings/{key}") +@router.get('/settings/{key}') async def get_setting( key: str, admin: User = Depends(get_current_admin_user), ) -> BanSettingDefinition: """Get a specific setting.""" api = _get_ban_api() - data = await _api_request(api, "get_setting", key=key) + data = await _api_request(api, 'get_setting', key=key) return _parse_setting_response(key, data) -@router.post("/settings/{key}") +@router.post('/settings/{key}') async def set_setting( key: str, value: str = Query(...), @@ -774,30 +817,31 @@ async def set_setting( ) -> BanSettingDefinition: """Set a setting value.""" api = _get_ban_api() - data = await _api_request(api, "set_setting", key=key, value=value) + data = await _api_request(api, 'set_setting', key=key, value=value) - logger.info(f"Admin {admin.id} changed Ban System setting {key} to {value}") + logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}') return _parse_setting_response(key, data) -@router.post("/settings/{key}/toggle") +@router.post('/settings/{key}/toggle') async def toggle_setting( key: str, admin: User = Depends(get_current_admin_user), ) -> BanSettingDefinition: """Toggle a boolean setting.""" api = _get_ban_api() - data = await _api_request(api, "toggle_setting", key=key) + data = await _api_request(api, 'toggle_setting', key=key) - logger.info(f"Admin {admin.id} toggled Ban System setting {key}") + logger.info(f'Admin {admin.id} toggled Ban System setting {key}') - return _parse_setting_response(key, data, default_type="bool") + return _parse_setting_response(key, data, default_type='bool') # === Whitelist === -@router.post("/settings/whitelist/add", response_model=UnbanResponse) + +@router.post('/settings/whitelist/add', response_model=UnbanResponse) async def whitelist_add( request: BanWhitelistRequest, admin: User = Depends(get_current_admin_user), @@ -805,16 +849,16 @@ async def whitelist_add( """Add user to whitelist.""" api = _get_ban_api() try: - await _api_request(api, "whitelist_add", username=request.username) - logger.info(f"Admin {admin.id} added {request.username} to Ban System whitelist") - return UnbanResponse(success=True, message=f"User {request.username} added to whitelist") + await _api_request(api, 'whitelist_add', username=request.username) + logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist') + return UnbanResponse(success=True, message=f'User {request.username} added to whitelist') except HTTPException: raise except Exception as e: return UnbanResponse(success=False, message=str(e)) -@router.post("/settings/whitelist/remove", response_model=UnbanResponse) +@router.post('/settings/whitelist/remove', response_model=UnbanResponse) async def whitelist_remove( request: BanWhitelistRequest, admin: User = Depends(get_current_admin_user), @@ -822,9 +866,9 @@ async def whitelist_remove( """Remove user from whitelist.""" api = _get_ban_api() try: - await _api_request(api, "whitelist_remove", username=request.username) - logger.info(f"Admin {admin.id} removed {request.username} from Ban System whitelist") - return UnbanResponse(success=True, message=f"User {request.username} removed from whitelist") + await _api_request(api, 'whitelist_remove', username=request.username) + logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist') + return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist') except HTTPException: raise except Exception as e: @@ -833,27 +877,30 @@ async def whitelist_remove( # === Reports === -@router.get("/report", response_model=BanReportResponse) + +@router.get('/report', response_model=BanReportResponse) async def get_report( hours: int = Query(24, ge=1, le=168), admin: User = Depends(get_current_admin_user), ) -> BanReportResponse: """Get period report.""" api = _get_ban_api() - data = await _api_request(api, "get_stats_period", hours=hours) + data = await _api_request(api, 'get_stats_period', hours=hours) top_violators = [] - punishment_stats = data.get("punishment_stats", {}) or {} - for v in punishment_stats.get("top_violators", []): - top_violators.append(BanReportTopViolator( - username=v.get("username", ""), - count=v.get("count", 0), - )) + punishment_stats = data.get('punishment_stats', {}) or {} + for v in punishment_stats.get('top_violators', []): + top_violators.append( + BanReportTopViolator( + username=v.get('username', ''), + count=v.get('count', 0), + ) + ) return BanReportResponse( period_hours=hours, - current_users=data.get("current_users", 0), - current_ips=data.get("current_ips", 0), + current_users=data.get('current_users', 0), + current_ips=data.get('current_ips', 0), punishment_stats=punishment_stats, top_violators=top_violators, ) @@ -861,54 +908,60 @@ async def get_report( # === Health === -@router.get("/health", response_model=BanHealthResponse) + +@router.get('/health', response_model=BanHealthResponse) async def get_health( admin: User = Depends(get_current_admin_user), ) -> BanHealthResponse: """Get Ban System health status.""" api = _get_ban_api() - data = await _api_request(api, "health_check") + data = await _api_request(api, 'health_check') components = [] - for name, info in data.get("components", {}).items(): + for name, info in data.get('components', {}).items(): if isinstance(info, dict): - components.append(BanHealthComponent( - name=name, - status=info.get("status", "unknown"), - message=info.get("message"), - details=info.get("details"), - )) + components.append( + BanHealthComponent( + name=name, + status=info.get('status', 'unknown'), + message=info.get('message'), + details=info.get('details'), + ) + ) else: - components.append(BanHealthComponent( - name=name, - status=str(info) if info else "unknown", - )) + components.append( + BanHealthComponent( + name=name, + status=str(info) if info else 'unknown', + ) + ) return BanHealthResponse( - status=data.get("status", "unknown"), - uptime=data.get("uptime"), + status=data.get('status', 'unknown'), + uptime=data.get('uptime'), components=components, ) -@router.get("/health/detailed", response_model=BanHealthDetailedResponse) +@router.get('/health/detailed', response_model=BanHealthDetailedResponse) async def get_health_detailed( admin: User = Depends(get_current_admin_user), ) -> BanHealthDetailedResponse: """Get detailed health information.""" api = _get_ban_api() - data = await _api_request(api, "health_detailed") + data = await _api_request(api, 'health_detailed') return BanHealthDetailedResponse( - status=data.get("status", "unknown"), - uptime=data.get("uptime"), - components=data.get("components", {}), + status=data.get('status', 'unknown'), + uptime=data.get('uptime'), + components=data.get('components', {}), ) # === Agent History === -@router.get("/agents/{node_name}/history", response_model=BanAgentHistoryResponse) + +@router.get('/agents/{node_name}/history', response_model=BanAgentHistoryResponse) async def get_agent_history( node_name: str, hours: int = Query(24, ge=1, le=168), @@ -916,32 +969,35 @@ async def get_agent_history( ) -> BanAgentHistoryResponse: """Get agent statistics history.""" api = _get_ban_api() - data = await _api_request(api, "get_agent_history", node_name=node_name, hours=hours) + data = await _api_request(api, 'get_agent_history', node_name=node_name, hours=hours) history = [] - for item in data.get("history", []): - history.append(BanAgentHistoryItem( - timestamp=item.get("timestamp"), - sent_total=item.get("sent_total", 0), - dropped_total=item.get("dropped_total", 0), - queue_size=item.get("queue_size", 0), - batches_total=item.get("batches_total", 0), - )) + for item in data.get('history', []): + history.append( + BanAgentHistoryItem( + timestamp=item.get('timestamp'), + sent_total=item.get('sent_total', 0), + dropped_total=item.get('dropped_total', 0), + queue_size=item.get('queue_size', 0), + batches_total=item.get('batches_total', 0), + ) + ) return BanAgentHistoryResponse( - node=data.get("node", node_name), - hours=data.get("hours", hours), - records=data.get("records", len(history)), - delta=data.get("delta"), - first=data.get("first"), - last=data.get("last"), + node=data.get('node', node_name), + hours=data.get('hours', hours), + records=data.get('records', len(history)), + delta=data.get('delta'), + first=data.get('first'), + last=data.get('last'), history=history, ) # === User Punishment History === -@router.get("/users/{email}/history", response_model=BanHistoryResponse) + +@router.get('/users/{email}/history', response_model=BanHistoryResponse) async def get_user_punishment_history( email: str, limit: int = Query(20, ge=1, le=100), @@ -949,25 +1005,27 @@ async def get_user_punishment_history( ) -> BanHistoryResponse: """Get punishment history for a specific user.""" api = _get_ban_api() - data = await _api_request(api, "get_punishment_history", query=email, limit=limit) + data = await _api_request(api, 'get_punishment_history', query=email, limit=limit) items = [] - history_data = data if isinstance(data, list) else data.get("items", []) + history_data = data if isinstance(data, list) else data.get('items', []) for p in history_data: - items.append(BanPunishmentItem( - id=p.get("id"), - user_id=p.get("user_id", ""), - uuid=p.get("uuid"), - username=p.get("username", ""), - reason=p.get("reason"), - punished_at=p.get("punished_at"), - enable_at=p.get("enable_at"), - ip_count=p.get("ip_count", 0), - limit=p.get("limit", 0), - enabled=p.get("enabled", False), - enabled_at=p.get("enabled_at"), - node_name=p.get("node_name"), - )) + items.append( + BanPunishmentItem( + id=p.get('id'), + user_id=p.get('user_id', ''), + uuid=p.get('uuid'), + username=p.get('username', ''), + reason=p.get('reason'), + punished_at=p.get('punished_at'), + enable_at=p.get('enable_at'), + ip_count=p.get('ip_count', 0), + limit=p.get('limit', 0), + enabled=p.get('enabled', False), + enabled_at=p.get('enabled_at'), + node_name=p.get('node_name'), + ) + ) return BanHistoryResponse( items=items, diff --git a/app/cabinet/routes/admin_broadcasts.py b/app/cabinet/routes/admin_broadcasts.py index 90f74181..b5d67157 100644 --- a/app/cabinet/routes/admin_broadcasts.py +++ b/app/cabinet/routes/admin_broadcasts.py @@ -2,20 +2,19 @@ import logging from datetime import datetime -from typing import List, Optional 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, Tariff, User, Subscription, SubscriptionStatus +from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User +from app.handlers.admin.messages import get_target_users_count from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS from app.services.broadcast_service import ( BroadcastConfig, BroadcastMediaConfig, broadcast_service, ) -from app.handlers.admin.messages import get_target_users_count from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.broadcasts import ( @@ -33,62 +32,64 @@ from ..schemas.broadcasts import ( TariffForBroadcast, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/broadcasts", tags=["Cabinet Admin Broadcasts"]) +router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts']) # ============ Filter Labels ============ FILTER_LABELS = { - "all": "Все пользователи", - "active": "Активные подписки", - "trial": "Триальные", - "no": "Без подписки", - "expiring": "Истекают (3 дня)", - "expired": "Истекшие", - "zero": "Нулевой трафик", - "active_zero": "Активные с нулевым трафиком", - "trial_zero": "Триальные с нулевым трафиком", + 'all': 'Все пользователи', + 'active': 'Активные подписки', + 'trial': 'Триальные', + 'no': 'Без подписки', + 'expiring': 'Истекают (3 дня)', + 'expired': 'Истекшие', + 'zero': 'Нулевой трафик', + 'active_zero': 'Активные с нулевым трафиком', + 'trial_zero': 'Триальные с нулевым трафиком', } FILTER_GROUPS = { - "all": "basic", - "active": "subscription", - "trial": "subscription", - "no": "subscription", - "expiring": "subscription", - "expired": "subscription", - "zero": "traffic", - "active_zero": "traffic", - "trial_zero": "traffic", + 'all': 'basic', + 'active': 'subscription', + 'trial': 'subscription', + 'no': 'subscription', + 'expiring': 'subscription', + 'expired': 'subscription', + 'zero': 'traffic', + 'active_zero': 'traffic', + 'trial_zero': 'traffic', } CUSTOM_FILTER_LABELS = { - "custom_today": "Регистрация сегодня", - "custom_week": "Регистрация за неделю", - "custom_month": "Регистрация за месяц", - "custom_active_today": "Активны сегодня", - "custom_inactive_week": "Неактивны 7+ дней", - "custom_inactive_month": "Неактивны 30+ дней", - "custom_referrals": "Пришли по рефералу", - "custom_direct": "Прямая регистрация", + 'custom_today': 'Регистрация сегодня', + 'custom_week': 'Регистрация за неделю', + 'custom_month': 'Регистрация за месяц', + 'custom_active_today': 'Активны сегодня', + 'custom_inactive_week': 'Неактивны 7+ дней', + 'custom_inactive_month': 'Неактивны 30+ дней', + 'custom_referrals': 'Пришли по рефералу', + 'custom_direct': 'Прямая регистрация', } CUSTOM_FILTER_GROUPS = { - "custom_today": "registration", - "custom_week": "registration", - "custom_month": "registration", - "custom_active_today": "activity", - "custom_inactive_week": "activity", - "custom_inactive_month": "activity", - "custom_referrals": "source", - "custom_direct": "source", + 'custom_today': 'registration', + 'custom_week': 'registration', + 'custom_month': 'registration', + 'custom_active_today': 'activity', + 'custom_inactive_week': 'activity', + 'custom_inactive_month': 'activity', + 'custom_referrals': 'source', + 'custom_direct': 'source', } # ============ Helper Functions ============ + def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse: """Serialize broadcast to response model.""" progress = 0.0 @@ -118,13 +119,10 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse: async def _get_tariff_user_counts(db: AsyncSession) -> dict: """Get count of active users per tariff.""" result = await db.execute( - select( - Subscription.tariff_id, - func.count(func.distinct(Subscription.user_id)).label("count") - ) + select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count')) .join(User, User.id == Subscription.user_id) .where( - User.status == "active", + User.status == 'active', Subscription.status == SubscriptionStatus.ACTIVE.value, ) .group_by(Subscription.tariff_id) @@ -138,26 +136,24 @@ def _validate_target(target: str, tariff_ids: set) -> bool: return True if target in CUSTOM_FILTER_LABELS: return True - if target.startswith("tariff_"): + if target.startswith('tariff_'): try: - tariff_id = int(target.split("_")[1]) + tariff_id = int(target.split('_')[1]) return tariff_id in tariff_ids except (ValueError, IndexError): return False return False -def _validate_buttons(buttons: List[str]) -> bool: +def _validate_buttons(buttons: list[str]) -> bool: """Validate button keys.""" - for button in buttons: - if button not in BROADCAST_BUTTONS: - return False - return True + return all(button in BROADCAST_BUTTONS for button in buttons) # ============ Endpoints ============ -@router.get("/filters", response_model=BroadcastFiltersResponse) + +@router.get('/filters', response_model=BroadcastFiltersResponse) async def get_filters( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -169,14 +165,16 @@ async def get_filters( try: count = await get_target_users_count(db, key) except Exception as e: - logger.warning(f"Failed to get count for filter {key}: {e}") + logger.warning(f'Failed to get count for filter {key}: {e}') count = 0 - filters.append(BroadcastFilter( - key=key, - label=label, - count=count, - group=FILTER_GROUPS.get(key), - )) + filters.append( + BroadcastFilter( + key=key, + label=label, + count=count, + group=FILTER_GROUPS.get(key), + ) + ) # Custom filters custom_filters = [] @@ -184,30 +182,32 @@ async def get_filters( try: count = await get_target_users_count(db, key) except Exception as e: - logger.warning(f"Failed to get count for custom filter {key}: {e}") + logger.warning(f'Failed to get count for custom filter {key}: {e}') count = 0 - custom_filters.append(BroadcastFilter( - key=key, - label=label, - count=count, - group=CUSTOM_FILTER_GROUPS.get(key), - )) + custom_filters.append( + BroadcastFilter( + key=key, + label=label, + count=count, + group=CUSTOM_FILTER_GROUPS.get(key), + ) + ) # Tariff filters tariff_counts = await _get_tariff_user_counts(db) - result = await db.execute( - select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name) - ) + result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name)) tariffs = result.scalars().all() tariff_filters = [] for tariff in tariffs: - tariff_filters.append(TariffFilter( - key=f"tariff_{tariff.id}", - label=tariff.name, - tariff_id=tariff.id, - count=tariff_counts.get(tariff.id, 0), - )) + tariff_filters.append( + TariffFilter( + key=f'tariff_{tariff.id}', + label=tariff.name, + tariff_id=tariff.id, + count=tariff_counts.get(tariff.id, 0), + ) + ) return BroadcastFiltersResponse( filters=filters, @@ -216,16 +216,14 @@ async def get_filters( ) -@router.get("/tariffs", response_model=BroadcastTariffsResponse) +@router.get('/tariffs', response_model=BroadcastTariffsResponse) async def get_tariffs( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ) -> BroadcastTariffsResponse: """Get tariffs for broadcast filtering.""" tariff_counts = await _get_tariff_user_counts(db) - result = await db.execute( - select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name) - ) + result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name)) tariffs = result.scalars().all() return BroadcastTariffsResponse( @@ -233,7 +231,7 @@ async def get_tariffs( TariffForBroadcast( id=t.id, name=t.name, - filter_key=f"tariff_{t.id}", + filter_key=f'tariff_{t.id}', active_users_count=tariff_counts.get(t.id, 0), ) for t in tariffs @@ -241,7 +239,7 @@ async def get_tariffs( ) -@router.get("/buttons", response_model=BroadcastButtonsResponse) +@router.get('/buttons', response_model=BroadcastButtonsResponse) async def get_buttons( admin: User = Depends(get_current_admin_user), ) -> BroadcastButtonsResponse: @@ -249,15 +247,17 @@ async def get_buttons( default_buttons = set(DEFAULT_BROADCAST_BUTTONS) buttons = [] for key, config in BROADCAST_BUTTONS.items(): - buttons.append(BroadcastButton( - key=key, - label=config.get("default_text", key), - default=key in default_buttons, - )) + buttons.append( + BroadcastButton( + key=key, + label=config.get('default_text', key), + default=key in default_buttons, + ) + ) return BroadcastButtonsResponse(buttons=buttons) -@router.post("/preview", response_model=BroadcastPreviewResponse) +@router.post('/preview', response_model=BroadcastPreviewResponse) async def preview_broadcast( request: BroadcastPreviewRequest, admin: User = Depends(get_current_admin_user), @@ -271,22 +271,22 @@ async def preview_broadcast( if not _validate_target(request.target, tariff_ids): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid target: {request.target}", + detail=f'Invalid target: {request.target}', ) try: count = await get_target_users_count(db, request.target) except Exception as e: - logger.error(f"Failed to get count for target {request.target}: {e}") + logger.error(f'Failed to get count for target {request.target}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to count recipients", + detail='Failed to count recipients', ) return BroadcastPreviewResponse(target=request.target, count=count) -@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED) async def create_broadcast( request: BroadcastCreateRequest, admin: User = Depends(get_current_admin_user), @@ -300,21 +300,21 @@ async def create_broadcast( if not _validate_target(request.target, tariff_ids): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid target: {request.target}", + detail=f'Invalid target: {request.target}', ) # Validate buttons if not _validate_buttons(request.selected_buttons): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid button key", + detail='Invalid button key', ) message_text = request.message_text.strip() if not message_text: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Message text must not be empty", + detail='Message text must not be empty', ) media_payload = request.media @@ -330,9 +330,9 @@ async def create_broadcast( total_count=0, sent_count=0, failed_count=0, - status="queued", + status='queued', admin_id=admin.id, - admin_name=admin.username or f"Admin #{admin.id}", + admin_name=admin.username or f'Admin #{admin.id}', ) db.add(broadcast) await db.commit() @@ -353,7 +353,7 @@ async def create_broadcast( message_text=message_text, selected_buttons=request.selected_buttons, media=media_config, - initiator_name=admin.username or f"Admin #{admin.id}", + initiator_name=admin.username or f'Admin #{admin.id}', ) # Start broadcast @@ -365,7 +365,7 @@ async def create_broadcast( return _serialize_broadcast(broadcast) -@router.get("", response_model=BroadcastListResponse) +@router.get('', response_model=BroadcastListResponse) async def list_broadcasts( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -376,10 +376,7 @@ async def list_broadcasts( 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) + select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) ) broadcasts = result.scalars().all() @@ -391,7 +388,7 @@ async def list_broadcasts( ) -@router.get("/{broadcast_id}", response_model=BroadcastResponse) +@router.get('/{broadcast_id}', response_model=BroadcastResponse) async def get_broadcast( broadcast_id: int, admin: User = Depends(get_current_admin_user), @@ -402,12 +399,12 @@ async def get_broadcast( if not broadcast: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Broadcast not found", + detail='Broadcast not found', ) return _serialize_broadcast(broadcast) -@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse) +@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse) async def stop_broadcast( broadcast_id: int, admin: User = Depends(get_current_admin_user), @@ -418,26 +415,26 @@ async def stop_broadcast( if not broadcast: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Broadcast not found", + detail='Broadcast not found', ) - if broadcast.status not in {"queued", "in_progress", "cancelling"}: + if broadcast.status not in {'queued', 'in_progress', 'cancelling'}: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Broadcast is not running", + detail='Broadcast is not running', ) is_running = await broadcast_service.request_stop(broadcast_id) if is_running: - broadcast.status = "cancelling" + broadcast.status = 'cancelling' else: - broadcast.status = "cancelled" + broadcast.status = 'cancelled' broadcast.completed_at = datetime.utcnow() await db.commit() await db.refresh(broadcast) - logger.info(f"Admin {admin.id} stopped broadcast {broadcast_id}") + logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}') return _serialize_broadcast(broadcast) diff --git a/app/cabinet/routes/admin_campaigns.py b/app/cabinet/routes/admin_campaigns.py index a23ed66b..117a7606 100644 --- a/app/cabinet/routes/admin_campaigns.py +++ b/app/cabinet/routes/admin_campaigns.py @@ -1,20 +1,12 @@ """Admin routes for managing advertising campaigns in cabinet.""" import logging -from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func from app.config import settings -from app.database.models import ( - User, - AdvertisingCampaign, - AdvertisingCampaignRegistration, - Subscription, - Tariff, -) from app.database.crud.campaign import ( create_campaign, delete_campaign, @@ -28,38 +20,45 @@ from app.database.crud.campaign import ( ) from app.database.crud.server_squad import get_all_server_squads from app.database.crud.tariff import get_all_tariffs +from app.database.models import ( + AdvertisingCampaignRegistration, + Subscription, + Tariff, + User, +) from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.campaigns import ( - CampaignListResponse, - CampaignListItem, - CampaignDetailResponse, CampaignCreateRequest, - CampaignUpdateRequest, - CampaignToggleResponse, - CampaignStatisticsResponse, + CampaignDetailResponse, + CampaignListItem, + CampaignListResponse, CampaignRegistrationItem, CampaignRegistrationsResponse, CampaignsOverviewResponse, - TariffInfo, + CampaignStatisticsResponse, + CampaignToggleResponse, + CampaignUpdateRequest, ServerSquadInfo, + TariffInfo, ) from ..schemas.tariffs import TariffListItem + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/campaigns", tags=["Cabinet Admin Campaigns"]) +router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns']) def _get_deep_link(start_parameter: str) -> str: """Generate deep link for campaign.""" bot_username = settings.get_bot_username() if bot_username: - return f"https://t.me/{bot_username}?start={start_parameter}" - return f"?start={start_parameter}" + return f'https://t.me/{bot_username}?start={start_parameter}' + return f'?start={start_parameter}' -@router.get("/overview", response_model=CampaignsOverviewResponse) +@router.get('/overview', response_model=CampaignsOverviewResponse) async def get_overview( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -70,24 +69,24 @@ async def get_overview( # Count tariff bonuses tariff_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( - AdvertisingCampaignRegistration.bonus_type == "tariff" + AdvertisingCampaignRegistration.bonus_type == 'tariff' ) ) tariff_count = tariff_result.scalar() or 0 return CampaignsOverviewResponse( - total=overview["total"], - active=overview["active"], - inactive=overview["inactive"], - total_registrations=overview["registrations"], - total_balance_issued_kopeks=overview["balance_total"], - total_balance_issued_rubles=overview["balance_total"] / 100, - total_subscription_issued=overview["subscription_total"], + total=overview['total'], + active=overview['active'], + inactive=overview['inactive'], + total_registrations=overview['registrations'], + total_balance_issued_kopeks=overview['balance_total'], + total_balance_issued_rubles=overview['balance_total'] / 100, + total_subscription_issued=overview['subscription_total'], total_tariff_issued=tariff_count, ) -@router.get("/available-servers", response_model=List[ServerSquadInfo]) +@router.get('/available-servers', response_model=list[ServerSquadInfo]) async def get_available_servers( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -105,7 +104,7 @@ async def get_available_servers( ] -@router.get("/available-tariffs", response_model=List[TariffListItem]) +@router.get('/available-tariffs', response_model=list[TariffListItem]) async def get_available_tariffs( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -134,7 +133,7 @@ async def get_available_tariffs( ] -@router.get("", response_model=CampaignListResponse) +@router.get('', response_model=CampaignListResponse) async def list_campaigns( include_inactive: bool = True, offset: int = Query(0, ge=0), @@ -143,31 +142,31 @@ async def list_campaigns( db: AsyncSession = Depends(get_cabinet_db), ): """Get list of all campaigns.""" - campaigns = await get_campaigns_list( - db, offset=offset, limit=limit, include_inactive=include_inactive - ) + campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive) total = await get_campaigns_count(db) items = [] for campaign in campaigns: # Get quick stats stats = await get_campaign_statistics(db, campaign.id) - items.append(CampaignListItem( - id=campaign.id, - name=campaign.name, - start_parameter=campaign.start_parameter, - bonus_type=campaign.bonus_type, - is_active=campaign.is_active, - registrations_count=stats["registrations"], - total_revenue_kopeks=stats["total_revenue_kopeks"], - conversion_rate=stats["conversion_rate"], - created_at=campaign.created_at, - )) + items.append( + CampaignListItem( + id=campaign.id, + name=campaign.name, + start_parameter=campaign.start_parameter, + bonus_type=campaign.bonus_type, + is_active=campaign.is_active, + registrations_count=stats['registrations'], + total_revenue_kopeks=stats['total_revenue_kopeks'], + conversion_rate=stats['conversion_rate'], + created_at=campaign.created_at, + ) + ) return CampaignListResponse(campaigns=items, total=total) -@router.get("/{campaign_id}", response_model=CampaignDetailResponse) +@router.get('/{campaign_id}', response_model=CampaignDetailResponse) async def get_campaign( campaign_id: int, admin: User = Depends(get_current_admin_user), @@ -178,7 +177,7 @@ async def get_campaign( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) tariff_info = None @@ -210,7 +209,7 @@ async def get_campaign( ) -@router.get("/{campaign_id}/stats", response_model=CampaignStatisticsResponse) +@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse) async def get_campaign_stats( campaign_id: int, admin: User = Depends(get_current_admin_user), @@ -221,7 +220,7 @@ async def get_campaign_stats( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) stats = await get_campaign_statistics(db, campaign_id) @@ -232,28 +231,28 @@ async def get_campaign_stats( start_parameter=campaign.start_parameter, bonus_type=campaign.bonus_type, is_active=campaign.is_active, - registrations=stats["registrations"], - balance_issued_kopeks=stats["balance_issued"], - balance_issued_rubles=stats["balance_issued"] / 100, - subscription_issued=stats["subscription_issued"], - last_registration=stats["last_registration"], - total_revenue_kopeks=stats["total_revenue_kopeks"], - total_revenue_rubles=stats["total_revenue_kopeks"] / 100, - avg_revenue_per_user_kopeks=stats["avg_revenue_per_user_kopeks"], - avg_revenue_per_user_rubles=stats["avg_revenue_per_user_kopeks"] / 100, - avg_first_payment_kopeks=stats["avg_first_payment_kopeks"], - avg_first_payment_rubles=stats["avg_first_payment_kopeks"] / 100, - trial_users_count=stats["trial_users_count"], - active_trials_count=stats["active_trials_count"], - conversion_count=stats["conversion_count"], - paid_users_count=stats["paid_users_count"], - conversion_rate=stats["conversion_rate"], - trial_conversion_rate=stats["trial_conversion_rate"], + registrations=stats['registrations'], + balance_issued_kopeks=stats['balance_issued'], + balance_issued_rubles=stats['balance_issued'] / 100, + subscription_issued=stats['subscription_issued'], + last_registration=stats['last_registration'], + total_revenue_kopeks=stats['total_revenue_kopeks'], + total_revenue_rubles=stats['total_revenue_kopeks'] / 100, + avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'], + avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100, + avg_first_payment_kopeks=stats['avg_first_payment_kopeks'], + avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100, + trial_users_count=stats['trial_users_count'], + active_trials_count=stats['active_trials_count'], + conversion_count=stats['conversion_count'], + paid_users_count=stats['paid_users_count'], + conversion_rate=stats['conversion_rate'], + trial_conversion_rate=stats['trial_conversion_rate'], deep_link=_get_deep_link(campaign.start_parameter), ) -@router.get("/{campaign_id}/registrations", response_model=CampaignRegistrationsResponse) +@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse) async def get_campaign_registrations( campaign_id: int, page: int = Query(1, ge=1), @@ -266,7 +265,7 @@ async def get_campaign_registrations( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) offset = (page - 1) * per_page @@ -284,8 +283,9 @@ async def get_campaign_registrations( # Count total count_result = await db.execute( - select(func.count(AdvertisingCampaignRegistration.id)) - .where(AdvertisingCampaignRegistration.campaign_id == campaign_id) + select(func.count(AdvertisingCampaignRegistration.id)).where( + AdvertisingCampaignRegistration.campaign_id == campaign_id + ) ) total = count_result.scalar() or 0 @@ -296,28 +296,30 @@ async def get_campaign_registrations( select(Subscription) .where( Subscription.user_id == user.id, - Subscription.status == "active", + Subscription.status == 'active', ) .limit(1) ) has_sub = sub_result.scalar_one_or_none() is not None - items.append(CampaignRegistrationItem( - id=reg.id, - user_id=user.id, - telegram_id=user.telegram_id, - username=user.username, - first_name=user.first_name, - bonus_type=reg.bonus_type, - balance_bonus_kopeks=reg.balance_bonus_kopeks or 0, - subscription_duration_days=reg.subscription_duration_days, - tariff_id=reg.tariff_id, - tariff_duration_days=reg.tariff_duration_days, - created_at=reg.created_at, - user_balance_kopeks=user.balance_kopeks or 0, - has_subscription=has_sub, - has_paid=user.has_had_paid_subscription or False, - )) + items.append( + CampaignRegistrationItem( + id=reg.id, + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + bonus_type=reg.bonus_type, + balance_bonus_kopeks=reg.balance_bonus_kopeks or 0, + subscription_duration_days=reg.subscription_duration_days, + tariff_id=reg.tariff_id, + tariff_duration_days=reg.tariff_duration_days, + created_at=reg.created_at, + user_balance_kopeks=user.balance_kopeks or 0, + has_subscription=has_sub, + has_paid=user.has_had_paid_subscription or False, + ) + ) return CampaignRegistrationsResponse( registrations=items, @@ -327,7 +329,7 @@ async def get_campaign_registrations( ) -@router.post("", response_model=CampaignDetailResponse) +@router.post('', response_model=CampaignDetailResponse) async def create_new_campaign( request: CampaignCreateRequest, admin: User = Depends(get_current_admin_user), @@ -343,20 +345,18 @@ async def create_new_campaign( ) # Validate tariff exists if tariff bonus type - if request.bonus_type == "tariff": + if request.bonus_type == 'tariff': if not request.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariff ID is required for tariff bonus type", + detail='Tariff ID is required for tariff bonus type', ) - tariff_result = await db.execute( - select(Tariff).where(Tariff.id == request.tariff_id) - ) + tariff_result = await db.execute(select(Tariff).where(Tariff.id == request.tariff_id)) tariff = tariff_result.scalar_one_or_none() if not tariff: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariff not found", + detail='Tariff not found', ) campaign = await create_campaign( @@ -378,12 +378,12 @@ async def create_new_campaign( # Reload to get tariff relationship campaign = await get_campaign_by_id(db, campaign.id) - logger.info(f"Admin {admin.id} created campaign {campaign.id}: {campaign.name}") + logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}') return await get_campaign(campaign.id, admin, db) -@router.put("/{campaign_id}", response_model=CampaignDetailResponse) +@router.put('/{campaign_id}', response_model=CampaignDetailResponse) async def update_existing_campaign( campaign_id: int, request: CampaignUpdateRequest, @@ -395,7 +395,7 @@ async def update_existing_campaign( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) # Check if start_parameter is unique (if changing) @@ -408,53 +408,51 @@ async def update_existing_campaign( ) # Validate tariff if changing to tariff bonus type - if request.bonus_type == "tariff" or (campaign.bonus_type == "tariff" and request.tariff_id): + if request.bonus_type == 'tariff' or (campaign.bonus_type == 'tariff' and request.tariff_id): tariff_id = request.tariff_id or campaign.tariff_id if tariff_id: - tariff_result = await db.execute( - select(Tariff).where(Tariff.id == tariff_id) - ) + tariff_result = await db.execute(select(Tariff).where(Tariff.id == tariff_id)) tariff = tariff_result.scalar_one_or_none() if not tariff: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariff not found", + detail='Tariff not found', ) # Build updates updates = {} if request.name is not None: - updates["name"] = request.name + updates['name'] = request.name if request.start_parameter is not None: - updates["start_parameter"] = request.start_parameter + updates['start_parameter'] = request.start_parameter if request.bonus_type is not None: - updates["bonus_type"] = request.bonus_type + updates['bonus_type'] = request.bonus_type if request.is_active is not None: - updates["is_active"] = request.is_active + updates['is_active'] = request.is_active if request.balance_bonus_kopeks is not None: - updates["balance_bonus_kopeks"] = request.balance_bonus_kopeks + updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks if request.subscription_duration_days is not None: - updates["subscription_duration_days"] = request.subscription_duration_days + updates['subscription_duration_days'] = request.subscription_duration_days if request.subscription_traffic_gb is not None: - updates["subscription_traffic_gb"] = request.subscription_traffic_gb + updates['subscription_traffic_gb'] = request.subscription_traffic_gb if request.subscription_device_limit is not None: - updates["subscription_device_limit"] = request.subscription_device_limit + updates['subscription_device_limit'] = request.subscription_device_limit if request.subscription_squads is not None: - updates["subscription_squads"] = request.subscription_squads + updates['subscription_squads'] = request.subscription_squads if request.tariff_id is not None: - updates["tariff_id"] = request.tariff_id + updates['tariff_id'] = request.tariff_id if request.tariff_duration_days is not None: - updates["tariff_duration_days"] = request.tariff_duration_days + updates['tariff_duration_days'] = request.tariff_duration_days if updates: await update_campaign(db, campaign, **updates) - logger.info(f"Admin {admin.id} updated campaign {campaign_id}") + logger.info(f'Admin {admin.id} updated campaign {campaign_id}') return await get_campaign(campaign_id, admin, db) -@router.delete("/{campaign_id}") +@router.delete('/{campaign_id}') async def delete_existing_campaign( campaign_id: int, admin: User = Depends(get_current_admin_user), @@ -465,7 +463,7 @@ async def delete_existing_campaign( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) # Check if campaign has registrations @@ -473,16 +471,16 @@ async def delete_existing_campaign( if reg_count > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Cannot delete campaign with {reg_count} registrations. Deactivate it instead.", + detail=f'Cannot delete campaign with {reg_count} registrations. Deactivate it instead.', ) await delete_campaign(db, campaign) - logger.info(f"Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}") + logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}') - return {"message": "Campaign deleted successfully"} + return {'message': 'Campaign deleted successfully'} -@router.post("/{campaign_id}/toggle", response_model=CampaignToggleResponse) +@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse) async def toggle_campaign( campaign_id: int, admin: User = Depends(get_current_admin_user), @@ -493,17 +491,17 @@ async def toggle_campaign( if not campaign: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Campaign not found", + detail='Campaign not found', ) new_status = not campaign.is_active await update_campaign(db, campaign, is_active=new_status) - status_text = "activated" if new_status else "deactivated" - logger.info(f"Admin {admin.id} {status_text} campaign {campaign_id}") + status_text = 'activated' if new_status else 'deactivated' + logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}') return CampaignToggleResponse( id=campaign_id, is_active=new_status, - message=f"Campaign {status_text}", + message=f'Campaign {status_text}', ) diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index a90577ce..3b7f7ab3 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -2,35 +2,37 @@ import logging import math -from typing import List, Optional from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, PaymentMethod +from app.database.models import PaymentMethod, User from app.services.payment_service import PaymentService from app.services.payment_verification_service import ( - list_recent_pending_payments, - get_payment_record, - run_manual_check, SUPPORTED_MANUAL_CHECK_METHODS, - method_display_name, PendingPayment, + get_payment_record, + list_recent_pending_payments, + method_display_name, + run_manual_check, ) from ..dependencies import get_cabinet_db, get_current_admin_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/payments", tags=["Cabinet Admin Payments"]) +router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments']) # ============ Schemas ============ + class PendingPaymentResponse(BaseModel): """Pending payment details.""" + id: int method: str method_display: str @@ -43,11 +45,11 @@ class PendingPaymentResponse(BaseModel): is_paid: bool is_checkable: bool created_at: datetime - expires_at: Optional[datetime] = None - payment_url: Optional[str] = None - user_id: Optional[int] = None - user_telegram_id: Optional[int] = None - user_username: Optional[str] = None + expires_at: datetime | None = None + payment_url: str | None = None + user_id: int | None = None + user_telegram_id: int | None = None + user_username: str | None = None class Config: from_attributes = True @@ -55,7 +57,8 @@ class PendingPaymentResponse(BaseModel): class PendingPaymentListResponse(BaseModel): """Paginated list of pending payments.""" - items: List[PendingPaymentResponse] + + items: list[PendingPaymentResponse] total: int page: int per_page: int @@ -64,120 +67,123 @@ class PendingPaymentListResponse(BaseModel): class ManualCheckResponse(BaseModel): """Response after manual payment status check.""" + success: bool message: str - payment: Optional[PendingPaymentResponse] = None + payment: PendingPaymentResponse | None = None status_changed: bool = False - old_status: Optional[str] = None - new_status: Optional[str] = None + old_status: str | None = None + new_status: str | None = None class PaymentsStatsResponse(BaseModel): """Statistics about pending payments.""" + total_pending: int by_method: dict # ============ Helper functions ============ + def _get_status_info(record: PendingPayment) -> tuple[str, str]: """Get status emoji and text for a pending payment.""" - status_str = (record.status or "").lower() + status_str = (record.status or '').lower() if record.is_paid: - return "✅", "Оплачено" + return '✅', 'Оплачено' if record.method == PaymentMethod.PAL24: mapping = { - "new": ("⏳", "Ожидает оплаты"), - "process": ("⌛", "Обрабатывается"), - "success": ("✅", "Оплачено"), - "fail": ("❌", "Ошибка"), - "canceled": ("❌", "Отменено"), + 'new': ('⏳', 'Ожидает оплаты'), + 'process': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'fail': ('❌', 'Ошибка'), + 'canceled': ('❌', 'Отменено'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.MULENPAY: mapping = { - "created": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "hold": ("🔒", "На удержании"), - "success": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), - "error": ("❌", "Ошибка"), + 'created': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'hold': ('🔒', 'На удержании'), + 'success': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.WATA: mapping = { - "opened": ("⏳", "Ожидает оплаты"), - "pending": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "paid": ("✅", "Оплачено"), - "closed": ("✅", "Оплачено"), - "declined": ("❌", "Отклонено"), - "canceled": ("❌", "Отменено"), - "expired": ("⌛", "Истёк"), + 'opened': ('⏳', 'Ожидает оплаты'), + 'pending': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'paid': ('✅', 'Оплачено'), + 'closed': ('✅', 'Оплачено'), + 'declined': ('❌', 'Отклонено'), + 'canceled': ('❌', 'Отменено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.PLATEGA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "inprogress": ("⌛", "Обрабатывается"), - "confirmed": ("✅", "Оплачено"), - "failed": ("❌", "Ошибка"), - "canceled": ("❌", "Отменено"), - "expired": ("⌛", "Истёк"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'inprogress': ('⌛', 'Обрабатывается'), + 'confirmed': ('✅', 'Оплачено'), + 'failed': ('❌', 'Ошибка'), + 'canceled': ('❌', 'Отменено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.HELEKET: - if status_str in {"pending", "created", "waiting", "check", "processing"}: - return "⏳", "Ожидает оплаты" - if status_str in {"paid", "paid_over"}: - return "✅", "Оплачено" - if status_str in {"cancel", "canceled", "fail", "failed", "expired"}: - return "❌", "Отменено" - return "❓", "Неизвестно" + if status_str in {'pending', 'created', 'waiting', 'check', 'processing'}: + return '⏳', 'Ожидает оплаты' + if status_str in {'paid', 'paid_over'}: + return '✅', 'Оплачено' + if status_str in {'cancel', 'canceled', 'fail', 'failed', 'expired'}: + return '❌', 'Отменено' + return '❓', 'Неизвестно' if record.method == PaymentMethod.YOOKASSA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "waiting_for_capture": ("⌛", "Обрабатывается"), - "succeeded": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'waiting_for_capture': ('⌛', 'Обрабатывается'), + 'succeeded': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.CRYPTOBOT: mapping = { - "active": ("⏳", "Ожидает оплаты"), - "paid": ("✅", "Оплачено"), - "expired": ("⌛", "Истёк"), + 'active': ('⏳', 'Ожидает оплаты'), + 'paid': ('✅', 'Оплачено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.CLOUDPAYMENTS: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "authorized": ("⌛", "Авторизовано"), - "completed": ("✅", "Оплачено"), - "failed": ("❌", "Ошибка"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'authorized': ('⌛', 'Авторизовано'), + 'completed': ('✅', 'Оплачено'), + 'failed': ('❌', 'Ошибка'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) if record.method == PaymentMethod.FREEKASSA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "success": ("✅", "Оплачено"), - "paid": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), - "error": ("❌", "Ошибка"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'success': ('✅', 'Оплачено'), + 'paid': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), } - return mapping.get(status_str, ("❓", "Неизвестно")) + return mapping.get(status_str, ('❓', 'Неизвестно')) - return "❓", "Неизвестно" + return '❓', 'Неизвестно' def _is_checkable(record: PendingPayment) -> bool: @@ -186,52 +192,50 @@ def _is_checkable(record: PendingPayment) -> bool: return False if not record.is_recent(): return False - status_str = (record.status or "").lower() + status_str = (record.status or '').lower() if record.method == PaymentMethod.PAL24: - return status_str in {"new", "process"} + return status_str in {'new', 'process'} if record.method == PaymentMethod.MULENPAY: - return status_str in {"created", "processing", "hold"} + return status_str in {'created', 'processing', 'hold'} if record.method == PaymentMethod.WATA: - return status_str in {"opened", "pending", "processing", "inprogress", "in_progress"} + return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'} if record.method == PaymentMethod.PLATEGA: - return status_str in {"pending", "inprogress", "in_progress"} + return status_str in {'pending', 'inprogress', 'in_progress'} if record.method == PaymentMethod.HELEKET: - return status_str not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"} + return status_str not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'} if record.method == PaymentMethod.YOOKASSA: - return status_str in {"pending", "waiting_for_capture"} + return status_str in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status_str in {"active"} + return status_str in {'active'} if record.method == PaymentMethod.CLOUDPAYMENTS: - return status_str in {"pending", "authorized"} + return status_str in {'pending', 'authorized'} if record.method == PaymentMethod.FREEKASSA: - return status_str in {"pending", "created", "processing"} + return status_str in {'pending', 'created', 'processing'} return False -def _get_payment_url(record: PendingPayment) -> Optional[str]: +def _get_payment_url(record: PendingPayment) -> str | None: """Extract payment URL from record.""" payment = record.payment - payment_url = getattr(payment, "payment_url", None) + payment_url = getattr(payment, 'payment_url', None) if record.method == PaymentMethod.PAL24: - payment_url = getattr(payment, "link_url", None) or getattr(payment, "link_page_url", None) or payment_url + payment_url = getattr(payment, 'link_url', None) or getattr(payment, 'link_page_url', None) or payment_url elif record.method == PaymentMethod.WATA: - payment_url = getattr(payment, "url", None) or payment_url + payment_url = getattr(payment, 'url', None) or payment_url elif record.method == PaymentMethod.YOOKASSA: - payment_url = getattr(payment, "confirmation_url", None) or payment_url + payment_url = getattr(payment, 'confirmation_url', None) or payment_url elif record.method == PaymentMethod.CRYPTOBOT: payment_url = ( - getattr(payment, "bot_invoice_url", None) - or getattr(payment, "mini_app_invoice_url", None) - or getattr(payment, "web_app_invoice_url", None) + getattr(payment, 'bot_invoice_url', None) + or getattr(payment, 'mini_app_invoice_url', None) + or getattr(payment, 'web_app_invoice_url', None) or payment_url ) elif record.method == PaymentMethod.PLATEGA: - payment_url = getattr(payment, "redirect_url", None) or payment_url - elif record.method == PaymentMethod.CLOUDPAYMENTS: - payment_url = getattr(payment, "payment_url", None) or payment_url - elif record.method == PaymentMethod.FREEKASSA: - payment_url = getattr(payment, "payment_url", None) or payment_url + payment_url = getattr(payment, 'redirect_url', None) or payment_url + elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA: + payment_url = getattr(payment, 'payment_url', None) or payment_url return payment_url @@ -246,7 +250,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse: identifier=record.identifier, amount_kopeks=record.amount_kopeks, amount_rubles=record.amount_kopeks / 100, - status=record.status or "", + status=record.status or '', status_emoji=status_emoji, status_text=status_text, is_paid=record.is_paid, @@ -262,11 +266,12 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse: # ============ Routes ============ -@router.get("", response_model=PendingPaymentListResponse) + +@router.get('', response_model=PendingPaymentListResponse) async def get_all_pending_payments( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), - method_filter: Optional[str] = Query(None, description="Filter by payment method"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), + method_filter: str | None = Query(None, description='Filter by payment method'), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -286,7 +291,7 @@ async def get_all_pending_payments( # Paginate start_idx = (page - 1) * per_page - page_payments = all_pending[start_idx:start_idx + per_page] + page_payments = all_pending[start_idx : start_idx + per_page] items = [_record_to_response(p) for p in page_payments] @@ -299,7 +304,7 @@ async def get_all_pending_payments( ) -@router.get("/stats", response_model=PaymentsStatsResponse) +@router.get('/stats', response_model=PaymentsStatsResponse) async def get_payments_stats( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -320,7 +325,7 @@ async def get_payments_stats( ) -@router.get("/{method}/{payment_id}", response_model=PendingPaymentResponse) +@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse) async def get_pending_payment_details( method: str, payment_id: int, @@ -333,7 +338,7 @@ async def get_pending_payment_details( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid payment method: {method}", + detail=f'Invalid payment method: {method}', ) record = await get_payment_record(db, payment_method, payment_id) @@ -341,13 +346,13 @@ async def get_pending_payment_details( if not record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Payment not found", + detail='Payment not found', ) return _record_to_response(record) -@router.post("/{method}/{payment_id}/check", response_model=ManualCheckResponse) +@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse) async def check_payment_status( method: str, payment_id: int, @@ -360,7 +365,7 @@ async def check_payment_status( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid payment method: {method}", + detail=f'Invalid payment method: {method}', ) # Get current record @@ -369,14 +374,14 @@ async def check_payment_status( if not record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Payment not found", + detail='Payment not found', ) # Check if manual check is available if not _is_checkable(record): return ManualCheckResponse( success=False, - message="Ручная проверка недоступна для этого платежа", + message='Ручная проверка недоступна для этого платежа', payment=_record_to_response(record), status_changed=False, ) @@ -391,7 +396,7 @@ async def check_payment_status( if not updated: return ManualCheckResponse( success=False, - message="Не удалось проверить статус платежа", + message='Не удалось проверить статус платежа', payment=_record_to_response(record), status_changed=False, ) @@ -400,12 +405,10 @@ async def check_payment_status( if status_changed: _, new_status_text = _get_status_info(updated) - message = f"Статус обновлён: {new_status_text}" - logger.info( - f"Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}" - ) + message = f'Статус обновлён: {new_status_text}' + logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}') else: - message = "Статус не изменился" + message = 'Статус не изменился' return ManualCheckResponse( success=True, diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index 9ca2af1b..f5a5e025 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import logging from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from aiogram import Bot from aiogram.client.default import DefaultBotProperties @@ -29,49 +29,51 @@ from app.database.crud.promo_offer_template import ( list_promo_offer_templates, update_promo_offer_template, ) -from app.database.crud.user import get_user_by_telegram_id, get_user_by_email +from app.database.crud.user import get_user_by_email, get_user_by_telegram_id from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User from app.handlers.admin.messages import get_custom_users, get_target_users from app.utils.miniapp_buttons import build_miniapp_or_callback_button from ..dependencies import get_cabinet_db, get_current_admin_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"]) +router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers']) # ============== Schemas ============== + class PromoOfferUserInfo(BaseModel): id: int - telegram_id: Optional[int] = None # Can be None for email-only users - email: Optional[str] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - full_name: Optional[str] = None + telegram_id: int | None = None # Can be None for email-only users + email: str | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None + full_name: str | None = None class PromoOfferResponse(BaseModel): id: int user_id: int - subscription_id: Optional[int] = None - notification_type: Optional[str] = None - discount_percent: Optional[int] = None - bonus_amount_kopeks: Optional[int] = None - expires_at: Optional[datetime] = None - claimed_at: Optional[datetime] = None + subscription_id: int | None = None + notification_type: str | None = None + discount_percent: int | None = None + bonus_amount_kopeks: int | None = None + expires_at: datetime | None = None + claimed_at: datetime | None = None is_active: bool - effect_type: Optional[str] = None - extra_data: Dict[str, Any] = Field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - user: Optional[PromoOfferUserInfo] = None + effect_type: str | None = None + extra_data: dict[str, Any] = Field(default_factory=dict) + created_at: datetime | None = None + updated_at: datetime | None = None + user: PromoOfferUserInfo | None = None class PromoOfferListResponse(BaseModel): - items: List[PromoOfferResponse] + items: list[PromoOfferResponse] total: int limit: int offset: int @@ -86,30 +88,30 @@ class PromoOfferTemplateResponse(BaseModel): valid_hours: int discount_percent: int bonus_amount_kopeks: int - active_discount_hours: Optional[int] = None - test_duration_hours: Optional[int] = None - test_squad_uuids: List[str] = Field(default_factory=list) + active_discount_hours: int | None = None + test_duration_hours: int | None = None + test_squad_uuids: list[str] = Field(default_factory=list) is_active: bool - created_by: Optional[int] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_by: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None class PromoOfferTemplateListResponse(BaseModel): - items: List[PromoOfferTemplateResponse] + items: list[PromoOfferTemplateResponse] class PromoOfferTemplateUpdateRequest(BaseModel): - name: Optional[str] = None - message_text: Optional[str] = None - button_text: Optional[str] = None - valid_hours: Optional[int] = Field(None, ge=1) - discount_percent: Optional[int] = Field(None, ge=0) - bonus_amount_kopeks: Optional[int] = Field(None, ge=0) - active_discount_hours: Optional[int] = Field(None, ge=1) - test_duration_hours: Optional[int] = Field(None, ge=1) - test_squad_uuids: Optional[List[str]] = None - is_active: Optional[bool] = None + name: str | None = None + message_text: str | None = None + button_text: str | None = None + valid_hours: int | None = Field(None, ge=1) + discount_percent: int | None = Field(None, ge=0) + bonus_amount_kopeks: int | None = Field(None, ge=0) + active_discount_hours: int | None = Field(None, ge=1) + test_duration_hours: int | None = Field(None, ge=1) + test_squad_uuids: list[str] | None = None + is_active: bool | None = None class PromoOfferBroadcastRequest(BaseModel): @@ -117,53 +119,53 @@ class PromoOfferBroadcastRequest(BaseModel): valid_hours: int = Field(..., ge=1) discount_percent: int = Field(0, ge=0) bonus_amount_kopeks: int = Field(0, ge=0) - effect_type: str = Field("percent_discount", min_length=1) - extra_data: Dict[str, Any] = Field(default_factory=dict) - target: Optional[str] = None - user_id: Optional[int] = None - telegram_id: Optional[int] = None - email: Optional[str] = Field(None, description="User email (for email-only users)") + effect_type: str = Field('percent_discount', min_length=1) + extra_data: dict[str, Any] = Field(default_factory=dict) + target: str | None = None + user_id: int | None = None + telegram_id: int | None = None + email: str | None = Field(None, description='User email (for email-only users)') # Telegram notification options - send_notification: bool = Field(False, description="Send Telegram notification to users") - message_text: Optional[str] = Field(None, description="Custom message text (HTML)") - button_text: Optional[str] = Field(None, description="Button text") + send_notification: bool = Field(False, description='Send Telegram notification to users') + message_text: str | None = Field(None, description='Custom message text (HTML)') + button_text: str | None = Field(None, description='Button text') class PromoOfferBroadcastResponse(BaseModel): created_offers: int - user_ids: List[int] - target: Optional[str] = None + user_ids: list[int] + target: str | None = None notifications_sent: int = 0 notifications_failed: int = 0 class PromoOfferLogOfferInfo(BaseModel): id: int - notification_type: Optional[str] = None - discount_percent: Optional[int] = None - bonus_amount_kopeks: Optional[int] = None - effect_type: Optional[str] = None - expires_at: Optional[datetime] = None - claimed_at: Optional[datetime] = None - is_active: Optional[bool] = None + notification_type: str | None = None + discount_percent: int | None = None + bonus_amount_kopeks: int | None = None + effect_type: str | None = None + expires_at: datetime | None = None + claimed_at: datetime | None = None + is_active: bool | None = None class PromoOfferLogResponse(BaseModel): id: int - user_id: Optional[int] = None - offer_id: Optional[int] = None + user_id: int | None = None + offer_id: int | None = None action: str - source: Optional[str] = None - percent: Optional[int] = None - effect_type: Optional[str] = None - details: Dict[str, Any] = Field(default_factory=dict) + source: str | None = None + percent: int | None = None + effect_type: str | None = None + details: dict[str, Any] = Field(default_factory=dict) created_at: datetime - user: Optional[PromoOfferUserInfo] = None - offer: Optional[PromoOfferLogOfferInfo] = None + user: PromoOfferUserInfo | None = None + offer: PromoOfferLogOfferInfo | None = None class PromoOfferLogListResponse(BaseModel): - items: List[PromoOfferLogResponse] + items: list[PromoOfferLogResponse] total: int limit: int offset: int @@ -171,7 +173,8 @@ class PromoOfferLogListResponse(BaseModel): # ============== Helpers ============== -def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: + +def _serialize_user(user: User | None) -> PromoOfferUserInfo | None: if not user: return None return PromoOfferUserInfo( @@ -181,7 +184,7 @@ def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: username=user.username, first_name=user.first_name, last_name=user.last_name, - full_name=getattr(user, "full_name", None), + full_name=getattr(user, 'full_name', None), ) @@ -200,7 +203,7 @@ def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse: extra_data=offer.extra_data or {}, created_at=offer.created_at, updated_at=offer.updated_at, - user=_serialize_user(getattr(offer, "user", None)), + user=_serialize_user(getattr(offer, 'user', None)), ) @@ -225,10 +228,10 @@ def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateRespo def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse: - user_info = _serialize_user(getattr(entry, "user", None)) + user_info = _serialize_user(getattr(entry, 'user', None)) - offer = getattr(entry, "offer", None) - offer_info: Optional[PromoOfferLogOfferInfo] = None + offer = getattr(entry, 'offer', None) + offer_info: PromoOfferLogOfferInfo | None = None if offer: offer_info = PromoOfferLogOfferInfo( id=offer.id, @@ -258,15 +261,16 @@ def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse: async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]: normalized = target.strip().lower() - if normalized.startswith("custom_"): - criteria = normalized[len("custom_"):] + if normalized.startswith('custom_'): + criteria = normalized[len('custom_') :] return await get_custom_users(db, criteria) return await get_target_users(db, normalized) # ============== Template Endpoints ============== -@router.get("/templates", response_model=PromoOfferTemplateListResponse) + +@router.get('/templates', response_model=PromoOfferTemplateListResponse) async def list_templates( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -278,12 +282,10 @@ async def list_templates( if not templates: templates = await ensure_default_templates(db, created_by=admin.id) - return PromoOfferTemplateListResponse( - items=[_serialize_template(template) for template in templates] - ) + return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates]) -@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse) async def get_template( template_id: int, admin: User = Depends(get_current_admin_user), @@ -292,11 +294,11 @@ async def get_template( """Get a promo offer template.""" template = await get_promo_offer_template_by_id(db, template_id) if not template: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found') return _serialize_template(template) -@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse) async def update_template( template_id: int, payload: PromoOfferTemplateUpdateRequest, @@ -306,7 +308,7 @@ async def update_template( """Update a promo offer template.""" template = await get_promo_offer_template_by_id(db, template_id) if not template: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found') if payload.test_squad_uuids is not None: normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()] @@ -333,14 +335,15 @@ async def update_template( # ============== Offer Endpoints ============== -@router.get("", response_model=PromoOfferListResponse) + +@router.get('', response_model=PromoOfferListResponse) async def list_offers( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), - user_id: Optional[int] = Query(None, ge=1), - is_active: Optional[bool] = Query(None), + user_id: int | None = Query(None, ge=1), + is_active: bool | None = Query(None), ) -> PromoOfferListResponse: """Get list of promo offers.""" offers = await list_discount_offers( @@ -378,24 +381,24 @@ def _build_default_promo_message( valid_hours: int, ) -> str: """Build default promo notification message.""" - lines = ["🎁 Специальное предложение для вас!\n"] + lines = ['🎁 Специальное предложение для вас!\n'] if discount_percent > 0: - lines.append(f"🔥 Скидка {discount_percent}% на подписку") + lines.append(f'🔥 Скидка {discount_percent}% на подписку') if bonus_amount_kopeks > 0: bonus_rub = bonus_amount_kopeks / 100 - lines.append(f"💰 Бонус {bonus_rub:.0f}₽ на баланс") + lines.append(f'💰 Бонус {bonus_rub:.0f}₽ на баланс') - lines.append(f"\n⏰ Предложение действует {valid_hours} ч.") - lines.append("\nНажмите кнопку ниже, чтобы активировать!") + lines.append(f'\n⏰ Предложение действует {valid_hours} ч.') + lines.append('\nНажмите кнопку ниже, чтобы активировать!') - return "\n".join(lines) + return '\n'.join(lines) async def _send_promo_notifications( - offers_to_notify: List[tuple[User, DiscountOffer]], - message_text: Optional[str], - button_text: Optional[str], + offers_to_notify: list[tuple[User, DiscountOffer]], + message_text: str | None, + button_text: str | None, discount_percent: int, bonus_amount_kopeks: int, valid_hours: int, @@ -420,14 +423,14 @@ async def _send_promo_notifications( ) # Default button text - btn_text = button_text or "🎁 Получить" + btn_text = button_text or '🎁 Получить' semaphore = asyncio.Semaphore(20) async def send_single(user: User, offer: DiscountOffer) -> bool: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f"Skipping promo notification for email-only user {user.id}") + logger.debug(f'Skipping promo notification for email-only user {user.id}') return False async with semaphore: @@ -437,13 +440,13 @@ async def _send_promo_notifications( [ build_miniapp_or_callback_button( text=btn_text, - callback_data=f"claim_discount_{offer.id}", + callback_data=f'claim_discount_{offer.id}', ) ], [ InlineKeyboardButton( - text="❌ Закрыть", - callback_data="promo_offer_close", + text='❌ Закрыть', + callback_data='promo_offer_close', ) ], ] @@ -457,14 +460,14 @@ async def _send_promo_notifications( return True except (TelegramForbiddenError, TelegramBadRequest) as exc: logger.warning( - "Failed to send promo notification to user %s: %s", + 'Failed to send promo notification to user %s: %s', user.telegram_id, exc, ) return False except Exception as exc: logger.error( - "Error sending promo notification to user %s: %s", + 'Error sending promo notification to user %s: %s', user.telegram_id, exc, ) @@ -493,7 +496,7 @@ async def _send_promo_notifications( return sent, failed -@router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED) +@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED) async def broadcast_offer( payload: PromoOfferBroadcastRequest, admin: User = Depends(get_current_admin_user), @@ -509,16 +512,16 @@ async def broadcast_offer( # Resolve specific user target_user_id = payload.user_id - user: Optional[User] = None + user: User | None = None if payload.telegram_id is not None: user = await get_user_by_telegram_id(db, payload.telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by telegram_id") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by telegram_id') if target_user_id and target_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Provided user_id does not match telegram_id", + 'Provided user_id does not match telegram_id', ) target_user_id = user.id @@ -526,11 +529,11 @@ async def broadcast_offer( if payload.email is not None and user is None: user = await get_user_by_email(db, payload.email) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found by email") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by email') if target_user_id and target_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Provided user_id does not match email", + 'Provided user_id does not match email', ) target_user_id = user.id @@ -538,18 +541,18 @@ async def broadcast_offer( if user is None: user = await db.get(User, target_user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') recipients[target_user_id] = user if not recipients: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "No recipients: specify target or user", + 'No recipients: specify target or user', ) # Create offers for all recipients and collect (user, offer) pairs created_offers = 0 - offers_to_notify: List[tuple[User, DiscountOffer]] = [] + offers_to_notify: list[tuple[User, DiscountOffer]] = [] for recipient in recipients.values(): offer = await upsert_discount_offer( @@ -592,14 +595,15 @@ async def broadcast_offer( # ============== Log Endpoints ============== -@router.get("/logs", response_model=PromoOfferLogListResponse) + +@router.get('/logs', response_model=PromoOfferLogListResponse) async def get_logs( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), - user_id: Optional[int] = Query(None, ge=1), - action: Optional[str] = Query(None, min_length=1), + user_id: int | None = Query(None, ge=1), + action: str | None = Query(None, min_length=1), ) -> PromoOfferLogListResponse: """Get promo offer logs.""" logs, total = await list_promo_offer_logs( diff --git a/app/cabinet/routes/admin_promocodes.py b/app/cabinet/routes/admin_promocodes.py index d262bc56..c9a32f49 100644 --- a/app/cabinet/routes/admin_promocodes.py +++ b/app/cabinet/routes/admin_promocodes.py @@ -2,13 +2,22 @@ from __future__ import annotations -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field 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.crud.promocode import ( create_promocode, delete_promocode, @@ -19,24 +28,17 @@ from app.database.crud.promocode import ( get_promocodes_list, update_promocode, ) -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 PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User from ..dependencies import get_cabinet_db, get_current_admin_user -router = APIRouter(prefix="/admin/promocodes", tags=["Admin Promocodes"]) + +router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes']) # ============== Schemas ============== + class PromoCodeResponse(BaseModel): id: int code: str @@ -51,9 +53,9 @@ class PromoCodeResponse(BaseModel): is_valid: bool first_purchase_only: bool valid_from: datetime - valid_until: Optional[datetime] = None - promo_group_id: Optional[int] = None - created_by: Optional[int] = None + valid_until: datetime | None = None + promo_group_id: int | None = None + created_by: int | None = None created_at: datetime updated_at: datetime @@ -68,9 +70,9 @@ class PromoCodeListResponse(BaseModel): 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 + user_username: str | None = None + user_full_name: str | None = None + user_telegram_id: int | None = None used_at: datetime @@ -86,28 +88,29 @@ class PromoCodeCreateRequest(BaseModel): 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 + valid_from: datetime | None = None + valid_until: datetime | None = None is_active: bool = True first_purchase_only: bool = False - promo_group_id: Optional[int] = None + promo_group_id: int | None = None class PromoCodeUpdateRequest(BaseModel): - code: Optional[str] = Field(default=None, min_length=1, max_length=50) - 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 - first_purchase_only: Optional[bool] = None - promo_group_id: Optional[int] = None + code: str | None = Field(default=None, min_length=1, max_length=50) + type: PromoCodeType | None = None + balance_bonus_kopeks: int | None = None + subscription_days: int | None = None + max_uses: int | None = Field(default=None, ge=0) + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + first_purchase_only: bool | None = None + promo_group_id: int | None = None # ============== PromoGroup Schemas ============== + class PromoGroupResponse(BaseModel): id: int name: str @@ -115,12 +118,12 @@ class PromoGroupResponse(BaseModel): 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 + auto_assign_total_spent_kopeks: int | None = None apply_discounts_to_addons: bool is_default: bool members_count: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None class PromoGroupListResponse(BaseModel): @@ -135,30 +138,31 @@ class PromoGroupCreateRequest(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Optional[dict[int, int]] = None - auto_assign_total_spent_kopeks: Optional[int] = None + period_discounts: dict[int, int] | None = None + auto_assign_total_spent_kopeks: int | None = None apply_discounts_to_addons: bool = True is_default: bool = False class PromoGroupUpdateRequest(BaseModel): - name: Optional[str] = None - server_discount_percent: Optional[int] = None - traffic_discount_percent: Optional[int] = None - 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 + name: str | None = None + server_discount_percent: int | None = None + traffic_discount_percent: int | None = None + device_discount_percent: int | None = None + period_discounts: dict[int, int] | None = None + auto_assign_total_spent_kopeks: int | None = None + apply_discounts_to_addons: bool | None = None + is_default: bool | None = None # ============== Helpers ============== -def _normalize_datetime(value: Optional[datetime]) -> Optional[datetime]: + +def _normalize_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is not None and value.utcoffset() is not None: - return value.astimezone(timezone.utc).replace(tzinfo=None) + return value.astimezone(UTC).replace(tzinfo=None) if value.tzinfo is not None: return value.replace(tzinfo=None) return value @@ -192,9 +196,9 @@ 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), + 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, ) @@ -223,54 +227,41 @@ def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGr apply_discounts_to_addons=group.apply_discounts_to_addons, is_default=group.is_default, members_count=members_count, - created_at=getattr(group, "created_at", None), - updated_at=getattr(group, "updated_at", None), + created_at=getattr(group, 'created_at', None), + updated_at=getattr(group, 'updated_at', None), ) 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") + 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" - ) + 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}: if payload.subscription_days <= 0: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Subscription days must be positive for this promo code type" + status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type' ) if payload.type == PromoCodeType.DISCOUNT: if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Discount percent must be between 1 and 100" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100') if payload.subscription_days <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Discount validity hours must be positive" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive') 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" - ) + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty') if payload.type is not None: new_type = payload.type @@ -278,74 +269,47 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo new_type = PromoCodeType(promocode.type) balance_bonus = ( - payload.balance_bonus_kopeks - if payload.balance_bonus_kopeks is not None - else promocode.balance_bonus_kopeks + 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 + 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" - ) + 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}: if subscription_days <= 0: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Subscription days must be positive for this promo code type" + status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type' ) if new_type == PromoCodeType.DISCOUNT: if balance_bonus <= 0 or balance_bonus > 100: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Discount percent must be between 1 and 100" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100') if subscription_days <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Discount validity hours must be positive" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive') - 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 - ) + 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" - ) + 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" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses') # ============== Promocode Endpoints ============== -@router.get("", response_model=PromoCodeListResponse) + +@router.get('', response_model=PromoCodeListResponse) async def list_promocodes( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), - is_active: Optional[bool] = Query(default=None), + is_active: bool | None = Query(default=None), ) -> PromoCodeListResponse: """Get list of all promocodes.""" total = await get_promocodes_count(db, is_active=is_active) or 0 @@ -359,7 +323,7 @@ async def list_promocodes( ) -@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse) +@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse) async def get_promocode( promocode_id: int, admin: User = Depends(get_current_admin_user), @@ -368,24 +332,21 @@ async def get_promocode( """Get promocode details with usage statistics.""" promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + 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", []) - ] + recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])] return PromoCodeDetailResponse( **base.model_dump(), - total_uses=stats.get("total_uses", 0), - today_uses=stats.get("today_uses", 0), + 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) +@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED) async def create_promocode_endpoint( payload: PromoCodeCreateRequest, admin: User = Depends(get_current_admin_user), @@ -400,10 +361,7 @@ async def create_promocode_endpoint( 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" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists') promocode = await create_promocode( db, @@ -418,15 +376,15 @@ async def create_promocode_endpoint( update_fields = {} if normalized_valid_from is not None: - update_fields["valid_from"] = normalized_valid_from + 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 + update_fields['is_active'] = payload.is_active if normalized_valid_until is not None: - update_fields["valid_until"] = normalized_valid_until + update_fields['valid_until'] = normalized_valid_until if payload.first_purchase_only: - update_fields["first_purchase_only"] = payload.first_purchase_only + update_fields['first_purchase_only'] = payload.first_purchase_only if payload.promo_group_id is not None: - update_fields["promo_group_id"] = payload.promo_group_id + update_fields['promo_group_id'] = payload.promo_group_id if update_fields: promocode = await update_promocode(db, promocode, **update_fields) @@ -434,7 +392,7 @@ async def create_promocode_endpoint( return _serialize_promocode(promocode) -@router.patch("/{promocode_id}", response_model=PromoCodeResponse) +@router.patch('/{promocode_id}', response_model=PromoCodeResponse) async def update_promocode_endpoint( promocode_id: int, payload: PromoCodeUpdateRequest, @@ -444,7 +402,7 @@ async def update_promocode_endpoint( """Update an existing promocode.""" promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found') _validate_update_payload(payload, promocode) @@ -455,38 +413,35 @@ async def update_promocode_endpoint( 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 + 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 + updates['type'] = payload.type.value if payload.balance_bonus_kopeks is not None: - updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks + updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks if payload.subscription_days is not None: - updates["subscription_days"] = payload.subscription_days + updates['subscription_days'] = payload.subscription_days if payload.max_uses is not None: - updates["max_uses"] = payload.max_uses + updates['max_uses'] = payload.max_uses if payload.valid_from is not None: - updates["valid_from"] = _normalize_datetime(payload.valid_from) + updates['valid_from'] = _normalize_datetime(payload.valid_from) if payload.valid_until is not None: - updates["valid_until"] = _normalize_datetime(payload.valid_until) + updates['valid_until'] = _normalize_datetime(payload.valid_until) if payload.is_active is not None: - updates["is_active"] = payload.is_active + updates['is_active'] = payload.is_active if payload.first_purchase_only is not None: - updates["first_purchase_only"] = payload.first_purchase_only + updates['first_purchase_only'] = payload.first_purchase_only if payload.promo_group_id is not None: - updates["promo_group_id"] = payload.promo_group_id + updates['promo_group_id'] = payload.promo_group_id if not updates: return _serialize_promocode(promocode) @@ -496,7 +451,7 @@ async def update_promocode_endpoint( @router.delete( - "/{promocode_id}", + '/{promocode_id}', status_code=status.HTTP_204_NO_CONTENT, response_class=Response, ) @@ -508,21 +463,21 @@ async def delete_promocode_endpoint( """Delete a promocode.""" promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Failed to delete promo code') return Response(status_code=status.HTTP_204_NO_CONTENT) # ============== PromoGroup Endpoints ============== -promo_groups_router = APIRouter(prefix="/admin/promo-groups", tags=["Admin Promo Groups"]) +promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups']) -@promo_groups_router.get("", response_model=PromoGroupListResponse) +@promo_groups_router.get('', response_model=PromoGroupListResponse) async def list_promo_groups( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -545,7 +500,7 @@ async def list_promo_groups( ) -@promo_groups_router.get("/{group_id}", response_model=PromoGroupResponse) +@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse) async def get_promo_group( group_id: int, admin: User = Depends(get_current_admin_user), @@ -554,13 +509,13 @@ async def get_promo_group( """Get promo group details.""" group = await get_promo_group_by_id(db, group_id) if not group: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found') members_count = await count_promo_group_members(db, group_id) return _serialize_promo_group(group, members_count=members_count) -@promo_groups_router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED) +@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED) async def create_promo_group_endpoint( payload: PromoGroupCreateRequest, admin: User = Depends(get_current_admin_user), @@ -585,13 +540,13 @@ async def create_promo_group_endpoint( await db.rollback() raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Promo group with this name already exists", + 'Promo group with this name already exists', ) return _serialize_promo_group(group, members_count=0) -@promo_groups_router.patch("/{group_id}", response_model=PromoGroupResponse) +@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse) async def update_promo_group_endpoint( group_id: int, payload: PromoGroupUpdateRequest, @@ -603,7 +558,7 @@ async def update_promo_group_endpoint( group = await get_promo_group_by_id(db, group_id) if not group: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found') try: group = await update_promo_group( @@ -622,14 +577,14 @@ async def update_promo_group_endpoint( await db.rollback() raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Promo group with this name already exists", + 'Promo group with this name already exists', ) members_count = await count_promo_group_members(db, group_id) return _serialize_promo_group(group, members_count=members_count) -@promo_groups_router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_promo_group_endpoint( group_id: int, admin: User = Depends(get_current_admin_user), @@ -638,13 +593,10 @@ async def delete_promo_group_endpoint( """Delete a promo group.""" group = await get_promo_group_by_id(db, group_id) if not group: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found") + 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" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group') return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/cabinet/routes/admin_remnawave.py b/app/cabinet/routes/admin_remnawave.py index 812753ce..f773d3e9 100644 --- a/app/cabinet/routes/admin_remnawave.py +++ b/app/cabinet/routes/admin_remnawave.py @@ -2,65 +2,65 @@ import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User from app.database.crud.server_squad import ( count_active_users_for_squad, get_all_server_squads, get_server_squad_by_uuid, sync_with_remnawave, ) -from app.config import settings +from app.database.models import User from app.utils.cache import cache from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.remnawave import ( - # Status & Connection - RemnaWaveStatusResponse, - ConnectionStatus, - # System Statistics - SystemStatsResponse, - SystemSummary, - ServerInfo, + AutoSyncRunResponse, + # Auto Sync + AutoSyncStatus, + AutoSyncToggleRequest, Bandwidth, - TrafficPeriods, - TrafficPeriod, + ConnectionStatus, + # Inbounds + InboundsListResponse, + # Migration + MigrationPreviewResponse, + MigrationRequest, + MigrationResponse, + MigrationStats, + NodeActionRequest, + NodeActionResponse, # Nodes NodeInfo, NodesListResponse, NodesOverview, NodeStatisticsResponse, NodeUsageResponse, - NodeActionRequest, - NodeActionResponse, + # Status & Connection + RemnaWaveStatusResponse, + ServerInfo, + SquadActionRequest, + SquadCreateRequest, + SquadDetailResponse, + SquadOperationResponse, + SquadsListResponse, + SquadUpdateRequest, # Squads SquadWithLocalInfo, - SquadsListResponse, - SquadDetailResponse, - SquadCreateRequest, - SquadUpdateRequest, - SquadActionRequest, - SquadOperationResponse, - # Migration - MigrationPreviewResponse, - MigrationRequest, - MigrationStats, - MigrationResponse, - # Inbounds - InboundsListResponse, - # Auto Sync - AutoSyncStatus, - AutoSyncToggleRequest, - AutoSyncRunResponse, # Manual Sync SyncMode, SyncResponse, + # System Statistics + SystemStatsResponse, + SystemSummary, + TrafficPeriod, + TrafficPeriods, ) + try: from app.services.remnawave_service import ( RemnaWaveConfigurationError, @@ -78,17 +78,18 @@ except Exception: logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/remnawave", tags=["Cabinet Admin RemnaWave"]) +router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave']) # ============ Helpers ============ + def _get_service() -> RemnaWaveService: """Get RemnaWave service instance.""" if RemnaWaveService is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="RemnaWave service is not available", + detail='RemnaWave service is not available', ) return RemnaWaveService() @@ -98,11 +99,11 @@ def _ensure_configured(service: RemnaWaveService) -> None: if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=service.configuration_error or "RemnaWave API is not configured", + detail=service.configuration_error or 'RemnaWave API is not configured', ) -def _parse_datetime(value: Any) -> Optional[datetime]: +def _parse_datetime(value: Any) -> datetime | None: """Parse datetime from various formats.""" if isinstance(value, datetime): return value @@ -114,46 +115,47 @@ def _parse_datetime(value: Any) -> Optional[datetime]: return None -def _serialize_node(node_data: Dict[str, Any]) -> NodeInfo: +def _serialize_node(node_data: dict[str, Any]) -> NodeInfo: """Serialize node data to NodeInfo model.""" return NodeInfo( - 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"), - last_status_change=_parse_datetime(node_data.get("last_status_change")), - last_status_message=node_data.get("last_status_message"), - xray_uptime=node_data.get("xray_uptime"), - is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)), - traffic_reset_day=node_data.get("traffic_reset_day"), - notify_percent=node_data.get("notify_percent"), - consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)), - cpu_count=node_data.get("cpu_count"), - cpu_model=node_data.get("cpu_model"), - total_ram=node_data.get("total_ram"), - created_at=_parse_datetime(node_data.get("created_at")), - updated_at=_parse_datetime(node_data.get("updated_at")), - provider_uuid=node_data.get("provider_uuid"), + 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'), + last_status_change=_parse_datetime(node_data.get('last_status_change')), + last_status_message=node_data.get('last_status_message'), + xray_uptime=node_data.get('xray_uptime'), + is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)), + traffic_reset_day=node_data.get('traffic_reset_day'), + notify_percent=node_data.get('notify_percent'), + consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)), + cpu_count=node_data.get('cpu_count'), + cpu_model=node_data.get('cpu_model'), + total_ram=node_data.get('total_ram'), + created_at=_parse_datetime(node_data.get('created_at')), + updated_at=_parse_datetime(node_data.get('updated_at')), + provider_uuid=node_data.get('provider_uuid'), ) # ============ Status & Connection ============ -@router.get("/status", response_model=RemnaWaveStatusResponse) + +@router.get('/status', response_model=RemnaWaveStatusResponse) async def get_remnawave_status( admin: User = Depends(get_current_admin_user), ) -> RemnaWaveStatusResponse: """Get RemnaWave configuration and connection status.""" service = _get_service() - connection_info: Optional[ConnectionStatus] = None + connection_info: ConnectionStatus | None = None connection_result = await service.test_api_connection() if connection_result: @@ -168,7 +170,8 @@ async def get_remnawave_status( # ============ System Statistics ============ -@router.get("/system", response_model=SystemStatsResponse) + +@router.get('/system', response_model=SystemStatsResponse) async def get_system_statistics( admin: User = Depends(get_current_admin_user), ) -> SystemStatsResponse: @@ -177,59 +180,60 @@ async def get_system_statistics( _ensure_configured(service) stats = await service.get_system_statistics() - if not stats or "system" not in stats: + if not stats or 'system' not in stats: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to get RemnaWave statistics", + detail='Failed to get RemnaWave statistics', ) - system_data = stats.get("system", {}) - server_data = stats.get("server_info", {}) - bandwidth_data = stats.get("bandwidth", {}) - traffic_data = stats.get("traffic_periods", {}) + system_data = stats.get('system', {}) + server_data = stats.get('server_info', {}) + bandwidth_data = stats.get('bandwidth', {}) + traffic_data = stats.get('traffic_periods', {}) return SystemStatsResponse( system=SystemSummary( - users_online=system_data.get("users_online", 0), - total_users=system_data.get("total_users", 0), - active_connections=system_data.get("active_connections", 0), - nodes_online=system_data.get("nodes_online", 0), - users_last_day=system_data.get("users_last_day", 0), - users_last_week=system_data.get("users_last_week", 0), - users_never_online=system_data.get("users_never_online", 0), - total_user_traffic=system_data.get("total_user_traffic", 0), + users_online=system_data.get('users_online', 0), + total_users=system_data.get('total_users', 0), + active_connections=system_data.get('active_connections', 0), + nodes_online=system_data.get('nodes_online', 0), + users_last_day=system_data.get('users_last_day', 0), + users_last_week=system_data.get('users_last_week', 0), + users_never_online=system_data.get('users_never_online', 0), + total_user_traffic=system_data.get('total_user_traffic', 0), ), - users_by_status=stats.get("users_by_status", {}), + users_by_status=stats.get('users_by_status', {}), server_info=ServerInfo( - cpu_cores=server_data.get("cpu_cores", 0), - cpu_physical_cores=server_data.get("cpu_physical_cores", 0), - memory_total=server_data.get("memory_total", 0), - memory_used=server_data.get("memory_used", 0), - memory_free=server_data.get("memory_free", 0), - memory_available=server_data.get("memory_available", 0), - uptime_seconds=server_data.get("uptime_seconds", 0), + cpu_cores=server_data.get('cpu_cores', 0), + cpu_physical_cores=server_data.get('cpu_physical_cores', 0), + memory_total=server_data.get('memory_total', 0), + memory_used=server_data.get('memory_used', 0), + memory_free=server_data.get('memory_free', 0), + memory_available=server_data.get('memory_available', 0), + uptime_seconds=server_data.get('uptime_seconds', 0), ), bandwidth=Bandwidth( - realtime_download=bandwidth_data.get("realtime_download", 0), - realtime_upload=bandwidth_data.get("realtime_upload", 0), - realtime_total=bandwidth_data.get("realtime_total", 0), + realtime_download=bandwidth_data.get('realtime_download', 0), + realtime_upload=bandwidth_data.get('realtime_upload', 0), + realtime_total=bandwidth_data.get('realtime_total', 0), ), traffic_periods=TrafficPeriods( - last_2_days=TrafficPeriod(**traffic_data.get("last_2_days", {"current": 0, "previous": 0})), - last_7_days=TrafficPeriod(**traffic_data.get("last_7_days", {"current": 0, "previous": 0})), - last_30_days=TrafficPeriod(**traffic_data.get("last_30_days", {"current": 0, "previous": 0})), - current_month=TrafficPeriod(**traffic_data.get("current_month", {"current": 0, "previous": 0})), - current_year=TrafficPeriod(**traffic_data.get("current_year", {"current": 0, "previous": 0})), + last_2_days=TrafficPeriod(**traffic_data.get('last_2_days', {'current': 0, 'previous': 0})), + last_7_days=TrafficPeriod(**traffic_data.get('last_7_days', {'current': 0, 'previous': 0})), + last_30_days=TrafficPeriod(**traffic_data.get('last_30_days', {'current': 0, 'previous': 0})), + current_month=TrafficPeriod(**traffic_data.get('current_month', {'current': 0, 'previous': 0})), + current_year=TrafficPeriod(**traffic_data.get('current_year', {'current': 0, 'previous': 0})), ), - nodes_realtime=stats.get("nodes_realtime", []), - nodes_weekly=stats.get("nodes_weekly", []), - last_updated=_parse_datetime(stats.get("last_updated")), + nodes_realtime=stats.get('nodes_realtime', []), + nodes_weekly=stats.get('nodes_weekly', []), + last_updated=_parse_datetime(stats.get('last_updated')), ) # ============ Nodes ============ -@router.get("/nodes", response_model=NodesListResponse) + +@router.get('/nodes', response_model=NodesListResponse) async def list_nodes( admin: User = Depends(get_current_admin_user), ) -> NodesListResponse: @@ -243,7 +247,7 @@ async def list_nodes( return NodesListResponse(items=serialized, total=len(serialized)) -@router.get("/nodes/overview", response_model=NodesOverview) +@router.get('/nodes/overview', response_model=NodesOverview) async def get_nodes_overview( admin: User = Depends(get_current_admin_user), ) -> NodesOverview: @@ -254,10 +258,10 @@ async def get_nodes_overview( nodes = await service.get_all_nodes() total = len(nodes) - online = sum(1 for n in nodes if n.get("is_connected") and not n.get("is_disabled")) - disabled = sum(1 for n in nodes if n.get("is_disabled")) + online = sum(1 for n in nodes if n.get('is_connected') and not n.get('is_disabled')) + disabled = sum(1 for n in nodes if n.get('is_disabled')) offline = total - online - disabled - total_users_online = sum(n.get("users_online", 0) or 0 for n in nodes) + total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes) return NodesOverview( total=total, @@ -269,10 +273,10 @@ async def get_nodes_overview( ) -@router.get("/nodes/realtime") +@router.get('/nodes/realtime') async def get_nodes_realtime( admin: User = Depends(get_current_admin_user), -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Get realtime node usage data.""" service = _get_service() _ensure_configured(service) @@ -280,7 +284,7 @@ async def get_nodes_realtime( return await service.get_nodes_realtime_usage() -@router.get("/nodes/{node_uuid}", response_model=NodeInfo) +@router.get('/nodes/{node_uuid}', response_model=NodeInfo) async def get_node_details( node_uuid: str, admin: User = Depends(get_current_admin_user), @@ -293,13 +297,13 @@ async def get_node_details( if not node: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Node not found", + detail='Node not found', ) return _serialize_node(node) -@router.get("/nodes/{node_uuid}/statistics", response_model=NodeStatisticsResponse) +@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse) async def get_node_statistics( node_uuid: str, admin: User = Depends(get_current_admin_user), @@ -309,25 +313,25 @@ async def get_node_statistics( _ensure_configured(service) stats = await service.get_node_statistics(node_uuid) - if not stats or not stats.get("node"): + if not stats or not stats.get('node'): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Node not found or no statistics available", + detail='Node not found or no statistics available', ) return NodeStatisticsResponse( - node=_serialize_node(stats["node"]), - realtime=stats.get("realtime"), - usage_history=stats.get("usage_history") or [], - last_updated=_parse_datetime(stats.get("last_updated")), + node=_serialize_node(stats['node']), + realtime=stats.get('realtime'), + usage_history=stats.get('usage_history') or [], + last_updated=_parse_datetime(stats.get('last_updated')), ) -@router.get("/nodes/{node_uuid}/usage", response_model=NodeUsageResponse) +@router.get('/nodes/{node_uuid}/usage', response_model=NodeUsageResponse) async def get_node_usage( node_uuid: str, - start: Optional[datetime] = Query(default=None), - end: Optional[datetime] = Query(default=None), + start: datetime | None = Query(default=None), + end: datetime | None = Query(default=None), admin: User = Depends(get_current_admin_user), ) -> NodeUsageResponse: """Get node usage history for a date range.""" @@ -340,14 +344,14 @@ async def get_node_usage( if start_dt >= end_dt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid date range", + detail='Invalid date range', ) usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt) return NodeUsageResponse(items=usage or []) -@router.post("/nodes/{node_uuid}/action", response_model=NodeActionResponse) +@router.post('/nodes/{node_uuid}/action', response_model=NodeActionResponse) async def perform_node_action( node_uuid: str, payload: NodeActionRequest, @@ -358,38 +362,37 @@ async def perform_node_action( _ensure_configured(service) # Get current node state for toggle operations - if payload.action in ("enable", "disable"): + if payload.action in ('enable', 'disable'): nodes = await service.get_all_nodes() - node = next((n for n in nodes if n.get("uuid") == node_uuid), None) + node = next((n for n in nodes if n.get('uuid') == node_uuid), None) if not node: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Node not found", + detail='Node not found', ) success = await service.manage_node(node_uuid, payload.action) messages = { - "enable": "Node enabled", - "disable": "Node disabled", - "restart": "Node restart initiated", + 'enable': 'Node enabled', + 'disable': 'Node disabled', + 'restart': 'Node restart initiated', } if success: - logger.info(f"Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}") + logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}') return NodeActionResponse( success=True, - message=messages.get(payload.action, "Action completed"), - is_disabled=payload.action == "disable" if payload.action in ("enable", "disable") else None, - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Failed to {payload.action} node", + message=messages.get(payload.action, 'Action completed'), + is_disabled=payload.action == 'disable' if payload.action in ('enable', 'disable') else None, ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Failed to {payload.action} node', + ) -@router.post("/nodes/restart-all", response_model=NodeActionResponse) +@router.post('/nodes/restart-all', response_model=NodeActionResponse) async def restart_all_nodes( admin: User = Depends(get_current_admin_user), ) -> NodeActionResponse: @@ -400,18 +403,18 @@ async def restart_all_nodes( success = await service.restart_all_nodes() if success: - logger.info(f"Admin {admin.telegram_id} restarted all nodes") - return NodeActionResponse(success=True, message="All nodes restart initiated") - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to restart all nodes", - ) + logger.info(f'Admin {admin.telegram_id} restarted all nodes') + return NodeActionResponse(success=True, message='All nodes restart initiated') + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to restart all nodes', + ) # ============ Squads (Internal Squads) ============ -@router.get("/squads", response_model=SquadsListResponse) + +@router.get('/squads', response_model=SquadsListResponse) async def list_squads( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -429,28 +432,30 @@ async def list_squads( items = [] for squad in rw_squads: - local = local_by_uuid.get(squad.get("uuid")) - items.append(SquadWithLocalInfo( - uuid=squad.get("uuid", ""), - name=squad.get("name", ""), - members_count=squad.get("members_count", 0), - inbounds_count=squad.get("inbounds_count", 0), - inbounds=squad.get("inbounds", []), - local_id=local.id if local else None, - display_name=local.display_name if local else None, - country_code=local.country_code if local else None, - is_available=local.is_available if local else None, - is_trial_eligible=local.is_trial_eligible if local else None, - price_kopeks=local.price_kopeks if local else None, - max_users=local.max_users if local else None, - current_users=local.current_users if local else None, - is_synced=local is not None, - )) + local = local_by_uuid.get(squad.get('uuid')) + items.append( + SquadWithLocalInfo( + uuid=squad.get('uuid', ''), + name=squad.get('name', ''), + members_count=squad.get('members_count', 0), + inbounds_count=squad.get('inbounds_count', 0), + inbounds=squad.get('inbounds', []), + local_id=local.id if local else None, + display_name=local.display_name if local else None, + country_code=local.country_code if local else None, + is_available=local.is_available if local else None, + is_trial_eligible=local.is_trial_eligible if local else None, + price_kopeks=local.price_kopeks if local else None, + max_users=local.max_users if local else None, + current_users=local.current_users if local else None, + is_synced=local is not None, + ) + ) return SquadsListResponse(items=items, total=len(items)) -@router.get("/squads/{squad_uuid}", response_model=SquadDetailResponse) +@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse) async def get_squad_details( squad_uuid: str, admin: User = Depends(get_current_admin_user), @@ -465,7 +470,7 @@ async def get_squad_details( if not squad: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Squad not found", + detail='Squad not found', ) # Get local info from DB @@ -473,11 +478,11 @@ async def get_squad_details( active_subs = await count_active_users_for_squad(db, squad_uuid) if local else 0 return SquadDetailResponse( - uuid=squad.get("uuid", ""), - name=squad.get("name", ""), - members_count=squad.get("members_count", 0), - inbounds_count=squad.get("inbounds_count", 0), - inbounds=squad.get("inbounds", []), + uuid=squad.get('uuid', ''), + name=squad.get('name', ''), + members_count=squad.get('members_count', 0), + inbounds_count=squad.get('inbounds_count', 0), + inbounds=squad.get('inbounds', []), local_id=local.id if local else None, display_name=local.display_name if local else None, country_code=local.country_code if local else None, @@ -493,7 +498,7 @@ async def get_squad_details( ) -@router.post("/squads", response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED) +@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED) async def create_squad( payload: SquadCreateRequest, admin: User = Depends(get_current_admin_user), @@ -505,20 +510,19 @@ async def create_squad( squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids) if squad_uuid: - logger.info(f"Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})") + logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})') return SquadOperationResponse( success=True, - message="Squad created successfully", - data={"uuid": squad_uuid}, - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to create squad", + message='Squad created successfully', + data={'uuid': squad_uuid}, ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to create squad', + ) -@router.patch("/squads/{squad_uuid}", response_model=SquadOperationResponse) +@router.patch('/squads/{squad_uuid}', response_model=SquadOperationResponse) async def update_squad( squad_uuid: str, payload: SquadUpdateRequest, @@ -531,7 +535,7 @@ async def update_squad( if payload.name is None and payload.inbound_uuids is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No update data provided", + detail='No update data provided', ) success = await service.update_squad( @@ -541,16 +545,15 @@ async def update_squad( ) if success: - logger.info(f"Admin {admin.telegram_id} updated squad {squad_uuid}") - return SquadOperationResponse(success=True, message="Squad updated") - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to update squad", - ) + logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}') + return SquadOperationResponse(success=True, message='Squad updated') + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to update squad', + ) -@router.post("/squads/{squad_uuid}/action", response_model=SquadOperationResponse) +@router.post('/squads/{squad_uuid}/action', response_model=SquadOperationResponse) async def perform_squad_action( squad_uuid: str, payload: SquadActionRequest, @@ -562,41 +565,41 @@ async def perform_squad_action( action = payload.action success = False - message = "Unknown action" + message = 'Unknown action' - if action == "add_all_users": + if action == 'add_all_users': success = await service.add_all_users_to_squad(squad_uuid) - message = "Users added" if success else "Failed to add users" - elif action == "remove_all_users": + message = 'Users added' if success else 'Failed to add users' + elif action == 'remove_all_users': success = await service.remove_all_users_from_squad(squad_uuid) - message = "Users removed" if success else "Failed to remove users" - elif action == "delete": + message = 'Users removed' if success else 'Failed to remove users' + elif action == 'delete': success = await service.delete_squad(squad_uuid) - message = "Squad deleted" if success else "Failed to delete squad" - elif action == "rename": + message = 'Squad deleted' if success else 'Failed to delete squad' + elif action == 'rename': if not payload.name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Name is required for rename action", + detail='Name is required for rename action', ) success = await service.rename_squad(squad_uuid, payload.name) - message = "Squad renamed" if success else "Failed to rename squad" - elif action == "update_inbounds": + message = 'Squad renamed' if success else 'Failed to rename squad' + elif action == 'update_inbounds': if not payload.inbound_uuids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Inbound UUIDs are required", + detail='Inbound UUIDs are required', ) success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids) - message = "Inbounds updated" if success else "Failed to update inbounds" + message = 'Inbounds updated' if success else 'Failed to update inbounds' if success: - logger.info(f"Admin {admin.telegram_id} performed {action} on squad {squad_uuid}") + logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}') return SquadOperationResponse(success=success, message=message) -@router.delete("/squads/{squad_uuid}", response_model=SquadOperationResponse) +@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse) async def delete_squad( squad_uuid: str, admin: User = Depends(get_current_admin_user), @@ -608,18 +611,18 @@ async def delete_squad( success = await service.delete_squad(squad_uuid) if success: - logger.info(f"Admin {admin.telegram_id} deleted squad {squad_uuid}") - return SquadOperationResponse(success=True, message="Squad deleted") - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to delete squad", - ) + logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}') + return SquadOperationResponse(success=True, message='Squad deleted') + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to delete squad', + ) # ============ Migration ============ -@router.get("/squads/{squad_uuid}/migration-preview", response_model=MigrationPreviewResponse) + +@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse) async def preview_migration( squad_uuid: str, admin: User = Depends(get_current_admin_user), @@ -630,7 +633,7 @@ async def preview_migration( if not squad: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Squad not found in local database", + detail='Squad not found in local database', ) users_to_migrate = await count_active_users_for_squad(db, squad_uuid) @@ -644,7 +647,7 @@ async def preview_migration( ) -@router.post("/squads/migrate", response_model=MigrationResponse) +@router.post('/squads/migrate', response_model=MigrationResponse) async def migrate_squad_users( payload: MigrationRequest, admin: User = Depends(get_current_admin_user), @@ -660,21 +663,21 @@ async def migrate_squad_users( if source_uuid == target_uuid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Source and target squads must be different", + detail='Source and target squads must be different', ) source = await get_server_squad_by_uuid(db, source_uuid) if not source: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Source squad not found", + detail='Source squad not found', ) target = await get_server_squad_by_uuid(db, target_uuid) if not target: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Target squad not found", + detail='Target squad not found', ) try: @@ -689,34 +692,35 @@ async def migrate_squad_users( detail=str(exc), ) - if not result.get("success"): + if not result.get('success'): return MigrationResponse( success=False, - message=result.get("message") or "Migration failed", - error=result.get("error"), + message=result.get('message') or 'Migration failed', + error=result.get('error'), ) - logger.info(f"Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}") + logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}') return MigrationResponse( success=True, - message=result.get("message") or "Migration completed", + message=result.get('message') or 'Migration completed', data=MigrationStats( source_uuid=source.squad_uuid, target_uuid=target.squad_uuid, - total=result.get("total", 0), - updated=result.get("updated", 0), - panel_updated=result.get("panel_updated", 0), - panel_failed=result.get("panel_failed", 0), - source_removed=result.get("source_removed", 0), - target_added=result.get("target_added", 0), + total=result.get('total', 0), + updated=result.get('updated', 0), + panel_updated=result.get('panel_updated', 0), + panel_failed=result.get('panel_failed', 0), + source_removed=result.get('source_removed', 0), + target_added=result.get('target_added', 0), ), ) # ============ Inbounds ============ -@router.get("/inbounds", response_model=InboundsListResponse) + +@router.get('/inbounds', response_model=InboundsListResponse) async def list_inbounds( admin: User = Depends(get_current_admin_user), ) -> InboundsListResponse: @@ -730,7 +734,8 @@ async def list_inbounds( # ============ Auto Sync ============ -@router.get("/sync/auto/status", response_model=AutoSyncStatus) + +@router.get('/sync/auto/status', response_model=AutoSyncStatus) async def get_auto_sync_status( admin: User = Depends(get_current_admin_user), ) -> AutoSyncStatus: @@ -738,14 +743,14 @@ async def get_auto_sync_status( if remnawave_sync_service is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Auto sync service is not available", + detail='Auto sync service is not available', ) status_obj = remnawave_sync_service.get_status() return AutoSyncStatus( enabled=status_obj.enabled, - times=[t.strftime("%H:%M") for t in status_obj.times] if status_obj.times else [], + times=[t.strftime('%H:%M') for t in status_obj.times] if status_obj.times else [], next_run=status_obj.next_run, is_running=status_obj.is_running, last_run_started_at=status_obj.last_run_started_at, @@ -758,7 +763,7 @@ async def get_auto_sync_status( ) -@router.post("/sync/auto/toggle", response_model=SyncResponse) +@router.post('/sync/auto/toggle', response_model=SyncResponse) async def toggle_auto_sync( payload: AutoSyncToggleRequest, admin: User = Depends(get_current_admin_user), @@ -767,7 +772,7 @@ async def toggle_auto_sync( if remnawave_sync_service is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Auto sync service is not available", + detail='Auto sync service is not available', ) # This would need to update settings - for now just return info @@ -777,26 +782,25 @@ async def toggle_auto_sync( if payload.enabled and not current_status.enabled: # Enable - would need to update settings and refresh schedule remnawave_sync_service.schedule_refresh(run_immediately=True) - logger.info(f"Admin {admin.telegram_id} enabled auto sync") + logger.info(f'Admin {admin.telegram_id} enabled auto sync') return SyncResponse( success=True, - message="Auto sync enabled and scheduled", + message='Auto sync enabled and scheduled', ) - elif not payload.enabled and current_status.enabled: + if not payload.enabled and current_status.enabled: # Disable - would need to update settings and stop scheduler - logger.info(f"Admin {admin.telegram_id} disabled auto sync") + logger.info(f'Admin {admin.telegram_id} disabled auto sync') return SyncResponse( success=True, - message="Auto sync setting change requested. Restart may be required.", - ) - else: - return SyncResponse( - success=True, - message="No change needed", + message='Auto sync setting change requested. Restart may be required.', ) + return SyncResponse( + success=True, + message='No change needed', + ) -@router.post("/sync/auto/run", response_model=AutoSyncRunResponse) +@router.post('/sync/auto/run', response_model=AutoSyncRunResponse) async def run_auto_sync_now( admin: User = Depends(get_current_admin_user), ) -> AutoSyncRunResponse: @@ -804,25 +808,26 @@ async def run_auto_sync_now( if remnawave_sync_service is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Auto sync service is not available", + detail='Auto sync service is not available', ) - logger.info(f"Admin {admin.telegram_id} triggered manual sync") - result = await remnawave_sync_service.run_sync_now(reason="manual") + logger.info(f'Admin {admin.telegram_id} triggered manual sync') + result = await remnawave_sync_service.run_sync_now(reason='manual') return AutoSyncRunResponse( - started=result.get("started", False), - success=result.get("success"), - error=result.get("error"), - user_stats=result.get("user_stats"), - server_stats=result.get("server_stats"), - reason="manual", + started=result.get('started', False), + success=result.get('success'), + error=result.get('error'), + user_stats=result.get('user_stats'), + server_stats=result.get('server_stats'), + reason='manual', ) # ============ Manual Sync ============ -@router.post("/sync/from-panel", response_model=SyncResponse) + +@router.post('/sync/from-panel', response_model=SyncResponse) async def sync_from_panel( payload: SyncMode, admin: User = Depends(get_current_admin_user), @@ -834,10 +839,10 @@ async def sync_from_panel( try: stats = await service.sync_users_from_panel(db, payload.mode) - logger.info(f"Admin {admin.telegram_id} synced from panel (mode: {payload.mode})") + logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})') return SyncResponse( success=True, - message="Sync from panel completed", + message='Sync from panel completed', data=stats, ) except RemnaWaveConfigurationError as exc: @@ -847,7 +852,7 @@ async def sync_from_panel( ) -@router.post("/sync/to-panel", response_model=SyncResponse) +@router.post('/sync/to-panel', response_model=SyncResponse) async def sync_to_panel( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -857,16 +862,16 @@ async def sync_to_panel( _ensure_configured(service) stats = await service.sync_users_to_panel(db) - logger.info(f"Admin {admin.telegram_id} synced to panel") + logger.info(f'Admin {admin.telegram_id} synced to panel') return SyncResponse( success=True, - message="Sync to panel completed", + message='Sync to panel completed', data=stats, ) -@router.post("/sync/servers", response_model=SyncResponse) +@router.post('/sync/servers', response_model=SyncResponse) async def sync_servers( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -879,31 +884,31 @@ async def sync_servers( if not squads: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to get squads from RemnaWave", + detail='Failed to get squads from RemnaWave', ) created, updated, removed = await sync_with_remnawave(db, squads) try: - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') except Exception as e: - logger.warning(f"Failed to clear countries cache: {e}") + logger.warning(f'Failed to clear countries cache: {e}') - logger.info(f"Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}") + logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}') return SyncResponse( success=True, - message="Servers synced successfully", + message='Servers synced successfully', data={ - "created": created, - "updated": updated, - "removed": removed, - "total": len(squads), + 'created': created, + 'updated': updated, + 'removed': removed, + 'total': len(squads), }, ) -@router.post("/sync/subscriptions/validate", response_model=SyncResponse) +@router.post('/sync/subscriptions/validate', response_model=SyncResponse) async def validate_subscriptions( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -913,16 +918,16 @@ async def validate_subscriptions( _ensure_configured(service) stats = await service.validate_and_fix_subscriptions(db) - logger.info(f"Admin {admin.telegram_id} validated subscriptions") + logger.info(f'Admin {admin.telegram_id} validated subscriptions') return SyncResponse( success=True, - message="Subscriptions validated", + message='Subscriptions validated', data=stats, ) -@router.post("/sync/subscriptions/cleanup", response_model=SyncResponse) +@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse) async def cleanup_subscriptions( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -932,16 +937,16 @@ async def cleanup_subscriptions( _ensure_configured(service) stats = await service.cleanup_orphaned_subscriptions(db) - logger.info(f"Admin {admin.telegram_id} cleaned up subscriptions") + logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions') return SyncResponse( success=True, - message="Cleanup completed", + message='Cleanup completed', data=stats, ) -@router.post("/sync/subscriptions/statuses", response_model=SyncResponse) +@router.post('/sync/subscriptions/statuses', response_model=SyncResponse) async def sync_subscription_statuses( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -951,16 +956,16 @@ async def sync_subscription_statuses( _ensure_configured(service) stats = await service.sync_subscription_statuses(db) - logger.info(f"Admin {admin.telegram_id} synced subscription statuses") + logger.info(f'Admin {admin.telegram_id} synced subscription statuses') return SyncResponse( success=True, - message="Subscription statuses synced", + message='Subscription statuses synced', data=stats, ) -@router.get("/sync/recommendations", response_model=SyncResponse) +@router.get('/sync/recommendations', response_model=SyncResponse) async def get_sync_recommendations( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -973,6 +978,6 @@ async def get_sync_recommendations( return SyncResponse( success=True, - message="Recommendations retrieved", + message='Recommendations retrieved', data=data, ) diff --git a/app/cabinet/routes/admin_servers.py b/app/cabinet/routes/admin_servers.py index 642eedf2..48072276 100644 --- a/app/cabinet/routes/admin_servers.py +++ b/app/cabinet/routes/admin_servers.py @@ -1,43 +1,42 @@ """Admin routes for managing servers in cabinet.""" import logging -from typing import List from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import String, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, String -from sqlalchemy.orm import selectinload -from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup from app.database.crud.server_squad import ( + count_active_users_for_squad, get_all_server_squads, get_server_squad_by_id, + sync_with_remnawave, update_server_squad, update_server_squad_promo_groups, - sync_with_remnawave, - count_active_users_for_squad, ) +from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User from app.services.subscription_service import SubscriptionService from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.servers import ( - ServerListResponse, - ServerListItem, + PromoGroupInfo, ServerDetailResponse, - ServerUpdateRequest, - ServerToggleResponse, - ServerTrialToggleResponse, + ServerListItem, + ServerListResponse, ServerStatsResponse, ServerSyncResponse, - PromoGroupInfo, + ServerToggleResponse, + ServerTrialToggleResponse, + ServerUpdateRequest, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"]) +router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers']) -async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]: +async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> list[PromoGroupInfo]: """Get promo group info for server.""" result = await db.execute(select(PromoGroup).order_by(PromoGroup.name)) all_groups = result.scalars().all() @@ -54,7 +53,7 @@ async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> Lis ] -async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]: +async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[str]: """Get list of tariff names using this server.""" # Get all tariffs and filter in Python since JSON array queries are DB-specific result = await db.execute(select(Tariff.name, Tariff.allowed_squads)) @@ -65,7 +64,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[s return tariff_names -@router.get("", response_model=ServerListResponse) +@router.get('', response_model=ServerListResponse) async def list_servers( include_unavailable: bool = True, admin: User = Depends(get_current_admin_user), @@ -79,28 +78,30 @@ async def list_servers( items = [] for server in servers: - items.append(ServerListItem( - id=server.id, - squad_uuid=server.squad_uuid, - display_name=server.display_name, - original_name=server.original_name, - country_code=server.country_code, - is_available=server.is_available, - is_trial_eligible=server.is_trial_eligible, - price_kopeks=server.price_kopeks, - price_rubles=server.price_kopeks / 100, - max_users=server.max_users, - current_users=server.current_users or 0, - sort_order=server.sort_order, - is_full=server.is_full, - availability_status=server.availability_status, - created_at=server.created_at, - )) + items.append( + ServerListItem( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + original_name=server.original_name, + country_code=server.country_code, + is_available=server.is_available, + is_trial_eligible=server.is_trial_eligible, + price_kopeks=server.price_kopeks, + price_rubles=server.price_kopeks / 100, + max_users=server.max_users, + current_users=server.current_users or 0, + sort_order=server.sort_order, + is_full=server.is_full, + availability_status=server.availability_status, + created_at=server.created_at, + ) + ) return ServerListResponse(servers=items, total=total) -@router.get("/{server_id}", response_model=ServerDetailResponse) +@router.get('/{server_id}', response_model=ServerDetailResponse) async def get_server( server_id: int, admin: User = Depends(get_current_admin_user), @@ -111,7 +112,7 @@ async def get_server( if not server: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Server not found", + detail='Server not found', ) promo_groups = await _get_server_promo_groups(db, server) @@ -142,7 +143,7 @@ async def get_server( ) -@router.put("/{server_id}", response_model=ServerDetailResponse) +@router.put('/{server_id}', response_model=ServerDetailResponse) async def update_existing_server( server_id: int, request: ServerUpdateRequest, @@ -154,27 +155,27 @@ async def update_existing_server( if not server: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Server not found", + detail='Server not found', ) # Build updates dict updates = {} if request.display_name is not None: - updates["display_name"] = request.display_name + updates['display_name'] = request.display_name if request.description is not None: - updates["description"] = request.description + updates['description'] = request.description if request.country_code is not None: - updates["country_code"] = request.country_code + updates['country_code'] = request.country_code if request.is_available is not None: - updates["is_available"] = request.is_available + updates['is_available'] = request.is_available if request.is_trial_eligible is not None: - updates["is_trial_eligible"] = request.is_trial_eligible + updates['is_trial_eligible'] = request.is_trial_eligible if request.price_kopeks is not None: - updates["price_kopeks"] = request.price_kopeks + updates['price_kopeks'] = request.price_kopeks if request.max_users is not None: - updates["max_users"] = request.max_users if request.max_users > 0 else None + updates['max_users'] = request.max_users if request.max_users > 0 else None if request.sort_order is not None: - updates["sort_order"] = request.sort_order + updates['sort_order'] = request.sort_order if updates: await update_server_squad(db, server_id, **updates) @@ -183,12 +184,12 @@ async def update_existing_server( if request.promo_group_ids is not None: await update_server_squad_promo_groups(db, server_id, request.promo_group_ids) - logger.info(f"Admin {admin.id} updated server {server_id}") + logger.info(f'Admin {admin.id} updated server {server_id}') return await get_server(server_id, admin, db) -@router.post("/{server_id}/toggle", response_model=ServerToggleResponse) +@router.post('/{server_id}/toggle', response_model=ServerToggleResponse) async def toggle_server( server_id: int, admin: User = Depends(get_current_admin_user), @@ -199,23 +200,23 @@ async def toggle_server( if not server: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Server not found", + detail='Server not found', ) new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - status_text = "enabled" if new_status else "disabled" - logger.info(f"Admin {admin.id} {status_text} server {server_id}") + status_text = 'enabled' if new_status else 'disabled' + logger.info(f'Admin {admin.id} {status_text} server {server_id}') return ServerToggleResponse( id=server_id, is_available=new_status, - message=f"Server {status_text}", + message=f'Server {status_text}', ) -@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse) +@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse) async def toggle_server_trial( server_id: int, admin: User = Depends(get_current_admin_user), @@ -226,23 +227,23 @@ async def toggle_server_trial( if not server: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Server not found", + detail='Server not found', ) new_status = not server.is_trial_eligible await update_server_squad(db, server_id, is_trial_eligible=new_status) - status_text = "enabled for trial" if new_status else "disabled for trial" - logger.info(f"Admin {admin.id} {status_text} server {server_id}") + status_text = 'enabled for trial' if new_status else 'disabled for trial' + logger.info(f'Admin {admin.id} {status_text} server {server_id}') return ServerTrialToggleResponse( id=server_id, is_trial_eligible=new_status, - message=f"Server {status_text}", + message=f'Server {status_text}', ) -@router.get("/{server_id}/stats", response_model=ServerStatsResponse) +@router.get('/{server_id}/stats', response_model=ServerStatsResponse) async def get_server_stats( server_id: int, admin: User = Depends(get_current_admin_user), @@ -253,7 +254,7 @@ async def get_server_stats( if not server: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Server not found", + detail='Server not found', ) active_subs = await count_active_users_for_squad(db, server.squad_uuid) @@ -261,10 +262,9 @@ async def get_server_stats( # Count trial subscriptions on this server # Use LIKE query for JSON array since .contains() is DB-specific trial_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.is_trial == True, - Subscription.status == "active", + Subscription.status == 'active', func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'), ) ) @@ -286,7 +286,7 @@ async def get_server_stats( ) -@router.post("/sync", response_model=ServerSyncResponse) +@router.post('/sync', response_model=ServerSyncResponse) async def sync_servers( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -297,7 +297,7 @@ async def sync_servers( if not subscription_service.is_configured: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="RemnaWave is not configured", + detail='RemnaWave is not configured', ) # Get squads from RemnaWave @@ -305,26 +305,26 @@ async def sync_servers( if squads is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to fetch squads from RemnaWave", + detail='Failed to fetch squads from RemnaWave', ) # Sync with database created, updated, removed = await sync_with_remnawave(db, squads) - logger.info(f"Admin {admin.id} synced servers: +{created} ~{updated} -{removed}") + logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}') return ServerSyncResponse( created=created, updated=updated, removed=removed, - message=f"Synced: {created} created, {updated} updated, {removed} removed", + message=f'Synced: {created} created, {updated} updated, {removed} removed', ) except HTTPException: raise except Exception as e: - logger.error(f"Failed to sync servers: {e}") + logger.error(f'Failed to sync servers: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Sync failed: {str(e)}", + detail=f'Sync failed: {e!s}', ) diff --git a/app/cabinet/routes/admin_settings.py b/app/cabinet/routes/admin_settings.py index 696a1274..a2fa69fb 100644 --- a/app/cabinet/routes/admin_settings.py +++ b/app/cabinet/routes/admin_settings.py @@ -1,9 +1,9 @@ """Admin settings routes for cabinet - system configuration management.""" import logging -from typing import Any, Optional, List +from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -15,44 +15,51 @@ from app.services.system_settings_service import ( from ..dependencies import get_cabinet_db, get_current_admin_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"]) +router = APIRouter(prefix='/admin/settings', tags=['Admin Settings']) # ============ Schemas ============ + class SettingCategoryRef(BaseModel): """Reference to category.""" + key: str label: str class SettingCategorySummary(BaseModel): """Category summary.""" + key: str label: str - description: str = "" + description: str = '' items: int class SettingChoice(BaseModel): """Choice option for setting.""" + value: Any label: str - description: Optional[str] = None + description: str | None = None class SettingHint(BaseModel): """Setting hints and guidance.""" - description: str = "" - format: str = "" - example: str = "" - warning: str = "" + + description: str = '' + format: str = '' + example: str = '' + warning: str = '' class SettingDefinition(BaseModel): """Full setting definition with current state.""" + key: str name: str category: SettingCategoryRef @@ -62,17 +69,19 @@ class SettingDefinition(BaseModel): original: Any = Field(default=None) has_override: bool read_only: bool = Field(default=False) - choices: List[SettingChoice] = Field(default_factory=list) - hint: Optional[SettingHint] = None + choices: list[SettingChoice] = Field(default_factory=list) + hint: SettingHint | None = None class SettingUpdateRequest(BaseModel): """Request to update setting value.""" + value: Any # ============ Helper Functions ============ + def _coerce_value(key: str, value: Any) -> Any: """Convert and validate value for a setting.""" definition = bot_configuration_service.get_definition(key) @@ -80,7 +89,7 @@ def _coerce_value(key: str, value: Any) -> Any: if value is None: if definition.is_optional: return None - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required') python_type = definition.python_type @@ -90,14 +99,14 @@ def _coerce_value(key: str, value: Any) -> Any: normalized = value elif isinstance(value, str): lowered = value.strip().lower() - if lowered in {"true", "1", "yes", "on", "да"}: + if lowered in {'true', '1', 'yes', 'on', 'да'}: normalized = True - elif lowered in {"false", "0", "no", "off", "нет"}: + elif lowered in {'false', '0', 'no', 'off', 'нет'}: normalized = False else: - raise ValueError("invalid bool") + raise ValueError('invalid bool') else: - raise ValueError("invalid bool") + raise ValueError('invalid bool') elif python_type is int: normalized = int(value) @@ -106,16 +115,16 @@ def _coerce_value(key: str, value: Any) -> Any: else: normalized = str(value) except ValueError: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None + 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) + 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}", + detail=f'Value must be one of: {readable}', ) return normalized @@ -127,7 +136,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe original = bot_configuration_service.get_original_value(definition.key) has_override = bot_configuration_service.has_override(definition.key) - choices: List[SettingChoice] = [] + choices: list[SettingChoice] = [] if include_choices: choices = [ SettingChoice( @@ -141,10 +150,10 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe # Get setting hints guidance = bot_configuration_service.get_setting_guidance(definition.key) hint = SettingHint( - description=guidance.get("description", ""), - format=guidance.get("format", ""), - example=guidance.get("example", ""), - warning=guidance.get("warning", ""), + description=guidance.get('description', ''), + format=guidance.get('format', ''), + example=guidance.get('example', ''), + warning=guidance.get('warning', ''), ) return SettingDefinition( @@ -167,7 +176,8 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe # ============ Routes ============ -@router.get("/categories", response_model=List[SettingCategorySummary]) + +@router.get('/categories', response_model=list[SettingCategorySummary]) async def list_categories( admin: User = Depends(get_current_admin_user), ): @@ -184,13 +194,13 @@ async def list_categories( ] -@router.get("", response_model=List[SettingDefinition]) +@router.get('', response_model=list[SettingDefinition]) async def list_settings( admin: User = Depends(get_current_admin_user), - category: Optional[str] = Query(default=None, alias="category_key"), + category: str | None = Query(default=None, alias='category_key'), ): """Get list of all settings or settings for a specific category.""" - items: List[SettingDefinition] = [] + items: list[SettingDefinition] = [] if category: definitions = bot_configuration_service.get_settings_for_category(category) @@ -204,7 +214,7 @@ async def list_settings( return items -@router.get("/{key}", response_model=SettingDefinition) +@router.get('/{key}', response_model=SettingDefinition) async def get_setting( key: str, admin: User = Depends(get_current_admin_user), @@ -213,12 +223,12 @@ async def get_setting( try: definition = bot_configuration_service.get_definition(key) except KeyError as error: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error return _serialize_definition(definition) -@router.put("/{key}", response_model=SettingDefinition) +@router.put('/{key}', response_model=SettingDefinition) async def update_setting( key: str, payload: SettingUpdateRequest, @@ -229,7 +239,7 @@ async def update_setting( try: definition = bot_configuration_service.get_definition(key) except KeyError as error: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error value = _coerce_value(key, payload.value) try: @@ -238,11 +248,11 @@ async def update_setting( raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() - logger.info(f"Admin {admin.telegram_id} updated setting {key} to {value}") + logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}') return _serialize_definition(definition) -@router.delete("/{key}", response_model=SettingDefinition) +@router.delete('/{key}', response_model=SettingDefinition) async def reset_setting( key: str, admin: User = Depends(get_current_admin_user), @@ -252,7 +262,7 @@ async def reset_setting( try: definition = bot_configuration_service.get_definition(key) except KeyError as error: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error try: await bot_configuration_service.reset_value(db, key) @@ -260,5 +270,5 @@ async def reset_setting( raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error await db.commit() - logger.info(f"Admin {admin.telegram_id} reset setting {key}") + logger.info(f'Admin {admin.telegram_id} reset setting {key}') return _serialize_definition(definition) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index 7dbd5671..ec2f586b 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -2,66 +2,74 @@ import logging from datetime import datetime, timedelta -from typing import List, Dict, Any, Optional from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ - -from app.database.crud.subscription import get_subscriptions_statistics -from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period +from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list from app.database.crud.server_squad import get_server_statistics -from app.database.crud.campaign import get_campaigns_list, get_campaign_statistics, get_campaigns_count +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics +from app.database.models import ( + ReferralEarning, + Subscription, + SubscriptionStatus, + Tariff, + Transaction, + TransactionType, + User, +) from app.services.remnawave_service import RemnaWaveService from ..dependencies import get_cabinet_db, get_current_admin_user -from app.database.models import ( - User, Subscription, Tariff, SubscriptionStatus, - Transaction, TransactionType, ReferralEarning, -) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/stats", tags=["Cabinet Admin Stats"]) +router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats']) # ============ Schemas ============ + class NodeStatus(BaseModel): """Node status info.""" + uuid: str name: str address: str is_connected: bool is_disabled: bool users_online: int - traffic_used_bytes: Optional[int] = None - uptime: Optional[str] = None - xray_version: Optional[str] = None - node_version: Optional[str] = None - last_status_message: Optional[str] = None - xray_uptime: Optional[str] = None - is_xray_running: Optional[bool] = None - cpu_count: Optional[int] = None - cpu_model: Optional[str] = None - total_ram: Optional[str] = None - country_code: Optional[str] = None + traffic_used_bytes: int | None = None + uptime: str | None = None + xray_version: str | None = None + node_version: str | None = None + last_status_message: str | None = None + xray_uptime: str | None = None + is_xray_running: bool | None = None + cpu_count: int | None = None + cpu_model: str | None = None + total_ram: str | None = None + country_code: str | None = None class NodesOverview(BaseModel): """Overview of all nodes.""" + total: int online: int offline: int disabled: int total_users_online: int - nodes: List[NodeStatus] + nodes: list[NodeStatus] class RevenueData(BaseModel): """Revenue data point.""" + date: str amount_kopeks: int amount_rubles: float @@ -69,6 +77,7 @@ class RevenueData(BaseModel): class SubscriptionStats(BaseModel): """Subscription statistics.""" + total: int active: int trial: int @@ -82,6 +91,7 @@ class SubscriptionStats(BaseModel): class FinancialStats(BaseModel): """Financial statistics.""" + income_today_kopeks: int income_today_rubles: float income_month_kopeks: int @@ -94,6 +104,7 @@ class FinancialStats(BaseModel): class ServerStats(BaseModel): """Server statistics.""" + total_servers: int available_servers: int servers_with_connections: int @@ -103,6 +114,7 @@ class ServerStats(BaseModel): class TariffStatItem(BaseModel): """Statistics for a single tariff.""" + tariff_id: int tariff_name: str active_subscriptions: int @@ -114,28 +126,32 @@ class TariffStatItem(BaseModel): class TariffStats(BaseModel): """Tariff statistics.""" - tariffs: List[TariffStatItem] + + tariffs: list[TariffStatItem] total_tariff_subscriptions: int class DashboardStats(BaseModel): """Complete dashboard statistics.""" + nodes: NodesOverview subscriptions: SubscriptionStats financial: FinancialStats servers: ServerStats - revenue_chart: List[RevenueData] - tariff_stats: Optional[TariffStats] = None + revenue_chart: list[RevenueData] + tariff_stats: TariffStats | None = None # ============ Extended Stats Schemas ============ + class TopReferrerItem(BaseModel): """Single referrer in top list.""" + user_id: int - telegram_id: Optional[int] = None # Can be None for email-only users - email: Optional[str] = None - username: Optional[str] = None + telegram_id: int | None = None # Can be None for email-only users + email: str | None = None + username: str | None = None display_name: str invited_count: int invited_today: int = 0 @@ -149,8 +165,9 @@ class TopReferrerItem(BaseModel): class TopReferrersResponse(BaseModel): """Top referrers response.""" - by_earnings: List[TopReferrerItem] - by_invited: List[TopReferrerItem] + + by_earnings: list[TopReferrerItem] + by_invited: list[TopReferrerItem] total_referrers: int total_referrals: int total_earnings_kopeks: int @@ -158,6 +175,7 @@ class TopReferrersResponse(BaseModel): class TopCampaignItem(BaseModel): """Single campaign in top list.""" + id: int name: str start_parameter: str @@ -168,12 +186,13 @@ class TopCampaignItem(BaseModel): conversion_rate: float total_revenue_kopeks: int avg_revenue_per_user_kopeks: int - created_at: Optional[str] = None + created_at: str | None = None class TopCampaignsResponse(BaseModel): """Top campaigns response.""" - campaigns: List[TopCampaignItem] + + campaigns: list[TopCampaignItem] total_campaigns: int total_registrations: int total_revenue_kopeks: int @@ -181,25 +200,27 @@ class TopCampaignsResponse(BaseModel): class RecentPaymentItem(BaseModel): """Single recent payment.""" + id: int user_id: int - telegram_id: Optional[int] = None # Can be None for email-only users - email: Optional[str] = None - username: Optional[str] = None + telegram_id: int | None = None # Can be None for email-only users + email: str | None = None + username: str | None = None display_name: str amount_kopeks: int amount_rubles: float type: str type_display: str - payment_method: Optional[str] = None - description: Optional[str] = None + payment_method: str | None = None + description: str | None = None created_at: str is_completed: bool class RecentPaymentsResponse(BaseModel): """Recent payments response.""" - payments: List[RecentPaymentItem] + + payments: list[RecentPaymentItem] total_count: int total_today_kopeks: int total_week_kopeks: int @@ -207,7 +228,8 @@ class RecentPaymentsResponse(BaseModel): # ============ Routes ============ -@router.get("/dashboard", response_model=DashboardStats) + +@router.get('/dashboard', response_model=DashboardStats) async def get_dashboard_stats( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -239,38 +261,40 @@ async def get_dashboard_stats( return DashboardStats( nodes=nodes_data, subscriptions=SubscriptionStats( - total=sub_stats.get("total_subscriptions", 0), - active=sub_stats.get("active_subscriptions", 0), - trial=sub_stats.get("trial_subscriptions", 0), - paid=sub_stats.get("paid_subscriptions", 0), - expired=sub_stats.get("total_subscriptions", 0) - sub_stats.get("active_subscriptions", 0), - purchased_today=sub_stats.get("purchased_today", 0), - purchased_week=sub_stats.get("purchased_week", 0), - purchased_month=sub_stats.get("purchased_month", 0), - trial_to_paid_conversion=sub_stats.get("trial_to_paid_conversion", 0.0), + total=sub_stats.get('total_subscriptions', 0), + active=sub_stats.get('active_subscriptions', 0), + trial=sub_stats.get('trial_subscriptions', 0), + paid=sub_stats.get('paid_subscriptions', 0), + expired=sub_stats.get('total_subscriptions', 0) - sub_stats.get('active_subscriptions', 0), + purchased_today=sub_stats.get('purchased_today', 0), + purchased_week=sub_stats.get('purchased_week', 0), + purchased_month=sub_stats.get('purchased_month', 0), + trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0), ), financial=FinancialStats( - income_today_kopeks=trans_stats.get("today", {}).get("income_kopeks", 0), - income_today_rubles=trans_stats.get("today", {}).get("income_kopeks", 0) / 100, - income_month_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0), - income_month_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100, - income_total_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0), - income_total_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100, - subscription_income_kopeks=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0), - subscription_income_rubles=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0) / 100, + income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0), + income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100, + income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), + income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, + income_total_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0), + income_total_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100, + subscription_income_kopeks=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0), + subscription_income_rubles=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0) / 100, ), servers=ServerStats( - total_servers=server_stats.get("total_servers", 0), - available_servers=server_stats.get("available_servers", 0), - servers_with_connections=server_stats.get("servers_with_connections", 0), - total_revenue_kopeks=server_stats.get("total_revenue_kopeks", 0), - total_revenue_rubles=server_stats.get("total_revenue_rubles", 0.0), + total_servers=server_stats.get('total_servers', 0), + available_servers=server_stats.get('available_servers', 0), + servers_with_connections=server_stats.get('servers_with_connections', 0), + total_revenue_kopeks=server_stats.get('total_revenue_kopeks', 0), + total_revenue_rubles=server_stats.get('total_revenue_rubles', 0.0), ), revenue_chart=[ RevenueData( - date=item.get("date", "").isoformat() if hasattr(item.get("date", ""), "isoformat") else str(item.get("date", "")), - amount_kopeks=item.get("amount_kopeks", 0), - amount_rubles=item.get("amount_kopeks", 0) / 100, + date=item.get('date', '').isoformat() + if hasattr(item.get('date', ''), 'isoformat') + else str(item.get('date', '')), + amount_kopeks=item.get('amount_kopeks', 0), + amount_rubles=item.get('amount_kopeks', 0) / 100, ) for item in revenue_data ], @@ -278,14 +302,14 @@ async def get_dashboard_stats( ) except Exception as e: - logger.error(f"Failed to get dashboard stats: {e}") + logger.error(f'Failed to get dashboard stats: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load dashboard statistics", + detail='Failed to load dashboard statistics', ) -@router.get("/nodes", response_model=NodesOverview) +@router.get('/nodes', response_model=NodesOverview) async def get_nodes_status( admin: User = Depends(get_current_admin_user), ): @@ -293,14 +317,14 @@ async def get_nodes_status( try: return await _get_nodes_overview() except Exception as e: - logger.error(f"Failed to get nodes status: {e}") + logger.error(f'Failed to get nodes status: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load nodes status", + detail='Failed to load nodes status', ) -@router.post("/nodes/{node_uuid}/restart") +@router.post('/nodes/{node_uuid}/restart') async def restart_node( node_uuid: str, admin: User = Depends(get_current_admin_user), @@ -308,27 +332,26 @@ async def restart_node( """Restart a node.""" try: service = RemnaWaveService() - success = await service.manage_node(node_uuid, "restart") + success = await service.manage_node(node_uuid, 'restart') if success: - logger.info(f"Admin {admin.id} restarted node {node_uuid}") - return {"success": True, "message": "Node restart initiated"} - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to restart node", - ) + logger.info(f'Admin {admin.id} restarted node {node_uuid}') + return {'success': True, 'message': 'Node restart initiated'} + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to restart node', + ) except HTTPException: raise except Exception as e: - logger.error(f"Failed to restart node {node_uuid}: {e}") + logger.error(f'Failed to restart node {node_uuid}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to restart node", + detail='Failed to restart node', ) -@router.post("/nodes/{node_uuid}/toggle") +@router.post('/nodes/{node_uuid}/toggle') async def toggle_node( node_uuid: str, admin: User = Depends(get_current_admin_user), @@ -338,32 +361,31 @@ async def toggle_node( service = RemnaWaveService() nodes = await service.get_all_nodes() - node = next((n for n in nodes if n.get("uuid") == node_uuid), None) + node = next((n for n in nodes if n.get('uuid') == node_uuid), None) if not node: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Node not found", + detail='Node not found', ) - is_disabled = node.get("is_disabled", False) - action = "enable" if is_disabled else "disable" + is_disabled = node.get('is_disabled', False) + action = 'enable' if is_disabled else 'disable' success = await service.manage_node(node_uuid, action) if success: - logger.info(f"Admin {admin.id} {action}d node {node_uuid}") - return {"success": True, "message": f"Node {action}d", "is_disabled": not is_disabled} - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Failed to {action} node", - ) + logger.info(f'Admin {admin.id} {action}d node {node_uuid}') + return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled} + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Failed to {action} node', + ) except HTTPException: raise except Exception as e: - logger.error(f"Failed to toggle node {node_uuid}: {e}") + logger.error(f'Failed to toggle node {node_uuid}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to toggle node", + detail='Failed to toggle node', ) @@ -374,30 +396,30 @@ async def _get_nodes_overview() -> NodesOverview: nodes = await service.get_all_nodes() total = len(nodes) - online = sum(1 for n in nodes if n.get("is_connected") and not n.get("is_disabled")) - disabled = sum(1 for n in nodes if n.get("is_disabled")) + online = sum(1 for n in nodes if n.get('is_connected') and not n.get('is_disabled')) + disabled = sum(1 for n in nodes if n.get('is_disabled')) offline = total - online - disabled - total_users_online = sum(n.get("users_online", 0) or 0 for n in nodes) + total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes) node_statuses = [ NodeStatus( - uuid=n.get("uuid", ""), - name=n.get("name", "Unknown"), - address=n.get("address", ""), - is_connected=n.get("is_connected", False), - is_disabled=n.get("is_disabled", False), - users_online=n.get("users_online", 0) or 0, - traffic_used_bytes=n.get("traffic_used_bytes"), - uptime=n.get("uptime"), - xray_version=n.get("xray_version"), - node_version=n.get("node_version"), - last_status_message=n.get("last_status_message"), - xray_uptime=n.get("xray_uptime"), - is_xray_running=n.get("is_xray_running"), - cpu_count=n.get("cpu_count"), - cpu_model=n.get("cpu_model"), - total_ram=n.get("total_ram"), - country_code=n.get("country_code"), + uuid=n.get('uuid', ''), + name=n.get('name', 'Unknown'), + address=n.get('address', ''), + is_connected=n.get('is_connected', False), + is_disabled=n.get('is_disabled', False), + users_online=n.get('users_online', 0) or 0, + traffic_used_bytes=n.get('traffic_used_bytes'), + uptime=n.get('uptime'), + xray_version=n.get('xray_version'), + node_version=n.get('node_version'), + last_status_message=n.get('last_status_message'), + xray_uptime=n.get('xray_uptime'), + is_xray_running=n.get('is_xray_running'), + cpu_count=n.get('cpu_count'), + cpu_model=n.get('cpu_model'), + total_ram=n.get('total_ram'), + country_code=n.get('country_code'), ) for n in nodes ] @@ -411,7 +433,7 @@ async def _get_nodes_overview() -> NodesOverview: nodes=node_statuses, ) except Exception as e: - logger.warning(f"Failed to get nodes from RemnaWave: {e}") + logger.warning(f'Failed to get nodes from RemnaWave: {e}') # Return empty data if RemnaWave is unavailable return NodesOverview( total=0, @@ -423,18 +445,15 @@ async def _get_nodes_overview() -> NodesOverview: ) -async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]: +async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None: """Get statistics for all tariffs.""" try: # Получаем ВСЕ тарифы (включая неактивные) для статистики - tariffs_result = await db.execute( - select(Tariff) - .order_by(Tariff.display_order) - ) + tariffs_result = await db.execute(select(Tariff).order_by(Tariff.display_order)) tariffs = tariffs_result.scalars().all() if not tariffs: - logger.info("📊 Нет тарифов в системе, пропускаем статистику") + logger.info('📊 Нет тарифов в системе, пропускаем статистику') return None now = datetime.utcnow() @@ -448,73 +467,69 @@ async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]: for tariff in tariffs: # Активные подписки на этом тарифе active_result = await db.execute( - select(func.count(Subscription.id)) - .where( - Subscription.tariff_id == tariff.id, - Subscription.status == SubscriptionStatus.ACTIVE.value + select(func.count(Subscription.id)).where( + Subscription.tariff_id == tariff.id, Subscription.status == SubscriptionStatus.ACTIVE.value ) ) active_count = active_result.scalar() or 0 # Триальные подписки на этом тарифе trial_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff.id, Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.is_trial == True + Subscription.is_trial == True, ) ) trial_count = trial_result.scalar() or 0 # Куплено сегодня (не триальные) today_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff.id, Subscription.created_at >= today_start, - Subscription.is_trial == False + Subscription.is_trial == False, ) ) purchased_today = today_result.scalar() or 0 # Куплено за неделю week_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff.id, Subscription.created_at >= week_ago, - Subscription.is_trial == False + Subscription.is_trial == False, ) ) purchased_week = week_result.scalar() or 0 # Куплено за месяц month_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff.id, Subscription.created_at >= month_ago, - Subscription.is_trial == False + Subscription.is_trial == False, ) ) purchased_month = month_result.scalar() or 0 logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}") - tariff_items.append(TariffStatItem( - tariff_id=tariff.id, - tariff_name=tariff.name, - active_subscriptions=active_count, - trial_subscriptions=trial_count, - purchased_today=purchased_today, - purchased_week=purchased_week, - purchased_month=purchased_month, - )) + tariff_items.append( + TariffStatItem( + tariff_id=tariff.id, + tariff_name=tariff.name, + active_subscriptions=active_count, + trial_subscriptions=trial_count, + purchased_today=purchased_today, + purchased_week=purchased_week, + purchased_month=purchased_month, + ) + ) total_tariff_subscriptions += active_count - logger.info(f"📊 Всего подписок по тарифам: {total_tariff_subscriptions}") + logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}') return TariffStats( tariffs=tariff_items, @@ -522,13 +537,14 @@ async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]: ) except Exception as e: - logger.error(f"Failed to get tariff stats: {e}", exc_info=True) + logger.error(f'Failed to get tariff stats: {e}', exc_info=True) return None # ============ Extended Stats Routes ============ -@router.get("/referrals/top", response_model=TopReferrersResponse) + +@router.get('/referrals/top', response_model=TopReferrersResponse) async def get_top_referrers( limit: int = 20, admin: User = Depends(get_current_admin_user), @@ -543,10 +559,7 @@ async def get_top_referrers( # Get all referrers with their stats referrers_query = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('total_invited') - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('total_invited')) .where(User.referred_by_id.isnot(None)) .group_by(User.referred_by_id) ) @@ -555,16 +568,8 @@ async def get_top_referrers( # Get invited counts by period for each referrer # Today today_invited_query = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('count') - ) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= today_start - ) - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('count')) + .where(and_(User.referred_by_id.isnot(None), User.created_at >= today_start)) .group_by(User.referred_by_id) ) for row in today_invited_query: @@ -573,16 +578,8 @@ async def get_top_referrers( # Week week_invited_query = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('count') - ) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= week_ago - ) - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('count')) + .where(and_(User.referred_by_id.isnot(None), User.created_at >= week_ago)) .group_by(User.referred_by_id) ) for row in week_invited_query: @@ -591,16 +588,8 @@ async def get_top_referrers( # Month month_invited_query = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('count') - ) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= month_ago - ) - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('count')) + .where(and_(User.referred_by_id.isnot(None), User.created_at >= month_ago)) .group_by(User.referred_by_id) ) for row in month_invited_query: @@ -611,10 +600,8 @@ async def get_top_referrers( # Total earnings total_earnings_query = await db.execute( select( - ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('total') - ) - .group_by(ReferralEarning.user_id) + ReferralEarning.user_id.label('referrer_id'), func.sum(ReferralEarning.amount_kopeks).label('total') + ).group_by(ReferralEarning.user_id) ) for row in total_earnings_query: if row.referrer_id in referrers_data: @@ -622,10 +609,7 @@ async def get_top_referrers( # Today earnings today_earnings_query = await db.execute( - select( - ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('total') - ) + select(ReferralEarning.user_id.label('referrer_id'), func.sum(ReferralEarning.amount_kopeks).label('total')) .where(ReferralEarning.created_at >= today_start) .group_by(ReferralEarning.user_id) ) @@ -635,10 +619,7 @@ async def get_top_referrers( # Week earnings week_earnings_query = await db.execute( - select( - ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('total') - ) + select(ReferralEarning.user_id.label('referrer_id'), func.sum(ReferralEarning.amount_kopeks).label('total')) .where(ReferralEarning.created_at >= week_ago) .group_by(ReferralEarning.user_id) ) @@ -648,10 +629,7 @@ async def get_top_referrers( # Month earnings month_earnings_query = await db.execute( - select( - ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('total') - ) + select(ReferralEarning.user_id.label('referrer_id'), func.sum(ReferralEarning.amount_kopeks).label('total')) .where(ReferralEarning.created_at >= month_ago) .group_by(ReferralEarning.user_id) ) @@ -661,78 +639,58 @@ async def get_top_referrers( # Also add REFERRAL_REWARD transactions trans_total_query = await db.execute( - select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('total') - ) + select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) .group_by(Transaction.user_id) ) for row in trans_total_query: if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_total'] = \ - referrers_data[row.referrer_id].get('earnings_total', 0) + (row.total or 0) + referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get( + 'earnings_total', 0 + ) + (row.total or 0) trans_today_query = await db.execute( - select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('total') - ) + select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= today_start - ) + and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start) ) .group_by(Transaction.user_id) ) for row in trans_today_query: if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_today'] = \ - referrers_data[row.referrer_id].get('earnings_today', 0) + (row.total or 0) + referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get( + 'earnings_today', 0 + ) + (row.total or 0) trans_week_query = await db.execute( - select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('total') - ) - .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= week_ago - ) - ) + select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) + .where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago)) .group_by(Transaction.user_id) ) for row in trans_week_query: if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_week'] = \ - referrers_data[row.referrer_id].get('earnings_week', 0) + (row.total or 0) + referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get( + 'earnings_week', 0 + ) + (row.total or 0) trans_month_query = await db.execute( - select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('total') - ) - .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= month_ago - ) - ) + select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total')) + .where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago)) .group_by(Transaction.user_id) ) for row in trans_month_query: if row.referrer_id in referrers_data: - referrers_data[row.referrer_id]['earnings_month'] = \ - referrers_data[row.referrer_id].get('earnings_month', 0) + (row.total or 0) + referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get( + 'earnings_month', 0 + ) + (row.total or 0) # Get user info for all referrers referrer_ids = list(referrers_data.keys()) if referrer_ids: users_query = await db.execute( - select(User.id, User.telegram_id, User.username, User.first_name, User.last_name) - .where(User.id.in_(referrer_ids)) + select(User.id, User.telegram_id, User.username, User.first_name, User.last_name).where( + User.id.in_(referrer_ids) + ) ) users_info = {u.id: u for u in users_query} else: @@ -745,35 +703,37 @@ async def get_top_referrers( if not user: continue - display_name = "" + display_name = '' if user.first_name: display_name = user.first_name if user.last_name: - display_name += f" {user.last_name}" + display_name += f' {user.last_name}' elif user.username: - display_name = f"@{user.username}" + display_name = f'@{user.username}' elif user.telegram_id: - display_name = f"ID{user.telegram_id}" + display_name = f'ID{user.telegram_id}' elif user.email: display_name = user.email.split('@')[0] else: - display_name = f"User#{user.id}" + display_name = f'User#{user.id}' - referrer_items.append(TopReferrerItem( - user_id=user.id, - telegram_id=user.telegram_id, - email=user.email, - username=user.username, - display_name=display_name, - invited_count=data.get('total_invited', 0), - invited_today=data.get('invited_today', 0), - invited_week=data.get('invited_week', 0), - invited_month=data.get('invited_month', 0), - earnings_today_kopeks=data.get('earnings_today', 0), - earnings_week_kopeks=data.get('earnings_week', 0), - earnings_month_kopeks=data.get('earnings_month', 0), - earnings_total_kopeks=data.get('earnings_total', 0), - )) + referrer_items.append( + TopReferrerItem( + user_id=user.id, + telegram_id=user.telegram_id, + email=user.email, + username=user.username, + display_name=display_name, + invited_count=data.get('total_invited', 0), + invited_today=data.get('invited_today', 0), + invited_week=data.get('invited_week', 0), + invited_month=data.get('invited_month', 0), + earnings_today_kopeks=data.get('earnings_today', 0), + earnings_week_kopeks=data.get('earnings_week', 0), + earnings_month_kopeks=data.get('earnings_month', 0), + earnings_total_kopeks=data.get('earnings_total', 0), + ) + ) # Sort by earnings and by invited by_earnings = sorted(referrer_items, key=lambda x: x.earnings_total_kopeks, reverse=True)[:limit] @@ -793,14 +753,14 @@ async def get_top_referrers( ) except Exception as e: - logger.error(f"Failed to get top referrers: {e}", exc_info=True) + logger.error(f'Failed to get top referrers: {e}', exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load referrers statistics", + detail='Failed to load referrers statistics', ) -@router.get("/campaigns/top", response_model=TopCampaignsResponse) +@router.get('/campaigns/top', response_model=TopCampaignsResponse) async def get_top_campaigns( limit: int = 20, admin: User = Depends(get_current_admin_user), @@ -818,22 +778,24 @@ async def get_top_campaigns( for campaign in campaigns: stats = await get_campaign_statistics(db, campaign.id) - campaign_items.append(TopCampaignItem( - id=campaign.id, - name=campaign.name, - start_parameter=campaign.start_parameter, - bonus_type=campaign.bonus_type, - is_active=campaign.is_active, - registrations=stats.get("registrations", 0), - conversions=stats.get("conversion_count", 0), - conversion_rate=stats.get("conversion_rate", 0.0), - total_revenue_kopeks=stats.get("total_revenue_kopeks", 0), - avg_revenue_per_user_kopeks=stats.get("avg_revenue_per_user_kopeks", 0), - created_at=campaign.created_at.isoformat() if campaign.created_at else None, - )) + campaign_items.append( + TopCampaignItem( + id=campaign.id, + name=campaign.name, + start_parameter=campaign.start_parameter, + bonus_type=campaign.bonus_type, + is_active=campaign.is_active, + registrations=stats.get('registrations', 0), + conversions=stats.get('conversion_count', 0), + conversion_rate=stats.get('conversion_rate', 0.0), + total_revenue_kopeks=stats.get('total_revenue_kopeks', 0), + avg_revenue_per_user_kopeks=stats.get('avg_revenue_per_user_kopeks', 0), + created_at=campaign.created_at.isoformat() if campaign.created_at else None, + ) + ) - total_registrations += stats.get("registrations", 0) - total_revenue += stats.get("total_revenue_kopeks", 0) + total_registrations += stats.get('registrations', 0) + total_revenue += stats.get('total_revenue_kopeks', 0) # Sort by revenue campaign_items.sort(key=lambda x: x.total_revenue_kopeks, reverse=True) @@ -848,14 +810,14 @@ async def get_top_campaigns( ) except Exception as e: - logger.error(f"Failed to get top campaigns: {e}", exc_info=True) + logger.error(f'Failed to get top campaigns: {e}', exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load campaigns statistics", + detail='Failed to load campaigns statistics', ) -@router.get("/payments/recent", response_model=RecentPaymentsResponse) +@router.get('/payments/recent', response_model=RecentPaymentsResponse) async def get_recent_payments( limit: int = 50, admin: User = Depends(get_current_admin_user), @@ -871,10 +833,12 @@ async def get_recent_payments( transactions_query = await db.execute( select(Transaction) .where( - Transaction.type.in_([ - TransactionType.DEPOSIT.value, - TransactionType.SUBSCRIPTION_PAYMENT.value, - ]) + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ) ) .order_by(Transaction.created_at.desc()) .limit(limit) @@ -882,11 +846,12 @@ async def get_recent_payments( transactions = transactions_query.scalars().all() # Get user info for all transactions - user_ids = list(set(t.user_id for t in transactions)) + user_ids = list({t.user_id for t in transactions}) if user_ids: users_query = await db.execute( - select(User.id, User.telegram_id, User.username, User.first_name, User.last_name) - .where(User.id.in_(user_ids)) + select(User.id, User.telegram_id, User.username, User.first_name, User.last_name).where( + User.id.in_(user_ids) + ) ) users_info = {u.id: u for u in users_query} else: @@ -894,12 +859,12 @@ async def get_recent_payments( # Type display names type_display = { - TransactionType.DEPOSIT.value: "Пополнение", - TransactionType.SUBSCRIPTION_PAYMENT.value: "Оплата подписки", - TransactionType.WITHDRAWAL.value: "Вывод", - TransactionType.REFUND.value: "Возврат", - TransactionType.REFERRAL_REWARD.value: "Реферальный бонус", - TransactionType.POLL_REWARD.value: "Награда за опрос", + TransactionType.DEPOSIT.value: 'Пополнение', + TransactionType.SUBSCRIPTION_PAYMENT.value: 'Оплата подписки', + TransactionType.WITHDRAWAL.value: 'Вывод', + TransactionType.REFUND.value: 'Возврат', + TransactionType.REFERRAL_REWARD.value: 'Реферальный бонус', + TransactionType.POLL_REWARD.value: 'Награда за опрос', } payment_items = [] @@ -908,68 +873,69 @@ async def get_recent_payments( if not user: continue - display_name = "" + display_name = '' if user.first_name: display_name = user.first_name if user.last_name: - display_name += f" {user.last_name}" + display_name += f' {user.last_name}' elif user.username: - display_name = f"@{user.username}" + display_name = f'@{user.username}' elif user.telegram_id: - display_name = f"ID{user.telegram_id}" + display_name = f'ID{user.telegram_id}' elif user.email: display_name = user.email.split('@')[0] else: - display_name = f"User#{user.id}" + display_name = f'User#{user.id}' - payment_items.append(RecentPaymentItem( - id=trans.id, - user_id=user.id, - telegram_id=user.telegram_id, - email=user.email, - username=user.username, - display_name=display_name, - amount_kopeks=trans.amount_kopeks, - amount_rubles=trans.amount_kopeks / 100, - type=trans.type, - type_display=type_display.get(trans.type, trans.type), - payment_method=trans.payment_method, - description=trans.description, - created_at=trans.created_at.isoformat() if trans.created_at else "", - is_completed=trans.is_completed, - )) + payment_items.append( + RecentPaymentItem( + id=trans.id, + user_id=user.id, + telegram_id=user.telegram_id, + email=user.email, + username=user.username, + display_name=display_name, + amount_kopeks=trans.amount_kopeks, + amount_rubles=trans.amount_kopeks / 100, + type=trans.type, + type_display=type_display.get(trans.type, trans.type), + payment_method=trans.payment_method, + description=trans.description, + created_at=trans.created_at.isoformat() if trans.created_at else '', + is_completed=trans.is_completed, + ) + ) # Calculate totals total_count_result = await db.execute( - select(func.count(Transaction.id)) - .where( - Transaction.type.in_([ - TransactionType.DEPOSIT.value, - TransactionType.SUBSCRIPTION_PAYMENT.value, - ]) + select(func.count(Transaction.id)).where( + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ) ) ) total_count = total_count_result.scalar() or 0 today_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, - Transaction.created_at >= today_start + Transaction.created_at >= today_start, ) ) ) total_today = today_total_result.scalar() or 0 week_total_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, - Transaction.created_at >= week_ago + Transaction.created_at >= week_ago, ) ) ) @@ -983,8 +949,8 @@ async def get_recent_payments( ) except Exception as e: - logger.error(f"Failed to get recent payments: {e}", exc_info=True) + logger.error(f'Failed to get recent payments: {e}', exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load recent payments", + detail='Failed to load recent payments', ) diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index 3375041d..da7c1e3f 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -1,51 +1,49 @@ """Admin routes for managing tariffs in cabinet.""" import logging -from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup, Transaction, TransactionType +from app.database.crud.server_squad import get_all_server_squads from app.database.crud.tariff import ( + create_tariff, + delete_tariff, get_all_tariffs, get_tariff_by_id, - create_tariff, - update_tariff, - delete_tariff, get_tariff_subscriptions_count, - set_tariff_promo_groups, load_period_prices_from_db, + set_tariff_promo_groups, + update_tariff, ) -from app.database.crud.server_squad import get_all_server_squads +from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.tariffs import ( - TariffListResponse, - TariffListItem, - TariffDetailResponse, + PeriodPrice, + PromoGroupInfo, + ServerInfo, + ServerTrafficLimit, TariffCreateRequest, - TariffUpdateRequest, + TariffDetailResponse, + TariffListItem, + TariffListResponse, + TariffStatsResponse, TariffToggleResponse, TariffTrialResponse, - TariffStatsResponse, - PeriodPrice, - ServerInfo, - PromoGroupInfo, - ServerTrafficLimit, + TariffUpdateRequest, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"]) +router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs']) async def _get_tariff_servers( - db: AsyncSession, - allowed_squads: List[str], - server_traffic_limits: dict = None -) -> List[ServerInfo]: + db: AsyncSession, allowed_squads: list[str], server_traffic_limits: dict = None +) -> list[ServerInfo]: """Get server info for tariff.""" servers, _ = await get_all_server_squads(db, available_only=False) limits = server_traffic_limits or {} @@ -60,18 +58,20 @@ async def _get_tariff_servers( elif isinstance(limit_data, int): server_limit = limit_data - result.append(ServerInfo( - id=server.id, - squad_uuid=server.squad_uuid, - display_name=server.display_name, - country_code=server.country_code, - is_selected=server.squad_uuid in allowed_squads, - traffic_limit_gb=server_limit, - )) + result.append( + ServerInfo( + id=server.id, + squad_uuid=server.squad_uuid, + display_name=server.display_name, + country_code=server.country_code, + is_selected=server.squad_uuid in allowed_squads, + traffic_limit_gb=server_limit, + ) + ) return result -async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[PromoGroupInfo]: +async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> list[PromoGroupInfo]: """Get promo group info for tariff.""" result = await db.execute(select(PromoGroup).order_by(PromoGroup.name)) all_groups = result.scalars().all() @@ -88,7 +88,7 @@ async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[Pro ] -def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]: +def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]: """Convert period_prices dict to list.""" if not period_prices: return [] @@ -98,12 +98,12 @@ def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]: ] -def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict: +def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict: """Convert period_prices list to dict.""" return {str(pp.days): pp.price_kopeks for pp in period_prices} -@router.get("", response_model=TariffListResponse) +@router.get('', response_model=TariffListResponse) async def list_tariffs( include_inactive: bool = True, admin: User = Depends(get_current_admin_user), @@ -115,28 +115,30 @@ async def list_tariffs( items = [] for tariff in tariffs: subs_count = await get_tariff_subscriptions_count(db, tariff.id) - items.append(TariffListItem( - id=tariff.id, - name=tariff.name, - description=tariff.description, - is_active=tariff.is_active, - is_trial_available=tariff.is_trial_available, - is_daily=tariff.is_daily, - daily_price_kopeks=tariff.daily_price_kopeks, - allow_traffic_topup=tariff.allow_traffic_topup, - traffic_limit_gb=tariff.traffic_limit_gb, - device_limit=tariff.device_limit, - tier_level=tariff.tier_level, - display_order=tariff.display_order, - servers_count=len(tariff.allowed_squads or []), - subscriptions_count=subs_count, - created_at=tariff.created_at, - )) + items.append( + TariffListItem( + id=tariff.id, + name=tariff.name, + description=tariff.description, + is_active=tariff.is_active, + is_trial_available=tariff.is_trial_available, + is_daily=tariff.is_daily, + daily_price_kopeks=tariff.daily_price_kopeks, + allow_traffic_topup=tariff.allow_traffic_topup, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + tier_level=tariff.tier_level, + display_order=tariff.display_order, + servers_count=len(tariff.allowed_squads or []), + subscriptions_count=subs_count, + created_at=tariff.created_at, + ) + ) return TariffListResponse(tariffs=items, total=len(items)) -@router.get("/available-servers", response_model=List[ServerInfo]) +@router.get('/available-servers', response_model=list[ServerInfo]) async def get_available_servers( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -155,7 +157,7 @@ async def get_available_servers( ] -@router.get("/{tariff_id}", response_model=TariffDetailResponse) +@router.get('/{tariff_id}', response_model=TariffDetailResponse) async def get_tariff( tariff_id: int, admin: User = Depends(get_current_admin_user), @@ -166,7 +168,7 @@ async def get_tariff( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) allowed_squads = tariff.allowed_squads or [] @@ -225,7 +227,7 @@ async def get_tariff( ) -@router.post("", response_model=TariffDetailResponse) +@router.post('', response_model=TariffDetailResponse) async def create_new_tariff( request: TariffCreateRequest, admin: User = Depends(get_current_admin_user), @@ -235,9 +237,11 @@ async def create_new_tariff( period_prices_dict = _period_prices_to_dict(request.period_prices) # Преобразуем ServerTrafficLimit в dict для хранения - server_limits_dict = { - uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items() - } if request.server_traffic_limits else {} + server_limits_dict = ( + {uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()} + if request.server_traffic_limits + else {} + ) tariff = await create_tariff( db=db, @@ -274,7 +278,7 @@ async def create_new_tariff( traffic_reset_mode=request.traffic_reset_mode, ) - logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}") + logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}') # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -283,7 +287,7 @@ async def create_new_tariff( return await get_tariff(tariff.id, admin, db) -@router.put("/{tariff_id}", response_model=TariffDetailResponse) +@router.put('/{tariff_id}', response_model=TariffDetailResponse) async def update_existing_tariff( tariff_id: int, request: TariffUpdateRequest, @@ -295,72 +299,72 @@ async def update_existing_tariff( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) # Build updates dict updates = {} if request.name is not None: - updates["name"] = request.name + updates['name'] = request.name if request.description is not None: - updates["description"] = request.description + updates['description'] = request.description if request.is_active is not None: - updates["is_active"] = request.is_active + updates['is_active'] = request.is_active if request.allow_traffic_topup is not None: - updates["allow_traffic_topup"] = request.allow_traffic_topup + updates['allow_traffic_topup'] = request.allow_traffic_topup if request.traffic_topup_enabled is not None: - updates["traffic_topup_enabled"] = request.traffic_topup_enabled + updates['traffic_topup_enabled'] = request.traffic_topup_enabled if request.traffic_topup_packages is not None: - updates["traffic_topup_packages"] = request.traffic_topup_packages + updates['traffic_topup_packages'] = request.traffic_topup_packages if request.max_topup_traffic_gb is not None: - updates["max_topup_traffic_gb"] = request.max_topup_traffic_gb + updates['max_topup_traffic_gb'] = request.max_topup_traffic_gb if request.traffic_limit_gb is not None: - updates["traffic_limit_gb"] = request.traffic_limit_gb + updates['traffic_limit_gb'] = request.traffic_limit_gb if request.device_limit is not None: - updates["device_limit"] = request.device_limit + updates['device_limit'] = request.device_limit if request.device_price_kopeks is not None: - updates["device_price_kopeks"] = request.device_price_kopeks + updates['device_price_kopeks'] = request.device_price_kopeks if request.max_device_limit is not None: - updates["max_device_limit"] = request.max_device_limit + updates['max_device_limit'] = request.max_device_limit if request.tier_level is not None: - updates["tier_level"] = request.tier_level + updates['tier_level'] = request.tier_level if request.display_order is not None: - updates["display_order"] = request.display_order + updates['display_order'] = request.display_order if request.period_prices is not None: - updates["period_prices"] = _period_prices_to_dict(request.period_prices) + updates['period_prices'] = _period_prices_to_dict(request.period_prices) if request.allowed_squads is not None: - updates["allowed_squads"] = request.allowed_squads + updates['allowed_squads'] = request.allowed_squads if request.server_traffic_limits is not None: # Преобразуем ServerTrafficLimit в dict для хранения - updates["server_traffic_limits"] = { + updates['server_traffic_limits'] = { uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items() } # Произвольное количество дней if request.custom_days_enabled is not None: - updates["custom_days_enabled"] = request.custom_days_enabled + updates['custom_days_enabled'] = request.custom_days_enabled if request.price_per_day_kopeks is not None: - updates["price_per_day_kopeks"] = request.price_per_day_kopeks + updates['price_per_day_kopeks'] = request.price_per_day_kopeks if request.min_days is not None: - updates["min_days"] = request.min_days + updates['min_days'] = request.min_days if request.max_days is not None: - updates["max_days"] = request.max_days + updates['max_days'] = request.max_days # Произвольный трафик при покупке if request.custom_traffic_enabled is not None: - updates["custom_traffic_enabled"] = request.custom_traffic_enabled + updates['custom_traffic_enabled'] = request.custom_traffic_enabled if request.traffic_price_per_gb_kopeks is not None: - updates["traffic_price_per_gb_kopeks"] = request.traffic_price_per_gb_kopeks + updates['traffic_price_per_gb_kopeks'] = request.traffic_price_per_gb_kopeks if request.min_traffic_gb is not None: - updates["min_traffic_gb"] = request.min_traffic_gb + updates['min_traffic_gb'] = request.min_traffic_gb if request.max_traffic_gb is not None: - updates["max_traffic_gb"] = request.max_traffic_gb + updates['max_traffic_gb'] = request.max_traffic_gb # Дневной тариф if request.is_daily is not None: - updates["is_daily"] = request.is_daily + updates['is_daily'] = request.is_daily if request.daily_price_kopeks is not None: - updates["daily_price_kopeks"] = request.daily_price_kopeks + updates['daily_price_kopeks'] = request.daily_price_kopeks # Режим сброса трафика (None допускается как значение для сброса к глобальной настройке) if 'traffic_reset_mode' in request.model_fields_set: - updates["traffic_reset_mode"] = request.traffic_reset_mode + updates['traffic_reset_mode'] = request.traffic_reset_mode if updates: await update_tariff(db, tariff, **updates) @@ -369,7 +373,7 @@ async def update_existing_tariff( if request.promo_group_ids is not None: await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids) - logger.info(f"Admin {admin.id} updated tariff {tariff_id}") + logger.info(f'Admin {admin.id} updated tariff {tariff_id}') # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -377,7 +381,7 @@ async def update_existing_tariff( return await get_tariff(tariff_id, admin, db) -@router.delete("/{tariff_id}") +@router.delete('/{tariff_id}') async def delete_existing_tariff( tariff_id: int, admin: User = Depends(get_current_admin_user), @@ -388,7 +392,7 @@ async def delete_existing_tariff( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) # Check if tariff has subscriptions @@ -396,19 +400,19 @@ async def delete_existing_tariff( if subs_count > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Cannot delete tariff with {subs_count} active subscriptions", + detail=f'Cannot delete tariff with {subs_count} active subscriptions', ) await delete_tariff(db, tariff) - logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}") + logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}') # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) - return {"message": "Tariff deleted successfully"} + return {'message': 'Tariff deleted successfully'} -@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse) +@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse) async def toggle_tariff( tariff_id: int, admin: User = Depends(get_current_admin_user), @@ -419,14 +423,14 @@ async def toggle_tariff( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) new_status = not tariff.is_active await update_tariff(db, tariff, is_active=new_status) - status_text = "activated" if new_status else "deactivated" - logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}") + status_text = 'activated' if new_status else 'deactivated' + logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}') # Перезагружаем периоды из БД для синхронизации с ботом await load_period_prices_from_db(db) @@ -434,11 +438,11 @@ async def toggle_tariff( return TariffToggleResponse( id=tariff_id, is_active=new_status, - message=f"Tariff {status_text}", + message=f'Tariff {status_text}', ) -@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse) +@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse) async def toggle_trial_tariff( tariff_id: int, admin: User = Depends(get_current_admin_user), @@ -453,7 +457,7 @@ async def toggle_trial_tariff( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) new_status = not tariff.is_trial_available @@ -461,26 +465,24 @@ async def toggle_trial_tariff( if new_status: # При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий # Это гарантирует, что триальным будет только один тариф - await db.execute( - Tariff.__table__.update().values(is_trial_available=False) - ) + await db.execute(Tariff.__table__.update().values(is_trial_available=False)) await db.commit() # Обновляем объект тарифа после массового обновления await db.refresh(tariff) await update_tariff(db, tariff, is_trial_available=new_status) - status_text = "set as trial" if new_status else "removed from trial" - logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}") + status_text = 'set as trial' if new_status else 'removed from trial' + logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}') return TariffTrialResponse( id=tariff_id, is_trial_available=new_status, - message=f"Tariff {status_text}", + message=f'Tariff {status_text}', ) -@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse) +@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse) async def get_tariff_stats( tariff_id: int, admin: User = Depends(get_current_admin_user), @@ -491,30 +493,25 @@ async def get_tariff_stats( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) # Count subscriptions - total_result = await db.execute( - select(func.count(Subscription.id)) - .where(Subscription.tariff_id == tariff_id) - ) + total_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)) total_count = total_result.scalar() or 0 # Count active subscriptions active_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff_id, - Subscription.status == "active", + Subscription.status == 'active', ) ) active_count = active_result.scalar() or 0 # Count trial subscriptions trial_result = await db.execute( - select(func.count(Subscription.id)) - .where( + select(func.count(Subscription.id)).where( Subscription.tariff_id == tariff_id, Subscription.is_trial == True, ) diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 5672a989..a5b4bac1 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -3,37 +3,38 @@ import logging import math from datetime import datetime -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, desc -from sqlalchemy.orm import selectinload +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field +from sqlalchemy import desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -from app.database.models import User, Ticket, TicketMessage -from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD -from app.database.crud.ticket_notification import TicketNotificationCRUD -from app.config import settings from app.cabinet.routes.websocket import notify_user_ticket_reply +from app.config import settings +from app.database.crud.ticket import TicketCRUD +from app.database.crud.ticket_notification import TicketNotificationCRUD +from app.database.models import Ticket, TicketMessage, User from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.tickets import TicketMessageResponse + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/tickets", tags=["Cabinet Admin Tickets"]) +router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets']) # Admin-specific schemas class AdminTicketUserInfo(BaseModel): """User info for admin view.""" + id: int - telegram_id: Optional[int] = None # Can be None for email-only users - email: Optional[str] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None # Can be None for email-only users + email: str | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None class Config: from_attributes = True @@ -41,16 +42,17 @@ class AdminTicketUserInfo(BaseModel): class AdminTicketResponse(BaseModel): """Ticket data for admin.""" + id: int title: str status: str priority: str created_at: datetime updated_at: datetime - closed_at: Optional[datetime] = None + closed_at: datetime | None = None messages_count: int = 0 - user: Optional[AdminTicketUserInfo] = None - last_message: Optional[TicketMessageResponse] = None + user: AdminTicketUserInfo | None = None + last_message: TicketMessageResponse | None = None class Config: from_attributes = True @@ -58,16 +60,17 @@ class AdminTicketResponse(BaseModel): class AdminTicketDetailResponse(BaseModel): """Ticket with all messages for admin.""" + id: int title: str status: str priority: str created_at: datetime updated_at: datetime - closed_at: Optional[datetime] = None + closed_at: datetime | None = None is_reply_blocked: bool = False - user: Optional[AdminTicketUserInfo] = None - messages: List[TicketMessageResponse] = [] + user: AdminTicketUserInfo | None = None + messages: list[TicketMessageResponse] = [] class Config: from_attributes = True @@ -75,7 +78,8 @@ class AdminTicketDetailResponse(BaseModel): class AdminTicketListResponse(BaseModel): """Paginated ticket list for admin.""" - items: List[AdminTicketResponse] + + items: list[AdminTicketResponse] total: int page: int per_page: int @@ -84,21 +88,25 @@ class AdminTicketListResponse(BaseModel): class AdminReplyRequest(BaseModel): """Admin reply to ticket.""" - message: str = Field(..., min_length=1, max_length=4000, description="Reply message") + + message: str = Field(..., min_length=1, max_length=4000, description='Reply message') class AdminStatusUpdateRequest(BaseModel): """Update ticket status.""" - status: str = Field(..., description="New status: open, answered, pending, closed") + + status: str = Field(..., description='New status: open, answered, pending, closed') class AdminPriorityUpdateRequest(BaseModel): """Update ticket priority.""" - priority: str = Field(..., description="New priority: low, normal, high, urgent") + + priority: str = Field(..., description='New priority: low, normal, high, urgent') class AdminStatsResponse(BaseModel): """Ticket statistics for admin.""" + total: int open: int pending: int @@ -108,6 +116,7 @@ class AdminStatsResponse(BaseModel): class TicketSettingsResponse(BaseModel): """Ticket system settings.""" + sla_enabled: bool sla_minutes: int sla_check_interval_seconds: int @@ -120,21 +129,24 @@ class TicketSettingsResponse(BaseModel): class TicketSettingsUpdateRequest(BaseModel): """Update ticket settings.""" - sla_enabled: Optional[bool] = None - sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)") - sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)") - sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)") - support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both") + + sla_enabled: bool | None = None + sla_minutes: int | None = Field(None, ge=1, le=1440, description='SLA time in minutes (1-1440)') + sla_check_interval_seconds: int | None = Field(None, ge=30, le=600, description='Check interval (30-600 seconds)') + sla_reminder_cooldown_minutes: int | None = Field( + None, ge=1, le=120, description='Reminder cooldown (1-120 minutes)' + ) + support_system_mode: str | None = Field(None, description='Support mode: tickets, contact, both') # Cabinet notifications settings - cabinet_user_notifications_enabled: Optional[bool] = Field(None, description="Enable user notifications in cabinet") - cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet") + cabinet_user_notifications_enabled: bool | None = Field(None, description='Enable user notifications in cabinet') + cabinet_admin_notifications_enabled: bool | None = Field(None, description='Enable admin notifications in cabinet') def _message_to_response(message: TicketMessage) -> TicketMessageResponse: """Convert TicketMessage to response.""" return TicketMessageResponse( id=message.id, - message_text=message.message_text or "", + message_text=message.message_text or '', is_from_admin=message.is_from_admin, has_media=bool(message.media_file_id), media_type=message.media_type, @@ -171,9 +183,9 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> return AdminTicketResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, @@ -183,7 +195,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> ) -@router.get("/stats", response_model=AdminStatsResponse) +@router.get('/stats', response_model=AdminStatsResponse) async def get_ticket_stats( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -195,36 +207,31 @@ async def get_ticket_stats( # Count by status statuses = {} - for status_name in ["open", "pending", "answered", "closed"]: - result = await db.execute( - select(func.count()).select_from(Ticket).where(Ticket.status == status_name) - ) + for status_name in ['open', 'pending', 'answered', 'closed']: + result = await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == status_name)) statuses[status_name] = result.scalar() or 0 return AdminStatsResponse( total=total, - open=statuses.get("open", 0), - pending=statuses.get("pending", 0), - answered=statuses.get("answered", 0), - closed=statuses.get("closed", 0), + open=statuses.get('open', 0), + pending=statuses.get('pending', 0), + answered=statuses.get('answered', 0), + closed=statuses.get('closed', 0), ) -@router.get("", response_model=AdminTicketListResponse) +@router.get('', response_model=AdminTicketListResponse) async def get_all_tickets( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), - status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), - priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), + status_filter: str | None = Query(None, alias='status', description='Filter by status'), + priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get all tickets for admin.""" # Base query with user relationship - query = ( - select(Ticket) - .options(selectinload(Ticket.messages), selectinload(Ticket.user)) - ) + query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user)) # Build count query count_query = select(func.count()).select_from(Ticket) @@ -261,7 +268,7 @@ async def get_all_tickets( ) -@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse) +@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse) async def get_ticket_detail( ticket_id: int, admin: User = Depends(get_current_admin_user), @@ -269,9 +276,7 @@ async def get_ticket_detail( ): """Get ticket with all messages for admin.""" query = ( - select(Ticket) - .where(Ticket.id == ticket_id) - .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user)) ) result = await db.execute(query) @@ -280,7 +285,7 @@ async def get_ticket_detail( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) messages = sorted(ticket.messages or [], key=lambda m: m.created_at) @@ -292,19 +297,19 @@ async def get_ticket_detail( return AdminTicketDetailResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, - is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False, user=user_info, messages=messages_response, ) -@router.post("/{ticket_id}/reply", response_model=TicketMessageResponse) +@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse) async def reply_to_ticket( ticket_id: int, request: AdminReplyRequest, @@ -318,7 +323,7 @@ async def reply_to_ticket( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) # Create admin message @@ -332,7 +337,7 @@ async def reply_to_ticket( db.add(message) # Update ticket status to answered - ticket.status = "answered" + ticket.status = 'answered' ticket.updated_at = datetime.utcnow() await db.commit() @@ -350,13 +355,14 @@ async def reply_to_ticket( ) try: from app.handlers.admin.tickets import notify_user_about_ticket_reply + await notify_user_about_ticket_reply(bot, ticket, request.message, db) except Exception as e: - logger.warning(f"Failed to notify user about ticket reply: {e}") + logger.warning(f'Failed to notify user about ticket reply: {e}') finally: await bot.session.close() except Exception as e: - logger.warning(f"Failed to send Telegram notification: {e}") + logger.warning(f'Failed to send Telegram notification: {e}') # Уведомить пользователя в кабинете try: @@ -365,14 +371,14 @@ async def reply_to_ticket( ) if notification: # Отправить WebSocket уведомление - await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100]) + await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100]) except Exception as e: - logger.warning(f"Failed to create cabinet notification for admin reply: {e}") + logger.warning(f'Failed to create cabinet notification for admin reply: {e}') return _message_to_response(message) -@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse) +@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse) async def update_ticket_status( ticket_id: int, request: AdminStatusUpdateRequest, @@ -380,17 +386,15 @@ async def update_ticket_status( db: AsyncSession = Depends(get_cabinet_db), ): """Update ticket status.""" - allowed_statuses = {"open", "pending", "answered", "closed"} + allowed_statuses = {'open', 'pending', 'answered', 'closed'} if request.status not in allowed_statuses: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid status. Allowed: {', '.join(allowed_statuses)}", + detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}', ) query = ( - select(Ticket) - .where(Ticket.id == ticket_id) - .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user)) ) result = await db.execute(query) @@ -399,12 +403,12 @@ async def update_ticket_status( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) ticket.status = request.status ticket.updated_at = datetime.utcnow() - if request.status == "closed": + if request.status == 'closed': ticket.closed_at = datetime.utcnow() else: ticket.closed_at = None @@ -421,19 +425,19 @@ async def update_ticket_status( return AdminTicketDetailResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, - is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False, user=user_info, messages=messages_response, ) -@router.post("/{ticket_id}/priority", response_model=AdminTicketDetailResponse) +@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse) async def update_ticket_priority( ticket_id: int, request: AdminPriorityUpdateRequest, @@ -441,17 +445,15 @@ async def update_ticket_priority( db: AsyncSession = Depends(get_cabinet_db), ): """Update ticket priority.""" - allowed_priorities = {"low", "normal", "high", "urgent"} + allowed_priorities = {'low', 'normal', 'high', 'urgent'} if request.priority not in allowed_priorities: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid priority. Allowed: {', '.join(allowed_priorities)}", + detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}', ) query = ( - select(Ticket) - .where(Ticket.id == ticket_id) - .options(selectinload(Ticket.messages), selectinload(Ticket.user)) + select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user)) ) result = await db.execute(query) @@ -460,7 +462,7 @@ async def update_ticket_priority( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) ticket.priority = request.priority @@ -478,19 +480,19 @@ async def update_ticket_priority( return AdminTicketDetailResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, - is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False, user=user_info, messages=messages_response, ) -@router.get("/settings", response_model=TicketSettingsResponse) +@router.get('/settings', response_model=TicketSettingsResponse) async def get_ticket_settings( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -509,24 +511,24 @@ async def get_ticket_settings( ) -@router.patch("/settings", response_model=TicketSettingsResponse) +@router.patch('/settings', response_model=TicketSettingsResponse) async def update_ticket_settings( request: TicketSettingsUpdateRequest, admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Update ticket system settings.""" - import os from pathlib import Path + from app.services.support_settings_service import SupportSettingsService # Validate support_system_mode if request.support_system_mode is not None: mode = request.support_system_mode.strip().lower() - if mode not in {"tickets", "contact", "both"}: + if mode not in {'tickets', 'contact', 'both'}: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid support_system_mode. Must be: tickets, contact, or both", + detail='Invalid support_system_mode. Must be: tickets, contact, or both', ) # Update in-memory settings @@ -549,21 +551,21 @@ async def update_ticket_settings( # Try to persist to .env file try: - env_file = Path(".env") + env_file = Path('.env') if env_file.exists(): lines = env_file.read_text().splitlines() updates = {} if request.sla_enabled is not None: - updates["SUPPORT_TICKET_SLA_ENABLED"] = str(request.sla_enabled).lower() + updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower() if request.sla_minutes is not None: - updates["SUPPORT_TICKET_SLA_MINUTES"] = str(request.sla_minutes) + updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes) if request.sla_check_interval_seconds is not None: - updates["SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS"] = str(request.sla_check_interval_seconds) + updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds) if request.sla_reminder_cooldown_minutes is not None: - updates["SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES"] = str(request.sla_reminder_cooldown_minutes) + updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes) if request.support_system_mode is not None: - updates["SUPPORT_SYSTEM_MODE"] = request.support_system_mode.strip().lower() + updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower() new_lines = [] updated_keys = set() @@ -571,8 +573,8 @@ async def update_ticket_settings( for line in lines: updated = False for key, value in updates.items(): - if line.startswith(f"{key}="): - new_lines.append(f"{key}={value}") + if line.startswith(f'{key}='): + new_lines.append(f'{key}={value}') updated_keys.add(key) updated = True break @@ -582,12 +584,12 @@ async def update_ticket_settings( # Add any keys that weren't found for key, value in updates.items(): if key not in updated_keys: - new_lines.append(f"{key}={value}") + new_lines.append(f'{key}={value}') - env_file.write_text("\n".join(new_lines) + "\n") - logger.info(f"Updated ticket settings in .env file") + env_file.write_text('\n'.join(new_lines) + '\n') + logger.info('Updated ticket settings in .env file') except Exception as e: - logger.warning(f"Failed to update .env file: {e}") + logger.warning(f'Failed to update .env file: {e}') return TicketSettingsResponse( sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED, diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index d61c52a5..dc03502a 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -1,86 +1,83 @@ """Admin routes for managing users in cabinet.""" import logging -from datetime import datetime, timedelta -from typing import List, Optional +from datetime import UTC, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import Integer, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_, or_, Integer +from app.database.crud.subscription import ( + extend_subscription, +) +from app.database.crud.tariff import get_tariff_by_id +from app.database.crud.user import ( + add_user_balance, + delete_user as soft_delete_user, + get_referrals, + get_user_by_id, + get_user_by_telegram_id, + get_users_count, + get_users_list, + get_users_spending_stats, + get_users_statistics, + subtract_user_balance, +) from app.database.models import ( - User, - UserStatus, + PromoGroup, Subscription, SubscriptionStatus, Transaction, TransactionType, - PromoGroup, - Tariff, + User, + UserStatus, ) -from app.database.crud.user import ( - get_user_by_id, - get_user_by_telegram_id, - get_users_list, - get_users_count, - get_users_statistics, - get_users_spending_stats, - add_user_balance, - subtract_user_balance, - update_user, - delete_user as soft_delete_user, - get_referrals, -) -from app.database.crud.subscription import ( - get_subscription_by_user_id, - extend_subscription, -) -from app.database.crud.tariff import get_tariff_by_id from ..dependencies import get_cabinet_db, get_current_admin_user from ..schemas.users import ( - UserListItem, - UsersListResponse, - UserDetailResponse, - UserSubscriptionInfo, - UserPromoGroupInfo, - UserTransactionItem, - UserReferralInfo, - UpdateBalanceRequest, - UpdateBalanceResponse, - UpdateSubscriptionRequest, - UpdateSubscriptionResponse, - UpdateUserStatusRequest, - UpdateUserStatusResponse, - UpdateRestrictionsRequest, - UpdateRestrictionsResponse, - UpdatePromoGroupRequest, - UpdatePromoGroupResponse, DeleteUserRequest, DeleteUserResponse, - UsersStatsResponse, - SortByEnum, - UserStatusEnum, - UserAvailableTariffItem, - UserAvailableTariffsResponse, - PeriodPriceInfo, + PanelSyncStatusResponse, PanelUserInfo, + PeriodPriceInfo, + SortByEnum, SyncFromPanelRequest, SyncFromPanelResponse, SyncToPanelRequest, SyncToPanelResponse, - PanelSyncStatusResponse, + UpdateBalanceRequest, + UpdateBalanceResponse, + UpdatePromoGroupRequest, + UpdatePromoGroupResponse, + UpdateRestrictionsRequest, + UpdateRestrictionsResponse, + UpdateSubscriptionRequest, + UpdateSubscriptionResponse, + UpdateUserStatusRequest, + UpdateUserStatusResponse, + UserAvailableTariffItem, + UserAvailableTariffsResponse, + UserDetailResponse, + UserListItem, + UserPromoGroupInfo, + UserReferralInfo, + UsersListResponse, + UsersStatsResponse, + UserStatusEnum, + UserSubscriptionInfo, + UserTransactionItem, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/users", tags=["Cabinet Admin Users"]) +router = APIRouter(prefix='/admin/users', tags=['Cabinet Admin Users']) def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListItem: """Build UserListItem from User model.""" stats = spending_stats or {} - user_stats = stats.get(user.id, {"total_spent": 0, "purchase_count": 0}) + user_stats = stats.get(user.id, {'total_spent': 0, 'purchase_count': 0}) subscription_status = None subscription_is_trial = False @@ -111,15 +108,15 @@ def _build_user_list_item(user: User, spending_stats: dict = None) -> UserListIt subscription_end_date=subscription_end_date, promo_group_id=user.promo_group_id, promo_group_name=user.promo_group.name if user.promo_group else None, - total_spent_kopeks=user_stats.get("total_spent", 0), - purchase_count=user_stats.get("purchase_count", 0), + total_spent_kopeks=user_stats.get('total_spent', 0), + purchase_count=user_stats.get('purchase_count', 0), has_restrictions=user.has_restrictions, restriction_topup=user.restriction_topup, restriction_subscription=user.restriction_subscription, ) -def _build_subscription_info(subscription: Subscription, tariff_name: Optional[str] = None) -> UserSubscriptionInfo: +def _build_subscription_info(subscription: Subscription, tariff_name: str | None = None) -> UserSubscriptionInfo: """Build UserSubscriptionInfo from Subscription model.""" days_remaining = 0 is_active = False @@ -127,10 +124,7 @@ def _build_subscription_info(subscription: Subscription, tariff_name: Optional[s if subscription.end_date: delta = subscription.end_date - datetime.utcnow() days_remaining = max(0, delta.days) - is_active = ( - subscription.status == SubscriptionStatus.ACTIVE.value - and subscription.end_date > datetime.utcnow() - ) + is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > datetime.utcnow() return UserSubscriptionInfo( id=subscription.id, @@ -166,15 +160,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription Returns dict with changes/errors. """ try: - from app.services.remnawave_service import RemnaWaveService - from app.external.remnawave_api import UserStatus as PanelUserStatus, TrafficLimitStrategy from app.config import settings + from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus + from app.services.remnawave_service import RemnaWaveService from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload service = RemnaWaveService() if not service.is_configured: - logger.warning(f"Remnawave not configured, skipping panel sync for user {user.id}") - return {"skipped": True, "reason": "Remnawave not configured"} + logger.warning(f'Remnawave not configured, skipping panel sync for user {user.id}') + return {'skipped': True, 'reason': 'Remnawave not configured'} is_active = ( subscription.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value) @@ -212,28 +206,28 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription if existing_users: panel_uuid = existing_users[0].uuid user.remnawave_uuid = panel_uuid - changes["remnawave_uuid_discovered"] = panel_uuid + changes['remnawave_uuid_discovered'] = panel_uuid if panel_uuid: # Update existing user update_kwargs = { - "uuid": panel_uuid, - "status": panel_status, - "traffic_limit_bytes": traffic_limit_bytes, - "traffic_limit_strategy": TrafficLimitStrategy.MONTH, - "description": description, + 'uuid': panel_uuid, + 'status': panel_status, + 'traffic_limit_bytes': traffic_limit_bytes, + 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'description': description, } if expire_at: - update_kwargs["expire_at"] = expire_at + update_kwargs['expire_at'] = expire_at if subscription.connected_squads: - update_kwargs["active_internal_squads"] = subscription.connected_squads + update_kwargs['active_internal_squads'] = subscription.connected_squads if hwid_limit is not None: - update_kwargs["hwid_device_limit"] = hwid_limit + update_kwargs['hwid_device_limit'] = hwid_limit try: await api.update_user(**update_kwargs) - changes["action"] = "updated" - logger.info(f"Updated user {user.id} in Remnawave panel") + changes['action'] = 'updated' + logger.info(f'Updated user {user.id} in Remnawave panel') except Exception as update_error: if hasattr(update_error, 'status_code') and update_error.status_code == 404: panel_uuid = None # Will create new @@ -243,25 +237,25 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription if not panel_uuid: # Create new user create_kwargs = { - "username": username, - "expire_at": expire_at or (datetime.utcnow() + timedelta(days=30)), - "status": panel_status, - "traffic_limit_bytes": traffic_limit_bytes, - "traffic_limit_strategy": TrafficLimitStrategy.MONTH, - "telegram_id": user.telegram_id, - "description": description, - "active_internal_squads": subscription.connected_squads or [], + 'username': username, + 'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)), + 'status': panel_status, + 'traffic_limit_bytes': traffic_limit_bytes, + 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'telegram_id': user.telegram_id, + 'description': description, + 'active_internal_squads': subscription.connected_squads or [], } if hwid_limit is not None: - create_kwargs["hwid_device_limit"] = hwid_limit + create_kwargs['hwid_device_limit'] = hwid_limit new_panel_user = await api.create_user(**create_kwargs) user.remnawave_uuid = new_panel_user.uuid subscription.remnawave_short_uuid = new_panel_user.short_uuid subscription.subscription_url = new_panel_user.subscription_url - changes["action"] = "created" - changes["panel_uuid"] = new_panel_user.uuid - logger.info(f"Created user {user.id} in Remnawave panel: {new_panel_user.uuid}") + changes['action'] = 'created' + changes['panel_uuid'] = new_panel_user.uuid + logger.info(f'Created user {user.id} in Remnawave panel: {new_panel_user.uuid}') user.last_remnawave_sync = datetime.utcnow() await db.commit() @@ -269,18 +263,19 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription return changes except Exception as e: - logger.error(f"Error syncing user {user.id} to panel: {e}") - return {"error": str(e)} + logger.error(f'Error syncing user {user.id} to panel: {e}') + return {'error': str(e)} # === List & Search === -@router.get("", response_model=UsersListResponse) + +@router.get('', response_model=UsersListResponse) async def list_users( offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), - search: Optional[str] = Query(None, max_length=255), - status: Optional[UserStatusEnum] = Query(None), + search: str | None = Query(None, max_length=255), + status: UserStatusEnum | None = Query(None), sort_by: SortByEnum = Query(SortByEnum.CREATED_AT), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -335,7 +330,7 @@ async def list_users( ) -@router.get("/stats", response_model=UsersStatsResponse) +@router.get('/stats', response_model=UsersStatsResponse) async def get_users_stats( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -345,7 +340,7 @@ async def get_users_stats( # Get subscription stats sub_stats_query = select( - func.count(Subscription.id).label("total"), + func.count(Subscription.id).label('total'), func.sum( func.cast( and_( @@ -354,8 +349,8 @@ async def get_users_stats( ), Integer, ) - ).label("active"), - func.sum(func.cast(Subscription.is_trial == True, Integer)).label("trial"), + ).label('active'), + func.sum(func.cast(Subscription.is_trial == True, Integer)).label('trial'), func.sum( func.cast( or_( @@ -364,7 +359,7 @@ async def get_users_stats( ), Integer, ) - ).label("expired"), + ).label('expired'), ) sub_result = await db.execute(sub_stats_query) sub_row = sub_result.one_or_none() @@ -376,8 +371,8 @@ async def get_users_stats( # Get balance stats balance_query = select( - func.sum(User.balance_kopeks).label("total"), - func.avg(User.balance_kopeks).label("avg"), + func.sum(User.balance_kopeks).label('total'), + func.avg(User.balance_kopeks).label('avg'), ).where(User.status == UserStatus.ACTIVE.value) balance_result = await db.execute(balance_query) balance_row = balance_result.one_or_none() @@ -412,13 +407,13 @@ async def get_users_stats( deleted_count = (await db.execute(deleted_q)).scalar() or 0 return UsersStatsResponse( - total_users=stats["total_users"], - active_users=stats["active_users"], - blocked_users=stats["blocked_users"], + total_users=stats['total_users'], + active_users=stats['active_users'], + blocked_users=stats['blocked_users'], deleted_users=deleted_count, - new_today=stats["new_today"], - new_week=stats["new_week"], - new_month=stats["new_month"], + new_today=stats['new_today'], + new_week=stats['new_week'], + new_month=stats['new_month'], users_with_subscription=users_with_subscription, users_with_active_subscription=users_with_active, users_with_trial=users_with_trial, @@ -434,7 +429,8 @@ async def get_users_stats( # === User Detail === -@router.get("/{user_id}", response_model=UserDetailResponse) + +@router.get('/{user_id}', response_model=UserDetailResponse) async def get_user_detail( user_id: int, admin: User = Depends(get_current_admin_user), @@ -445,12 +441,12 @@ async def get_user_detail( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) # Get spending stats spending_stats = await get_users_spending_stats(db, [user.id]) - user_stats = spending_stats.get(user.id, {"total_spent": 0, "purchase_count": 0}) + user_stats = spending_stats.get(user.id, {'total_spent': 0, 'purchase_count': 0}) # Build subscription info subscription_info = None @@ -488,7 +484,7 @@ async def get_user_detail( referred_by_username = referrer.username or referrer.full_name referral_info = UserReferralInfo( - referral_code=user.referral_code or "", + referral_code=user.referral_code or '', referrals_count=referrals_count, total_earnings_kopeks=referral_earnings, commission_percent=user.referral_commission_percent, @@ -498,10 +494,7 @@ async def get_user_detail( # Get recent transactions transactions_q = ( - select(Transaction) - .where(Transaction.user_id == user.id) - .order_by(Transaction.created_at.desc()) - .limit(20) + select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.created_at.desc()).limit(20) ) transactions_result = await db.execute(transactions_q) transactions = transactions_result.scalars().all() @@ -540,8 +533,8 @@ async def get_user_detail( subscription=subscription_info, promo_group=promo_group_info, referral=referral_info, - total_spent_kopeks=user_stats.get("total_spent", 0), - purchase_count=user_stats.get("purchase_count", 0), + total_spent_kopeks=user_stats.get('total_spent', 0), + purchase_count=user_stats.get('purchase_count', 0), used_promocodes=user.used_promocodes, has_had_paid_subscription=user.has_had_paid_subscription, lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0, @@ -556,7 +549,7 @@ async def get_user_detail( ) -@router.get("/by-telegram/{telegram_id}", response_model=UserDetailResponse) +@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse) async def get_user_by_telegram( telegram_id: int, admin: User = Depends(get_current_admin_user), @@ -567,14 +560,15 @@ async def get_user_by_telegram( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) return await get_user_detail(user.id, admin, db) # === Balance Management === -@router.post("/{user_id}/balance", response_model=UpdateBalanceResponse) + +@router.post('/{user_id}/balance', response_model=UpdateBalanceResponse) async def update_user_balance( user_id: int, request: UpdateBalanceRequest, @@ -591,7 +585,7 @@ async def update_user_balance( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) old_balance = user.balance_kopeks @@ -612,7 +606,7 @@ async def update_user_balance( if user.balance_kopeks < amount_to_subtract: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Insufficient balance. Current: {user.balance_kopeks}, requested: {amount_to_subtract}", + detail=f'Insufficient balance. Current: {user.balance_kopeks}, requested: {amount_to_subtract}', ) success = await subtract_user_balance( db=db, @@ -625,28 +619,29 @@ async def update_user_balance( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update balance", + detail='Failed to update balance', ) # Refresh user await db.refresh(user) logger.info( - f"Admin {admin.id} updated balance for user {user_id}: " - f"{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})" + f'Admin {admin.id} updated balance for user {user_id}: ' + f'{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})' ) return UpdateBalanceResponse( success=True, old_balance_kopeks=old_balance, new_balance_kopeks=user.balance_kopeks, - message=f"Balance updated: {old_balance/100:.2f}₽ -> {user.balance_kopeks/100:.2f}₽", + message=f'Balance updated: {old_balance / 100:.2f}₽ -> {user.balance_kopeks / 100:.2f}₽', ) # === Subscription Management === -@router.post("/{user_id}/subscription", response_model=UpdateSubscriptionResponse) + +@router.post('/{user_id}/subscription', response_model=UpdateSubscriptionResponse) async def update_user_subscription( user_id: int, request: UpdateSubscriptionRequest, @@ -670,17 +665,17 @@ async def update_user_subscription( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) subscription = user.subscription - if request.action == "create": + if request.action == 'create': # Create new subscription if subscription: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User already has a subscription", + detail='User already has a subscription', ) from app.database.crud.subscription import create_paid_subscription @@ -716,49 +711,46 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, new_sub) - logger.info(f"Admin {admin.id} created subscription for user {user_id}") + logger.info(f'Admin {admin.id} created subscription for user {user_id}') return UpdateSubscriptionResponse( success=True, - message=f"Subscription created for {days} days", + message=f'Subscription created for {days} days', subscription=await _build_subscription_info_async(db, new_sub), ) if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User has no subscription", + detail='User has no subscription', ) - if request.action == "extend": + if request.action == 'extend': if not request.days: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Days parameter is required for extend action", + detail='Days parameter is required for extend action', ) - old_end = subscription.end_date await extend_subscription(db, subscription, request.days) await db.refresh(subscription) # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info( - f"Admin {admin.id} extended subscription for user {user_id} by {request.days} days" - ) + logger.info(f'Admin {admin.id} extended subscription for user {user_id} by {request.days} days') return UpdateSubscriptionResponse( success=True, - message=f"Subscription extended by {request.days} days", + message=f'Subscription extended by {request.days} days', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "set_end_date": + if request.action == 'set_end_date': if not request.end_date: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="end_date parameter is required", + detail='end_date parameter is required', ) subscription.end_date = request.end_date @@ -773,26 +765,26 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f"Admin {admin.id} set end_date for user {user_id} subscription") + logger.info(f'Admin {admin.id} set end_date for user {user_id} subscription') return UpdateSubscriptionResponse( success=True, - message=f"Subscription end date set to {request.end_date.isoformat()}", + message=f'Subscription end date set to {request.end_date.isoformat()}', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "change_tariff": + if request.action == 'change_tariff': if request.tariff_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="tariff_id parameter is required", + detail='tariff_id parameter is required', ) tariff = await get_tariff_by_id(db, request.tariff_id) if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) subscription.tariff_id = request.tariff_id @@ -807,17 +799,15 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info( - f"Admin {admin.id} changed tariff for user {user_id} to {tariff.name}" - ) + logger.info(f'Admin {admin.id} changed tariff for user {user_id} to {tariff.name}') return UpdateSubscriptionResponse( success=True, - message=f"Tariff changed to {tariff.name}", + message=f'Tariff changed to {tariff.name}', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "set_traffic": + if request.action == 'set_traffic': if request.traffic_limit_gb is not None: subscription.traffic_limit_gb = request.traffic_limit_gb @@ -830,35 +820,35 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f"Admin {admin.id} updated traffic for user {user_id}") + logger.info(f'Admin {admin.id} updated traffic for user {user_id}') return UpdateSubscriptionResponse( success=True, - message="Traffic settings updated", + message='Traffic settings updated', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "toggle_autopay": + if request.action == 'toggle_autopay': if request.autopay_enabled is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="autopay_enabled parameter is required", + detail='autopay_enabled parameter is required', ) subscription.autopay_enabled = request.autopay_enabled await db.commit() await db.refresh(subscription) - state = "enabled" if request.autopay_enabled else "disabled" - logger.info(f"Admin {admin.id} {state} autopay for user {user_id}") + state = 'enabled' if request.autopay_enabled else 'disabled' + logger.info(f'Admin {admin.id} {state} autopay for user {user_id}') return UpdateSubscriptionResponse( success=True, - message=f"Autopay {state}", + message=f'Autopay {state}', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "cancel": + if request.action == 'cancel': subscription.status = SubscriptionStatus.EXPIRED.value subscription.end_date = datetime.utcnow() await db.commit() @@ -867,15 +857,15 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f"Admin {admin.id} cancelled subscription for user {user_id}") + logger.info(f'Admin {admin.id} cancelled subscription for user {user_id}') return UpdateSubscriptionResponse( success=True, - message="Subscription cancelled", + message='Subscription cancelled', subscription=await _build_subscription_info_async(db, subscription), ) - elif request.action == "activate": + if request.action == 'activate': subscription.status = SubscriptionStatus.ACTIVE.value if subscription.end_date and subscription.end_date <= datetime.utcnow(): # Extend by 30 days if expired @@ -886,27 +876,27 @@ async def update_user_subscription( # Sync to Remnawave panel await _sync_subscription_to_panel(db, user, subscription) - logger.info(f"Admin {admin.id} activated subscription for user {user_id}") + logger.info(f'Admin {admin.id} activated subscription for user {user_id}') return UpdateSubscriptionResponse( success=True, - message="Subscription activated", + message='Subscription activated', subscription=await _build_subscription_info_async(db, subscription), ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unknown action: {request.action}", - ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'Unknown action: {request.action}', + ) # === Available Tariffs === -@router.get("/{user_id}/available-tariffs", response_model=UserAvailableTariffsResponse) + +@router.get('/{user_id}/available-tariffs', response_model=UserAvailableTariffsResponse) async def get_user_available_tariffs( user_id: int, - include_inactive: bool = Query(False, description="Include inactive tariffs"), + include_inactive: bool = Query(False, description='Include inactive tariffs'), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -920,7 +910,7 @@ async def get_user_available_tariffs( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) # Get all tariffs @@ -948,32 +938,36 @@ async def get_user_available_tariffs( if tariff.period_prices: for days_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): days = int(days_str) - period_prices.append(PeriodPriceInfo( - days=days, - price_kopeks=price_kopeks, - price_rubles=price_kopeks / 100, - )) + period_prices.append( + PeriodPriceInfo( + days=days, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + ) + ) - tariff_items.append(UserAvailableTariffItem( - id=tariff.id, - name=tariff.name, - description=tariff.description, - is_active=tariff.is_active, - is_trial_available=tariff.is_trial_available, - traffic_limit_gb=tariff.traffic_limit_gb, - device_limit=tariff.device_limit, - tier_level=tariff.tier_level, - display_order=tariff.display_order, - period_prices=period_prices, - is_daily=tariff.is_daily, - daily_price_kopeks=tariff.daily_price_kopeks, - custom_days_enabled=tariff.custom_days_enabled, - price_per_day_kopeks=tariff.price_per_day_kopeks, - min_days=tariff.min_days, - max_days=tariff.max_days, - is_available=is_available, - requires_promo_group=requires_promo_group, - )) + tariff_items.append( + UserAvailableTariffItem( + id=tariff.id, + name=tariff.name, + description=tariff.description, + is_active=tariff.is_active, + is_trial_available=tariff.is_trial_available, + traffic_limit_gb=tariff.traffic_limit_gb, + device_limit=tariff.device_limit, + tier_level=tariff.tier_level, + display_order=tariff.display_order, + period_prices=period_prices, + is_daily=tariff.is_daily, + daily_price_kopeks=tariff.daily_price_kopeks, + custom_days_enabled=tariff.custom_days_enabled, + price_per_day_kopeks=tariff.price_per_day_kopeks, + min_days=tariff.min_days, + max_days=tariff.max_days, + is_available=is_available, + requires_promo_group=requires_promo_group, + ) + ) # Sort by display_order, then by tier_level tariff_items.sort(key=lambda t: (t.display_order, t.tier_level)) @@ -991,7 +985,8 @@ async def get_user_available_tariffs( # === Status Management === -@router.post("/{user_id}/status", response_model=UpdateUserStatusResponse) + +@router.post('/{user_id}/status', response_model=UpdateUserStatusResponse) async def update_user_status( user_id: int, request: UpdateUserStatusRequest, @@ -1003,7 +998,7 @@ async def update_user_status( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) old_status = user.status @@ -1014,7 +1009,7 @@ async def update_user_status( success=True, old_status=old_status, new_status=new_status, - message="Status unchanged", + message='Status unchanged', ) user.status = new_status @@ -1022,24 +1017,24 @@ async def update_user_status( await db.commit() await db.refresh(user) - action = f"{old_status} -> {new_status}" + action = f'{old_status} -> {new_status}' if request.reason: - action += f" (reason: {request.reason})" + action += f' (reason: {request.reason})' - logger.info(f"Admin {admin.id} changed status for user {user_id}: {action}") + logger.info(f'Admin {admin.id} changed status for user {user_id}: {action}') return UpdateUserStatusResponse( success=True, old_status=old_status, new_status=new_status, - message=f"Status changed from {old_status} to {new_status}", + message=f'Status changed from {old_status} to {new_status}', ) -@router.post("/{user_id}/block", response_model=UpdateUserStatusResponse) +@router.post('/{user_id}/block', response_model=UpdateUserStatusResponse) async def block_user( user_id: int, - reason: Optional[str] = None, + reason: str | None = None, admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -1048,7 +1043,7 @@ async def block_user( return await update_user_status(user_id, request, admin, db) -@router.post("/{user_id}/unblock", response_model=UpdateUserStatusResponse) +@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse) async def unblock_user( user_id: int, admin: User = Depends(get_current_admin_user), @@ -1061,7 +1056,8 @@ async def unblock_user( # === Restrictions Management === -@router.post("/{user_id}/restrictions", response_model=UpdateRestrictionsResponse) + +@router.post('/{user_id}/restrictions', response_model=UpdateRestrictionsResponse) async def update_user_restrictions( user_id: int, request: UpdateRestrictionsRequest, @@ -1073,7 +1069,7 @@ async def update_user_restrictions( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) if request.restriction_topup is not None: @@ -1090,8 +1086,8 @@ async def update_user_restrictions( await db.refresh(user) logger.info( - f"Admin {admin.id} updated restrictions for user {user_id}: " - f"topup={user.restriction_topup}, subscription={user.restriction_subscription}" + f'Admin {admin.id} updated restrictions for user {user_id}: ' + f'topup={user.restriction_topup}, subscription={user.restriction_subscription}' ) return UpdateRestrictionsResponse( @@ -1099,13 +1095,14 @@ async def update_user_restrictions( restriction_topup=user.restriction_topup, restriction_subscription=user.restriction_subscription, restriction_reason=user.restriction_reason, - message="Restrictions updated", + message='Restrictions updated', ) # === Promo Group Management === -@router.post("/{user_id}/promo-group", response_model=UpdatePromoGroupResponse) + +@router.post('/{user_id}/promo-group', response_model=UpdatePromoGroupResponse) async def update_user_promo_group( user_id: int, request: UpdatePromoGroupRequest, @@ -1117,7 +1114,7 @@ async def update_user_promo_group( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) old_promo_group_id = user.promo_group_id @@ -1126,14 +1123,12 @@ async def update_user_promo_group( if new_promo_group_id is not None: # Verify promo group exists - result = await db.execute( - select(PromoGroup).where(PromoGroup.id == new_promo_group_id) - ) + result = await db.execute(select(PromoGroup).where(PromoGroup.id == new_promo_group_id)) promo_group = result.scalar_one_or_none() if not promo_group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Promo group not found", + detail='Promo group not found', ) promo_group_name = promo_group.name @@ -1143,8 +1138,7 @@ async def update_user_promo_group( await db.refresh(user) logger.info( - f"Admin {admin.id} changed promo group for user {user_id}: " - f"{old_promo_group_id} -> {new_promo_group_id}" + f'Admin {admin.id} changed promo group for user {user_id}: {old_promo_group_id} -> {new_promo_group_id}' ) return UpdatePromoGroupResponse( @@ -1152,13 +1146,14 @@ async def update_user_promo_group( old_promo_group_id=old_promo_group_id, new_promo_group_id=new_promo_group_id, promo_group_name=promo_group_name, - message="Promo group updated", + message='Promo group updated', ) # === Delete User === -@router.delete("/{user_id}", response_model=DeleteUserResponse) + +@router.delete('/{user_id}', response_model=DeleteUserResponse) async def delete_user( user_id: int, request: DeleteUserRequest = DeleteUserRequest(), @@ -1175,30 +1170,31 @@ async def delete_user( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) if request.soft_delete: await soft_delete_user(db, user) - action = "soft deleted" + action = 'soft deleted' else: # Hard delete await db.delete(user) await db.commit() - action = "permanently deleted" + action = 'permanently deleted' - reason_text = f" (reason: {request.reason})" if request.reason else "" - logger.info(f"Admin {admin.id} {action} user {user_id}{reason_text}") + reason_text = f' (reason: {request.reason})' if request.reason else '' + logger.info(f'Admin {admin.id} {action} user {user_id}{reason_text}') return DeleteUserResponse( success=True, - message=f"User {action} successfully", + message=f'User {action} successfully', ) # === User Referrals === -@router.get("/{user_id}/referrals", response_model=UsersListResponse) + +@router.get('/{user_id}/referrals', response_model=UsersListResponse) async def get_user_referrals( user_id: int, offset: int = Query(0, ge=0), @@ -1211,7 +1207,7 @@ async def get_user_referrals( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) referrals = await get_referrals(db, user.id) @@ -1236,12 +1232,13 @@ async def get_user_referrals( # === User Transactions === -@router.get("/{user_id}/transactions") + +@router.get('/{user_id}/transactions') async def get_user_transactions( user_id: int, offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), - transaction_type: Optional[str] = Query(None), + transaction_type: str | None = Query(None), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -1250,7 +1247,7 @@ async def get_user_transactions( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) query = select(Transaction).where(Transaction.user_id == user.id) @@ -1284,16 +1281,17 @@ async def get_user_transactions( ] return { - "transactions": items, - "total": total, - "offset": offset, - "limit": limit, + 'transactions': items, + 'total': total, + 'offset': offset, + 'limit': limit, } # === Panel Sync === -@router.get("/{user_id}/sync/status", response_model=PanelSyncStatusResponse) + +@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse) async def get_user_sync_status( user_id: int, admin: User = Depends(get_current_admin_user), @@ -1308,7 +1306,7 @@ async def get_user_sync_status( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) # Bot data @@ -1317,7 +1315,7 @@ async def get_user_sync_status( bot_traffic_limit = 0 bot_traffic_used = 0.0 bot_device_limit = 0 - bot_squads: List[str] = [] + bot_squads: list[str] = [] if user.subscription: bot_sub_status = user.subscription.status @@ -1334,7 +1332,7 @@ async def get_user_sync_status( panel_traffic_limit = 0.0 panel_traffic_used = 0.0 panel_device_limit = 0 - panel_squads: List[str] = [] + panel_squads: list[str] = [] differences = [] try: @@ -1349,27 +1347,34 @@ async def get_user_sync_status( panel_found = True panel_status = panel_user.status.value if panel_user.status else None panel_expire_at = panel_user.expire_at - panel_traffic_limit = panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0 - panel_traffic_used = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0 + panel_traffic_limit = ( + panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0 + ) + panel_traffic_used = ( + panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0 + ) panel_device_limit = panel_user.hwid_device_limit or 0 # Extract squad UUIDs from active_internal_squads - panel_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')] + panel_squads = [ + s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid') + ] # Check differences if bot_sub_status and panel_status: - bot_active = bot_sub_status in ("active", "trial") - panel_active = panel_status.upper() == "ACTIVE" + bot_active = bot_sub_status in ('active', 'trial') + panel_active = panel_status.upper() == 'ACTIVE' if bot_active != panel_active: - differences.append(f"Status: bot={bot_sub_status}, panel={panel_status}") + differences.append(f'Status: bot={bot_sub_status}, panel={panel_status}') if bot_sub_end_date and panel_expire_at: # Convert both to naive UTC for comparison # Bot dates are stored as naive UTC - bot_end_utc = bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date + bot_end_utc = ( + bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date + ) # Panel dates might be timezone-aware, convert to UTC first if panel_expire_at.tzinfo: - from datetime import timezone - panel_end_utc = panel_expire_at.astimezone(timezone.utc).replace(tzinfo=None) + panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None) else: # Panel might return naive datetime in MSK (UTC+3), try both interpretations panel_end_utc = panel_expire_at @@ -1379,17 +1384,21 @@ async def get_user_sync_status( # If diff is ~3 hours (10800 sec) +/- 5 min, assume it's timezone issue is_timezone_diff = abs(diff_seconds - 10800) < 300 # 3 hours +/- 5 min if diff_seconds > 3600 and not is_timezone_diff: # More than 1 hour and not timezone - differences.append(f"End date differs by {diff_seconds/3600:.1f} hours") + differences.append(f'End date differs by {diff_seconds / 3600:.1f} hours') if abs(bot_traffic_limit - panel_traffic_limit) > 1: - differences.append(f"Traffic limit: bot={bot_traffic_limit}GB, panel={panel_traffic_limit:.1f}GB") + differences.append( + f'Traffic limit: bot={bot_traffic_limit}GB, panel={panel_traffic_limit:.1f}GB' + ) if abs(bot_traffic_used - panel_traffic_used) > 0.5: - differences.append(f"Traffic used: bot={bot_traffic_used:.2f}GB, panel={panel_traffic_used:.2f}GB") + differences.append( + f'Traffic used: bot={bot_traffic_used:.2f}GB, panel={panel_traffic_used:.2f}GB' + ) # Compare device limits if bot_device_limit != panel_device_limit: - differences.append(f"Device limit: bot={bot_device_limit}, panel={panel_device_limit}") + differences.append(f'Device limit: bot={bot_device_limit}, panel={panel_device_limit}') # Compare squads bot_squads_set = set(bot_squads) if bot_squads else set() @@ -1399,14 +1408,14 @@ async def get_user_sync_status( only_in_panel = panel_squads_set - bot_squads_set squad_diff_parts = [] if only_in_bot: - squad_diff_parts.append(f"only in bot: {len(only_in_bot)}") + squad_diff_parts.append(f'only in bot: {len(only_in_bot)}') if only_in_panel: - squad_diff_parts.append(f"only in panel: {len(only_in_panel)}") - differences.append(f"Squads mismatch ({', '.join(squad_diff_parts)})") + squad_diff_parts.append(f'only in panel: {len(only_in_panel)}') + differences.append(f'Squads mismatch ({", ".join(squad_diff_parts)})') except Exception as e: - logger.warning(f"Failed to get panel data for user {user_id}: {e}") - differences.append(f"Error fetching panel data: {str(e)}") + logger.warning(f'Failed to get panel data for user {user_id}: {e}') + differences.append(f'Error fetching panel data: {e!s}') return PanelSyncStatusResponse( user_id=user.id, @@ -1431,7 +1440,7 @@ async def get_user_sync_status( ) -@router.post("/{user_id}/sync/from-panel", response_model=SyncFromPanelResponse) +@router.post('/{user_id}/sync/from-panel', response_model=SyncFromPanelResponse) async def sync_user_from_panel( user_id: int, request: SyncFromPanelRequest = SyncFromPanelRequest(), @@ -1447,17 +1456,17 @@ async def sync_user_from_panel( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) try: - from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError + from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=service.configuration_error or "Remnawave API not configured", + detail=service.configuration_error or 'Remnawave API not configured', ) changes = {} @@ -1468,7 +1477,7 @@ async def sync_user_from_panel( if not user.telegram_id: return SyncFromPanelResponse( success=False, - message="Cannot sync email-only user", + message='Cannot sync email-only user', errors=["Email-only users don't have telegram_id for panel lookup"], ) @@ -1479,8 +1488,8 @@ async def sync_user_from_panel( if not panel_users: return SyncFromPanelResponse( success=False, - message="User not found in panel", - errors=["No user with this telegram_id found in Remnawave panel"], + message='User not found in panel', + errors=['No user with this telegram_id found in Remnawave panel'], ) panel_user = panel_users[0] @@ -1509,7 +1518,7 @@ async def sync_user_from_panel( # Update remnawave_uuid if different if user.remnawave_uuid != panel_user.uuid: - changes["remnawave_uuid"] = {"old": user.remnawave_uuid, "new": panel_user.uuid} + changes['remnawave_uuid'] = {'old': user.remnawave_uuid, 'new': panel_user.uuid} user.remnawave_uuid = panel_user.uuid # Update subscription if requested @@ -1520,22 +1529,26 @@ async def sync_user_from_panel( if panel_user.expire_at: # Convert panel expire_at to naive UTC for storage if panel_user.expire_at.tzinfo: - from datetime import timezone - panel_expire_utc = panel_user.expire_at.astimezone(timezone.utc).replace(tzinfo=None) + panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None) else: panel_expire_utc = panel_user.expire_at - sub_end_naive = sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date + sub_end_naive = ( + sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date + ) if sub_end_naive != panel_expire_utc: - changes["end_date"] = {"old": sub.end_date.isoformat() if sub.end_date else None, "new": panel_expire_utc.isoformat()} + changes['end_date'] = { + 'old': sub.end_date.isoformat() if sub.end_date else None, + 'new': panel_expire_utc.isoformat(), + } sub.end_date = panel_expire_utc # Update status - panel_status_str = panel_user.status.value if panel_user.status else "DISABLED" + panel_status_str = panel_user.status.value if panel_user.status else 'DISABLED' now = datetime.utcnow() # Compare with normalized panel expire date panel_expire_for_check = panel_expire_utc if panel_user.expire_at else None - if panel_status_str == "ACTIVE" and panel_expire_for_check and panel_expire_for_check > now: + if panel_status_str == 'ACTIVE' and panel_expire_for_check and panel_expire_for_check > now: new_status = SubscriptionStatus.ACTIVE.value elif panel_expire_for_check and panel_expire_for_check <= now: new_status = SubscriptionStatus.EXPIRED.value @@ -1543,52 +1556,55 @@ async def sync_user_from_panel( new_status = SubscriptionStatus.DISABLED.value if sub.status != new_status: - changes["status"] = {"old": sub.status, "new": new_status} + changes['status'] = {'old': sub.status, 'new': new_status} sub.status = new_status # Update traffic limit - panel_traffic_limit = int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 0 + panel_traffic_limit = ( + int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 0 + ) if sub.traffic_limit_gb != panel_traffic_limit: - changes["traffic_limit_gb"] = {"old": sub.traffic_limit_gb, "new": panel_traffic_limit} + changes['traffic_limit_gb'] = {'old': sub.traffic_limit_gb, 'new': panel_traffic_limit} sub.traffic_limit_gb = panel_traffic_limit # Update device limit panel_device_limit = panel_user.hwid_device_limit or 1 if sub.device_limit != panel_device_limit: - changes["device_limit"] = {"old": sub.device_limit, "new": panel_device_limit} + changes['device_limit'] = {'old': sub.device_limit, 'new': panel_device_limit} sub.device_limit = panel_device_limit # Update connected squads if active_squads and sub.connected_squads != active_squads: - changes["connected_squads"] = {"old": sub.connected_squads, "new": active_squads} + changes['connected_squads'] = {'old': sub.connected_squads, 'new': active_squads} sub.connected_squads = active_squads # Update subscription URL if panel_user.subscription_url and sub.subscription_url != panel_user.subscription_url: - changes["subscription_url"] = {"old": sub.subscription_url, "new": panel_user.subscription_url} + changes['subscription_url'] = {'old': sub.subscription_url, 'new': panel_user.subscription_url} sub.subscription_url = panel_user.subscription_url # Update short UUID if panel_user.short_uuid and sub.remnawave_short_uuid != panel_user.short_uuid: - changes["remnawave_short_uuid"] = {"old": sub.remnawave_short_uuid, "new": panel_user.short_uuid} + changes['remnawave_short_uuid'] = {'old': sub.remnawave_short_uuid, 'new': panel_user.short_uuid} sub.remnawave_short_uuid = panel_user.short_uuid # Update traffic usage if requested if request.update_traffic and user.subscription: panel_traffic_used = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0 if abs((user.subscription.traffic_used_gb or 0) - panel_traffic_used) > 0.01: - changes["traffic_used_gb"] = {"old": user.subscription.traffic_used_gb, "new": panel_traffic_used} + changes['traffic_used_gb'] = {'old': user.subscription.traffic_used_gb, 'new': panel_traffic_used} user.subscription.traffic_used_gb = panel_traffic_used # Create subscription if missing but user exists in panel if request.create_if_missing and not user.subscription and panel_user.expire_at: from app.database.crud.subscription import create_paid_subscription - panel_traffic_limit = int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100 + panel_traffic_limit = ( + int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100 + ) # Normalize panel expire date for calculation if panel_user.expire_at.tzinfo: - from datetime import timezone - panel_expire_naive = panel_user.expire_at.astimezone(timezone.utc).replace(tzinfo=None) + panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None) else: panel_expire_naive = panel_user.expire_at days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days) @@ -1603,7 +1619,7 @@ async def sync_user_from_panel( ) new_sub.remnawave_short_uuid = panel_user.short_uuid new_sub.subscription_url = panel_user.subscription_url - changes["subscription_created"] = True + changes['subscription_created'] = True # Update last sync time user.last_remnawave_sync = datetime.utcnow() @@ -1611,11 +1627,11 @@ async def sync_user_from_panel( await db.commit() - logger.info(f"Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}") + logger.info(f'Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}') return SyncFromPanelResponse( success=True, - message=f"Synced {len(changes)} changes from panel" if changes else "No changes needed", + message=f'Synced {len(changes)} changes from panel' if changes else 'No changes needed', panel_user=panel_info, changes=changes, errors=errors, @@ -1624,14 +1640,14 @@ async def sync_user_from_panel( except HTTPException: raise except Exception as e: - logger.error(f"Error syncing user {user_id} from panel: {e}") + logger.error(f'Error syncing user {user_id} from panel: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Sync error: {str(e)}", + detail=f'Sync error: {e!s}', ) -@router.post("/{user_id}/sync/to-panel", response_model=SyncToPanelResponse) +@router.post('/{user_id}/sync/to-panel', response_model=SyncToPanelResponse) async def sync_user_to_panel( user_id: int, request: SyncToPanelRequest = SyncToPanelRequest(), @@ -1647,32 +1663,32 @@ async def sync_user_to_panel( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) if not user.subscription: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User has no subscription to sync", + detail='User has no subscription to sync', ) try: - from app.services.remnawave_service import RemnaWaveService - from app.external.remnawave_api import UserStatus as PanelUserStatus, TrafficLimitStrategy from app.config import settings + from app.external.remnawave_api import TrafficLimitStrategy, UserStatus as PanelUserStatus + from app.services.remnawave_service import RemnaWaveService from app.utils.subscription_utils import resolve_hwid_device_limit_for_payload service = RemnaWaveService() if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=service.configuration_error or "Remnawave API not configured", + detail=service.configuration_error or 'Remnawave API not configured', ) sub = user.subscription changes = {} errors = [] - action = "no_changes" + action = 'no_changes' panel_uuid = user.remnawave_uuid # Prepare data for panel @@ -1710,37 +1726,37 @@ async def sync_user_to_panel( if existing_users: panel_uuid = existing_users[0].uuid user.remnawave_uuid = panel_uuid - changes["remnawave_uuid_discovered"] = panel_uuid + changes['remnawave_uuid_discovered'] = panel_uuid if panel_uuid: # Update existing user - update_kwargs = {"uuid": panel_uuid} + update_kwargs = {'uuid': panel_uuid} if request.update_status: - update_kwargs["status"] = panel_status - changes["status"] = panel_status.value + update_kwargs['status'] = panel_status + changes['status'] = panel_status.value if request.update_expire_date and expire_at: - update_kwargs["expire_at"] = expire_at - changes["expire_at"] = expire_at.isoformat() + update_kwargs['expire_at'] = expire_at + changes['expire_at'] = expire_at.isoformat() if request.update_traffic_limit: - update_kwargs["traffic_limit_bytes"] = traffic_limit_bytes - update_kwargs["traffic_limit_strategy"] = TrafficLimitStrategy.MONTH - changes["traffic_limit_gb"] = sub.traffic_limit_gb + update_kwargs['traffic_limit_bytes'] = traffic_limit_bytes + update_kwargs['traffic_limit_strategy'] = TrafficLimitStrategy.MONTH + changes['traffic_limit_gb'] = sub.traffic_limit_gb if request.update_squads and sub.connected_squads: - update_kwargs["active_internal_squads"] = sub.connected_squads - changes["connected_squads"] = sub.connected_squads + update_kwargs['active_internal_squads'] = sub.connected_squads + changes['connected_squads'] = sub.connected_squads - update_kwargs["description"] = description + update_kwargs['description'] = description if hwid_limit is not None: - update_kwargs["hwid_device_limit"] = hwid_limit - changes["device_limit"] = hwid_limit + update_kwargs['hwid_device_limit'] = hwid_limit + changes['device_limit'] = hwid_limit try: await api.update_user(**update_kwargs) - action = "updated" + action = 'updated' except Exception as update_error: if hasattr(update_error, 'status_code') and update_error.status_code == 404: # User not found in panel, create new @@ -1751,18 +1767,18 @@ async def sync_user_to_panel( if not panel_uuid and request.create_if_missing: # Create new user in panel create_kwargs = { - "username": username, - "expire_at": expire_at or (datetime.utcnow() + timedelta(days=30)), - "status": panel_status, - "traffic_limit_bytes": traffic_limit_bytes, - "traffic_limit_strategy": TrafficLimitStrategy.MONTH, - "telegram_id": user.telegram_id, - "description": description, - "active_internal_squads": sub.connected_squads or [], + 'username': username, + 'expire_at': expire_at or (datetime.utcnow() + timedelta(days=30)), + 'status': panel_status, + 'traffic_limit_bytes': traffic_limit_bytes, + 'traffic_limit_strategy': TrafficLimitStrategy.MONTH, + 'telegram_id': user.telegram_id, + 'description': description, + 'active_internal_squads': sub.connected_squads or [], } if hwid_limit is not None: - create_kwargs["hwid_device_limit"] = hwid_limit + create_kwargs['hwid_device_limit'] = hwid_limit new_panel_user = await api.create_user(**create_kwargs) panel_uuid = new_panel_user.uuid @@ -1770,10 +1786,10 @@ async def sync_user_to_panel( sub.remnawave_short_uuid = new_panel_user.short_uuid sub.subscription_url = new_panel_user.subscription_url - changes["created_in_panel"] = True - changes["panel_uuid"] = panel_uuid - changes["short_uuid"] = new_panel_user.short_uuid - action = "created" + changes['created_in_panel'] = True + changes['panel_uuid'] = panel_uuid + changes['short_uuid'] = new_panel_user.short_uuid + action = 'created' # Update last sync time user.last_remnawave_sync = datetime.utcnow() @@ -1781,11 +1797,11 @@ async def sync_user_to_panel( await db.commit() - logger.info(f"Admin {admin.id} synced user {user_id} to panel. Action: {action}") + logger.info(f'Admin {admin.id} synced user {user_id} to panel. Action: {action}') return SyncToPanelResponse( success=True, - message=f"User {action} in panel" if action != "no_changes" else "No changes needed", + message=f'User {action} in panel' if action != 'no_changes' else 'No changes needed', action=action, panel_uuid=panel_uuid, changes=changes, @@ -1795,8 +1811,8 @@ async def sync_user_to_panel( except HTTPException: raise except Exception as e: - logger.error(f"Error syncing user {user_id} to panel: {e}") + logger.error(f'Error syncing user {user_id} to panel: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Sync error: {str(e)}", + detail=f'Sync error: {e!s}', ) diff --git a/app/cabinet/routes/admin_wheel.py b/app/cabinet/routes/admin_wheel.py index ff3770d4..e137a54f 100644 --- a/app/cabinet/routes/admin_wheel.py +++ b/app/cabinet/routes/admin_wheel.py @@ -1,46 +1,46 @@ """ API роуты колеса удачи для администраторов. """ + import logging import math from datetime import datetime -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException, status, Query + +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User -from app.database.crud.wheel import ( - get_or_create_wheel_config, - update_wheel_config, - get_wheel_prizes, - get_wheel_prize_by_id, - create_wheel_prize, - update_wheel_prize, - delete_wheel_prize, - reorder_wheel_prizes, - get_all_spins, - get_wheel_statistics, -) -from app.services.wheel_service import wheel_service from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user from app.cabinet.schemas.wheel import ( - AdminWheelConfigResponse, - WheelPrizeAdminResponse, - UpdateWheelConfigRequest, - CreatePrizeRequest, - UpdatePrizeRequest, - ReorderPrizesRequest, - AdminSpinsResponse, AdminSpinItem, + AdminSpinsResponse, + AdminWheelConfigResponse, + CreatePrizeRequest, + ReorderPrizesRequest, + UpdatePrizeRequest, + UpdateWheelConfigRequest, + WheelPrizeAdminResponse, WheelStatisticsResponse, ) +from app.database.crud.wheel import ( + create_wheel_prize, + delete_wheel_prize, + get_all_spins, + get_or_create_wheel_config, + get_wheel_prizes, + reorder_wheel_prizes, + update_wheel_config, + update_wheel_prize, +) +from app.database.models import User +from app.services.wheel_service import wheel_service + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"]) +router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel']) -@router.get("/config", response_model=AdminWheelConfigResponse) +@router.get('/config', response_model=AdminWheelConfigResponse) async def get_admin_wheel_config( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -90,7 +90,7 @@ async def get_admin_wheel_config( ) -@router.put("/config", response_model=AdminWheelConfigResponse) +@router.put('/config', response_model=AdminWheelConfigResponse) async def update_admin_wheel_config( request: UpdateWheelConfigRequest, admin: User = Depends(get_current_admin_user), @@ -102,12 +102,12 @@ async def update_admin_wheel_config( if not update_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No fields to update", + detail='No fields to update', ) config = await update_wheel_config(db, **update_data) - logger.info(f"🎡 Admin {admin.telegram_id} updated wheel config: {update_data}") + logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}') # Возвращаем полную конфигурацию prizes = await get_wheel_prizes(db, config.id, active_only=False) @@ -153,7 +153,7 @@ async def update_admin_wheel_config( ) -@router.get("/prizes", response_model=List[WheelPrizeAdminResponse]) +@router.get('/prizes', response_model=list[WheelPrizeAdminResponse]) async def get_prizes( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -185,7 +185,7 @@ async def get_prizes( ] -@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED) +@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED) async def create_prize( request: CreatePrizeRequest, admin: User = Depends(get_current_admin_user), @@ -211,7 +211,7 @@ async def create_prize( promo_traffic_gb=request.promo_traffic_gb, ) - logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}") + logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}') return WheelPrizeAdminResponse( id=prize.id, @@ -233,7 +233,7 @@ async def create_prize( ) -@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse) +@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse) async def update_prize( prize_id: int, request: UpdatePrizeRequest, @@ -244,13 +244,13 @@ async def update_prize( update_data = request.model_dump(exclude_unset=True) # Конвертируем enum в строку если есть - if 'prize_type' in update_data and update_data['prize_type']: + if update_data.get('prize_type'): update_data['prize_type'] = update_data['prize_type'].value if not update_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No fields to update", + detail='No fields to update', ) prize = await update_wheel_prize(db, prize_id, **update_data) @@ -258,10 +258,10 @@ async def update_prize( if not prize: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Prize not found", + detail='Prize not found', ) - logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}") + logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}') return WheelPrizeAdminResponse( id=prize.id, @@ -283,7 +283,7 @@ async def update_prize( ) -@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_prize_endpoint( prize_id: int, admin: User = Depends(get_current_admin_user), @@ -295,13 +295,13 @@ async def delete_prize_endpoint( if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Prize not found", + detail='Prize not found', ) - logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}") + logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}') -@router.post("/prizes/reorder", status_code=status.HTTP_200_OK) +@router.post('/prizes/reorder', status_code=status.HTTP_200_OK) async def reorder_prizes( request: ReorderPrizesRequest, admin: User = Depends(get_current_admin_user), @@ -309,14 +309,14 @@ async def reorder_prizes( ): """Переупорядочить призы.""" await reorder_wheel_prizes(db, request.prize_ids) - logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}") - return {"success": True} + logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}') + return {'success': True} -@router.get("/statistics", response_model=WheelStatisticsResponse) +@router.get('/statistics', response_model=WheelStatisticsResponse) async def get_statistics( - date_from: Optional[datetime] = Query(None), - date_to: Optional[datetime] = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -324,24 +324,24 @@ async def get_statistics( stats = await wheel_service.get_statistics(db, date_from, date_to) return WheelStatisticsResponse( - total_spins=stats["total_spins"], - total_revenue_kopeks=stats["total_revenue_kopeks"], - total_payout_kopeks=stats["total_payout_kopeks"], - actual_rtp_percent=stats["actual_rtp_percent"], - configured_rtp_percent=stats["configured_rtp_percent"], - spins_by_payment_type=stats["spins_by_payment_type"], - prizes_distribution=stats["prizes_distribution"], - top_wins=stats["top_wins"], - period_from=stats["period_from"], - period_to=stats["period_to"], + total_spins=stats['total_spins'], + total_revenue_kopeks=stats['total_revenue_kopeks'], + total_payout_kopeks=stats['total_payout_kopeks'], + actual_rtp_percent=stats['actual_rtp_percent'], + configured_rtp_percent=stats['configured_rtp_percent'], + spins_by_payment_type=stats['spins_by_payment_type'], + prizes_distribution=stats['prizes_distribution'], + top_wins=stats['top_wins'], + period_from=stats['period_from'], + period_to=stats['period_to'], ) -@router.get("/spins", response_model=AdminSpinsResponse) +@router.get('/spins', response_model=AdminSpinsResponse) async def get_all_spins_endpoint( - user_id: Optional[int] = Query(None), - date_from: Optional[datetime] = Query(None), - date_to: Optional[datetime] = Query(None), + user_id: int | None = Query(None), + date_from: datetime | None = Query(None), + date_to: datetime | None = Query(None), page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=100), admin: User = Depends(get_current_admin_user), diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index 09bb27da..ce5ca414 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -4,53 +4,53 @@ import asyncio import hashlib import logging from datetime import datetime -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status, Request -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, CabinetRefreshToken -from app.database.crud.user import get_user_by_telegram_id, get_user_by_id, create_user, create_user_by_email from app.config import settings +from app.database.crud.user import create_user, create_user_by_email, get_user_by_id, get_user_by_telegram_id +from app.database.models import CabinetRefreshToken, User -from ..dependencies import get_cabinet_db, get_current_cabinet_user -from ..schemas.auth import ( - TelegramAuthRequest, - TelegramWidgetAuthRequest, - EmailRegisterRequest, - EmailRegisterStandaloneRequest, - EmailVerifyRequest, - EmailLoginRequest, - RefreshTokenRequest, - PasswordForgotRequest, - PasswordResetRequest, - TokenResponse, - UserResponse, - AuthResponse, -) from ..auth import ( - validate_telegram_login_widget, - validate_telegram_init_data, create_access_token, create_refresh_token, get_token_payload, hash_password, + validate_telegram_init_data, + validate_telegram_login_widget, verify_password, ) -from ..auth.jwt_handler import get_refresh_token_expires_at from ..auth.email_verification import ( - generate_verification_token, generate_password_reset_token, - get_verification_expires_at, + generate_verification_token, get_password_reset_expires_at, + get_verification_expires_at, is_token_expired, ) +from ..auth.jwt_handler import get_refresh_token_expires_at +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.auth import ( + AuthResponse, + EmailLoginRequest, + EmailRegisterRequest, + EmailRegisterStandaloneRequest, + EmailVerifyRequest, + PasswordForgotRequest, + PasswordResetRequest, + RefreshTokenRequest, + TelegramAuthRequest, + TelegramWidgetAuthRequest, + TokenResponse, + UserResponse, +) from ..services.email_service import email_service + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/auth", tags=["Cabinet Auth"]) +router = APIRouter(prefix='/auth', tags=['Cabinet Auth']) def _user_to_response(user: User) -> UserResponse: @@ -81,7 +81,7 @@ def _create_auth_response(user: User) -> AuthResponse: return AuthResponse( access_token=access_token, refresh_token=refresh_token, - token_type="bearer", + token_type='bearer', expires_in=expires_in, user=_user_to_response(user), ) @@ -91,18 +91,14 @@ async def _store_refresh_token( db: AsyncSession, user_id: int, refresh_token: str, - device_info: Optional[str] = None, + device_info: str | None = None, ) -> None: """Store refresh token hash in database.""" token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() expires_at = get_refresh_token_expires_at() # Check if token already exists (handles race conditions) - existing = await db.execute( - select(CabinetRefreshToken).where( - CabinetRefreshToken.token_hash == token_hash - ) - ) + existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash)) if existing.scalar_one_or_none(): # Token already stored, skip return @@ -121,7 +117,7 @@ async def _store_refresh_token( await db.rollback() -@router.post("/telegram", response_model=AuthResponse) +@router.post('/telegram', response_model=AuthResponse) async def auth_telegram( request: TelegramAuthRequest, db: AsyncSession = Depends(get_cabinet_db), @@ -137,27 +133,27 @@ async def auth_telegram( if not user_data: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired Telegram authentication data", + detail='Invalid or expired Telegram authentication data', ) - telegram_id = user_data.get("id") + telegram_id = user_data.get('id') if not telegram_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing Telegram user ID", + detail='Missing Telegram user ID', ) user = await get_user_by_telegram_id(db, telegram_id) # Get user data from initData - tg_username = user_data.get("username") - tg_first_name = user_data.get("first_name") - tg_last_name = user_data.get("last_name") - tg_language = user_data.get("language_code", "ru") + tg_username = user_data.get('username') + tg_first_name = user_data.get('first_name') + tg_last_name = user_data.get('last_name') + tg_language = user_data.get('language_code', 'ru') if not user: # Create new user from Telegram initData - logger.info(f"Creating new user from cabinet (initData): telegram_id={telegram_id}") + logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}') user = await create_user( db=db, telegram_id=telegram_id, @@ -166,7 +162,7 @@ async def auth_telegram( last_name=tg_last_name, language=tg_language, ) - logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}") + logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') else: # Update user info from initData (like bot middleware does) updated = False @@ -180,12 +176,12 @@ async def auth_telegram( user.last_name = tg_last_name updated = True if updated: - logger.info(f"User {user.id} profile updated from initData") + logger.info(f'User {user.id} profile updated from initData') - if user.status != "active": + if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User account is not active", + detail='User account is not active', ) # Update last login @@ -200,7 +196,7 @@ async def auth_telegram( return response -@router.post("/telegram/widget", response_model=AuthResponse) +@router.post('/telegram/widget', response_model=AuthResponse) async def auth_telegram_widget( request: TelegramWidgetAuthRequest, db: AsyncSession = Depends(get_cabinet_db), @@ -216,28 +212,28 @@ async def auth_telegram_widget( if not validate_telegram_login_widget(widget_data): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired Telegram authentication data", + detail='Invalid or expired Telegram authentication data', ) user = await get_user_by_telegram_id(db, request.id) if not user: # Create new user from Telegram data - logger.info(f"Creating new user from cabinet: telegram_id={request.id}, username={request.username}") + logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}') user = await create_user( db=db, telegram_id=request.id, username=request.username, first_name=request.first_name, last_name=request.last_name, - language="ru", + language='ru', ) - logger.info(f"User created successfully: id={user.id}, telegram_id={user.telegram_id}") + logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}') - if user.status != "active": + if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User account is not active", + detail='User account is not active', ) # Update user info from widget data @@ -257,7 +253,7 @@ async def auth_telegram_widget( return response -@router.post("/email/register") +@router.post('/email/register') async def register_email( request: EmailRegisterRequest, user: User = Depends(get_current_cabinet_user), @@ -270,20 +266,18 @@ async def register_email( Sends verification email to the provided address. """ # Check if email already exists - existing_user = await db.execute( - select(User).where(User.email == request.email) - ) + existing_user = await db.execute(select(User).where(User.email == request.email)) if existing_user.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This email is already registered", + detail='This email is already registered', ) # Check if user already has email if user.email and user.email_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="You already have a verified email", + detail='You already have a verified email', ) # Generate verification token @@ -302,7 +296,7 @@ async def register_email( # Send verification email asynchronously (smtplib is blocking) if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): # TODO: Get actual verification URL from settings - verification_url = "https://example.com/cabinet/verify-email" + verification_url = 'https://example.com/cabinet/verify-email' await asyncio.to_thread( email_service.send_verification_email, to_email=request.email, @@ -312,12 +306,12 @@ async def register_email( ) return { - "message": "Verification email sent", - "email": request.email, + 'message': 'Verification email sent', + 'email': request.email, } -@router.post("/email/register/standalone", response_model=AuthResponse) +@router.post('/email/register/standalone', response_model=AuthResponse) async def register_email_standalone( request: EmailRegisterStandaloneRequest, db: AsyncSession = Depends(get_cabinet_db), @@ -341,18 +335,16 @@ async def register_email_standalone( if not settings.validate_test_email_password(request.email, request.password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid test email password", + detail='Invalid test email password', ) - logger.info(f"Test email registration: {request.email}") + logger.info(f'Test email registration: {request.email}') # Проверить что email не занят - existing = await db.execute( - select(User).where(User.email == request.email) - ) + existing = await db.execute(select(User).where(User.email == request.email)) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This email is already registered", + detail='This email is already registered', ) # Хешировать пароль @@ -372,7 +364,7 @@ async def register_email_standalone( user.email_verified = True user.email_verified_at = datetime.utcnow() await db.commit() - logger.info(f"Test email auto-verified: {request.email}, user_id={user.id}") + logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}') else: # Сгенерировать токен верификации verification_token = generate_verification_token() @@ -385,13 +377,13 @@ async def register_email_standalone( # Отправить email верификации if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): cabinet_url = getattr(settings, 'CABINET_URL', 'https://example.com/cabinet') - verification_url = f"{cabinet_url}/verify-email?token={verification_token}" + verification_url = f'{cabinet_url}/verify-email?token={verification_token}' await asyncio.to_thread( email_service.send_verification_email, to_email=request.email, verification_token=verification_token, verification_url=verification_url, - username=user.first_name or "User", + username=user.first_name or 'User', ) # Создать токены и вернуть ответ @@ -401,28 +393,26 @@ async def register_email_standalone( return response -@router.post("/email/verify") +@router.post('/email/verify') async def verify_email( request: EmailVerifyRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Verify email with token.""" # Find user with this token - result = await db.execute( - select(User).where(User.email_verification_token == request.token) - ) + result = await db.execute(select(User).where(User.email_verification_token == request.token)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid verification token", + detail='Invalid verification token', ) if is_token_expired(user.email_verification_expires): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Verification token has expired", + detail='Verification token has expired', ) # Mark email as verified @@ -433,10 +423,10 @@ async def verify_email( await db.commit() - return {"message": "Email verified successfully"} + return {'message': 'Email verified successfully'} -@router.post("/email/resend") +@router.post('/email/resend') async def resend_verification( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -445,13 +435,13 @@ async def resend_verification( if not user.email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No email address to verify", + detail='No email address to verify', ) if user.email_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Email is already verified", + detail='Email is already verified', ) # Generate new token @@ -465,7 +455,7 @@ async def resend_verification( # Send verification email asynchronously (smtplib is blocking) if settings.is_cabinet_email_verification_enabled() and email_service.is_configured(): - verification_url = "https://example.com/cabinet/verify-email" + verification_url = 'https://example.com/cabinet/verify-email' await asyncio.to_thread( email_service.send_verification_email, to_email=user.email, @@ -476,18 +466,18 @@ async def resend_verification( elif not settings.is_cabinet_email_verification_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Email verification is disabled", + detail='Email verification is disabled', ) elif not email_service.is_configured(): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Email service is not configured", + detail='Email service is not configured', ) - return {"message": "Verification email sent"} + return {'message': 'Verification email sent'} -@router.post("/email/login", response_model=AuthResponse) +@router.post('/email/login', response_model=AuthResponse) async def login_email( request: EmailLoginRequest, db: AsyncSession = Depends(get_cabinet_db), @@ -500,22 +490,20 @@ async def login_email( is_test_email = settings.is_test_email(request.email) # Find user by email - result = await db.execute( - select(User).where(User.email == request.email) - ) + result = await db.execute(select(User).where(User.email == request.email)) user = result.scalar_one_or_none() if not user: # For test email - auto-create user if not exists if is_test_email and settings.validate_test_email_password(request.email, request.password): - logger.info(f"Test email login - creating new user: {request.email}") + logger.info(f'Test email login - creating new user: {request.email}') password_hash = hash_password(request.password) user = await create_user_by_email( db=db, email=request.email, password_hash=password_hash, - first_name="Test User", - language="ru", + first_name='Test User', + language='ru', ) user.email_verified = True user.email_verified_at = datetime.utcnow() @@ -523,32 +511,32 @@ async def login_email( else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", + detail='Invalid email or password', ) if not user.password_hash: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Password login not configured for this account", + detail='Password login not configured for this account', ) if not verify_password(request.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", + detail='Invalid email or password', ) # Test email bypasses verification check if not user.email_verified and not is_test_email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Please verify your email first", + detail='Please verify your email first', ) - if user.status != "active": + if user.status != 'active': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User account is not active", + detail='User account is not active', ) user.cabinet_last_login = datetime.utcnow() @@ -560,26 +548,26 @@ async def login_email( return response -@router.post("/refresh", response_model=TokenResponse) +@router.post('/refresh', response_model=TokenResponse) async def refresh_token( request: RefreshTokenRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Refresh access token using refresh token.""" - payload = get_token_payload(request.refresh_token, expected_type="refresh") + payload = get_token_payload(request.refresh_token, expected_type='refresh') if not payload: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired refresh token", + detail='Invalid or expired refresh token', ) try: - user_id = int(payload.get("sub")) + user_id = int(payload.get('sub')) except (TypeError, ValueError): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token payload", + detail='Invalid token payload', ) # Verify token exists in database and is not revoked @@ -595,21 +583,21 @@ async def refresh_token( if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token not found or revoked", + detail='Refresh token not found or revoked', ) if not token_record.is_valid: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token is no longer valid", + detail='Refresh token is no longer valid', ) user = await get_user_by_id(db, user_id) - if not user or user.status != "active": + if not user or user.status != 'active': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not found or inactive", + detail='User not found or inactive', ) access_token = create_access_token(user.id, user.telegram_id) @@ -618,12 +606,12 @@ async def refresh_token( return TokenResponse( access_token=access_token, refresh_token=request.refresh_token, - token_type="bearer", + token_type='bearer', expires_in=expires_in, ) -@router.post("/logout") +@router.post('/logout') async def logout( request: RefreshTokenRequest, db: AsyncSession = Depends(get_cabinet_db), @@ -642,23 +630,21 @@ async def logout( token_record.revoked_at = datetime.utcnow() await db.commit() - return {"message": "Logged out successfully"} + return {'message': 'Logged out successfully'} -@router.post("/password/forgot") +@router.post('/password/forgot') async def forgot_password( request: PasswordForgotRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Request password reset.""" - result = await db.execute( - select(User).where(User.email == request.email) - ) + result = await db.execute(select(User).where(User.email == request.email)) user = result.scalar_one_or_none() # Always return success to prevent email enumeration if not user or not user.email_verified: - return {"message": "If the email exists, a password reset link has been sent"} + return {'message': 'If the email exists, a password reset link has been sent'} # Generate reset token reset_token = generate_password_reset_token() @@ -671,7 +657,7 @@ async def forgot_password( # Send reset email asynchronously (smtplib is blocking) if email_service.is_configured(): - reset_url = "https://example.com/cabinet/reset-password" + reset_url = 'https://example.com/cabinet/reset-password' await asyncio.to_thread( email_service.send_password_reset_email, to_email=user.email, @@ -680,30 +666,28 @@ async def forgot_password( username=user.first_name, ) - return {"message": "If the email exists, a password reset link has been sent"} + return {'message': 'If the email exists, a password reset link has been sent'} -@router.post("/password/reset") +@router.post('/password/reset') async def reset_password( request: PasswordResetRequest, db: AsyncSession = Depends(get_cabinet_db), ): """Reset password with token.""" - result = await db.execute( - select(User).where(User.password_reset_token == request.token) - ) + result = await db.execute(select(User).where(User.password_reset_token == request.token)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid reset token", + detail='Invalid reset token', ) if is_token_expired(user.password_reset_expires): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Reset token has expired", + detail='Reset token has expired', ) # Update password @@ -713,10 +697,10 @@ async def reset_password( await db.commit() - return {"message": "Password reset successfully"} + return {'message': 'Password reset successfully'} -@router.get("/me", response_model=UserResponse) +@router.get('/me', response_model=UserResponse) async def get_current_user( user: User = Depends(get_current_cabinet_user), ): @@ -724,13 +708,10 @@ async def get_current_user( return _user_to_response(user) -@router.get("/me/is-admin") +@router.get('/me/is-admin') async def check_is_admin( user: User = Depends(get_current_cabinet_user), ): """Check if current user is an admin.""" - is_admin = settings.is_admin( - telegram_id=user.telegram_id, - email=user.email if user.email_verified else None - ) - return {"is_admin": is_admin} + is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) + return {'is_admin': is_admin} diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index 52ee962d..8af35346 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -3,49 +3,49 @@ import logging import math import time -from typing import List, Optional import httpx -from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, desc -from app.database.models import User, Transaction, PaymentMethod from app.config import settings -from app.services.yookassa_service import YooKassaService -from app.external.cryptobot import CryptoBotService from app.database.crud.user import get_user_by_id +from app.database.models import PaymentMethod, Transaction, User +from app.external.cryptobot import CryptoBotService from app.services.payment_service import PaymentService +from app.services.payment_verification_service import ( + SUPPORTED_MANUAL_CHECK_METHODS, + PendingPayment, + get_payment_record, + list_recent_pending_payments, + method_display_name, + run_manual_check, +) +from app.services.yookassa_service import YooKassaService from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.balance import ( BalanceResponse, - TransactionResponse, - TransactionListResponse, + ManualCheckResponse, PaymentMethodResponse, - TopUpRequest, - TopUpResponse, + PendingPaymentListResponse, + PendingPaymentResponse, StarsInvoiceRequest, StarsInvoiceResponse, - PendingPaymentResponse, - PendingPaymentListResponse, - ManualCheckResponse, -) -from app.services.payment_verification_service import ( - list_recent_pending_payments, - get_payment_record, - run_manual_check, - SUPPORTED_MANUAL_CHECK_METHODS, - method_display_name, - PendingPayment, + TopUpRequest, + TopUpResponse, + TransactionListResponse, + TransactionResponse, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/balance", tags=["Cabinet Balance"]) +router = APIRouter(prefix='/balance', tags=['Cabinet Balance']) -@router.get("", response_model=BalanceResponse) +@router.get('', response_model=BalanceResponse) async def get_balance( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -55,7 +55,7 @@ async def get_balance( # (user object is from different session in get_current_cabinet_user) fresh_user = await get_user_by_id(db, user.id) if not fresh_user: - raise HTTPException(status_code=404, detail="User not found") + raise HTTPException(status_code=404, detail='User not found') return BalanceResponse( balance_kopeks=fresh_user.balance_kopeks, @@ -63,11 +63,11 @@ async def get_balance( ) -@router.get("/transactions", response_model=TransactionListResponse) +@router.get('/transactions', response_model=TransactionListResponse) async def get_transactions( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), - type: Optional[str] = Query(None, description="Filter by transaction type"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), + type: str | None = Query(None, description='Filter by transaction type'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -102,17 +102,19 @@ async def get_transactions( is_debit = t.type in ['subscription_payment', 'withdrawal'] amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks) - items.append(TransactionResponse( - id=t.id, - type=t.type, - amount_kopeks=amount_kopeks, - amount_rubles=amount_kopeks / 100, - description=t.description, - payment_method=t.payment_method, - is_completed=t.is_completed, - created_at=t.created_at, - completed_at=t.completed_at, - )) + items.append( + TransactionResponse( + id=t.id, + type=t.type, + amount_kopeks=amount_kopeks, + amount_rubles=amount_kopeks / 100, + description=t.description, + payment_method=t.payment_method, + is_completed=t.is_completed, + created_at=t.created_at, + completed_at=t.completed_at, + ) + ) pages = math.ceil(total / per_page) if total > 0 else 1 @@ -125,84 +127,96 @@ async def get_transactions( ) -@router.get("/payment-methods", response_model=List[PaymentMethodResponse]) +@router.get('/payment-methods', response_model=list[PaymentMethodResponse]) async def get_payment_methods(): """Get available payment methods.""" methods = [] # YooKassa - with card and SBP options if settings.is_yookassa_enabled(): - methods.append(PaymentMethodResponse( - id="yookassa", - name=settings.get_yookassa_display_name(), - description="Pay via YooKassa", - min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, - is_available=True, - options=[ - {"id": "card", "name": "💳 Карта", "description": "Банковская карта"}, - {"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей (QR)"}, - ], - )) + methods.append( + PaymentMethodResponse( + id='yookassa', + name=settings.get_yookassa_display_name(), + description='Pay via YooKassa', + min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + options=[ + {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, + {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'}, + ], + ) + ) # CryptoBot if settings.is_cryptobot_enabled(): - methods.append(PaymentMethodResponse( - id="cryptobot", - name=settings.get_cryptobot_display_name(), - description="Pay with cryptocurrency via CryptoBot", - min_amount_kopeks=1000, - max_amount_kopeks=10000000, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='cryptobot', + name=settings.get_cryptobot_display_name(), + description='Pay with cryptocurrency via CryptoBot', + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + ) + ) # Telegram Stars if settings.TELEGRAM_STARS_ENABLED: - methods.append(PaymentMethodResponse( - id="telegram_stars", - name=settings.get_telegram_stars_display_name(), - description="Pay with Telegram Stars", - min_amount_kopeks=100, - max_amount_kopeks=1000000, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='telegram_stars', + name=settings.get_telegram_stars_display_name(), + description='Pay with Telegram Stars', + min_amount_kopeks=100, + max_amount_kopeks=1000000, + is_available=True, + ) + ) # Heleket if settings.is_heleket_enabled(): - methods.append(PaymentMethodResponse( - id="heleket", - name=settings.get_heleket_display_name(), - description="Pay with cryptocurrency via Heleket", - min_amount_kopeks=1000, - max_amount_kopeks=10000000, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='heleket', + name=settings.get_heleket_display_name(), + description='Pay with cryptocurrency via Heleket', + min_amount_kopeks=1000, + max_amount_kopeks=10000000, + is_available=True, + ) + ) # MulenPay if settings.is_mulenpay_enabled(): - methods.append(PaymentMethodResponse( - id="mulenpay", - name=settings.get_mulenpay_display_name(), - description="MulenPay payment", - min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='mulenpay', + name=settings.get_mulenpay_display_name(), + description='MulenPay payment', + min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) # PAL24 - add options for card/sbp if settings.is_pal24_enabled(): - methods.append(PaymentMethodResponse( - id="pal24", - name=settings.get_pal24_display_name(), - description="Pay via PAL24", - min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, - is_available=True, - options=[ - {"id": "sbp", "name": "🏦 СБП", "description": "Система быстрых платежей"}, - {"id": "card", "name": "💳 Карта", "description": "Банковская карта"}, - ], - )) + methods.append( + PaymentMethodResponse( + id='pal24', + name=settings.get_pal24_display_name(), + description='Pay via PAL24', + min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, + is_available=True, + options=[ + {'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'}, + {'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'}, + ], + ) + ) # Platega - add options for different payment methods if settings.is_platega_enabled(): @@ -211,59 +225,69 @@ async def get_payment_methods(): platega_options = [] for method_code in platega_methods: info = definitions.get(method_code, {}) - platega_options.append({ - "id": str(method_code), - "name": info.get("title") or info.get("name") or f"Platega {method_code}", - "description": info.get("description") or info.get("name") or "", - }) + platega_options.append( + { + 'id': str(method_code), + 'name': info.get('title') or info.get('name') or f'Platega {method_code}', + 'description': info.get('description') or info.get('name') or '', + } + ) - methods.append(PaymentMethodResponse( - id="platega", - name=settings.get_platega_display_name(), - description="Pay via Platega", - min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, - is_available=True, - options=platega_options if platega_options else None, - )) + methods.append( + PaymentMethodResponse( + id='platega', + name=settings.get_platega_display_name(), + description='Pay via Platega', + min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.PLATEGA_MAX_AMOUNT_KOPEKS, + is_available=True, + options=platega_options if platega_options else None, + ) + ) # Wata if settings.is_wata_enabled(): - methods.append(PaymentMethodResponse( - id="wata", - name=settings.get_wata_display_name(), - description="Pay via Wata", - min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='wata', + name=settings.get_wata_display_name(), + description='Pay via Wata', + min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) # CloudPayments if settings.is_cloudpayments_enabled(): - methods.append(PaymentMethodResponse( - id="cloudpayments", - name=settings.get_cloudpayments_display_name(), - description="Pay with bank card via CloudPayments", - min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='cloudpayments', + name=settings.get_cloudpayments_display_name(), + description='Pay with bank card via CloudPayments', + min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) # FreeKassa if settings.is_freekassa_enabled(): - methods.append(PaymentMethodResponse( - id="freekassa", - name=settings.get_freekassa_display_name(), - description="Pay via FreeKassa", - min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, - max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, - is_available=True, - )) + methods.append( + PaymentMethodResponse( + id='freekassa', + name=settings.get_freekassa_display_name(), + description='Pay via FreeKassa', + min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, + max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, + is_available=True, + ) + ) return methods -@router.post("/stars-invoice", response_model=StarsInvoiceResponse) +@router.post('/stars-invoice', response_model=StarsInvoiceResponse) async def create_stars_invoice( request: StarsInvoiceRequest, user: User = Depends(get_current_cabinet_user), @@ -276,20 +300,20 @@ async def create_stars_invoice( if not settings.TELEGRAM_STARS_ENABLED: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Telegram Stars payments are not enabled", + detail='Telegram Stars payments are not enabled', ) # Validate amount if request.amount_kopeks < 100: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Minimum amount is 1.00 RUB", + detail='Minimum amount is 1.00 RUB', ) if request.amount_kopeks > 1000000: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Maximum amount is 10,000.00 RUB", + detail='Maximum amount is 10,000.00 RUB', ) # Calculate Stars amount @@ -300,46 +324,46 @@ async def create_stars_invoice( if stars_amount <= 0: stars_amount = 1 except Exception as e: - logger.error(f"Error calculating Stars amount: {e}") + logger.error(f'Error calculating Stars amount: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to calculate Stars amount", + detail='Failed to calculate Stars amount', ) # Create payload for tracking payment - payload = f"balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}" + payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}' # Create invoice through Telegram Bot API try: bot_token = settings.BOT_TOKEN - api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink" + api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' async with httpx.AsyncClient() as client: response = await client.post( api_url, json={ - "title": "Пополнение баланса VPN", - "description": f"Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)", - "payload": payload, - "provider_token": "", # Empty for Stars - "currency": "XTR", - "prices": [{"label": "Пополнение баланса", "amount": stars_amount}], + 'title': 'Пополнение баланса VPN', + 'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)', + 'payload': payload, + 'provider_token': '', # Empty for Stars + 'currency': 'XTR', + 'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}], }, ) result = response.json() - if not result.get("ok"): - logger.error(f"Telegram API error: {result}") + if not result.get('ok'): + logger.error(f'Telegram API error: {result}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create Stars invoice", + detail='Failed to create Stars invoice', ) - invoice_url = result["result"] + invoice_url = result['result'] logger.info( - f"Created Stars invoice for balance top-up: user={user.id}, " - f"amount={request.amount_kopeks} kopeks, stars={stars_amount}" + f'Created Stars invoice for balance top-up: user={user.id}, ' + f'amount={request.amount_kopeks} kopeks, stars={stars_amount}' ) return StarsInvoiceResponse( @@ -349,14 +373,14 @@ async def create_stars_invoice( ) except httpx.HTTPError as e: - logger.error(f"HTTP error creating Stars invoice: {e}") + logger.error(f'HTTP error creating Stars invoice: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to connect to Telegram API", + detail='Failed to connect to Telegram API', ) -@router.post("/topup", response_model=TopUpResponse) +@router.post('/topup', response_model=TopUpResponse) async def create_topup( request: TopUpRequest, user: User = Depends(get_current_cabinet_user), @@ -370,20 +394,20 @@ async def create_topup( if not method or not method.is_available: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or unavailable payment method", + detail='Invalid or unavailable payment method', ) # Validate amount if request.amount_kopeks < method.min_amount_kopeks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Minimum amount is {method.min_amount_kopeks / 100:.2f} RUB", + detail=f'Minimum amount is {method.min_amount_kopeks / 100:.2f} RUB', ) if request.amount_kopeks > method.max_amount_kopeks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Maximum amount is {method.max_amount_kopeks / 100:.2f} RUB", + detail=f'Maximum amount is {method.max_amount_kopeks / 100:.2f} RUB', ) amount_rubles = request.amount_kopeks / 100 @@ -391,29 +415,28 @@ async def create_topup( payment_id = None try: - if request.payment_method == "yookassa": + if request.payment_method == 'yookassa': yookassa_service = YooKassaService() yookassa_metadata = { - "user_id": str(user.id), - "user_telegram_id": str(user.telegram_id) if user.telegram_id else "", - "user_username": user.username or "", - "amount_kopeks": str(request.amount_kopeks), - "type": "balance_topup", - "source": "cabinet", + 'user_id': str(user.id), + 'user_telegram_id': str(user.telegram_id) if user.telegram_id else '', + 'user_username': user.username or '', + 'amount_kopeks': str(request.amount_kopeks), + 'type': 'balance_topup', + 'source': 'cabinet', } # Use payment_option to select card or sbp (default: card) - option = (request.payment_option or "").strip().lower() + option = (request.payment_option or '').strip().lower() # Use description with telegram_id for tax receipts description = settings.get_balance_payment_description( - request.amount_kopeks, - telegram_user_id=user.telegram_id + request.amount_kopeks, telegram_user_id=user.telegram_id ) - if option == "sbp": + if option == 'sbp': # Create SBP payment with QR code result = await yookassa_service.create_sbp_payment( amount=amount_rubles, - currency="RUB", + currency='RUB', description=description, metadata=yookassa_metadata, ) @@ -421,65 +444,65 @@ async def create_topup( # Default: card payment result = await yookassa_service.create_payment( amount=amount_rubles, - currency="RUB", + currency='RUB', description=description, metadata=yookassa_metadata, ) - if result and not result.get("error"): - payment_url = result.get("confirmation_url") - payment_id = result.get("id") + if result and not result.get('error'): + payment_url = result.get('confirmation_url') + payment_id = result.get('id') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create YooKassa payment", + detail='Failed to create YooKassa payment', ) - elif request.payment_method == "cryptobot": + elif request.payment_method == 'cryptobot': cryptobot_service = CryptoBotService() # Convert RUB to USDT (approximate) usdt_amount = amount_rubles / 100 # Approximate rate result = await cryptobot_service.create_invoice( amount=usdt_amount, - asset="USDT", - description=f"Balance top-up {amount_rubles:.2f} RUB", - payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}", + asset='USDT', + description=f'Balance top-up {amount_rubles:.2f} RUB', + payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}', ) if result: # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback payment_url = ( - result.get("web_app_invoice_url") - or result.get("mini_app_invoice_url") - or result.get("bot_invoice_url") - or result.get("pay_url") + result.get('web_app_invoice_url') + or result.get('mini_app_invoice_url') + or result.get('bot_invoice_url') + or result.get('pay_url') ) - payment_id = str(result.get("invoice_id")) + payment_id = str(result.get('invoice_id')) else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create CryptoBot invoice", + detail='Failed to create CryptoBot invoice', ) - elif request.payment_method == "telegram_stars": + elif request.payment_method == 'telegram_stars': # Telegram Stars payments require bot interaction - bot_username = settings.get_bot_username() or "bot" + bot_username = settings.get_bot_username() or 'bot' raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Telegram Stars payments are only available through the bot. Please use @{bot_username}", + detail=f'Telegram Stars payments are only available through the bot. Please use @{bot_username}', ) - elif request.payment_method == "platega": + elif request.payment_method == 'platega': if not settings.is_platega_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Platega payment method is unavailable", + detail='Platega payment method is unavailable', ) active_methods = settings.get_platega_active_methods() if not active_methods: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No Platega payment methods configured", + detail='No Platega payment methods configured', ) # Use payment_option if provided, otherwise use first active method @@ -489,13 +512,13 @@ async def create_topup( except (TypeError, ValueError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Platega payment option", + detail='Invalid Platega payment option', ) if method_code not in active_methods: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Selected Platega method is unavailable", + detail='Selected Platega method is unavailable', ) payment_service = PaymentService() @@ -503,25 +526,27 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, payment_method_code=method_code, ) - if result and result.get("redirect_url"): - payment_url = result.get("redirect_url") - payment_id = result.get("transaction_id") or str(result.get("local_payment_id", "pending")) + if result and result.get('redirect_url'): + payment_url = result.get('redirect_url') + payment_id = result.get('transaction_id') or str(result.get('local_payment_id', 'pending')) else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create Platega payment", + detail='Failed to create Platega payment', ) - elif request.payment_method == "heleket": + elif request.payment_method == 'heleket': if not settings.is_heleket_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Heleket payment method is unavailable", + detail='Heleket payment method is unavailable', ) payment_service = PaymentService() @@ -533,20 +558,20 @@ async def create_topup( language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) - if result and result.get("payment_url"): - payment_url = result.get("payment_url") - payment_id = str(result.get("local_payment_id") or result.get("uuid") or "pending") + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('uuid') or 'pending') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create Heleket payment", + detail='Failed to create Heleket payment', ) - elif request.payment_method == "mulenpay": + elif request.payment_method == 'mulenpay': if not settings.is_mulenpay_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="MulenPay payment method is unavailable", + detail='MulenPay payment method is unavailable', ) payment_service = PaymentService() @@ -558,27 +583,27 @@ async def create_topup( language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) - if result and result.get("payment_url"): - payment_url = result.get("payment_url") - payment_id = str(result.get("local_payment_id") or result.get("mulen_payment_id") or "pending") + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('mulen_payment_id') or 'pending') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create MulenPay payment", + detail='Failed to create MulenPay payment', ) - elif request.payment_method == "pal24": + elif request.payment_method == 'pal24': if not settings.is_pal24_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="PAL24 payment method is unavailable", + detail='PAL24 payment method is unavailable', ) # Use payment_option to select card or sbp (default: sbp) - option = (request.payment_option or "").strip().lower() - if option not in {"card", "sbp"}: - option = "sbp" - provider_method = "card" if option == "card" else "sbp" + option = (request.payment_option or '').strip().lower() + if option not in {'card', 'sbp'}: + option = 'sbp' + provider_method = 'card' if option == 'card' else 'sbp' payment_service = PaymentService() result = await payment_service.create_pal24_payment( @@ -593,30 +618,32 @@ async def create_topup( if result: # Select appropriate URL based on payment option preferred_urls = [] - if option == "sbp": - preferred_urls.append(result.get("sbp_url") or result.get("transfer_url")) - elif option == "card": - preferred_urls.append(result.get("card_url")) - preferred_urls.extend([ - result.get("link_url"), - result.get("link_page_url"), - result.get("payment_url"), - result.get("transfer_url"), - ]) + if option == 'sbp': + preferred_urls.append(result.get('sbp_url') or result.get('transfer_url')) + elif option == 'card': + preferred_urls.append(result.get('card_url')) + preferred_urls.extend( + [ + result.get('link_url'), + result.get('link_page_url'), + result.get('payment_url'), + result.get('transfer_url'), + ] + ) payment_url = next((url for url in preferred_urls if url), None) - payment_id = str(result.get("local_payment_id") or result.get("bill_id") or "pending") + payment_id = str(result.get('local_payment_id') or result.get('bill_id') or 'pending') if not payment_url: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create PAL24 payment", + detail='Failed to create PAL24 payment', ) - elif request.payment_method == "wata": + elif request.payment_method == 'wata': if not settings.is_wata_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Wata payment method is unavailable", + detail='Wata payment method is unavailable', ) payment_service = PaymentService() @@ -628,20 +655,20 @@ async def create_topup( language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) - if result and result.get("payment_url"): - payment_url = result.get("payment_url") - payment_id = str(result.get("local_payment_id") or result.get("payment_link_id") or "pending") + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('payment_link_id') or 'pending') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create Wata payment", + detail='Failed to create Wata payment', ) - elif request.payment_method == "cloudpayments": + elif request.payment_method == 'cloudpayments': if not settings.is_cloudpayments_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="CloudPayments payment method is unavailable", + detail='CloudPayments payment method is unavailable', ) payment_service = PaymentService() @@ -654,20 +681,20 @@ async def create_topup( language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) - if result and result.get("payment_url"): - payment_url = result.get("payment_url") - payment_id = str(result.get("local_payment_id") or result.get("invoice_id") or "pending") + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('invoice_id') or 'pending') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create CloudPayments payment", + detail='Failed to create CloudPayments payment', ) - elif request.payment_method == "freekassa": + elif request.payment_method == 'freekassa': if not settings.is_freekassa_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="FreeKassa payment method is unavailable", + detail='FreeKassa payment method is unavailable', ) payment_service = PaymentService() @@ -679,145 +706,145 @@ async def create_topup( language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) - if result and result.get("payment_url"): - payment_url = result.get("payment_url") - payment_id = str(result.get("local_payment_id") or result.get("order_id") or "pending") + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create FreeKassa payment", + detail='Failed to create FreeKassa payment', ) else: # For other payment methods, redirect to bot raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This payment method is only available through the Telegram bot.", + detail='This payment method is only available through the Telegram bot.', ) except HTTPException: raise except Exception as e: - logger.error(f"Payment creation error: {e}") + logger.error(f'Payment creation error: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create payment. Please try again later.", + detail='Failed to create payment. Please try again later.', ) if not payment_url: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Payment URL not received", + detail='Payment URL not received', ) return TopUpResponse( - payment_id=payment_id or "pending", + payment_id=payment_id or 'pending', payment_url=payment_url, amount_kopeks=request.amount_kopeks, amount_rubles=amount_rubles, - status="pending", + status='pending', expires_at=None, ) def _get_status_info(record: PendingPayment) -> tuple[str, str]: """Get status emoji and text for a pending payment.""" - status = (record.status or "").lower() + status = (record.status or '').lower() if record.is_paid: - return "✅", "Оплачено" + return '✅', 'Оплачено' if record.method == PaymentMethod.PAL24: mapping = { - "new": ("⏳", "Ожидает оплаты"), - "process": ("⌛", "Обрабатывается"), - "success": ("✅", "Оплачено"), - "fail": ("❌", "Ошибка"), - "canceled": ("❌", "Отменено"), + 'new': ('⏳', 'Ожидает оплаты'), + 'process': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачено'), + 'fail': ('❌', 'Ошибка'), + 'canceled': ('❌', 'Отменено'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.MULENPAY: mapping = { - "created": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "hold": ("🔒", "На удержании"), - "success": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), - "error": ("❌", "Ошибка"), + 'created': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'hold': ('🔒', 'На удержании'), + 'success': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.WATA: mapping = { - "opened": ("⏳", "Ожидает оплаты"), - "pending": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "paid": ("✅", "Оплачено"), - "closed": ("✅", "Оплачено"), - "declined": ("❌", "Отклонено"), - "canceled": ("❌", "Отменено"), - "expired": ("⌛", "Истёк"), + 'opened': ('⏳', 'Ожидает оплаты'), + 'pending': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'paid': ('✅', 'Оплачено'), + 'closed': ('✅', 'Оплачено'), + 'declined': ('❌', 'Отклонено'), + 'canceled': ('❌', 'Отменено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.PLATEGA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "inprogress": ("⌛", "Обрабатывается"), - "confirmed": ("✅", "Оплачено"), - "failed": ("❌", "Ошибка"), - "canceled": ("❌", "Отменено"), - "expired": ("⌛", "Истёк"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'inprogress': ('⌛', 'Обрабатывается'), + 'confirmed': ('✅', 'Оплачено'), + 'failed': ('❌', 'Ошибка'), + 'canceled': ('❌', 'Отменено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.HELEKET: - if status in {"pending", "created", "waiting", "check", "processing"}: - return "⏳", "Ожидает оплаты" - if status in {"paid", "paid_over"}: - return "✅", "Оплачено" - if status in {"cancel", "canceled", "fail", "failed", "expired"}: - return "❌", "Отменено" - return "❓", "Неизвестно" + if status in {'pending', 'created', 'waiting', 'check', 'processing'}: + return '⏳', 'Ожидает оплаты' + if status in {'paid', 'paid_over'}: + return '✅', 'Оплачено' + if status in {'cancel', 'canceled', 'fail', 'failed', 'expired'}: + return '❌', 'Отменено' + return '❓', 'Неизвестно' if record.method == PaymentMethod.YOOKASSA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "waiting_for_capture": ("⌛", "Обрабатывается"), - "succeeded": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'waiting_for_capture': ('⌛', 'Обрабатывается'), + 'succeeded': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.CRYPTOBOT: mapping = { - "active": ("⏳", "Ожидает оплаты"), - "paid": ("✅", "Оплачено"), - "expired": ("⌛", "Истёк"), + 'active': ('⏳', 'Ожидает оплаты'), + 'paid': ('✅', 'Оплачено'), + 'expired': ('⌛', 'Истёк'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.CLOUDPAYMENTS: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "authorized": ("⌛", "Авторизовано"), - "completed": ("✅", "Оплачено"), - "failed": ("❌", "Ошибка"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'authorized': ('⌛', 'Авторизовано'), + 'completed': ('✅', 'Оплачено'), + 'failed': ('❌', 'Ошибка'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) if record.method == PaymentMethod.FREEKASSA: mapping = { - "pending": ("⏳", "Ожидает оплаты"), - "success": ("✅", "Оплачено"), - "paid": ("✅", "Оплачено"), - "canceled": ("❌", "Отменено"), - "error": ("❌", "Ошибка"), + 'pending': ('⏳', 'Ожидает оплаты'), + 'success': ('✅', 'Оплачено'), + 'paid': ('✅', 'Оплачено'), + 'canceled': ('❌', 'Отменено'), + 'error': ('❌', 'Ошибка'), } - return mapping.get(status, ("❓", "Неизвестно")) + return mapping.get(status, ('❓', 'Неизвестно')) - return "❓", "Неизвестно" + return '❓', 'Неизвестно' def _is_checkable(record: PendingPayment) -> bool: @@ -826,52 +853,50 @@ def _is_checkable(record: PendingPayment) -> bool: return False if not record.is_recent(): return False - status = (record.status or "").lower() + status = (record.status or '').lower() if record.method == PaymentMethod.PAL24: - return status in {"new", "process"} + return status in {'new', 'process'} if record.method == PaymentMethod.MULENPAY: - return status in {"created", "processing", "hold"} + return status in {'created', 'processing', 'hold'} if record.method == PaymentMethod.WATA: - return status in {"opened", "pending", "processing", "inprogress", "in_progress"} + return status in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'} if record.method == PaymentMethod.PLATEGA: - return status in {"pending", "inprogress", "in_progress"} + return status in {'pending', 'inprogress', 'in_progress'} if record.method == PaymentMethod.HELEKET: - return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"} + return status not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'} if record.method == PaymentMethod.YOOKASSA: - return status in {"pending", "waiting_for_capture"} + return status in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status in {"active"} + return status in {'active'} if record.method == PaymentMethod.CLOUDPAYMENTS: - return status in {"pending", "authorized"} + return status in {'pending', 'authorized'} if record.method == PaymentMethod.FREEKASSA: - return status in {"pending", "created", "processing"} + return status in {'pending', 'created', 'processing'} return False -def _get_payment_url(record: PendingPayment) -> Optional[str]: +def _get_payment_url(record: PendingPayment) -> str | None: """Extract payment URL from record.""" payment = record.payment - payment_url = getattr(payment, "payment_url", None) + payment_url = getattr(payment, 'payment_url', None) if record.method == PaymentMethod.PAL24: - payment_url = getattr(payment, "link_url", None) or getattr(payment, "link_page_url", None) or payment_url + payment_url = getattr(payment, 'link_url', None) or getattr(payment, 'link_page_url', None) or payment_url elif record.method == PaymentMethod.WATA: - payment_url = getattr(payment, "url", None) or payment_url + payment_url = getattr(payment, 'url', None) or payment_url elif record.method == PaymentMethod.YOOKASSA: - payment_url = getattr(payment, "confirmation_url", None) or payment_url + payment_url = getattr(payment, 'confirmation_url', None) or payment_url elif record.method == PaymentMethod.CRYPTOBOT: payment_url = ( - getattr(payment, "bot_invoice_url", None) - or getattr(payment, "mini_app_invoice_url", None) - or getattr(payment, "web_app_invoice_url", None) + getattr(payment, 'bot_invoice_url', None) + or getattr(payment, 'mini_app_invoice_url', None) + or getattr(payment, 'web_app_invoice_url', None) or payment_url ) elif record.method == PaymentMethod.PLATEGA: - payment_url = getattr(payment, "redirect_url", None) or payment_url - elif record.method == PaymentMethod.CLOUDPAYMENTS: - payment_url = getattr(payment, "payment_url", None) or payment_url - elif record.method == PaymentMethod.FREEKASSA: - payment_url = getattr(payment, "payment_url", None) or payment_url + payment_url = getattr(payment, 'redirect_url', None) or payment_url + elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA: + payment_url = getattr(payment, 'payment_url', None) or payment_url return payment_url @@ -886,7 +911,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse: identifier=record.identifier, amount_kopeks=record.amount_kopeks, amount_rubles=record.amount_kopeks / 100, - status=record.status or "", + status=record.status or '', status_emoji=status_emoji, status_text=status_text, is_paid=record.is_paid, @@ -900,10 +925,10 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse: ) -@router.get("/pending-payments", response_model=PendingPaymentListResponse) +@router.get('/pending-payments', response_model=PendingPaymentListResponse) async def get_pending_payments( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(10, ge=1, le=50, description="Items per page"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(10, ge=1, le=50, description='Items per page'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -918,7 +943,7 @@ async def get_pending_payments( # Paginate start_idx = (page - 1) * per_page - page_payments = user_payments[start_idx:start_idx + per_page] + page_payments = user_payments[start_idx : start_idx + per_page] items = [_record_to_response(p) for p in page_payments] @@ -931,7 +956,7 @@ async def get_pending_payments( ) -@router.get("/pending-payments/{method}/{payment_id}", response_model=PendingPaymentResponse) +@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse) async def get_pending_payment_details( method: str, payment_id: int, @@ -944,7 +969,7 @@ async def get_pending_payment_details( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid payment method: {method}", + detail=f'Invalid payment method: {method}', ) record = await get_payment_record(db, payment_method, payment_id) @@ -952,20 +977,20 @@ async def get_pending_payment_details( if not record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Payment not found", + detail='Payment not found', ) # Check that payment belongs to the current user if not record.user or record.user.id != user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied", + detail='Access denied', ) return _record_to_response(record) -@router.post("/pending-payments/{method}/{payment_id}/check", response_model=ManualCheckResponse) +@router.post('/pending-payments/{method}/{payment_id}/check', response_model=ManualCheckResponse) async def check_payment_status( method: str, payment_id: int, @@ -978,7 +1003,7 @@ async def check_payment_status( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid payment method: {method}", + detail=f'Invalid payment method: {method}', ) # Get current record @@ -987,21 +1012,21 @@ async def check_payment_status( if not record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Payment not found", + detail='Payment not found', ) # Check that payment belongs to the current user if not record.user or record.user.id != user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied", + detail='Access denied', ) # Check if manual check is available if not _is_checkable(record): return ManualCheckResponse( success=False, - message="Ручная проверка недоступна для этого платежа", + message='Ручная проверка недоступна для этого платежа', payment=_record_to_response(record), status_changed=False, ) @@ -1016,7 +1041,7 @@ async def check_payment_status( if not updated: return ManualCheckResponse( success=False, - message="Не удалось проверить статус платежа", + message='Не удалось проверить статус платежа', payment=_record_to_response(record), status_changed=False, ) @@ -1025,9 +1050,9 @@ async def check_payment_status( if status_changed: _, new_status_text = _get_status_info(updated) - message = f"Статус обновлён: {new_status_text}" + message = f'Статус обновлён: {new_status_text}' else: - message = "Статус не изменился" + message = 'Статус не изменился' return ManualCheckResponse( success=True, diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index 63e6b745..9c89b7b0 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -1,160 +1,168 @@ """Branding routes for cabinet - logo, project name, and theme colors management.""" +import json import logging import os -import json from pathlib import Path -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, SystemSetting from app.config import settings +from app.database.models import SystemSetting, User from ..dependencies import get_cabinet_db, get_current_admin_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/branding", tags=["Branding"]) +router = APIRouter(prefix='/branding', tags=['Branding']) # Directory for storing branding assets -BRANDING_DIR = Path("data/branding") -LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"] +BRANDING_DIR = Path('data/branding') +LOGO_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg'] # Settings keys -BRANDING_NAME_KEY = "CABINET_BRANDING_NAME" -BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default" -THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors -ENABLED_THEMES_KEY = "CABINET_ENABLED_THEMES" # Stores JSON with enabled themes {"dark": true, "light": false} -ANIMATION_ENABLED_KEY = "CABINET_ANIMATION_ENABLED" # Stores "true" or "false" -FULLSCREEN_ENABLED_KEY = "CABINET_FULLSCREEN_ENABLED" # Stores "true" or "false" +BRANDING_NAME_KEY = 'CABINET_BRANDING_NAME' +BRANDING_LOGO_KEY = 'CABINET_BRANDING_LOGO' # Stores "custom" or "default" +THEME_COLORS_KEY = 'CABINET_THEME_COLORS' # Stores JSON with theme colors +ENABLED_THEMES_KEY = 'CABINET_ENABLED_THEMES' # Stores JSON with enabled themes {"dark": true, "light": false} +ANIMATION_ENABLED_KEY = 'CABINET_ANIMATION_ENABLED' # Stores "true" or "false" +FULLSCREEN_ENABLED_KEY = 'CABINET_FULLSCREEN_ENABLED' # Stores "true" or "false" # Allowed image types -ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"} +ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'} MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos # ============ Schemas ============ + class BrandingResponse(BaseModel): """Current branding settings.""" + name: str - logo_url: Optional[str] = None + logo_url: str | None = None logo_letter: str has_custom_logo: bool class BrandingNameUpdate(BaseModel): """Request to update branding name.""" + name: str class ThemeColorsResponse(BaseModel): """Theme colors settings.""" - accent: str = "#3b82f6" - darkBackground: str = "#0a0f1a" - darkSurface: str = "#0f172a" - darkText: str = "#f1f5f9" - darkTextSecondary: str = "#94a3b8" - lightBackground: str = "#F7E7CE" - lightSurface: str = "#FEF9F0" - lightText: str = "#1F1A12" - lightTextSecondary: str = "#7D6B48" - success: str = "#22c55e" - warning: str = "#f59e0b" - error: str = "#ef4444" + + accent: str = '#3b82f6' + darkBackground: str = '#0a0f1a' + darkSurface: str = '#0f172a' + darkText: str = '#f1f5f9' + darkTextSecondary: str = '#94a3b8' + lightBackground: str = '#F7E7CE' + lightSurface: str = '#FEF9F0' + lightText: str = '#1F1A12' + lightTextSecondary: str = '#7D6B48' + success: str = '#22c55e' + warning: str = '#f59e0b' + error: str = '#ef4444' class ThemeColorsUpdate(BaseModel): """Request to update theme colors (partial update allowed).""" - accent: Optional[str] = None - darkBackground: Optional[str] = None - darkSurface: Optional[str] = None - darkText: Optional[str] = None - darkTextSecondary: Optional[str] = None - lightBackground: Optional[str] = None - lightSurface: Optional[str] = None - lightText: Optional[str] = None - lightTextSecondary: Optional[str] = None - success: Optional[str] = None - warning: Optional[str] = None - error: Optional[str] = None + + accent: str | None = None + darkBackground: str | None = None + darkSurface: str | None = None + darkText: str | None = None + darkTextSecondary: str | None = None + lightBackground: str | None = None + lightSurface: str | None = None + lightText: str | None = None + lightTextSecondary: str | None = None + success: str | None = None + warning: str | None = None + error: str | None = None class EnabledThemesResponse(BaseModel): """Enabled themes settings.""" + dark: bool = True light: bool = True class EnabledThemesUpdate(BaseModel): """Request to update enabled themes.""" - dark: Optional[bool] = None - light: Optional[bool] = None + + dark: bool | None = None + light: bool | None = None class AnimationEnabledResponse(BaseModel): """Animation enabled setting.""" + enabled: bool = True class AnimationEnabledUpdate(BaseModel): """Request to update animation setting.""" + enabled: bool class FullscreenEnabledResponse(BaseModel): """Fullscreen enabled setting.""" + enabled: bool = False class FullscreenEnabledUpdate(BaseModel): """Request to update fullscreen setting.""" + enabled: bool # Default theme colors DEFAULT_THEME_COLORS = { - "accent": "#3b82f6", - "darkBackground": "#0a0f1a", - "darkSurface": "#0f172a", - "darkText": "#f1f5f9", - "darkTextSecondary": "#94a3b8", - "lightBackground": "#F7E7CE", - "lightSurface": "#FEF9F0", - "lightText": "#1F1A12", - "lightTextSecondary": "#7D6B48", - "success": "#22c55e", - "warning": "#f59e0b", - "error": "#ef4444", + 'accent': '#3b82f6', + 'darkBackground': '#0a0f1a', + 'darkSurface': '#0f172a', + 'darkText': '#f1f5f9', + 'darkTextSecondary': '#94a3b8', + 'lightBackground': '#F7E7CE', + 'lightSurface': '#FEF9F0', + 'lightText': '#1F1A12', + 'lightTextSecondary': '#7D6B48', + 'success': '#22c55e', + 'warning': '#f59e0b', + 'error': '#ef4444', } # ============ Helper Functions ============ + def ensure_branding_dir(): """Ensure branding directory exists.""" BRANDING_DIR.mkdir(parents=True, exist_ok=True) -async def get_setting_value(db: AsyncSession, key: str) -> Optional[str]: +async def get_setting_value(db: AsyncSession, key: str) -> str | None: """Get a setting value from database.""" - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == key) - ) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) setting = result.scalar_one_or_none() return setting.value if setting else None async def set_setting_value(db: AsyncSession, key: str, value: str): """Set a setting value in database.""" - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == key) - ) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) setting = result.scalar_one_or_none() if setting: @@ -166,14 +174,14 @@ async def set_setting_value(db: AsyncSession, key: str, value: str): await db.commit() -def get_logo_path() -> Optional[Path]: +def get_logo_path() -> Path | None: """Get the path to the custom logo file (any supported format).""" if not BRANDING_DIR.exists(): return None # Search for logo file with any supported extension for ext in LOGO_EXTENSIONS: - logo_path = BRANDING_DIR / f"logo{ext}" + logo_path = BRANDING_DIR / f'logo{ext}' if logo_path.exists(): return logo_path @@ -187,7 +195,8 @@ def has_custom_logo() -> bool: # ============ Routes ============ -@router.get("", response_model=BrandingResponse) + +@router.get('', response_model=BrandingResponse) async def get_branding( db: AsyncSession = Depends(get_cabinet_db), ): @@ -198,24 +207,23 @@ async def get_branding( # Get name from database or use default from env/settings name = await get_setting_value(db, BRANDING_NAME_KEY) if name is None: # Only use fallback if not set at all (empty string is valid) - name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ - os.getenv('VITE_APP_NAME', 'Cabinet') + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet') # Check for custom logo custom_logo = has_custom_logo() # Get first letter for logo fallback (use "V" if name is empty) - logo_letter = name[0].upper() if name else "V" + logo_letter = name[0].upper() if name else 'V' return BrandingResponse( name=name, - logo_url="/cabinet/branding/logo" if custom_logo else None, + logo_url='/cabinet/branding/logo' if custom_logo else None, logo_letter=logo_letter, has_custom_logo=custom_logo, ) -@router.get("/logo") +@router.get('/logo') async def get_logo(): """ Get the custom logo image. @@ -224,61 +232,51 @@ async def get_logo(): logo_path = get_logo_path() if logo_path is None or not logo_path.exists(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No custom logo set" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set') # Determine media type from file extension suffix = logo_path.suffix.lower() media_types = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".webp": "image/webp", - ".svg": "image/svg+xml", + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', } - media_type = media_types.get(suffix, "image/png") + media_type = media_types.get(suffix, 'image/png') - return FileResponse( - logo_path, - media_type=media_type, - headers={"Cache-Control": "public, max-age=3600"} - ) + return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'}) -@router.put("/name", response_model=BrandingResponse) +@router.put('/name', response_model=BrandingResponse) async def update_branding_name( payload: BrandingNameUpdate, admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Update the project name. Admin only. Empty name allowed (logo only mode).""" - name = payload.name.strip() if payload.name else "" + name = payload.name.strip() if payload.name else '' if len(name) > 50: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Name too long (max 50 characters)" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name too long (max 50 characters)') await set_setting_value(db, BRANDING_NAME_KEY, name) - logger.info(f"Admin {admin.telegram_id} updated branding name to: {name}") + logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}') # Return updated branding custom_logo = has_custom_logo() - logo_letter = name[0].upper() if name else "C" + logo_letter = name[0].upper() if name else 'C' return BrandingResponse( name=name, - logo_url="/cabinet/branding/logo" if custom_logo else None, + logo_url='/cabinet/branding/logo' if custom_logo else None, logo_letter=logo_letter, has_custom_logo=custom_logo, ) -@router.post("/logo", response_model=BrandingResponse) +@router.post('/logo', response_model=BrandingResponse) async def upload_logo( file: UploadFile = File(...), admin: User = Depends(get_current_admin_user), @@ -288,8 +286,7 @@ async def upload_logo( # Validate content type if file.content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG" + status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG' ) # Read file content @@ -299,7 +296,7 @@ async def upload_logo( if len(content) > MAX_FILE_SIZE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB" + detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB', ) # Ensure directory exists @@ -307,65 +304,63 @@ async def upload_logo( # Determine file extension from content type ext_map = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/webp": ".webp", - "image/svg+xml": ".svg", + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', } - extension = ext_map.get(file.content_type, ".png") + extension = ext_map.get(file.content_type, '.png') # Remove old logo files with any extension - for old_file in BRANDING_DIR.glob("logo.*"): + for old_file in BRANDING_DIR.glob('logo.*'): old_file.unlink() # Save new logo - logo_path = BRANDING_DIR / f"logo{extension}" + logo_path = BRANDING_DIR / f'logo{extension}' logo_path.write_bytes(content) # Mark that we have a custom logo - await set_setting_value(db, BRANDING_LOGO_KEY, "custom") + await set_setting_value(db, BRANDING_LOGO_KEY, 'custom') - logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}") + logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}') # Get current name for response name = await get_setting_value(db, BRANDING_NAME_KEY) if name is None: # Only use fallback if not set at all (empty string is valid) - name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ - os.getenv('VITE_APP_NAME', 'Cabinet') + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet') - logo_letter = name[0].upper() if name else "C" + logo_letter = name[0].upper() if name else 'C' return BrandingResponse( name=name, - logo_url="/cabinet/branding/logo", + logo_url='/cabinet/branding/logo', logo_letter=logo_letter, has_custom_logo=True, ) -@router.delete("/logo", response_model=BrandingResponse) +@router.delete('/logo', response_model=BrandingResponse) async def delete_logo( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Delete custom logo and revert to letter. Admin only.""" # Remove logo files - for old_file in BRANDING_DIR.glob("logo.*"): + for old_file in BRANDING_DIR.glob('logo.*'): old_file.unlink() # Update setting - await set_setting_value(db, BRANDING_LOGO_KEY, "default") + await set_setting_value(db, BRANDING_LOGO_KEY, 'default') - logger.info(f"Admin {admin.telegram_id} deleted custom logo") + logger.info(f'Admin {admin.telegram_id} deleted custom logo') # Get current name for response name = await get_setting_value(db, BRANDING_NAME_KEY) if name is None: # Only use fallback if not set at all (empty string is valid) - name = getattr(settings, 'CABINET_BRANDING_NAME', None) or \ - os.getenv('VITE_APP_NAME', 'Cabinet') + name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet') - logo_letter = name[0].upper() if name else "C" + logo_letter = name[0].upper() if name else 'C' return BrandingResponse( name=name, @@ -377,11 +372,12 @@ async def delete_logo( # ============ Theme Colors Routes ============ + def validate_hex_color(color: str) -> bool: """Validate hex color format.""" if not color or not isinstance(color, str): return False - if not color.startswith("#"): + if not color.startswith('#'): return False hex_part = color[1:] if len(hex_part) not in (3, 6): @@ -393,7 +389,7 @@ def validate_hex_color(color: str) -> bool: return False -@router.get("/colors", response_model=ThemeColorsResponse) +@router.get('/colors', response_model=ThemeColorsResponse) async def get_theme_colors( db: AsyncSession = Depends(get_cabinet_db), ): @@ -415,7 +411,7 @@ async def get_theme_colors( return ThemeColorsResponse(**DEFAULT_THEME_COLORS) -@router.patch("/colors", response_model=ThemeColorsResponse) +@router.patch('/colors', response_model=ThemeColorsResponse) async def update_theme_colors( payload: ThemeColorsUpdate, admin: User = Depends(get_current_admin_user), @@ -438,22 +434,19 @@ async def update_theme_colors( # Validate hex colors for key, value in update_data.items(): if not validate_hex_color(value): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid hex color for {key}: {value}" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid hex color for {key}: {value}') current_colors.update(update_data) # Save to database await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors)) - logger.info(f"Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}") + logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}') return ThemeColorsResponse(**current_colors) -@router.post("/colors/reset", response_model=ThemeColorsResponse) +@router.post('/colors/reset', response_model=ThemeColorsResponse) async def reset_theme_colors( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -462,17 +455,17 @@ async def reset_theme_colors( # Save default colors await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS)) - logger.info(f"Admin {admin.telegram_id} reset theme colors to defaults") + logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults') return ThemeColorsResponse(**DEFAULT_THEME_COLORS) # ============ Enabled Themes Routes ============ -DEFAULT_ENABLED_THEMES = {"dark": True, "light": True} +DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True} -@router.get("/themes", response_model=EnabledThemesResponse) +@router.get('/themes', response_model=EnabledThemesResponse) async def get_enabled_themes( db: AsyncSession = Depends(get_cabinet_db), ): @@ -492,7 +485,7 @@ async def get_enabled_themes( return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES) -@router.patch("/themes", response_model=EnabledThemesResponse) +@router.patch('/themes', response_model=EnabledThemesResponse) async def update_enabled_themes( payload: EnabledThemesUpdate, admin: User = Depends(get_current_admin_user), @@ -514,23 +507,21 @@ async def update_enabled_themes( current_themes.update(update_data) # Ensure at least one theme is enabled - if not current_themes.get("dark") and not current_themes.get("light"): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="At least one theme must be enabled" - ) + if not current_themes.get('dark') and not current_themes.get('light'): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='At least one theme must be enabled') # Save to database await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes)) - logger.info(f"Admin {admin.telegram_id} updated enabled themes: {current_themes}") + logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}') return EnabledThemesResponse(**current_themes) # ============ Animation Routes ============ -@router.get("/animation", response_model=AnimationEnabledResponse) + +@router.get('/animation', response_model=AnimationEnabledResponse) async def get_animation_enabled( db: AsyncSession = Depends(get_cabinet_db), ): @@ -541,14 +532,14 @@ async def get_animation_enabled( animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY) if animation_value is not None: - enabled = animation_value.lower() == "true" + enabled = animation_value.lower() == 'true' return AnimationEnabledResponse(enabled=enabled) # Default: enabled return AnimationEnabledResponse(enabled=True) -@router.patch("/animation", response_model=AnimationEnabledResponse) +@router.patch('/animation', response_model=AnimationEnabledResponse) async def update_animation_enabled( payload: AnimationEnabledUpdate, admin: User = Depends(get_current_admin_user), @@ -557,14 +548,15 @@ async def update_animation_enabled( """Update animation enabled setting. Admin only.""" await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f"Admin {admin.telegram_id} set animation enabled: {payload.enabled}") + logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}') return AnimationEnabledResponse(enabled=payload.enabled) # ============ Fullscreen Routes ============ -@router.get("/fullscreen", response_model=FullscreenEnabledResponse) + +@router.get('/fullscreen', response_model=FullscreenEnabledResponse) async def get_fullscreen_enabled( db: AsyncSession = Depends(get_cabinet_db), ): @@ -575,14 +567,14 @@ async def get_fullscreen_enabled( fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY) if fullscreen_value is not None: - enabled = fullscreen_value.lower() == "true" + enabled = fullscreen_value.lower() == 'true' return FullscreenEnabledResponse(enabled=enabled) # Default: disabled return FullscreenEnabledResponse(enabled=False) -@router.patch("/fullscreen", response_model=FullscreenEnabledResponse) +@router.patch('/fullscreen', response_model=FullscreenEnabledResponse) async def update_fullscreen_enabled( payload: FullscreenEnabledUpdate, admin: User = Depends(get_current_admin_user), @@ -591,6 +583,6 @@ async def update_fullscreen_enabled( """Update fullscreen enabled setting. Admin only.""" await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower()) - logger.info(f"Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}") + logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}') return FullscreenEnabledResponse(enabled=payload.enabled) diff --git a/app/cabinet/routes/contests.py b/app/cabinet/routes/contests.py index 1217404f..f7f865ef 100644 --- a/app/cabinet/routes/contests.py +++ b/app/cabinet/routes/contests.py @@ -3,45 +3,48 @@ import logging import random from datetime import datetime, timedelta -from typing import List, Optional, Dict, Any +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, SubscriptionStatus from app.database.crud.contest import ( + create_attempt, get_active_rounds, get_attempt, - create_attempt, increment_winner_count, ) -from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription +from app.database.crud.subscription import get_subscription_by_user_id +from app.database.models import SubscriptionStatus, User from app.services.contest_rotation_service import ( - GAME_QUEST, - GAME_LOCKS, - GAME_CIPHER, - GAME_SERVER, - GAME_BLITZ, - GAME_EMOJI, GAME_ANAGRAM, + GAME_BLITZ, + GAME_CIPHER, + GAME_EMOJI, + GAME_LOCKS, + GAME_QUEST, + GAME_SERVER, ) from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/contests", tags=["Cabinet Contests"]) +router = APIRouter(prefix='/contests', tags=['Cabinet Contests']) # ============ Schemas ============ + class ContestInfo(BaseModel): """Contest/game info.""" + id: int slug: str name: str - description: Optional[str] = None + description: str | None = None prize_type: str prize_value: str is_available: bool @@ -50,28 +53,32 @@ class ContestInfo(BaseModel): class ContestGameData(BaseModel): """Data for playing a contest game.""" + round_id: int game_type: str - game_data: Dict[str, Any] + game_data: dict[str, Any] instructions: str class ContestAnswerRequest(BaseModel): """Request to submit contest answer.""" + round_id: int answer: str class ContestResult(BaseModel): """Result of contest attempt.""" + is_winner: bool message: str - prize_type: Optional[str] = None - prize_value: Optional[str] = None + prize_type: str | None = None + prize_value: str | None = None # ============ Helpers ============ + def _user_allowed(subscription) -> bool: """Check if user is allowed to participate in contests.""" if not subscription: @@ -84,55 +91,57 @@ def _user_allowed(subscription) -> bool: async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str: """Award prize to winner.""" - if prize_type == "days": + if prize_type == 'days': try: days = int(prize_value) except ValueError: - return "Error: invalid prize value" + return 'Error: invalid prize value' subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - return "Error: subscription not found" + return 'Error: subscription not found' subscription.end_date = subscription.end_date + timedelta(days=days) subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) - logger.info(f"🎁 Extended subscription for user {user_id} by {days} days (contest prize)") - return f"Subscription extended by {days} days" + logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)') + return f'Subscription extended by {days} days' - elif prize_type == "balance": + if prize_type == 'balance': from app.database.crud.user import get_user_by_id + try: amount = float(prize_value) except ValueError: - return "Error: invalid prize value" + return 'Error: invalid prize value' user = await get_user_by_id(db, user_id) if not user: - return "Error: user not found" + return 'Error: user not found' user.balance += amount await db.commit() await db.refresh(user) - logger.info(f"🎁 Added {amount} to balance for user {user_id} (contest prize)") - return f"Balance increased by {amount}" + logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)') + return f'Balance increased by {amount}' - else: - logger.warning(f"Unknown prize type: {prize_type}") - return f"Prize type '{prize_type}' not supported" + logger.warning(f'Unknown prize type: {prize_type}') + return f"Prize type '{prize_type}' not supported" # ============ Routes ============ + class ContestsCountResponse(BaseModel): """Count of available contests.""" + count: int -@router.get("/count", response_model=ContestsCountResponse) +@router.get('/count', response_model=ContestsCountResponse) async def get_contests_count( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -151,7 +160,7 @@ async def get_contests_count( for rnd in active_rounds: if not rnd.template or not rnd.template.is_enabled: continue - tpl_slug = rnd.template.slug if rnd.template else "" + tpl_slug = rnd.template.slug if rnd.template else '' if tpl_slug in seen_templates: continue seen_templates.add(tpl_slug) @@ -164,7 +173,7 @@ async def get_contests_count( return ContestsCountResponse(count=count) -@router.get("", response_model=List[ContestInfo]) +@router.get('', response_model=list[ContestInfo]) async def get_contests( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -175,7 +184,7 @@ async def get_contests( if not _user_allowed(subscription): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Contests are only available for users with active or trial subscriptions", + detail='Contests are only available for users with active or trial subscriptions', ) active_rounds = await get_active_rounds(db) @@ -185,7 +194,7 @@ async def get_contests( for rnd in active_rounds: if not rnd.template or not rnd.template.is_enabled: continue - tpl_slug = rnd.template.slug if rnd.template else "" + tpl_slug = rnd.template.slug if rnd.template else '' if tpl_slug not in unique_templates: unique_templates[tpl_slug] = rnd @@ -194,21 +203,23 @@ async def get_contests( # Check if user already played this round attempt = await get_attempt(db, rnd.id, user.id) - contests.append(ContestInfo( - id=rnd.id, - slug=tpl_slug, - name=rnd.template.name if rnd.template else tpl_slug, - description=rnd.template.description if rnd.template else None, - prize_type=rnd.template.prize_type if rnd.template else "days", - prize_value=rnd.template.prize_value if rnd.template else "1", - is_available=True, - already_played=attempt is not None, - )) + contests.append( + ContestInfo( + id=rnd.id, + slug=tpl_slug, + name=rnd.template.name if rnd.template else tpl_slug, + description=rnd.template.description if rnd.template else None, + prize_type=rnd.template.prize_type if rnd.template else 'days', + prize_value=rnd.template.prize_value if rnd.template else '1', + is_available=True, + already_played=attempt is not None, + ) + ) return contests -@router.get("/{round_id}", response_model=ContestGameData) +@router.get('/{round_id}', response_model=ContestGameData) async def get_contest_game( round_id: int, user: User = Depends(get_current_cabinet_user), @@ -220,7 +231,7 @@ async def get_contest_game( if not _user_allowed(subscription): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Contests are only available for users with active or trial subscriptions", + detail='Contests are only available for users with active or trial subscriptions', ) active_rounds = await get_active_rounds(db) @@ -229,13 +240,13 @@ async def get_contest_game( if not round_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Contest round not found or already finished", + detail='Contest round not found or already finished', ) if not round_obj.template or not round_obj.template.is_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This contest is disabled", + detail='This contest is disabled', ) # Check if already played @@ -243,80 +254,80 @@ async def get_contest_game( if attempt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="You have already played this round", + detail='You have already played this round', ) tpl = round_obj.template game_type = tpl.slug game_data = {} - instructions = "" + instructions = '' if game_type == GAME_QUEST: - rows = round_obj.payload.get("rows", 3) - cols = round_obj.payload.get("cols", 3) + rows = round_obj.payload.get('rows', 3) + cols = round_obj.payload.get('cols', 3) secret = random.randint(0, rows * cols - 1) game_data = { - "rows": rows, - "cols": cols, - "secret": secret, - "grid_size": rows * cols, + 'rows': rows, + 'cols': cols, + 'secret': secret, + 'grid_size': rows * cols, } - instructions = "Select one of the nodes in the grid. Find the hidden server!" + instructions = 'Select one of the nodes in the grid. Find the hidden server!' elif game_type == GAME_LOCKS: - total = round_obj.payload.get("total", 20) + total = round_obj.payload.get('total', 20) secret = random.randint(0, total - 1) game_data = { - "total": total, - "secret": secret, + 'total': total, + 'secret': secret, } - instructions = "Find the unlocked button among the locks!" + instructions = 'Find the unlocked button among the locks!' elif game_type == GAME_SERVER: - flags = round_obj.payload.get("flags") or [] + flags = round_obj.payload.get('flags') or [] shuffled_flags = flags.copy() random.shuffle(shuffled_flags) game_data = { - "flags": shuffled_flags, + 'flags': shuffled_flags, } - instructions = "Choose a server by clicking on a flag!" + instructions = 'Choose a server by clicking on a flag!' elif game_type == GAME_CIPHER: - question = round_obj.payload.get("question", "") + question = round_obj.payload.get('question', '') game_data = { - "question": question, - "input_type": "text", + 'question': question, + 'input_type': 'text', } - instructions = "Decrypt the cipher and enter the answer!" + instructions = 'Decrypt the cipher and enter the answer!' elif game_type == GAME_EMOJI: - question = round_obj.payload.get("question", "🤔") + question = round_obj.payload.get('question', '🤔') emoji_list = question.split() random.shuffle(emoji_list) game_data = { - "question": " ".join(emoji_list), - "input_type": "text", + 'question': ' '.join(emoji_list), + 'input_type': 'text', } - instructions = "Guess the service by emojis!" + instructions = 'Guess the service by emojis!' elif game_type == GAME_ANAGRAM: - letters = round_obj.payload.get("letters", "") + letters = round_obj.payload.get('letters', '') game_data = { - "letters": letters, - "input_type": "text", + 'letters': letters, + 'input_type': 'text', } - instructions = "Make a word from the given letters!" + instructions = 'Make a word from the given letters!' elif game_type == GAME_BLITZ: game_data = { - "button_text": "I'm here!", + 'button_text': "I'm here!", } - instructions = "Click the button as fast as you can!" + instructions = 'Click the button as fast as you can!' else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Unknown contest type", + detail='Unknown contest type', ) return ContestGameData( @@ -327,7 +338,7 @@ async def get_contest_game( ) -@router.post("/{round_id}/answer", response_model=ContestResult) +@router.post('/{round_id}/answer', response_model=ContestResult) async def submit_contest_answer( round_id: int, request: ContestAnswerRequest, @@ -340,7 +351,7 @@ async def submit_contest_answer( if not _user_allowed(subscription): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Contests are only available for users with active or trial subscriptions", + detail='Contests are only available for users with active or trial subscriptions', ) active_rounds = await get_active_rounds(db) @@ -349,7 +360,7 @@ async def submit_contest_answer( if not round_obj: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Contest round not found or already finished", + detail='Contest round not found or already finished', ) # Check if already played @@ -357,7 +368,7 @@ async def submit_contest_answer( if attempt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="You have already played this round", + detail='You have already played this round', ) tpl = round_obj.template @@ -366,14 +377,14 @@ async def submit_contest_answer( # Determine if winner based on game type if tpl.slug == GAME_SERVER: - flags = round_obj.payload.get("flags") or [] - secret_idx = round_obj.payload.get("secret_idx") - correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else "" + flags = round_obj.payload.get('flags') or [] + secret_idx = round_obj.payload.get('secret_idx') + correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else '' is_winner = answer == correct_flag elif tpl.slug in {GAME_QUEST, GAME_LOCKS}: try: - parts = answer.split("_") + parts = answer.split('_') if len(parts) >= 2: idx = int(parts[0]) secret = int(parts[1]) @@ -382,38 +393,31 @@ async def submit_contest_answer( is_winner = False elif tpl.slug == GAME_BLITZ: - is_winner = answer.lower() == "blitz" + is_winner = answer.lower() == 'blitz' elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}: - correct = (round_obj.payload.get("answer") or "").upper() + correct = (round_obj.payload.get('answer') or '').upper() is_winner = correct and answer.upper() == correct # Record attempt - await create_attempt( - db, - round_id=round_obj.id, - user_id=user.id, - answer=str(answer), - is_winner=is_winner - ) + await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner) if is_winner: await increment_winner_count(db, round_obj) prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value) return ContestResult( is_winner=True, - message=f"🎉 Congratulations! You won! {prize_text}", + message=f'🎉 Congratulations! You won! {prize_text}', prize_type=tpl.prize_type, prize_value=tpl.prize_value, ) - else: - lose_messages = { - GAME_QUEST: ["Empty node", "Wrong server", "Try another"], - GAME_LOCKS: ["Locked", "No access", "Try again"], - GAME_SERVER: ["Server overloaded", "No response", "Try tomorrow"], - } - messages = lose_messages.get(tpl.slug, ["Incorrect", "Try again next round"]) - return ContestResult( - is_winner=False, - message=random.choice(messages), - ) + lose_messages = { + GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'], + GAME_LOCKS: ['Locked', 'No access', 'Try again'], + GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'], + } + messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round']) + return ContestResult( + is_winner=False, + message=random.choice(messages), + ) diff --git a/app/cabinet/routes/info.py b/app/cabinet/routes/info.py index ad0c8393..dc416818 100644 --- a/app/cabinet/routes/info.py +++ b/app/cabinet/routes/info.py @@ -1,31 +1,32 @@ """Info pages routes for cabinet - FAQ, rules, privacy policy, etc.""" import logging -from typing import List, Optional, Dict, Any -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User from app.config import settings +from app.database.crud.rules import get_current_rules_content, get_rules_by_language +from app.database.models import User from app.services.faq_service import FaqService from app.services.privacy_policy_service import PrivacyPolicyService from app.services.public_offer_service import PublicOfferService -from app.database.crud.rules import get_rules_by_language, get_current_rules_content -from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user +from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/info", tags=["Cabinet Info"]) +router = APIRouter(prefix='/info', tags=['Cabinet Info']) # ============ Schemas ============ + class FaqPageResponse(BaseModel): """FAQ page.""" + id: int title: str content: str @@ -34,44 +35,50 @@ class FaqPageResponse(BaseModel): class RulesResponse(BaseModel): """Service rules.""" + content: str - updated_at: Optional[str] = None + updated_at: str | None = None class PrivacyPolicyResponse(BaseModel): """Privacy policy.""" + content: str - updated_at: Optional[str] = None + updated_at: str | None = None class PublicOfferResponse(BaseModel): """Public offer.""" + content: str - updated_at: Optional[str] = None + updated_at: str | None = None class ServiceInfoResponse(BaseModel): """General service info.""" + name: str - description: Optional[str] = None - support_email: Optional[str] = None - support_telegram: Optional[str] = None - website: Optional[str] = None + description: str | None = None + support_email: str | None = None + support_telegram: str | None = None + website: str | None = None class SupportConfigResponse(BaseModel): """Support/tickets configuration for miniapp.""" + tickets_enabled: bool support_type: str # "tickets", "profile", "url" - support_url: Optional[str] = None - support_username: Optional[str] = None + support_url: str | None = None + support_username: str | None = None # ============ Routes ============ -@router.get("/faq", response_model=List[FaqPageResponse]) + +@router.get('/faq', response_model=list[FaqPageResponse]) async def get_faq_pages( - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), db: AsyncSession = Depends(get_cabinet_db), ): """Get list of FAQ pages.""" @@ -87,17 +94,17 @@ async def get_faq_pages( FaqPageResponse( id=page.id, title=page.title, - content=page.content or "", + content=page.content or '', order=page.display_order or 0, ) for page in pages ] -@router.get("/faq/{page_id}", response_model=FaqPageResponse) +@router.get('/faq/{page_id}', response_model=FaqPageResponse) async def get_faq_page( page_id: int, - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), db: AsyncSession = Depends(get_cabinet_db), ): """Get a specific FAQ page by ID.""" @@ -113,24 +120,24 @@ async def get_faq_page( if not page: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="FAQ page not found", + detail='FAQ page not found', ) return FaqPageResponse( id=page.id, title=page.title, - content=page.content or "", + content=page.content or '', order=page.display_order or 0, ) -@router.get("/rules", response_model=RulesResponse) +@router.get('/rules', response_model=RulesResponse) async def get_rules( - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), db: AsyncSession = Depends(get_cabinet_db), ): """Get service rules - uses same function as bot.""" - requested_lang = language.split("-")[0].lower() + requested_lang = language.split('-')[0].lower() # Use the same function as bot to ensure consistent content content = await get_current_rules_content(db, requested_lang) @@ -144,9 +151,9 @@ async def get_rules( return RulesResponse(content=content, updated_at=updated_at) -@router.get("/privacy-policy", response_model=PrivacyPolicyResponse) +@router.get('/privacy-policy', response_model=PrivacyPolicyResponse) async def get_privacy_policy( - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), db: AsyncSession = Depends(get_cabinet_db), ): """Get privacy policy.""" @@ -167,9 +174,9 @@ async def get_privacy_policy( ) -@router.get("/public-offer", response_model=PublicOfferResponse) +@router.get('/public-offer', response_model=PublicOfferResponse) async def get_public_offer( - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), db: AsyncSession = Depends(get_cabinet_db), ): """Get public offer.""" @@ -190,7 +197,7 @@ async def get_public_offer( ) -@router.get("/service", response_model=ServiceInfoResponse) +@router.get('/service', response_model=ServiceInfoResponse) async def get_service_info(): """Get general service information.""" return ServiceInfoResponse( @@ -202,50 +209,50 @@ async def get_service_info(): ) -@router.get("/languages") +@router.get('/languages') async def get_available_languages(): """Get list of available languages.""" return { - "languages": [ - {"code": "ru", "name": "Русский", "flag": "🇷🇺"}, - {"code": "en", "name": "English", "flag": "🇬🇧"}, + 'languages': [ + {'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'}, + {'code': 'en', 'name': 'English', 'flag': '🇬🇧'}, ], - "default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru', + 'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru', } -@router.get("/user/language") +@router.get('/user/language') async def get_user_language( user: User = Depends(get_current_cabinet_user), ): """Get current user's language.""" - return {"language": user.language or "ru"} + return {'language': user.language or 'ru'} -@router.patch("/user/language") +@router.patch('/user/language') async def update_user_language( - request: Dict[str, str], + request: dict[str, str], user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Update user's language preference.""" - language = request.get("language", "ru") + language = request.get('language', 'ru') - valid_languages = ["ru", "en"] + valid_languages = ['ru', 'en'] if language not in valid_languages: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid language. Supported: {', '.join(valid_languages)}", + detail=f'Invalid language. Supported: {", ".join(valid_languages)}', ) user.language = language await db.commit() await db.refresh(user) - return {"language": user.language} + return {'language': user.language} -@router.get("/support-config", response_model=SupportConfigResponse) +@router.get('/support-config', response_model=SupportConfigResponse) async def get_support_config(): """Get support/tickets configuration for cabinet.""" # Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel) @@ -255,15 +262,15 @@ async def get_support_config(): # - "tickets" mode -> tickets only, no contact # - "contact" mode -> contact only (profile), no tickets # - "both" mode -> tickets enabled, contact available as fallback - if support_mode == "tickets": + if support_mode == 'tickets': tickets_enabled = True - support_type = "tickets" - elif support_mode == "contact": + support_type = 'tickets' + elif support_mode == 'contact': tickets_enabled = False - support_type = "profile" + support_type = 'profile' else: # both tickets_enabled = True - support_type = "tickets" + support_type = 'tickets' return SupportConfigResponse( tickets_enabled=tickets_enabled, diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py index ec9ac2c9..ed137992 100644 --- a/app/cabinet/routes/media.py +++ b/app/cabinet/routes/media.py @@ -2,7 +2,6 @@ import logging import mimetypes -from typing import Optional from aiogram import Bot from aiogram.client.default import DefaultBotProperties @@ -10,26 +9,27 @@ from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status from pydantic import BaseModel -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..dependencies import get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/media", tags=["Cabinet Media"]) +router = APIRouter(prefix='/media', tags=['Cabinet Media']) -ALLOWED_MEDIA_TYPES = {"photo", "video", "document"} +ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB class MediaUploadResponse(BaseModel): """Response after successful media upload.""" + media_type: str file_id: str - file_unique_id: Optional[str] = None + file_unique_id: str | None = None media_url: str @@ -45,31 +45,31 @@ def _resolve_target_chat_id() -> int: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No chat configured for file uploads", + detail='No chat configured for file uploads', ) def _build_media_url(request: Request, file_id: str) -> str: """Build URL for downloading media.""" - return str(request.url_for("cabinet_download_media", file_id=file_id)) + return str(request.url_for('cabinet_download_media', file_id=file_id)) -@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED) +@router.post('/upload', response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED) async def upload_media( request: Request, user: User = Depends(get_current_cabinet_user), file: UploadFile = File(...), - media_type: str = Form("photo", description="File type: photo, video, or document"), + media_type: str = Form('photo', description='File type: photo, video, or document'), ): """ Upload media file for use in ticket messages. Returns file_id that can be used when creating ticket or adding message. """ - media_type_normalized = (media_type or "").strip().lower() + media_type_normalized = (media_type or '').strip().lower() if media_type_normalized not in ALLOWED_MEDIA_TYPES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported media type. Allowed: {', '.join(ALLOWED_MEDIA_TYPES)}", + detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}', ) # Read and validate file @@ -77,26 +77,26 @@ async def upload_media( if not file_bytes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="File is empty", + detail='File is empty', ) if len(file_bytes) > MAX_FILE_SIZE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB", + detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB', ) # Validate content type for photos - if media_type_normalized == "photo": - allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp"} + if media_type_normalized == 'photo': + allowed_image_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'} if file.content_type and file.content_type not in allowed_image_types: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid image type. Allowed: JPEG, PNG, GIF, WebP", + detail='Invalid image type. Allowed: JPEG, PNG, GIF, WebP', ) target_chat_id = _resolve_target_chat_id() - upload = BufferedInputFile(file_bytes, filename=file.filename or "upload") + upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') bot = Bot( token=settings.BOT_TOKEN, @@ -104,13 +104,13 @@ async def upload_media( ) try: - if media_type_normalized == "photo": + if media_type_normalized == 'photo': message = await bot.send_photo( chat_id=target_chat_id, photo=upload, ) media = message.photo[-1] - elif media_type_normalized == "video": + elif media_type_normalized == 'video': message = await bot.send_video( chat_id=target_chat_id, video=upload, @@ -125,27 +125,27 @@ async def upload_media( media_url = _build_media_url(request, media.file_id) - logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}") + logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}') return MediaUploadResponse( media_type=media_type_normalized, file_id=media.file_id, - file_unique_id=getattr(media, "file_unique_id", None), + file_unique_id=getattr(media, 'file_unique_id', None), media_url=media_url, ) except HTTPException: raise except Exception as error: - logger.error(f"Failed to upload media for user {user.telegram_id}: {error}") + logger.error(f'Failed to upload media for user {user.telegram_id}: {error}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to upload media", + detail='Failed to upload media', ) from error finally: await bot.session.close() -@router.get("/{file_id}", name="cabinet_download_media") +@router.get('/{file_id}', name='cabinet_download_media') async def download_media( file_id: str, ) -> Response: @@ -163,34 +163,34 @@ async def download_media( if not file.file_path: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Media file not found", + detail='Media file not found', ) buffer = await bot.download_file(file.file_path) - if hasattr(buffer, "seek"): + if hasattr(buffer, 'seek'): buffer.seek(0) - content = buffer.read() if hasattr(buffer, "read") else bytes(buffer) - filename = file.file_path.split("/")[-1] + content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer) + filename = file.file_path.split('/')[-1] - media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' return Response( content=content, media_type=media_type, headers={ - "Content-Disposition": f"inline; filename={filename}", - "Cache-Control": "public, max-age=86400", # Cache for 24 hours + 'Content-Disposition': f'inline; filename={filename}', + 'Cache-Control': 'public, max-age=86400', # Cache for 24 hours }, ) except HTTPException: raise except Exception as error: - logger.error(f"Failed to download media {file_id}: {error}") + logger.error(f'Failed to download media {file_id}: {error}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to download media", + detail='Failed to download media', ) from error finally: await bot.session.close() diff --git a/app/cabinet/routes/notifications.py b/app/cabinet/routes/notifications.py index 0404d80a..c2d4bdd6 100644 --- a/app/cabinet/routes/notifications.py +++ b/app/cabinet/routes/notifications.py @@ -2,9 +2,9 @@ import logging from datetime import datetime -from typing import Optional, Dict, Any +from typing import Any -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -12,15 +12,18 @@ from app.database.models import User from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/notifications", tags=["Cabinet Notifications"]) +router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications']) # ============ Schemas ============ + class NotificationSettingsResponse(BaseModel): """User notification settings.""" + subscription_expiry_enabled: bool = True subscription_expiry_days: int = 3 traffic_warning_enabled: bool = True @@ -33,36 +36,38 @@ class NotificationSettingsResponse(BaseModel): class NotificationSettingsUpdate(BaseModel): """Update notification settings.""" - subscription_expiry_enabled: Optional[bool] = None - subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30) - traffic_warning_enabled: Optional[bool] = None - traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99) - balance_low_enabled: Optional[bool] = None - balance_low_threshold: Optional[int] = Field(None, ge=0) - news_enabled: Optional[bool] = None - promo_offers_enabled: Optional[bool] = None + + subscription_expiry_enabled: bool | None = None + subscription_expiry_days: int | None = Field(None, ge=1, le=30) + traffic_warning_enabled: bool | None = None + traffic_warning_percent: int | None = Field(None, ge=50, le=99) + balance_low_enabled: bool | None = None + balance_low_threshold: int | None = Field(None, ge=0) + news_enabled: bool | None = None + promo_offers_enabled: bool | None = None # ============ Helpers ============ -def _get_notification_settings(user: User) -> Dict[str, Any]: + +def _get_notification_settings(user: User) -> dict[str, Any]: """Get notification settings from user object.""" # Try to get from user's settings field or use defaults settings_data = getattr(user, 'notification_settings', None) or {} return { - "subscription_expiry_enabled": settings_data.get("subscription_expiry_enabled", True), - "subscription_expiry_days": settings_data.get("subscription_expiry_days", 3), - "traffic_warning_enabled": settings_data.get("traffic_warning_enabled", True), - "traffic_warning_percent": settings_data.get("traffic_warning_percent", 80), - "balance_low_enabled": settings_data.get("balance_low_enabled", True), - "balance_low_threshold": settings_data.get("balance_low_threshold", 100), - "news_enabled": settings_data.get("news_enabled", True), - "promo_offers_enabled": settings_data.get("promo_offers_enabled", True), + 'subscription_expiry_enabled': settings_data.get('subscription_expiry_enabled', True), + 'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3), + 'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True), + 'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80), + 'balance_low_enabled': settings_data.get('balance_low_enabled', True), + 'balance_low_threshold': settings_data.get('balance_low_threshold', 100), + 'news_enabled': settings_data.get('news_enabled', True), + 'promo_offers_enabled': settings_data.get('promo_offers_enabled', True), } -def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[str, Any]: +def _update_notification_settings(user: User, updates: dict[str, Any]) -> dict[str, Any]: """Update notification settings on user object.""" current_settings = _get_notification_settings(user) @@ -75,7 +80,8 @@ def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[s # ============ Routes ============ -@router.get("", response_model=NotificationSettingsResponse) + +@router.get('', response_model=NotificationSettingsResponse) async def get_notification_settings( user: User = Depends(get_current_cabinet_user), ): @@ -84,7 +90,7 @@ async def get_notification_settings( return NotificationSettingsResponse(**settings) -@router.patch("", response_model=NotificationSettingsResponse) +@router.patch('', response_model=NotificationSettingsResponse) async def update_notification_settings( request: NotificationSettingsUpdate, user: User = Depends(get_current_cabinet_user), @@ -114,7 +120,7 @@ async def update_notification_settings( return NotificationSettingsResponse(**new_settings) -@router.post("/test") +@router.post('/test') async def send_test_notification( user: User = Depends(get_current_cabinet_user), ): @@ -122,12 +128,12 @@ async def send_test_notification( # This would typically trigger a notification via Telegram bot # For now, just return success return { - "success": True, - "message": "Test notification request received. You will receive a test message shortly.", + 'success': True, + 'message': 'Test notification request received. You will receive a test message shortly.', } -@router.get("/history") +@router.get('/history') async def get_notification_history( limit: int = 20, offset: int = 0, @@ -138,8 +144,8 @@ async def get_notification_history( # For now, return empty list - notification history can be implemented later # when there's a notification log table return { - "notifications": [], - "total": 0, - "limit": limit, - "offset": offset, + 'notifications': [], + 'total': 0, + 'limit': limit, + 'offset': offset, } diff --git a/app/cabinet/routes/polls.py b/app/cabinet/routes/polls.py index 414dc18d..1585ef61 100644 --- a/app/cabinet/routes/polls.py +++ b/app/cabinet/routes/polls.py @@ -2,33 +2,35 @@ import logging from datetime import datetime -from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field -from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import User, Poll, PollResponse, PollQuestion +from app.config import settings from app.database.crud.poll import ( get_poll_response_by_id, record_poll_answer, ) +from app.database.models import Poll, PollQuestion, PollResponse, User from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll -from app.config import settings from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/polls", tags=["Cabinet Polls"]) +router = APIRouter(prefix='/polls', tags=['Cabinet Polls']) # ============ Schemas ============ + class PollOptionResponse(BaseModel): """Poll option.""" + id: int text: str order: int @@ -36,26 +38,29 @@ class PollOptionResponse(BaseModel): class PollQuestionResponse(BaseModel): """Poll question with options.""" + id: int text: str order: int - options: List[PollOptionResponse] + options: list[PollOptionResponse] class PollInfo(BaseModel): """Poll info for user.""" + id: int response_id: int title: str - description: Optional[str] = None + description: str | None = None total_questions: int answered_questions: int is_completed: bool - reward_amount: Optional[int] = None + reward_amount: int | None = None class PollStartResponse(BaseModel): """Response when starting a poll.""" + response_id: int current_question_index: int total_questions: int @@ -64,22 +69,25 @@ class PollStartResponse(BaseModel): class AnswerRequest(BaseModel): """Request to answer a poll question.""" + option_id: int class AnswerResponse(BaseModel): """Response after answering.""" + success: bool is_completed: bool - next_question: Optional[PollQuestionResponse] = None - current_question_index: Optional[int] = None + next_question: PollQuestionResponse | None = None + current_question_index: int | None = None total_questions: int - reward_granted: Optional[int] = None - message: Optional[str] = None + reward_granted: int | None = None + message: str | None = None # ============ Helpers ============ + def _question_to_response(question: PollQuestion) -> PollQuestionResponse: """Convert question model to response.""" options = [ @@ -100,12 +108,14 @@ def _question_to_response(question: PollQuestion) -> PollQuestionResponse: # ============ Routes ============ + class PollsCountResponse(BaseModel): """Count of available polls.""" + count: int -@router.get("/count", response_model=PollsCountResponse) +@router.get('/count', response_model=PollsCountResponse) async def get_polls_count( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -120,7 +130,7 @@ async def get_polls_count( return PollsCountResponse(count=len(responses)) -@router.get("", response_model=List[PollInfo]) +@router.get('', response_model=list[PollInfo]) async def get_available_polls( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -151,21 +161,23 @@ async def get_available_polls( if response.poll.reward_amount_kopeks: reward_amount = response.poll.reward_amount_kopeks // 100 - polls.append(PollInfo( - id=response.poll.id, - response_id=response.id, - title=response.poll.title, - description=response.poll.description, - total_questions=total_questions, - answered_questions=answered_count, - is_completed=response.completed_at is not None, - reward_amount=reward_amount, - )) + polls.append( + PollInfo( + id=response.poll.id, + response_id=response.id, + title=response.poll.title, + description=response.poll.description, + total_questions=total_questions, + answered_questions=answered_count, + is_completed=response.completed_at is not None, + reward_amount=reward_amount, + ) + ) return polls -@router.get("/{response_id}", response_model=PollInfo) +@router.get('/{response_id}', response_model=PollInfo) async def get_poll_details( response_id: int, user: User = Depends(get_current_cabinet_user), @@ -177,13 +189,13 @@ async def get_poll_details( if not response or response.user_id != user.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Poll not found", + detail='Poll not found', ) if not response.poll: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Poll data not available", + detail='Poll data not available', ) answered_count = len(response.answers) if response.answers else 0 @@ -206,7 +218,7 @@ async def get_poll_details( ) -@router.post("/{response_id}/start", response_model=PollStartResponse) +@router.post('/{response_id}/start', response_model=PollStartResponse) async def start_poll( response_id: int, user: User = Depends(get_current_cabinet_user), @@ -218,19 +230,19 @@ async def start_poll( if not response or response.user_id != user.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Poll not found", + detail='Poll not found', ) if response.completed_at: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This poll has already been completed", + detail='This poll has already been completed', ) if not response.poll or not response.poll.questions: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Poll is not available", + detail='Poll is not available', ) # Mark as started if not already @@ -244,7 +256,7 @@ async def start_poll( if not question: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No questions available", + detail='No questions available', ) return PollStartResponse( @@ -255,7 +267,7 @@ async def start_poll( ) -@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse) +@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse) async def answer_question( response_id: int, question_id: int, @@ -269,19 +281,19 @@ async def answer_question( if not response or response.user_id != user.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Poll not found", + detail='Poll not found', ) if response.completed_at: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This poll has already been completed", + detail='This poll has already been completed', ) if not response.poll: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Poll is not available", + detail='Poll is not available', ) # Find the question @@ -289,7 +301,7 @@ async def answer_question( if not question: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Question not found", + detail='Question not found', ) # Validate option @@ -297,7 +309,7 @@ async def answer_question( if not option: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid answer option", + detail='Invalid answer option', ) # Record the answer @@ -310,13 +322,13 @@ async def answer_question( # Refresh to get updated answers try: - await db.refresh(response, attribute_names=["answers"]) + await db.refresh(response, attribute_names=['answers']) except Exception: response = await get_poll_response_by_id(db, response_id) if not response: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to process answer", + detail='Failed to process answer', ) # Get next question @@ -340,9 +352,9 @@ async def answer_question( # Award reward if any reward_amount = await reward_user_for_poll(db, response) - message = "Thank you for completing the poll!" + message = 'Thank you for completing the poll!' if reward_amount: - message += f" Reward of {settings.format_price(reward_amount)} has been added to your balance." + message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.' return AnswerResponse( success=True, diff --git a/app/cabinet/routes/promo.py b/app/cabinet/routes/promo.py index fe058103..df213b53 100644 --- a/app/cabinet/routes/promo.py +++ b/app/cabinet/routes/promo.py @@ -2,102 +2,111 @@ import logging from datetime import datetime, timedelta -from typing import List, Optional, Dict, Any +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel +from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ -from app.database.models import User, DiscountOffer from app.database.crud.discount_offer import ( get_offer_by_id, mark_offer_claimed, ) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.promo_group import get_auto_assign_promo_groups +from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.transaction import get_user_total_spent_kopeks +from app.database.models import DiscountOffer, User from app.services.promo_offer_service import promo_offer_service -from app.config import settings from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/promo", tags=["Cabinet Promo"]) +router = APIRouter(prefix='/promo', tags=['Cabinet Promo']) # ============ Schemas ============ + class PromoOfferInfo(BaseModel): """Promo offer info.""" + id: int notification_type: str - discount_percent: Optional[int] = None + discount_percent: int | None = None effect_type: str expires_at: datetime is_active: bool is_claimed: bool - claimed_at: Optional[datetime] = None - extra_data: Optional[Dict[str, Any]] = None + claimed_at: datetime | None = None + extra_data: dict[str, Any] | None = None class ActiveDiscountInfo(BaseModel): """User's active discount info.""" + discount_percent: int - source: Optional[str] = None - expires_at: Optional[datetime] = None + source: str | None = None + expires_at: datetime | None = None is_active: bool class ClaimOfferRequest(BaseModel): """Request to claim an offer.""" + offer_id: int class ClaimOfferResponse(BaseModel): """Response after claiming offer.""" + success: bool message: str - discount_percent: Optional[int] = None - expires_at: Optional[datetime] = None + discount_percent: int | None = None + expires_at: datetime | None = None class PromoGroupDiscounts(BaseModel): """User's promo group discounts.""" - group_name: Optional[str] = None + + group_name: str | None = None server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[str, int] = {} + period_discounts: dict[str, int] = {} class LoyaltyTierInfo(BaseModel): """Info about a single loyalty tier (promo group).""" + id: int name: str threshold_rubles: float server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[str, int] = {} + period_discounts: dict[str, int] = {} is_current: bool = False is_achieved: bool = False class LoyaltyTiersResponse(BaseModel): """Response with all loyalty tiers and user progress.""" - tiers: List[LoyaltyTierInfo] + + tiers: list[LoyaltyTierInfo] current_spent_rubles: float - current_tier_name: Optional[str] = None - next_tier_name: Optional[str] = None - next_tier_threshold_rubles: Optional[float] = None + current_tier_name: str | None = None + next_tier_name: str | None = None + next_tier_threshold_rubles: float | None = None progress_percent: float = 0 # ============ Routes ============ -@router.get("/offers", response_model=List[PromoOfferInfo]) + +@router.get('/offers', response_model=list[PromoOfferInfo]) async def get_promo_offers( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -120,9 +129,9 @@ async def get_promo_offers( return [ PromoOfferInfo( id=offer.id, - notification_type=offer.notification_type or "", + notification_type=offer.notification_type or '', discount_percent=offer.discount_percent, - effect_type=offer.effect_type or "percent_discount", + effect_type=offer.effect_type or 'percent_discount', expires_at=offer.expires_at, is_active=offer.is_active and offer.claimed_at is None, is_claimed=offer.claimed_at is not None, @@ -133,7 +142,7 @@ async def get_promo_offers( ] -@router.get("/active-discount", response_model=ActiveDiscountInfo) +@router.get('/active-discount', response_model=ActiveDiscountInfo) async def get_active_discount( user: User = Depends(get_current_cabinet_user), ): @@ -153,13 +162,13 @@ async def get_active_discount( ) -@router.get("/group-discounts", response_model=PromoGroupDiscounts) +@router.get('/group-discounts', response_model=PromoGroupDiscounts) async def get_promo_group_discounts( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get user's promo group discounts.""" - await db.refresh(user, ["promo_group", "user_promo_groups"]) + await db.refresh(user, ['promo_group', 'user_promo_groups']) promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None @@ -168,7 +177,7 @@ async def get_promo_group_discounts( # Get period discounts period_discounts = {} - raw_period_discounts = getattr(promo_group, "period_discounts", None) + raw_period_discounts = getattr(promo_group, 'period_discounts', None) if isinstance(raw_period_discounts, dict): for key, value in raw_period_discounts.items(): try: @@ -185,7 +194,7 @@ async def get_promo_group_discounts( ) -@router.get("/loyalty-tiers", response_model=LoyaltyTiersResponse) +@router.get('/loyalty-tiers', response_model=LoyaltyTiersResponse) async def get_loyalty_tiers( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -196,16 +205,16 @@ async def get_loyalty_tiers( total_spent_rubles = total_spent_kopeks / 100 # Get user's current promo group - await db.refresh(user, ["promo_group", "user_promo_groups"]) + await db.refresh(user, ['promo_group', 'user_promo_groups']) current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None current_tier_name = current_promo_group.name if current_promo_group else None # Get all auto-assign promo groups (sorted by threshold ascending) auto_groups = await get_auto_assign_promo_groups(db) - tiers: List[LoyaltyTierInfo] = [] - next_tier_name: Optional[str] = None - next_tier_threshold: Optional[float] = None + tiers: list[LoyaltyTierInfo] = [] + next_tier_name: str | None = None + next_tier_threshold: float | None = None for group in auto_groups: threshold_kopeks = group.auto_assign_total_spent_kopeks or 0 @@ -215,7 +224,7 @@ async def get_loyalty_tiers( # Get period discounts period_discounts = {} - raw_period_discounts = getattr(group, "period_discounts", None) + raw_period_discounts = getattr(group, 'period_discounts', None) if isinstance(raw_period_discounts, dict): for key, value in raw_period_discounts.items(): try: @@ -260,7 +269,7 @@ async def get_loyalty_tiers( ) -@router.post("/claim", response_model=ClaimOfferResponse) +@router.post('/claim', response_model=ClaimOfferResponse) async def claim_promo_offer( request: ClaimOfferRequest, user: User = Depends(get_current_cabinet_user), @@ -272,7 +281,7 @@ async def claim_promo_offer( if not offer or offer.user_id != user.id: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Offer not found", + detail='Offer not found', ) now = datetime.utcnow() @@ -280,7 +289,7 @@ async def claim_promo_offer( if offer.claimed_at is not None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This offer has already been claimed", + detail='This offer has already been claimed', ) if not offer.is_active or offer.expires_at <= now: @@ -288,14 +297,14 @@ async def claim_promo_offer( await db.commit() raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This offer has expired", + detail='This offer has expired', ) - effect_type = (offer.effect_type or "percent_discount").lower() + effect_type = (offer.effect_type or 'percent_discount').lower() # Handle test access offers - if effect_type == "test_access": - await db.refresh(user, ["subscription"]) + if effect_type == 'test_access': + await db.refresh(user, ['subscription']) success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( db, user, @@ -304,29 +313,29 @@ async def claim_promo_offer( if not success: error_messages = { - "subscription_missing": "Active subscription required for this offer", - "squads_missing": "Could not determine servers for test access", - "already_connected": "These servers are already connected", - "remnawave_sync_failed": "Failed to connect servers. Please try again later", + 'subscription_missing': 'Active subscription required for this offer', + 'squads_missing': 'Could not determine servers for test access', + 'already_connected': 'These servers are already connected', + 'remnawave_sync_failed': 'Failed to connect servers. Please try again later', } raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=error_messages.get(error_code, "Failed to activate offer"), + detail=error_messages.get(error_code, 'Failed to activate offer'), ) await mark_offer_claimed( db, offer, details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": expires_at.isoformat() if expires_at else None, + 'context': 'test_access_claim', + 'new_squads': newly_added, + 'expires_at': expires_at.isoformat() if expires_at else None, }, ) return ClaimOfferResponse( success=True, - message=f"Test access activated until {expires_at.strftime('%Y-%m-%d %H:%M') if expires_at else 'unlimited'}", + message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}', expires_at=expires_at, ) @@ -335,7 +344,7 @@ async def claim_promo_offer( if discount_percent <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid offer", + detail='Invalid offer', ) user.promo_offer_discount_percent = discount_percent @@ -344,10 +353,10 @@ async def claim_promo_offer( # Calculate expiration extra_data = offer.extra_data or {} - raw_duration = extra_data.get("active_discount_hours") - template_id = extra_data.get("template_id") + raw_duration = extra_data.get('active_discount_hours') + template_id = extra_data.get('template_id') - if raw_duration in (None, "") and template_id: + if raw_duration in (None, '') and template_id: try: template = await get_promo_offer_template_by_id(db, int(template_id)) except (ValueError, TypeError): @@ -371,26 +380,26 @@ async def claim_promo_offer( db, offer, details={ - "context": "discount_claim", - "discount_percent": discount_percent, - "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None, + 'context': 'discount_claim', + 'discount_percent': discount_percent, + 'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None, }, ) await db.refresh(user) - expires_text = "" + expires_text = '' if discount_expires_at: - expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}" + expires_text = f' Valid until {discount_expires_at.strftime("%Y-%m-%d %H:%M")}' return ClaimOfferResponse( success=True, - message=f"Discount of {discount_percent}% activated!{expires_text}", + message=f'Discount of {discount_percent}% activated!{expires_text}', discount_percent=discount_percent, expires_at=discount_expires_at, ) -@router.delete("/active-discount") +@router.delete('/active-discount') async def clear_active_discount( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -403,4 +412,4 @@ async def clear_active_discount( await db.commit() - return {"message": "Active discount cleared"} + return {'message': 'Active discount cleared'} diff --git a/app/cabinet/routes/promocode.py b/app/cabinet/routes/promocode.py index 1562663c..de578428 100644 --- a/app/cabinet/routes/promocode.py +++ b/app/cabinet/routes/promocode.py @@ -1,7 +1,6 @@ """Promo code routes for cabinet.""" import logging -from typing import Dict, Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field @@ -12,18 +11,21 @@ from app.services.promocode_service import PromoCodeService from ..dependencies import get_cabinet_db, get_current_cabinet_user + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/promocode", tags=["Cabinet Promocode"]) +router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode']) class PromocodeActivateRequest(BaseModel): """Request to activate a promo code.""" - code: str = Field(..., min_length=1, max_length=50, description="Promo code to activate") + + code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate') class PromocodeActivateResponse(BaseModel): """Response after activating a promo code.""" + success: bool message: str balance_before: float = 0 @@ -31,7 +33,7 @@ class PromocodeActivateResponse(BaseModel): bonus_description: str | None = None -@router.post("/activate", response_model=PromocodeActivateResponse) +@router.post('/activate', response_model=PromocodeActivateResponse) async def activate_promocode( request: PromocodeActivateRequest, user: User = Depends(get_current_cabinet_user), @@ -40,36 +42,32 @@ async def activate_promocode( """Activate a promo code for the current user.""" promocode_service = PromoCodeService() - result = await promocode_service.activate_promocode( - db=db, - user_id=user.id, - code=request.code.strip() - ) + result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip()) - if result["success"]: - balance_before_rubles = result.get("balance_before_kopeks", 0) / 100 - balance_after_rubles = result.get("balance_after_kopeks", 0) / 100 + if result['success']: + balance_before_rubles = result.get('balance_before_kopeks', 0) / 100 + balance_after_rubles = result.get('balance_after_kopeks', 0) / 100 return PromocodeActivateResponse( success=True, - message="Promo code activated successfully", + message='Promo code activated successfully', balance_before=balance_before_rubles, balance_after=balance_after_rubles, - bonus_description=result.get("description"), + bonus_description=result.get('description'), ) # Map error codes to messages error_messages = { - "not_found": "Promo code not found", - "expired": "Promo code has expired", - "used": "Promo code has been fully used", - "already_used_by_user": "You have already used this promo code", - "user_not_found": "User not found", - "server_error": "Server error occurred", + 'not_found': 'Promo code not found', + 'expired': 'Promo code has expired', + 'used': 'Promo code has been fully used', + 'already_used_by_user': 'You have already used this promo code', + 'user_not_found': 'User not found', + 'server_error': 'Server error occurred', } - error_code = result.get("error", "server_error") - error_message = error_messages.get(error_code, "Failed to activate promo code") + error_code = result.get('error', 'server_error') + error_message = error_messages.get(error_code, 'Failed to activate promo code') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/app/cabinet/routes/referral.py b/app/cabinet/routes/referral.py index 872932a7..43ef4c4e 100644 --- a/app/cabinet/routes/referral.py +++ b/app/cabinet/routes/referral.py @@ -2,32 +2,32 @@ import logging import math -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, Query +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, desc from sqlalchemy.orm import selectinload -from app.database.models import User, ReferralEarning from app.config import settings +from app.database.models import ReferralEarning, User from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.referral import ( + ReferralEarningResponse, + ReferralEarningsListResponse, ReferralInfoResponse, ReferralItemResponse, ReferralListResponse, - ReferralEarningResponse, - ReferralEarningsListResponse, ReferralTermsResponse, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/referral", tags=["Cabinet Referral"]) +router = APIRouter(prefix='/referral', tags=['Cabinet Referral']) -@router.get("", response_model=ReferralInfoResponse) +@router.get('', response_model=ReferralInfoResponse) async def get_referral_info( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -49,9 +49,8 @@ async def get_referral_info( active_referrals = active_result.scalar() or 0 # Get total earnings - earnings_query = ( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.user_id == user.id) + earnings_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user.id ) earnings_result = await db.execute(earnings_query) total_earnings = earnings_result.scalar() or 0 @@ -62,11 +61,11 @@ async def get_referral_info( commission_percent = settings.REFERRAL_COMMISSION_PERCENT # Build referral link - bot_username = settings.get_bot_username() or "bot" - referral_link = f"https://t.me/{bot_username}?start={user.referral_code}" + bot_username = settings.get_bot_username() or 'bot' + referral_link = f'https://t.me/{bot_username}?start={user.referral_code}' return ReferralInfoResponse( - referral_code=user.referral_code or "", + referral_code=user.referral_code or '', referral_link=referral_link, total_referrals=total_referrals, active_referrals=active_referrals, @@ -76,10 +75,10 @@ async def get_referral_info( ) -@router.get("/list", response_model=ReferralListResponse) +@router.get('/list', response_model=ReferralListResponse) async def get_referral_list( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -122,10 +121,10 @@ async def get_referral_list( ) -@router.get("/earnings", response_model=ReferralEarningsListResponse) +@router.get('/earnings', response_model=ReferralEarningsListResponse) async def get_referral_earnings( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -138,9 +137,8 @@ async def get_referral_earnings( total_result = await db.execute(count_query) total = total_result.scalar() or 0 - sum_query = ( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.user_id == user.id) + sum_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user.id ) sum_result = await db.execute(sum_query) total_amount = sum_result.scalar() or 0 @@ -159,15 +157,17 @@ async def get_referral_earnings( referral_result = await db.execute(referral_query) referral_user = referral_result.scalar_one_or_none() - items.append(ReferralEarningResponse( - id=e.id, - amount_kopeks=e.amount_kopeks, - amount_rubles=e.amount_kopeks / 100, - reason=e.reason or "Referral commission", - referral_username=referral_user.username if referral_user else None, - referral_first_name=referral_user.first_name if referral_user else None, - created_at=e.created_at, - )) + items.append( + ReferralEarningResponse( + id=e.id, + amount_kopeks=e.amount_kopeks, + amount_rubles=e.amount_kopeks / 100, + reason=e.reason or 'Referral commission', + referral_username=referral_user.username if referral_user else None, + referral_first_name=referral_user.first_name if referral_user else None, + created_at=e.created_at, + ) + ) pages = math.ceil(total / per_page) if total > 0 else 1 @@ -182,7 +182,7 @@ async def get_referral_earnings( ) -@router.get("/terms", response_model=ReferralTermsResponse) +@router.get('/terms', response_model=ReferralTermsResponse) async def get_referral_terms(): """Get referral program terms.""" return ReferralTermsResponse( diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index ff47bf68..27d0dd3d 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -4,79 +4,78 @@ import base64 import json import logging import re -from datetime import datetime, timedelta -from typing import List, Optional, Dict, Any +from datetime import UTC, datetime, timedelta +from typing import Any from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User, Subscription, ServerSquad, Tariff, TransactionType -from app.database.crud.subscription import ( - create_trial_subscription, - get_subscription_by_user_id, - create_paid_subscription, - extend_subscription, -) -from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id +from app.config import PERIOD_PRICES, settings from app.database.crud.server_squad import get_server_squad_by_uuid -from app.database.crud.user import subtract_user_balance +from app.database.crud.subscription import ( + create_paid_subscription, + create_trial_subscription, + extend_subscription, + get_subscription_by_user_id, +) +from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user from app.database.crud.transaction import create_transaction -from sqlalchemy import select -from app.config import settings, PERIOD_PRICES -from app.utils.pricing_utils import format_period_description -from app.services.subscription_service import SubscriptionService -from app.services.system_settings_service import bot_configuration_service +from app.database.crud.user import subtract_user_balance +from app.database.models import ServerSquad, Subscription, Tariff, TransactionType, User from app.services.remnawave_service import RemnaWaveService from app.services.subscription_purchase_service import ( MiniAppSubscriptionPurchaseService, - PurchaseValidationError, PurchaseBalanceError, + PurchaseValidationError, ) +from app.services.subscription_service import SubscriptionService +from app.services.system_settings_service import bot_configuration_service from app.services.user_cart_service import user_cart_service -from app.utils.cache import cache, cache_key, RateLimitCache +from app.utils.cache import RateLimitCache, cache, cache_key +from app.utils.pricing_utils import format_period_description from app.utils.promo_offer import get_user_active_promo_discount_percent from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.subscription import ( - SubscriptionResponse, - ServerInfo, - TrafficPurchaseInfo, + AutopayUpdateRequest, + DevicePurchaseRequest, + PurchasePreviewRequest, RenewalOptionResponse, RenewalRequest, + ServerInfo, + SubscriptionResponse, + TariffPurchaseRequest, TrafficPackageResponse, TrafficPurchaseRequest, - DevicePurchaseRequest, - AutopayUpdateRequest, TrialInfoResponse, - PurchaseSelectionRequest, - PurchasePreviewRequest, - TariffPurchaseRequest, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/subscription", tags=["Cabinet Subscription"]) +router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription']) def _subscription_to_response( subscription: Subscription, - servers: Optional[List[ServerInfo]] = None, - tariff_name: Optional[str] = None, - traffic_purchases: Optional[List[Dict[str, Any]]] = None, + servers: list[ServerInfo] | None = None, + tariff_name: str | None = None, + traffic_purchases: list[dict[str, Any]] | None = None, ) -> SubscriptionResponse: """Convert Subscription model to response.""" now = datetime.utcnow() # Use actual_status property for correct status (same as bot uses) actual_status = subscription.actual_status - is_expired = actual_status == "expired" - is_active = actual_status in ("active", "trial") + is_expired = actual_status == 'expired' + is_active = actual_status in ('active', 'trial') # Calculate time remaining days_left = 0 hours_left = 0 minutes_left = 0 - time_left_display = "" + time_left_display = '' if subscription.end_date and not is_expired: time_delta = subscription.end_date - now @@ -89,15 +88,15 @@ def _subscription_to_response( # Create human-readable display if days_left > 0: - time_left_display = f"{days_left}d {hours_left}h" + time_left_display = f'{days_left}d {hours_left}h' elif hours_left > 0: - time_left_display = f"{hours_left}h {minutes_left}m" + time_left_display = f'{hours_left}h {minutes_left}m' elif minutes_left > 0: - time_left_display = f"{minutes_left}m" + time_left_display = f'{minutes_left}m' else: - time_left_display = "0m" + time_left_display = '0m' else: - time_left_display = "0m" + time_left_display = '0m' traffic_limit_gb = subscription.traffic_limit_gb or 0 traffic_used_gb = subscription.traffic_used_gb or 0.0 @@ -139,7 +138,7 @@ def _subscription_to_response( return SubscriptionResponse( id=subscription.id, status=actual_status, # Use actual_status instead of raw status - is_trial=subscription.is_trial or actual_status == "trial", + is_trial=subscription.is_trial or actual_status == 'trial', start_date=subscription.start_date, end_date=subscription.end_date, days_left=days_left, @@ -168,7 +167,7 @@ def _subscription_to_response( ) -@router.get("", response_model=SubscriptionResponse) +@router.get('', response_model=SubscriptionResponse) async def get_subscription( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -177,12 +176,13 @@ async def get_subscription( # Reload user from current session to get fresh data # (user object is from different session in get_current_cabinet_user) from app.database.crud.user import get_user_by_id + fresh_user = await get_user_by_id(db, user.id) if not fresh_user or not fresh_user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) # Load tariff for daily subscription check and tariff name @@ -194,20 +194,13 @@ async def get_subscription( tariff_name = tariff.name # Fetch server names for connected squads - servers: List[ServerInfo] = [] + servers: list[ServerInfo] = [] connected_squads = fresh_user.subscription.connected_squads or [] if connected_squads: - result = await db.execute( - select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)) - ) + result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads))) server_squads = result.scalars().all() servers = [ - ServerInfo( - uuid=sq.squad_uuid, - name=sq.display_name, - country_code=sq.country_code - ) - for sq in server_squads + ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads ] # Fetch traffic purchases (monthly packages) @@ -229,21 +222,25 @@ async def get_subscription( days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() elapsed_seconds = (now - purchase.created_at).total_seconds() - progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + progress_percent = min( + 100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0) + ) - traffic_purchases_data.append({ - "id": purchase.id, - "traffic_gb": purchase.traffic_gb, - "expires_at": purchase.expires_at, - "created_at": purchase.created_at, - "days_remaining": days_remaining, - "progress_percent": round(progress_percent, 1) - }) + traffic_purchases_data.append( + { + 'id': purchase.id, + 'traffic_gb': purchase.traffic_gb, + 'expires_at': purchase.expires_at, + 'created_at': purchase.created_at, + 'days_remaining': days_remaining, + 'progress_percent': round(progress_percent, 1), + } + ) return _subscription_to_response(fresh_user.subscription, servers, tariff_name, traffic_purchases_data) -@router.get("/renewal-options", response_model=List[RenewalOptionResponse]) +@router.get('/renewal-options', response_model=list[RenewalOptionResponse]) async def get_renewal_options( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -280,8 +277,8 @@ async def get_renewal_options( # Apply user's discount if any discount_percent = 0 - if hasattr(user, "get_promo_discount"): - discount_percent = user.get_promo_discount("period", period) + if hasattr(user, 'get_promo_discount'): + discount_percent = user.get_promo_discount('period', period) if discount_percent > 0: original_price = price_kopeks @@ -289,30 +286,32 @@ async def get_renewal_options( else: original_price = None - options.append(RenewalOptionResponse( - period_days=period, - price_kopeks=price_kopeks, - price_rubles=price_kopeks / 100, - discount_percent=discount_percent, - original_price_kopeks=original_price, - )) + options.append( + RenewalOptionResponse( + period_days=period, + price_kopeks=price_kopeks, + price_rubles=price_kopeks / 100, + discount_percent=discount_percent, + original_price_kopeks=original_price, + ) + ) return options -@router.post("/renew") +@router.post('/renew') async def renew_subscription( request: RenewalRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Renew subscription (pay from balance).""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) # В режиме тарифов берём цену из тарифа пользователя @@ -329,14 +328,14 @@ async def renew_subscription( if price_kopeks <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid renewal period", + detail='Invalid renewal period', ) # Apply promo group discount original_price_kopeks = price_kopeks promo_group_discount_percent = 0 - if hasattr(user, "get_promo_discount"): - promo_group_discount_percent = user.get_promo_discount("period", request.period_days) + if hasattr(user, 'get_promo_discount'): + promo_group_discount_percent = user.get_promo_discount('period', request.period_days) if promo_group_discount_percent > 0: price_kopeks = int(price_kopeks * (100 - promo_group_discount_percent) / 100) @@ -384,7 +383,8 @@ async def renew_subscription( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Продление подписки на {request.period_days} дней" + (f" ({tariff_name})" if tariff_name else ""), + 'description': f'Продление подписки на {request.period_days} дней' + + (f' ({tariff_name})' if tariff_name else ''), 'discount_percent': discount_percent, 'source': 'cabinet', } @@ -397,18 +397,18 @@ async def renew_subscription( try: await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f"Cart saved for auto-renewal (cabinet) user {user.id}") + logger.info(f'Cart saved for auto-renewal (cabinet) user {user.id}') except Exception as e: - logger.error(f"Error saving cart for auto-renewal (cabinet): {e}") + logger.error(f'Error saving cart for auto-renewal (cabinet): {e}') raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", - "missing_amount": missing, - "cart_saved": True, - "cart_mode": "extend", + 'code': 'insufficient_funds', + 'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}', + 'missing_amount': missing, + 'cart_saved': True, + 'cart_mode': 'extend', }, ) @@ -429,34 +429,34 @@ async def renew_subscription( user.subscription.end_date = now + timedelta(days=request.period_days) user.subscription.start_date = now - user.subscription.status = "active" + user.subscription.status = 'active' user.subscription.is_trial = False await db.commit() response = { - "message": "Subscription renewed successfully", - "new_end_date": user.subscription.end_date.isoformat(), - "amount_paid_kopeks": price_kopeks, + 'message': 'Subscription renewed successfully', + 'new_end_date': user.subscription.end_date.isoformat(), + 'amount_paid_kopeks': price_kopeks, } # Add discount info to response if promo_offer_discount_value > 0: - response["promo_discount_percent"] = promo_offer_discount_percent - response["promo_discount_amount_kopeks"] = promo_offer_discount_value - response["original_price_kopeks"] = original_price_kopeks + response['promo_discount_percent'] = promo_offer_discount_percent + response['promo_discount_amount_kopeks'] = promo_offer_discount_value + response['original_price_kopeks'] = original_price_kopeks return response -@router.get("/traffic-packages", response_model=List[TrafficPackageResponse]) +@router.get('/traffic-packages', response_model=list[TrafficPackageResponse]) async def get_traffic_packages( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get available traffic packages.""" - from app.database.crud.user import get_user_by_id from app.database.crud.tariff import get_tariff_by_id + from app.database.crud.user import get_user_by_id fresh_user = await get_user_by_id(db, user.id) if not fresh_user or not fresh_user.subscription: @@ -480,12 +480,14 @@ async def get_traffic_packages( result = [] for gb, price in packages.items(): - result.append(TrafficPackageResponse( - gb=gb, - price_kopeks=price, - price_rubles=price / 100, - is_unlimited=False, - )) + result.append( + TrafficPackageResponse( + gb=gb, + price_kopeks=price, + price_rubles=price / 100, + is_unlimited=False, + ) + ) return sorted(result, key=lambda x: x.gb) @@ -503,20 +505,22 @@ async def get_traffic_packages( result = [] for pkg in packages: - if not pkg.get("enabled", True): + if not pkg.get('enabled', True): continue - result.append(TrafficPackageResponse( - gb=pkg["gb"], - price_kopeks=pkg["price"], - price_rubles=pkg["price"] / 100, - is_unlimited=pkg["gb"] == 0, - )) + result.append( + TrafficPackageResponse( + gb=pkg['gb'], + price_kopeks=pkg['price'], + price_rubles=pkg['price'] / 100, + is_unlimited=pkg['gb'] == 0, + ) + ) return result -@router.post("/traffic") +@router.post('/traffic') async def purchase_traffic( request: TrafficPurchaseRequest, user: User = Depends(get_current_cabinet_user), @@ -527,12 +531,12 @@ async def purchase_traffic( from app.database.crud.tariff import get_tariff_by_id from app.utils.pricing_utils import calculate_prorated_price - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) subscription = user.subscription @@ -546,21 +550,21 @@ async def purchase_traffic( if not tariff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found", + detail='Tariff not found', ) # Проверяем, разрешена ли докупка if not getattr(tariff, 'traffic_topup_enabled', False): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Traffic top-up is disabled for this tariff", + detail='Traffic top-up is disabled for this tariff', ) # Проверяем безлимит if tariff.traffic_limit_gb == 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot add traffic to unlimited subscription", + detail='Cannot add traffic to unlimited subscription', ) # Проверяем лимит докупки @@ -572,7 +576,7 @@ async def purchase_traffic( available_gb = max(0, max_topup_limit - current_traffic) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB", + detail=f'Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB', ) # Получаем цену из тарифа @@ -580,7 +584,7 @@ async def purchase_traffic( if request.gb not in packages: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Traffic package {request.gb}GB is not available", + detail=f'Traffic package {request.gb}GB is not available', ) base_price_kopeks = packages[request.gb] @@ -589,7 +593,7 @@ async def purchase_traffic( if not settings.is_traffic_topup_enabled(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Traffic top-up feature is disabled", + detail='Traffic top-up feature is disabled', ) # Проверяем настройку тарифа (allow_traffic_topup) @@ -598,25 +602,26 @@ async def purchase_traffic( if tariff and not tariff.allow_traffic_topup: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Traffic top-up is not available for your tariff", + detail='Traffic top-up is not available for your tariff', ) # Получаем цену из глобальных настроек packages = settings.get_traffic_packages() - matching_pkg = next( - (pkg for pkg in packages if pkg["gb"] == request.gb and pkg.get("enabled", True)), - None - ) + matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None) if not matching_pkg: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid traffic package", + detail='Invalid traffic package', ) - base_price_kopeks = matching_pkg["price"] + base_price_kopeks = matching_pkg['price'] # Применяем скидку промогруппы traffic_discount_percent = 0 - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) if promo_group: apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) if apply_to_addons: @@ -635,21 +640,21 @@ async def purchase_traffic( if user.balance_kopeks < final_price: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", + detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB', ) # Формируем описание if traffic_discount_percent > 0: - traffic_description = f"Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)" + traffic_description = f'Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)' else: - traffic_description = f"Докупка {request.gb} ГБ трафика" + traffic_description = f'Докупка {request.gb} ГБ трафика' # Списываем баланс success = await subtract_user_balance(db, user, final_price, traffic_description) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to charge balance", + detail='Failed to charge balance', ) # Добавляем трафик @@ -663,20 +668,21 @@ async def purchase_traffic( # При повторной докупке дата НЕ продлевается if not subscription.traffic_reset_at: from datetime import timedelta + subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30) - logger.info(f"Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}") + logger.info(f'Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}') await db.commit() # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user(db, subscription) else: await subscription_service.create_remnawave_user(db, subscription) except Exception as e: - logger.error(f"Failed to sync traffic with RemnaWave: {e}") + logger.error(f'Failed to sync traffic with RemnaWave: {e}') # Создаём транзакцию await create_transaction( @@ -691,29 +697,29 @@ async def purchase_traffic( await db.refresh(subscription) return { - "success": True, - "message": "Traffic purchased successfully", - "gb_added": request.gb, - "new_traffic_limit_gb": subscription.traffic_limit_gb, - "amount_paid_kopeks": final_price, - "discount_percent": traffic_discount_percent, - "new_balance_kopeks": user.balance_kopeks, + 'success': True, + 'message': 'Traffic purchased successfully', + 'gb_added': request.gb, + 'new_traffic_limit_gb': subscription.traffic_limit_gb, + 'amount_paid_kopeks': final_price, + 'discount_percent': traffic_discount_percent, + 'new_balance_kopeks': user.balance_kopeks, } -@router.post("/devices") +@router.post('/devices') async def purchase_devices( request: DevicePurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Purchase additional device slots.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) price_per_device = settings.PRICE_PER_DEVICE @@ -723,7 +729,7 @@ async def purchase_devices( if user.balance_kopeks < total_price: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Insufficient balance", + detail='Insufficient balance', ) # Check max devices limit @@ -734,7 +740,7 @@ async def purchase_devices( if new_devices > max_devices: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Maximum device limit is {max_devices}", + detail=f'Maximum device limit is {max_devices}', ) # Deduct balance and add devices @@ -744,26 +750,26 @@ async def purchase_devices( await db.commit() return { - "message": "Devices added successfully", - "devices_added": request.devices, - "new_device_limit": new_devices, - "amount_paid_kopeks": total_price, + 'message': 'Devices added successfully', + 'devices_added': request.devices, + 'new_device_limit': new_devices, + 'amount_paid_kopeks': total_price, } -@router.patch("/autopay") +@router.patch('/autopay') async def update_autopay( request: AutopayUpdateRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Update autopay settings.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) user.subscription.autopay_enabled = request.enabled @@ -774,19 +780,19 @@ async def update_autopay( await db.commit() return { - "message": "Autopay settings updated", - "autopay_enabled": user.subscription.autopay_enabled, - "autopay_days_before": user.subscription.autopay_days_before, + 'message': 'Autopay settings updated', + 'autopay_enabled': user.subscription.autopay_enabled, + 'autopay_days_before': user.subscription.autopay_days_before, } -@router.get("/trial", response_model=TrialInfoResponse) +@router.get('/trial', response_model=TrialInfoResponse) async def get_trial_info( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get trial subscription info and availability.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) duration_days = settings.TRIAL_DURATION_DAYS traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB @@ -798,9 +804,7 @@ async def get_trial_info( if user.subscription: now = datetime.utcnow() is_active = ( - user.subscription.status == "active" - and user.subscription.end_date - and user.subscription.end_date > now + user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now ) if is_active: return TrialInfoResponse( @@ -811,7 +815,7 @@ async def get_trial_info( requires_payment=requires_payment, price_kopeks=price_kopeks, price_rubles=price_kopeks / 100, - reason_unavailable="You already have an active subscription", + reason_unavailable='You already have an active subscription', ) # Check if user already used trial @@ -824,7 +828,7 @@ async def get_trial_info( requires_payment=requires_payment, price_kopeks=price_kopeks, price_rubles=price_kopeks / 100, - reason_unavailable="Trial already used", + reason_unavailable='Trial already used', ) return TrialInfoResponse( @@ -838,33 +842,31 @@ async def get_trial_info( ) -@router.post("/trial", response_model=SubscriptionResponse) +@router.post('/trial', response_model=SubscriptionResponse) async def activate_trial( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Activate trial subscription.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) # Check if user already has an active subscription if user.subscription: now = datetime.utcnow() is_active = ( - user.subscription.status == "active" - and user.subscription.end_date - and user.subscription.end_date > now + user.subscription.status == 'active' and user.subscription.end_date and user.subscription.end_date > now ) if is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="You already have an active subscription", + detail='You already have an active subscription', ) # Check if user already used trial if user.subscription.is_trial or user.has_had_paid_subscription: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Trial already used", + detail='Trial already used', ) # Check if trial requires payment @@ -874,10 +876,10 @@ async def activate_trial( if user.balance_kopeks < price_kopeks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Insufficient balance. Need {price_kopeks / 100:.2f} RUB", + detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB', ) user.balance_kopeks -= price_kopeks - logger.info(f"User {user.id} paid {price_kopeks} kopeks for trial activation") + logger.info(f'User {user.id} paid {price_kopeks} kopeks for trial activation') # Get trial parameters from tariff if configured (same logic as bot handler) trial_duration = settings.TRIAL_DURATION_DAYS @@ -890,6 +892,7 @@ async def activate_trial( if trial_tariff_id: try: from app.database.crud.tariff import get_tariff_by_id + trial_tariff = await get_tariff_by_id(db, trial_tariff_id) if trial_tariff: trial_traffic_limit = trial_tariff.traffic_limit_gb @@ -899,9 +902,11 @@ async def activate_trial( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f"Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}") + logger.info( + f'Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}' + ) except Exception as e: - logger.error(f"Error getting trial tariff: {e}") + logger.error(f'Error getting trial tariff: {e}') # Create trial subscription subscription = await create_trial_subscription( @@ -914,7 +919,7 @@ async def activate_trial( tariff_id=tariff_id_for_trial, ) - logger.info(f"Trial subscription activated for user {user.id}") + logger.info(f'Trial subscription activated for user {user.id}') # Create RemnaWave user try: @@ -923,11 +928,12 @@ async def activate_trial( await subscription_service.create_remnawave_user(db, subscription) await db.refresh(subscription) except Exception as e: - logger.error(f"Failed to create RemnaWave user for trial: {e}") + logger.error(f'Failed to create RemnaWave user for trial: {e}') # Send admin notification about trial activation try: from aiogram import Bot + from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: @@ -941,7 +947,7 @@ async def activate_trial( finally: await bot.session.close() except Exception as e: - logger.error(f"Failed to send trial activation notification: {e}") + logger.error(f'Failed to send trial activation notification: {e}') return _subscription_to_response(subscription) @@ -954,10 +960,10 @@ purchase_service = MiniAppSubscriptionPurchaseService() async def _build_tariff_response( db: AsyncSession, tariff: Tariff, - current_tariff_id: Optional[int] = None, - language: str = "ru", - user: Optional[User] = None, -) -> Dict[str, Any]: + current_tariff_id: int | None = None, + language: str = 'ru', + user: User | None = None, +) -> dict[str, Any]: """Build tariff model for API response with promo group discounts applied.""" servers = [] servers_count = 0 @@ -967,10 +973,12 @@ async def _build_tariff_response( for squad_uuid in tariff.allowed_squads[:5]: # Limit for preview server = await get_server_squad_by_uuid(db, squad_uuid) if server: - servers.append({ - "uuid": squad_uuid, - "name": server.display_name or squad_uuid[:8], - }) + servers.append( + { + 'uuid': squad_uuid, + 'name': server.display_name or squad_uuid[:8], + } + ) # Get promo group for discount calculation promo_group = user.get_primary_promo_group() if user and hasattr(user, 'get_primary_promo_group') else None @@ -990,7 +998,7 @@ async def _build_tariff_response( final_price = original_price if promo_group: - discount_percent = promo_group.get_discount_percent("period", period_days) + discount_percent = promo_group.get_discount_percent('period', period_days) if discount_percent > 0: discount_amount = original_price * discount_percent // 100 final_price = original_price - discount_amount @@ -1000,28 +1008,28 @@ async def _build_tariff_response( original_per_month = original_price // months if months > 0 else original_price period_data = { - "days": period_days, - "months": months, - "label": format_period_description(period_days, language), - "price_kopeks": final_price, - "price_label": settings.format_price(final_price), - "price_per_month_kopeks": per_month, - "price_per_month_label": settings.format_price(per_month), + 'days': period_days, + 'months': months, + 'label': format_period_description(period_days, language), + 'price_kopeks': final_price, + 'price_label': settings.format_price(final_price), + 'price_per_month_kopeks': per_month, + 'price_per_month_label': settings.format_price(per_month), } # Add discount info if discount is applied if discount_percent > 0: - period_data["original_price_kopeks"] = original_price - period_data["original_price_label"] = settings.format_price(original_price) - period_data["original_per_month_kopeks"] = original_per_month - period_data["original_per_month_label"] = settings.format_price(original_per_month) - period_data["discount_percent"] = discount_percent - period_data["discount_amount_kopeks"] = discount_amount - period_data["discount_label"] = f"-{discount_percent}%" + period_data['original_price_kopeks'] = original_price + period_data['original_price_label'] = settings.format_price(original_price) + period_data['original_per_month_kopeks'] = original_per_month + period_data['original_per_month_label'] = settings.format_price(original_per_month) + period_data['discount_percent'] = discount_percent + period_data['discount_amount_kopeks'] = discount_amount + period_data['discount_label'] = f'-{discount_percent}%' periods.append(period_data) - traffic_label = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + traffic_label = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' # Apply discount to daily price if applicable daily_price = getattr(tariff, 'daily_price_kopeks', 0) @@ -1029,7 +1037,7 @@ async def _build_tariff_response( daily_discount_percent = 0 if promo_group and daily_price > 0: # For daily tariffs, use period discount with period_days=1 - daily_discount_percent = promo_group.get_discount_percent("period", 1) + daily_discount_percent = promo_group.get_discount_percent('period', 1) if daily_discount_percent > 0: discount_amount = daily_price * daily_discount_percent // 100 daily_price = daily_price - discount_amount @@ -1039,7 +1047,7 @@ async def _build_tariff_response( original_price_per_day = price_per_day custom_days_discount_percent = 0 if promo_group and price_per_day > 0: - custom_days_discount_percent = promo_group.get_discount_percent("period", 30) # Use 30-day rate as base + custom_days_discount_percent = promo_group.get_discount_percent('period', 30) # Use 30-day rate as base if custom_days_discount_percent > 0: discount_amount = price_per_day * custom_days_discount_percent // 100 price_per_day = price_per_day - discount_amount @@ -1049,83 +1057,85 @@ async def _build_tariff_response( original_device_price = device_price device_discount_percent = 0 if promo_group and device_price > 0: - device_discount_percent = promo_group.get_discount_percent("devices") + device_discount_percent = promo_group.get_discount_percent('devices') if device_discount_percent > 0: discount_amount = device_price * device_discount_percent // 100 device_price = device_price - discount_amount response = { - "id": tariff.id, - "name": tariff.name, - "description": tariff.description, - "tier_level": tariff.tier_level, - "traffic_limit_gb": tariff.traffic_limit_gb, - "traffic_limit_label": traffic_label, - "is_unlimited_traffic": tariff.traffic_limit_gb == 0, - "device_limit": tariff.device_limit, - "device_price_kopeks": device_price, - "servers_count": servers_count, - "servers": servers, - "periods": periods, - "is_current": current_tariff_id == tariff.id if current_tariff_id else False, - "is_available": tariff.is_active, + 'id': tariff.id, + 'name': tariff.name, + 'description': tariff.description, + 'tier_level': tariff.tier_level, + 'traffic_limit_gb': tariff.traffic_limit_gb, + 'traffic_limit_label': traffic_label, + 'is_unlimited_traffic': tariff.traffic_limit_gb == 0, + 'device_limit': tariff.device_limit, + 'device_price_kopeks': device_price, + 'servers_count': servers_count, + 'servers': servers, + 'periods': periods, + 'is_current': current_tariff_id == tariff.id if current_tariff_id else False, + 'is_available': tariff.is_active, # Произвольное количество дней - "custom_days_enabled": tariff.custom_days_enabled, - "price_per_day_kopeks": price_per_day, - "min_days": tariff.min_days, - "max_days": tariff.max_days, + 'custom_days_enabled': tariff.custom_days_enabled, + 'price_per_day_kopeks': price_per_day, + 'min_days': tariff.min_days, + 'max_days': tariff.max_days, # Произвольный трафик при покупке - "custom_traffic_enabled": tariff.custom_traffic_enabled, - "traffic_price_per_gb_kopeks": tariff.traffic_price_per_gb_kopeks, - "min_traffic_gb": tariff.min_traffic_gb, - "max_traffic_gb": tariff.max_traffic_gb, + 'custom_traffic_enabled': tariff.custom_traffic_enabled, + 'traffic_price_per_gb_kopeks': tariff.traffic_price_per_gb_kopeks, + 'min_traffic_gb': tariff.min_traffic_gb, + 'max_traffic_gb': tariff.max_traffic_gb, # Докупка трафика - "traffic_topup_enabled": tariff.traffic_topup_enabled, - "traffic_topup_packages": tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}, - "max_topup_traffic_gb": tariff.max_topup_traffic_gb, + 'traffic_topup_enabled': tariff.traffic_topup_enabled, + 'traffic_topup_packages': tariff.get_traffic_topup_packages() + if hasattr(tariff, 'get_traffic_topup_packages') + else {}, + 'max_topup_traffic_gb': tariff.max_topup_traffic_gb, # Дневной тариф - "is_daily": getattr(tariff, 'is_daily', False), - "daily_price_kopeks": daily_price, + 'is_daily': getattr(tariff, 'is_daily', False), + 'daily_price_kopeks': daily_price, } # Add promo group info if user has discounts if promo_group_name: - response["promo_group_name"] = promo_group_name + response['promo_group_name'] = promo_group_name # Add original prices if discounts were applied if device_discount_percent > 0: - response["original_device_price_kopeks"] = original_device_price - response["device_discount_percent"] = device_discount_percent + response['original_device_price_kopeks'] = original_device_price + response['device_discount_percent'] = device_discount_percent if daily_discount_percent > 0 and original_daily_price > 0: - response["original_daily_price_kopeks"] = original_daily_price - response["daily_discount_percent"] = daily_discount_percent + response['original_daily_price_kopeks'] = original_daily_price + response['daily_discount_percent'] = daily_discount_percent if custom_days_discount_percent > 0 and original_price_per_day > 0: - response["original_price_per_day_kopeks"] = original_price_per_day - response["custom_days_discount_percent"] = custom_days_discount_percent + response['original_price_per_day_kopeks'] = original_price_per_day + response['custom_days_discount_percent'] = custom_days_discount_percent return response -@router.get("/purchase-options") +@router.get('/purchase-options') async def get_purchase_options( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get all subscription purchase options (periods, servers, traffic, devices).""" try: - sales_mode = settings.get_sales_mode() + settings.get_sales_mode() # Tariffs mode - return list of tariffs if settings.is_tariffs_mode(): - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) promo_group_id = promo_group.id if promo_group else None tariffs = await get_tariffs_for_user(db, promo_group_id) subscription = await get_subscription_by_user_id(db, user.id) current_tariff_id = subscription.tariff_id if subscription else None - language = getattr(user, "language", "ru") or "ru" + language = getattr(user, 'language', 'ru') or 'ru' tariff_responses = [] for tariff in tariffs: @@ -1133,17 +1143,17 @@ async def get_purchase_options( tariff_responses.append(tariff_data) return { - "sales_mode": "tariffs", - "tariffs": tariff_responses, - "current_tariff_id": current_tariff_id, - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'sales_mode': 'tariffs', + 'tariffs': tariff_responses, + 'current_tariff_id': current_tariff_id, + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } # Classic mode - return periods context = await purchase_service.build_options(db, user) payload = context.payload - payload["sales_mode"] = "classic" + payload['sales_mode'] = 'classic' return payload except PurchaseValidationError as e: @@ -1152,25 +1162,25 @@ async def get_purchase_options( detail=str(e), ) except Exception as e: - logger.error(f"Failed to build purchase options for user {user.id}: {e}") + logger.error(f'Failed to build purchase options for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to load purchase options", + detail='Failed to load purchase options', ) -@router.post("/purchase-preview") +@router.post('/purchase-preview') async def preview_purchase( request: PurchasePreviewRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Calculate and preview the total price for selected options (classic mode only).""" # This endpoint is for classic mode only, tariffs mode uses /purchase-tariff if settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.", + detail='This endpoint is not available in tariffs mode. Use /purchase-tariff instead.', ) try: @@ -1178,11 +1188,11 @@ async def preview_purchase( # Convert request to dict for parsing selection_dict = { - "period_id": request.selection.period_id, - "period_days": request.selection.period_days, - "traffic_value": request.selection.traffic_value, - "servers": request.selection.servers, - "devices": request.selection.devices, + 'period_id': request.selection.period_id, + 'period_days': request.selection.period_days, + 'traffic_value': request.selection.traffic_value, + 'servers': request.selection.servers, + 'devices': request.selection.devices, } selection = purchase_service.parse_selection(context, selection_dict) @@ -1197,25 +1207,25 @@ async def preview_purchase( detail=str(e), ) except Exception as e: - logger.error(f"Failed to calculate purchase preview for user {user.id}: {e}") + logger.error(f'Failed to calculate purchase preview for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to calculate price", + detail='Failed to calculate price', ) -@router.post("/purchase") +@router.post('/purchase') async def submit_purchase( request: PurchasePreviewRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Submit subscription purchase (deduct from balance, classic mode only).""" # This endpoint is for classic mode only, tariffs mode uses /purchase-tariff if settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This endpoint is not available in tariffs mode. Use /purchase-tariff instead.", + detail='This endpoint is not available in tariffs mode. Use /purchase-tariff instead.', ) try: @@ -1223,24 +1233,24 @@ async def submit_purchase( # Convert request to dict for parsing selection_dict = { - "period_id": request.selection.period_id, - "period_days": request.selection.period_days, - "traffic_value": request.selection.traffic_value, - "servers": request.selection.servers, - "devices": request.selection.devices, + 'period_id': request.selection.period_id, + 'period_days': request.selection.period_days, + 'traffic_value': request.selection.traffic_value, + 'servers': request.selection.servers, + 'devices': request.selection.devices, } selection = purchase_service.parse_selection(context, selection_dict) pricing = await purchase_service.calculate_pricing(db, context, selection) result = await purchase_service.submit_purchase(db, context, pricing) - subscription = result["subscription"] + subscription = result['subscription'] return { - "success": True, - "message": result["message"], - "subscription": _subscription_to_response(subscription), - "was_trial_conversion": result.get("was_trial_conversion", False), + 'success': True, + 'message': result['message'], + 'subscription': _subscription_to_response(subscription), + 'was_trial_conversion': result.get('was_trial_conversion', False), } except PurchaseValidationError as e: @@ -1266,42 +1276,43 @@ async def submit_purchase( 'source': 'cabinet', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f"Cart saved for auto-purchase (cabinet /purchase) user {user.id}") + logger.info(f'Cart saved for auto-purchase (cabinet /purchase) user {user.id}') except Exception as cart_error: - logger.error(f"Error saving cart for auto-purchase (cabinet /purchase): {cart_error}") + logger.error(f'Error saving cart for auto-purchase (cabinet /purchase): {cart_error}') raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": str(e), - "cart_saved": True, - "cart_mode": "subscription_purchase", + 'code': 'insufficient_funds', + 'message': str(e), + 'cart_saved': True, + 'cart_mode': 'subscription_purchase', }, ) except Exception as e: - logger.error(f"Failed to submit purchase for user {user.id}: {e}") + logger.error(f'Failed to submit purchase for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to process purchase", + detail='Failed to process purchase', ) # ============ Tariff Purchase (for tariffs mode) ============ -@router.post("/purchase-tariff") + +@router.post('/purchase-tariff') async def purchase_tariff( request: TariffPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Purchase a tariff (for tariffs mode).""" try: # Check tariffs mode if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariffs mode is not enabled", + detail='Tariffs mode is not enabled', ) # Get tariff @@ -1309,7 +1320,7 @@ async def purchase_tariff( if not tariff or not tariff.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found or inactive", + detail='Tariff not found or inactive', ) # Check tariff availability for user's promo group and get promo group for discounts @@ -1318,7 +1329,7 @@ async def purchase_tariff( if not tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="This tariff is not available for your promo group", + detail='This tariff is not available for your promo group', ) # Handle daily tariffs specially @@ -1331,12 +1342,12 @@ async def purchase_tariff( if daily_price <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Daily tariff has invalid price", + detail='Daily tariff has invalid price', ) original_price = daily_price # Apply promo group discount for daily tariff if promo_group: - discount_percent = promo_group.get_discount_percent("period", 1) + discount_percent = promo_group.get_discount_percent('period', 1) if discount_percent > 0: discount_amount = daily_price * discount_percent // 100 daily_price = daily_price - discount_amount @@ -1354,18 +1365,18 @@ async def purchase_tariff( if price_kopeks is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Period must be between {tariff.min_days} and {tariff.max_days} days", + detail=f'Period must be between {tariff.min_days} and {tariff.max_days} days', ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid period for this tariff", + detail='Invalid period for this tariff', ) original_price = price_kopeks # Apply promo group discount for period if promo_group and price_kopeks > 0: - discount_percent = promo_group.get_discount_percent("period", period_days) + discount_percent = promo_group.get_discount_percent('period', period_days) if discount_percent > 0: discount_amount = price_kopeks * discount_percent // 100 price_kopeks = price_kopeks - discount_amount @@ -1379,11 +1390,11 @@ async def purchase_tariff( if traffic_price_kopeks is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB", + detail=f'Traffic must be between {tariff.min_traffic_gb} and {tariff.max_traffic_gb} GB', ) # Apply traffic discount if promo group has it if promo_group and traffic_price_kopeks > 0: - traffic_discount_percent = promo_group.get_discount_percent("traffic", period_days) + traffic_discount_percent = promo_group.get_discount_percent('traffic', period_days) if traffic_discount_percent > 0: traffic_discount = traffic_price_kopeks * traffic_discount_percent // 100 traffic_price_kopeks = traffic_price_kopeks - traffic_discount @@ -1414,7 +1425,7 @@ async def purchase_tariff( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Покупка суточного тарифа {tariff.name}", + 'description': f'Покупка суточного тарифа {tariff.name}', 'traffic_limit_gb': tariff.traffic_limit_gb, 'device_limit': tariff.device_limit, 'allowed_squads': tariff.allowed_squads or [], @@ -1430,7 +1441,7 @@ async def purchase_tariff( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Покупка тарифа {tariff.name} на {period_days} дней", + 'description': f'Покупка тарифа {tariff.name} на {period_days} дней', 'traffic_limit_gb': traffic_limit_gb, 'device_limit': tariff.device_limit, 'allowed_squads': tariff.allowed_squads or [], @@ -1440,18 +1451,18 @@ async def purchase_tariff( try: await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f"Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}") + logger.info(f'Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}') except Exception as e: - logger.error(f"Error saving cart for auto-purchase (cabinet): {e}") + logger.error(f'Error saving cart for auto-purchase (cabinet): {e}') raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", - "missing_amount": missing, - "cart_saved": True, - "cart_mode": cart_data['cart_mode'], + 'code': 'insufficient_funds', + 'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}', + 'missing_amount': missing, + 'cart_saved': True, + 'cart_mode': cart_data['cart_mode'], }, ) @@ -1463,6 +1474,7 @@ async def purchase_tariff( # If allowed_squads is empty, it means "all servers" if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -1472,14 +1484,14 @@ async def purchase_tariff( else: description = f"Покупка тарифа '{tariff.name}' на {period_days} дней" if discount_percent > 0: - description += f" (скидка {discount_percent}%)" + description += f' (скидка {discount_percent}%)' if promo_offer_discount_value > 0: - description += f" (промо -{promo_offer_discount_percent}%)" + description += f' (промо -{promo_offer_discount_percent}%)' success = await subtract_user_balance(db, user, price_kopeks, description) if not success: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to charge balance", + detail='Failed to charge balance', ) # Consume promo offer discount if it was used @@ -1531,86 +1543,88 @@ async def purchase_tariff( # При покупке тарифа ВСЕГДА сбрасываем трафик в панели service = SubscriptionService() try: - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await service.update_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа (cabinet)", + reset_reason='покупка тарифа (cabinet)', ) else: await service.create_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа (cabinet)", + reset_reason='покупка тарифа (cabinet)', ) except Exception as remnawave_error: - logger.error(f"Failed to sync subscription with RemnaWave: {remnawave_error}") + logger.error(f'Failed to sync subscription with RemnaWave: {remnawave_error}') # Save cart for auto-renewal (not for daily tariffs - they have their own charging) if not is_daily_tariff: try: from app.services.user_cart_service import user_cart_service + cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": period_days, - "total_price": price_kopeks, - "tariff_id": tariff.id, - "description": f"Продление тарифа {tariff.name} на {period_days} дней", + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': period_days, + 'total_price': price_kopeks, + 'tariff_id': tariff.id, + 'description': f'Продление тарифа {tariff.name} на {period_days} дней', } await user_cart_service.save_user_cart(user.id, cart_data) - logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.id}") + logger.info(f'Tariff cart saved for auto-renewal (cabinet) user {user.id}') except Exception as e: - logger.error(f"Error saving tariff cart (cabinet): {e}") + logger.error(f'Error saving tariff cart (cabinet): {e}') await db.refresh(user) response = { - "success": True, - "message": f"Тариф '{tariff.name}' успешно активирован", - "subscription": _subscription_to_response(subscription), - "tariff_id": tariff.id, - "tariff_name": tariff.name, - "charged_amount": price_kopeks, - "charged_label": settings.format_price(price_kopeks), - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'success': True, + 'message': f"Тариф '{tariff.name}' успешно активирован", + 'subscription': _subscription_to_response(subscription), + 'tariff_id': tariff.id, + 'tariff_name': tariff.name, + 'charged_amount': price_kopeks, + 'charged_label': settings.format_price(price_kopeks), + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } # Add discount info if discount was applied if discount_percent > 0: - response["discount_percent"] = discount_percent - response["original_price_kopeks"] = original_price - response["original_price_label"] = settings.format_price(original_price) - response["discount_amount_kopeks"] = original_price - price_before_promo_offer - response["discount_label"] = settings.format_price(original_price - price_before_promo_offer) + response['discount_percent'] = discount_percent + response['original_price_kopeks'] = original_price + response['original_price_label'] = settings.format_price(original_price) + response['discount_amount_kopeks'] = original_price - price_before_promo_offer + response['discount_label'] = settings.format_price(original_price - price_before_promo_offer) if promo_group: - response["promo_group_name"] = promo_group.name + response['promo_group_name'] = promo_group.name # Add promo offer discount info if it was applied if promo_offer_discount_value > 0: - response["promo_offer_discount_percent"] = promo_offer_discount_percent - response["promo_offer_discount_amount_kopeks"] = promo_offer_discount_value - response["promo_offer_discount_label"] = settings.format_price(promo_offer_discount_value) - response["price_before_promo_offer_kopeks"] = price_before_promo_offer + response['promo_offer_discount_percent'] = promo_offer_discount_percent + response['promo_offer_discount_amount_kopeks'] = promo_offer_discount_value + response['promo_offer_discount_label'] = settings.format_price(promo_offer_discount_value) + response['price_before_promo_offer_kopeks'] = price_before_promo_offer return response except HTTPException: raise except Exception as e: - logger.error(f"Failed to purchase tariff for user {user.id}: {e}") + logger.error(f'Failed to purchase tariff for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to process tariff purchase", + detail='Failed to process tariff purchase', ) # ============ Device Purchase ============ -@router.post("/devices/purchase") + +@router.post('/devices/purchase') async def purchase_devices( request: DevicePurchaseRequest, user: User = Depends(get_current_cabinet_user), @@ -1618,25 +1632,26 @@ async def purchase_devices( ): """Purchase additional device slots for subscription.""" try: - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) subscription = user.subscription if not subscription: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="У вас нет активной подписки", + detail='У вас нет активной подписки', ) if subscription.status not in ['active', 'trial']: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ваша подписка неактивна", + detail='Ваша подписка неактивна', ) # Get tariff for device price (if exists) tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) # Determine device price and max limit from tariff or settings @@ -1651,7 +1666,7 @@ async def purchase_devices( if not device_price or device_price <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Докупка устройств недоступна", + detail='Докупка устройств недоступна', ) # Check max device limit @@ -1660,15 +1675,16 @@ async def purchase_devices( if max_device_limit and new_device_count > max_device_limit: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Максимальное количество устройств: {max_device_limit}", + detail=f'Максимальное количество устройств: {max_device_limit}', ) # Calculate prorated price based on remaining days - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + + now = datetime.now(UTC) end_date = subscription.end_date if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) + end_date = end_date.replace(tzinfo=UTC) days_left = max(1, (end_date - now).days) total_days = 30 # Base period for device price calculation @@ -1683,20 +1699,21 @@ async def purchase_devices( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "error": "Insufficient balance", - "required_kopeks": price_kopeks, - "current_kopeks": user.balance_kopeks, - "missing_kopeks": missing, + 'error': 'Insufficient balance', + 'required_kopeks': price_kopeks, + 'current_kopeks': user.balance_kopeks, + 'missing_kopeks': missing, }, ) # Deduct balance from app.database.crud.user import subtract_user_balance + await subtract_user_balance( db=db, user=user, amount_kopeks=price_kopeks, - description=f"Покупка {request.devices} доп. устройств", + description=f'Покупка {request.devices} доп. устройств', ) # Increase device limit @@ -1707,59 +1724,58 @@ async def purchase_devices( # Sync with RemnaWave service = SubscriptionService() try: - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await service.update_remnawave_user(db, subscription) else: await service.create_remnawave_user(db, subscription) except Exception as e: - logger.error(f"Failed to sync devices with RemnaWave: {e}") + logger.error(f'Failed to sync devices with RemnaWave: {e}') await db.refresh(user) - logger.info( - f"User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks" - ) + logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks') return { - "success": True, - "message": f"Добавлено {request.devices} устройств", - "devices_added": request.devices, - "new_device_limit": subscription.device_limit, - "price_kopeks": price_kopeks, - "price_label": settings.format_price(price_kopeks), - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'success': True, + 'message': f'Добавлено {request.devices} устройств', + 'devices_added': request.devices, + 'new_device_limit': subscription.device_limit, + 'price_kopeks': price_kopeks, + 'price_label': settings.format_price(price_kopeks), + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } except HTTPException: raise except Exception as e: - logger.error(f"Failed to purchase devices for user {user.id}: {e}") + logger.error(f'Failed to purchase devices for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Не удалось обработать покупку устройств", + detail='Не удалось обработать покупку устройств', ) -@router.get("/devices/price") +@router.get('/devices/price') async def get_device_price( devices: int = 1, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Get price for additional devices.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) subscription = user.subscription if not subscription or subscription.status not in ['active', 'trial']: return { - "available": False, - "reason": "Нет активной подписки", + 'available': False, + 'reason': 'Нет активной подписки', } tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) # Determine device price and max limit from tariff or settings @@ -1773,8 +1789,8 @@ async def get_device_price( if not device_price or device_price <= 0: return { - "available": False, - "reason": "Докупка устройств недоступна", + 'available': False, + 'reason': 'Докупка устройств недоступна', } # Check max device limit @@ -1783,27 +1799,28 @@ async def get_device_price( if max_device_limit and current_devices >= max_device_limit: return { - "available": False, - "reason": f"Достигнут максимум устройств ({max_device_limit})", - "current_device_limit": current_devices, - "max_device_limit": max_device_limit, + 'available': False, + 'reason': f'Достигнут максимум устройств ({max_device_limit})', + 'current_device_limit': current_devices, + 'max_device_limit': max_device_limit, } if max_device_limit and current_devices + devices > max_device_limit: return { - "available": False, - "reason": f"Можно добавить максимум {can_add} устройств", - "current_device_limit": current_devices, - "max_device_limit": max_device_limit, - "can_add": can_add, + 'available': False, + 'reason': f'Можно добавить максимум {can_add} устройств', + 'current_device_limit': current_devices, + 'max_device_limit': max_device_limit, + 'can_add': can_add, } # Calculate prorated price - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + + now = datetime.now(UTC) end_date = subscription.end_date if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) + end_date = end_date.replace(tzinfo=UTC) days_left = max(1, (end_date - now).days) total_days = 30 @@ -1813,39 +1830,40 @@ async def get_device_price( total_price_kopeks = price_per_device_kopeks * devices return { - "available": True, - "devices": devices, - "price_per_device_kopeks": price_per_device_kopeks, - "price_per_device_label": settings.format_price(price_per_device_kopeks), - "total_price_kopeks": total_price_kopeks, - "total_price_label": settings.format_price(total_price_kopeks), - "current_device_limit": current_devices, - "max_device_limit": max_device_limit, - "can_add": can_add, - "days_left": days_left, - "base_device_price_kopeks": device_price, + 'available': True, + 'devices': devices, + 'price_per_device_kopeks': price_per_device_kopeks, + 'price_per_device_label': settings.format_price(price_per_device_kopeks), + 'total_price_kopeks': total_price_kopeks, + 'total_price_label': settings.format_price(total_price_kopeks), + 'current_device_limit': current_devices, + 'max_device_limit': max_device_limit, + 'can_add': can_add, + 'days_left': days_left, + 'base_device_price_kopeks': device_price, } # ============ App Config for Connection ============ -def _load_app_config_from_file() -> Dict[str, Any]: + +def _load_app_config_from_file() -> dict[str, Any]: """Load app-config.json file.""" try: config_path = settings.get_app_config_path() - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, encoding='utf-8') as f: data = json.load(f) if isinstance(data, dict): return data except Exception as e: - logger.error(f"Failed to load app-config.json: {e}") + logger.error(f'Failed to load app-config.json: {e}') return {} -def _get_remnawave_config_uuid() -> Optional[str]: +def _get_remnawave_config_uuid() -> str | None: """Get RemnaWave config UUID from system settings or env.""" try: - return bot_configuration_service.get_current_value("CABINET_REMNA_SUB_CONFIG") + return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG') except Exception: return settings.CABINET_REMNA_SUB_CONFIG @@ -1855,152 +1873,160 @@ def _is_subscription_link_template(url: str) -> bool: if not url: return False # RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc. - if url.startswith("{{") and url.endswith("}}"): + if url.startswith('{{') and url.endswith('}}'): return True # Also check for button type "subscriptionLink" indicator return False -def _convert_remnawave_block_to_step(block: Dict[str, Any], url_scheme: str = "") -> Dict[str, Any]: +def _convert_remnawave_block_to_step(block: dict[str, Any], url_scheme: str = '') -> dict[str, Any]: """Convert RemnaWave block format to cabinet step format.""" step = { - "description": block.get("description", {}), + 'description': block.get('description', {}), } - if block.get("title"): - step["title"] = block["title"] - if block.get("buttons"): + if block.get('title'): + step['title'] = block['title'] + if block.get('buttons'): buttons = [] - for btn in block["buttons"]: - btn_url = btn.get("url", "") or btn.get("link", "") - btn_type = btn.get("type", "") + for btn in block['buttons']: + btn_url = btn.get('url', '') or btn.get('link', '') + btn_type = btn.get('type', '') # Replace subscription link templates with {{deepLink}} placeholder # RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink" - if _is_subscription_link_template(btn_url) or btn_type == "subscriptionLink": - btn_url = "{{deepLink}}" - # Also check for urlScheme-based URLs - elif url_scheme and btn_url and ( - btn_url.startswith(url_scheme) or - btn_url.endswith("://") or - btn_url.endswith("://add/") or - ("://" in btn_url and not btn_url.startswith("http")) + if ( + _is_subscription_link_template(btn_url) + or btn_type == 'subscriptionLink' + or ( + url_scheme + and btn_url + and ( + btn_url.startswith(url_scheme) + or btn_url.endswith('://') + or btn_url.endswith('://add/') + or ('://' in btn_url and not btn_url.startswith('http')) + ) + ) ): - btn_url = "{{deepLink}}" + btn_url = '{{deepLink}}' - buttons.append({ - "buttonLink": btn_url, - "buttonText": btn.get("text", {}), - }) - step["buttons"] = buttons + buttons.append( + { + 'buttonLink': btn_url, + 'buttonText': btn.get('text', {}), + } + ) + step['buttons'] = buttons return step - -def _extract_scheme_from_buttons(buttons: List[Dict[str, Any]]) -> str: +def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> str: """Extract URL scheme from buttons list.""" for btn in buttons: if not isinstance(btn, dict): continue - link = btn.get("link", "") or btn.get("url", "") or btn.get("buttonLink", "") + link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '') if not link: continue # Check for subscription link placeholder (case-insensitive) link_upper = link.upper() - if "{{SUBSCRIPTION_LINK}}" in link_upper or "SUBSCRIPTION_LINK" in link_upper: + if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper: # Extract scheme: "prizrak-box://install-config?url={{SUBSCRIPTION_LINK}}" -> "prizrak-box://install-config?url=" scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE) - if scheme and "://" in scheme: + if scheme and '://' in scheme: return scheme # Also check for type="subscriptionLink" buttons with custom schemes - btn_type = btn.get("type", "") - if btn_type == "subscriptionLink" and "://" in link and not link.startswith("http"): + btn_type = btn.get('type', '') + if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'): # Extract base scheme from link like "prizrak-box://install-config?url=" - scheme = link.split("{{")[0] if "{{" in link else link - if scheme and "://" in scheme: + scheme = link.split('{{')[0] if '{{' in link else link + if scheme and '://' in scheme: return scheme - return "" + return '' -def _get_url_scheme_for_app(app: Dict[str, Any]) -> str: +def _get_url_scheme_for_app(app: dict[str, Any]) -> str: """Get URL scheme for app - from config, buttons, or fallback by name.""" # 1. Check urlScheme field - scheme = str(app.get("urlScheme", "")).strip() + scheme = str(app.get('urlScheme', '')).strip() if scheme: return scheme # 2. Extract from buttons in blocks (RemnaWave format) - blocks = app.get("blocks", []) + blocks = app.get('blocks', []) for block in blocks: if not isinstance(block, dict): continue - buttons = block.get("buttons", []) + buttons = block.get('buttons', []) scheme = _extract_scheme_from_buttons(buttons) if scheme: return scheme # 3. Check buttons directly in app (alternative structure) - direct_buttons = app.get("buttons", []) + direct_buttons = app.get('buttons', []) if direct_buttons: scheme = _extract_scheme_from_buttons(direct_buttons) if scheme: return scheme # 4. Check in step structures (cabinet format) - for step_key in ["installationStep", "addSubscriptionStep", "connectAndUseStep"]: + for step_key in ['installationStep', 'addSubscriptionStep', 'connectAndUseStep']: step = app.get(step_key, {}) if isinstance(step, dict): - step_buttons = step.get("buttons", []) + step_buttons = step.get('buttons', []) scheme = _extract_scheme_from_buttons(step_buttons) if scheme: return scheme # No scheme found - logger.debug(f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', " - f"has blocks: {bool(app.get('blocks'))}, " - f"has buttons: {bool(app.get('buttons'))}, " - f"has urlScheme: {bool(app.get('urlScheme'))}") - return "" + logger.debug( + f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', " + f'has blocks: {bool(app.get("blocks"))}, ' + f'has buttons: {bool(app.get("buttons"))}, ' + f'has urlScheme: {bool(app.get("urlScheme"))}' + ) + return '' -def _find_subscription_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: +def _find_subscription_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None: """Find block that contains subscriptionLink button.""" for block in blocks: if not isinstance(block, dict): continue - buttons = block.get("buttons", []) + buttons = block.get('buttons', []) for btn in buttons: if not isinstance(btn, dict): continue # Check for subscriptionLink type or {{SUBSCRIPTION_LINK}} in link - btn_type = btn.get("type", "") - link = btn.get("link", "") or btn.get("url", "") - if btn_type == "subscriptionLink" or (link and "SUBSCRIPTION_LINK" in link.upper()): + btn_type = btn.get('type', '') + link = btn.get('link', '') or btn.get('url', '') + if btn_type == 'subscriptionLink' or (link and 'SUBSCRIPTION_LINK' in link.upper()): return block return None -def _find_connect_block(blocks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: +def _find_connect_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None: """Find block that is about connection/usage (usually last or has specific keywords).""" # Look for block with "connect" or "use" in title for block in blocks: if not isinstance(block, dict): continue - title = block.get("title", {}) - title_en = title.get("en", "") if isinstance(title, dict) else "" + title = block.get('title', {}) + title_en = title.get('en', '') if isinstance(title, dict) else '' title_lower = title_en.lower() - if "connect" in title_lower or "use" in title_lower: + if 'connect' in title_lower or 'use' in title_lower: return block # Fallback to last block if no match return blocks[-1] if blocks else None -def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]: +def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]: """Convert RemnaWave app format to cabinet app format.""" - blocks = app.get("blocks", []) + blocks = app.get('blocks', []) url_scheme = _get_url_scheme_for_app(app) # Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально) - app_name = app.get("name", "unknown") + app_name = app.get('name', 'unknown') if url_scheme: logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'") @@ -2013,55 +2039,59 @@ def _convert_remnawave_app_to_cabinet(app: Dict[str, Any]) -> Dict[str, Any]: connect_block = _find_connect_block(blocks) # Convert blocks to steps - installation_step = _convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {"description": {}} - subscription_step = _convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {"description": {}} - connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {"description": {}} + installation_step = ( + _convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {'description': {}} + ) + subscription_step = ( + _convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {'description': {}} + ) + connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {'description': {}} # Ensure subscription step has a deepLink button if urlScheme exists if url_scheme: has_deeplink_button = False - if "buttons" in subscription_step: - for btn in subscription_step["buttons"]: - if btn.get("buttonLink") == "{{deepLink}}": + if 'buttons' in subscription_step: + for btn in subscription_step['buttons']: + if btn.get('buttonLink') == '{{deepLink}}': has_deeplink_button = True break if not has_deeplink_button: # Add deepLink button at the beginning deeplink_button = { - "buttonLink": "{{deepLink}}", - "buttonText": { - "en": "Open app", - "ru": "Открыть приложение", - "zh": "打开应用", - "fa": "باز کردن برنامه", + 'buttonLink': '{{deepLink}}', + 'buttonText': { + 'en': 'Open app', + 'ru': 'Открыть приложение', + 'zh': '打开应用', + 'fa': 'باز کردن برنامه', }, } - if "buttons" not in subscription_step: - subscription_step["buttons"] = [] - subscription_step["buttons"].insert(0, deeplink_button) + if 'buttons' not in subscription_step: + subscription_step['buttons'] = [] + subscription_step['buttons'].insert(0, deeplink_button) return { - "id": app.get("name", "").lower().replace(" ", "-"), - "name": app.get("name", ""), - "isFeatured": app.get("featured", False), - "urlScheme": url_scheme, - "isNeedBase64Encoding": app.get("isNeedBase64Encoding", False), - "installationStep": installation_step, - "addSubscriptionStep": subscription_step, - "connectAndUseStep": connect_step, + 'id': app.get('name', '').lower().replace(' ', '-'), + 'name': app.get('name', ''), + 'isFeatured': app.get('featured', False), + 'urlScheme': url_scheme, + 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), + 'installationStep': installation_step, + 'addSubscriptionStep': subscription_step, + 'connectAndUseStep': connect_step, } -def _convert_remnawave_config_to_cabinet(config: Dict[str, Any]) -> Dict[str, Any]: +def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, Any]: """Convert RemnaWave config format to cabinet format.""" platforms = {} - remnawave_platforms = config.get("platforms", {}) + remnawave_platforms = config.get('platforms', {}) for platform_key, platform_data in remnawave_platforms.items(): if not isinstance(platform_data, dict): continue - apps = platform_data.get("apps", []) + apps = platform_data.get('apps', []) if not isinstance(apps, list): continue @@ -2075,23 +2105,23 @@ def _convert_remnawave_config_to_cabinet(config: Dict[str, Any]) -> Dict[str, An # Convert branding branding = {} - if config.get("brandingSettings"): + if config.get('brandingSettings'): branding = { - "name": config["brandingSettings"].get("name", ""), - "logoUrl": config["brandingSettings"].get("logoUrl", ""), - "supportUrl": config["brandingSettings"].get("supportUrl", ""), + 'name': config['brandingSettings'].get('name', ''), + 'logoUrl': config['brandingSettings'].get('logoUrl', ''), + 'supportUrl': config['brandingSettings'].get('supportUrl', ''), } return { - "config": { - "additionalLocales": ["zh", "fa"], - "branding": branding, + 'config': { + 'additionalLocales': ['zh', 'fa'], + 'branding': branding, }, - "platforms": platforms, + 'platforms': platforms, } -async def _load_app_config_async() -> Dict[str, Any]: +async def _load_app_config_async() -> dict[str, Any]: """Load app config from RemnaWave (if configured) or local file.""" remnawave_uuid = _get_remnawave_config_uuid() @@ -2101,42 +2131,45 @@ async def _load_app_config_async() -> Dict[str, Any]: async with service.get_api_client() as api: config = await api.get_subscription_page_config(remnawave_uuid) if config and config.config: - logger.debug(f"Loaded app config from RemnaWave: {remnawave_uuid}") + logger.debug(f'Loaded app config from RemnaWave: {remnawave_uuid}') # Debug: log raw RemnaWave config structure import json - logger.debug(f"RemnaWave raw config: {json.dumps(config.config, ensure_ascii=False, indent=2)[:2000]}") + + logger.debug( + f'RemnaWave raw config: {json.dumps(config.config, ensure_ascii=False, indent=2)[:2000]}' + ) converted = _convert_remnawave_config_to_cabinet(config.config) - logger.debug(f"Converted config platforms: {list(converted.get('platforms', {}).keys())}") + logger.debug(f'Converted config platforms: {list(converted.get("platforms", {}).keys())}') # Log first app from each platform for platform, apps in converted.get('platforms', {}).items(): if apps: first_app = apps[0] - logger.debug(f"Platform {platform} first app: name={first_app.get('name')}, urlScheme={first_app.get('urlScheme')}") + logger.debug( + f'Platform {platform} first app: name={first_app.get("name")}, urlScheme={first_app.get("urlScheme")}' + ) return converted except Exception as e: - logger.warning(f"Failed to load RemnaWave config, falling back to file: {e}") + logger.warning(f'Failed to load RemnaWave config, falling back to file: {e}') # Fallback to local file return _load_app_config_from_file() -def _load_app_config() -> Dict[str, Any]: +def _load_app_config() -> dict[str, Any]: """Load app-config.json file (sync version for compatibility).""" return _load_app_config_from_file() -def _is_happ_app(app: Dict[str, Any]) -> bool: +def _is_happ_app(app: dict[str, Any]) -> bool: """Check if app is Happ (uses happ_cryptolink scheme).""" - name = str(app.get("name", "")).lower() - svg_icon_key = str(app.get("svgIconKey", "")).lower() - return name == "happ" or svg_icon_key == "happ" + name = str(app.get('name', '')).lower() + svg_icon_key = str(app.get('svgIconKey', '')).lower() + return name == 'happ' or svg_icon_key == 'happ' def _create_deep_link( - app: Dict[str, Any], - subscription_url: str, - subscription_crypto_link: Optional[str] = None -) -> Optional[str]: + app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None +) -> str | None: """Create deep link for app with subscription URL. Uses urlScheme from RemnaWave config or fallback by app name. @@ -2159,34 +2192,33 @@ def _create_deep_link( payload = subscription_url - if app.get("isNeedBase64Encoding"): + if app.get('isNeedBase64Encoding'): try: - payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8") + payload = base64.b64encode(subscription_url.encode('utf-8')).decode('utf-8') except Exception as e: - logger.warning(f"Failed to encode subscription URL to base64: {e}") + logger.warning(f'Failed to encode subscription URL to base64: {e}') payload = subscription_url - return f"{scheme}{payload}" + return f'{scheme}{payload}' # ============ Countries Management ============ -@router.get("/countries") + +@router.get('/countries') async def get_available_countries( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get available countries/servers for the user.""" from app.database.crud.server_squad import get_available_server_squads - from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) promo_group_id = user.promo_group_id # Exclude trial-only servers from available servers for purchase - available_servers = await get_available_server_squads( - db, promo_group_id=promo_group_id, exclude_trial_only=True - ) + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True) connected_squads = [] days_left = 0 @@ -2195,6 +2227,7 @@ async def get_available_countries( # Calculate days left for prorated pricing if user.subscription.end_date: from datetime import datetime + delta = user.subscription.end_date - datetime.utcnow() days_left = max(0, delta.days) @@ -2202,7 +2235,7 @@ async def get_available_countries( servers_discount_percent = 0 promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None if promo_group: - servers_discount_percent = promo_group.get_discount_percent("servers", None) + servers_discount_percent = promo_group.get_discount_percent('servers', None) countries = [] for server in available_servers: @@ -2222,71 +2255,71 @@ async def get_available_countries( user.subscription.end_date, ) - countries.append({ - "uuid": server.squad_uuid, - "name": server.display_name, - "country_code": server.country_code, - "base_price_kopeks": base_price, - "price_kopeks": prorated_price, # Prorated price with discount - "price_per_month_kopeks": discounted_price, # Monthly price with discount - "price_rubles": prorated_price / 100, - "is_available": server.is_available and not server.is_full, - "is_connected": server.squad_uuid in connected_squads, - "has_discount": servers_discount_percent > 0, - "discount_percent": servers_discount_percent, - }) + countries.append( + { + 'uuid': server.squad_uuid, + 'name': server.display_name, + 'country_code': server.country_code, + 'base_price_kopeks': base_price, + 'price_kopeks': prorated_price, # Prorated price with discount + 'price_per_month_kopeks': discounted_price, # Monthly price with discount + 'price_rubles': prorated_price / 100, + 'is_available': server.is_available and not server.is_full, + 'is_connected': server.squad_uuid in connected_squads, + 'has_discount': servers_discount_percent > 0, + 'discount_percent': servers_discount_percent, + } + ) return { - "countries": countries, - "connected_count": len(connected_squads), - "has_subscription": user.subscription is not None, - "days_left": days_left, - "discount_percent": servers_discount_percent, + 'countries': countries, + 'connected_count': len(connected_squads), + 'has_subscription': user.subscription is not None, + 'days_left': days_left, + 'discount_percent': servers_discount_percent, } -@router.post("/countries") +@router.post('/countries') async def update_countries( - request: Dict[str, Any], + request: dict[str, Any], user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Update subscription countries/servers.""" - from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids from app.database.crud.subscription import add_subscription_servers from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance from app.database.models import TransactionType - from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) if user.subscription.is_trial: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Country management is not available for trial subscriptions", + detail='Country management is not available for trial subscriptions', ) - selected_countries = request.get("countries", []) + selected_countries = request.get('countries', []) if not selected_countries: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="At least one country must be selected", + detail='At least one country must be selected', ) current_countries = user.subscription.connected_squads or [] promo_group_id = user.promo_group_id # Exclude trial-only servers from available servers for purchase - available_servers = await get_available_server_squads( - db, promo_group_id=promo_group_id, exclude_trial_only=True - ) + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id, exclude_trial_only=True) allowed_country_ids = {server.squad_uuid for server in available_servers} # Validate selected countries @@ -2294,7 +2327,7 @@ async def update_countries( if country_uuid not in allowed_country_ids and country_uuid not in current_countries: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Country {country_uuid} is not available", + detail=f'Country {country_uuid} is not available', ) added = [c for c in selected_countries if c not in current_countries] @@ -2302,8 +2335,8 @@ async def update_countries( if not added and not removed: return { - "message": "No changes detected", - "connected_squads": current_countries, + 'message': 'No changes detected', + 'connected_squads': current_countries, } # Calculate cost for added servers @@ -2314,7 +2347,7 @@ async def update_countries( servers_discount_percent = 0 promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None if promo_group: - servers_discount_percent = promo_group.get_discount_percent("servers", None) + servers_discount_percent = promo_group.get_discount_percent('servers', None) added_server_prices = [] @@ -2345,19 +2378,16 @@ async def update_countries( if total_cost > 0 and user.balance_kopeks < total_cost: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail=f"Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB", + detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB', ) # Deduct balance and update subscription if added and total_cost > 0: - success = await subtract_user_balance( - db, user, total_cost, - f"Adding countries: {', '.join(added_names)}" - ) + success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}') if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to charge balance", + detail='Failed to charge balance', ) await create_transaction( @@ -2365,7 +2395,7 @@ async def update_countries( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_cost, - description=f"Adding countries to subscription: {', '.join(added_names)}" + description=f'Adding countries to subscription: {", ".join(added_names)}', ) # Add servers to subscription @@ -2383,126 +2413,128 @@ async def update_countries( # Sync with RemnaWave try: subscription_service = SubscriptionService() - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user(db, user.subscription) else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f"Failed to sync countries with RemnaWave: {e}") + logger.error(f'Failed to sync countries with RemnaWave: {e}') await db.refresh(user.subscription) return { - "message": "Countries updated successfully", - "added": added_names, - "removed": removed_names, - "amount_paid_kopeks": total_cost, - "connected_squads": user.subscription.connected_squads, + 'message': 'Countries updated successfully', + 'added': added_names, + 'removed': removed_names, + 'amount_paid_kopeks': total_cost, + 'connected_squads': user.subscription.connected_squads, } # ============ Connection Link ============ -@router.get("/connection-link") + +@router.get('/connection-link') async def get_connection_link( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get subscription connection link and instructions.""" from app.utils.subscription_utils import ( + convert_subscription_link_to_happ_scheme, get_display_subscription_link, get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, ) - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) subscription_url = user.subscription.subscription_url if not subscription_url: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Subscription link not yet generated", + detail='Subscription link not yet generated', ) display_link = get_display_subscription_link(user.subscription) happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None - happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None + happ_scheme_link = ( + convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None + ) connect_mode = settings.CONNECT_BUTTON_MODE hide_subscription_link = settings.should_hide_subscription_link() return { - "subscription_url": subscription_url if not hide_subscription_link else None, - "display_link": display_link if not hide_subscription_link else None, - "happ_redirect_link": happ_redirect, - "happ_scheme_link": happ_scheme_link, - "connect_mode": connect_mode, - "hide_link": hide_subscription_link, - "instructions": { - "steps": [ - "Copy the subscription link", - "Open your VPN application", + 'subscription_url': subscription_url if not hide_subscription_link else None, + 'display_link': display_link if not hide_subscription_link else None, + 'happ_redirect_link': happ_redirect, + 'happ_scheme_link': happ_scheme_link, + 'connect_mode': connect_mode, + 'hide_link': hide_subscription_link, + 'instructions': { + 'steps': [ + 'Copy the subscription link', + 'Open your VPN application', "Find 'Add subscription' or 'Import' option", - "Paste the copied link", + 'Paste the copied link', ] - } + }, } # ============ hApp Downloads ============ -@router.get("/happ-downloads") + +@router.get('/happ-downloads') async def get_happ_downloads( user: User = Depends(get_current_cabinet_user), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get hApp download links for different platforms.""" platforms = { - "ios": { - "name": "iOS (iPhone/iPad)", - "icon": "🍎", - "link": settings.get_happ_download_link("ios"), + 'ios': { + 'name': 'iOS (iPhone/iPad)', + 'icon': '🍎', + 'link': settings.get_happ_download_link('ios'), }, - "android": { - "name": "Android", - "icon": "🤖", - "link": settings.get_happ_download_link("android"), + 'android': { + 'name': 'Android', + 'icon': '🤖', + 'link': settings.get_happ_download_link('android'), }, - "macos": { - "name": "macOS", - "icon": "🖥️", - "link": settings.get_happ_download_link("macos"), + 'macos': { + 'name': 'macOS', + 'icon': '🖥️', + 'link': settings.get_happ_download_link('macos'), }, - "windows": { - "name": "Windows", - "icon": "💻", - "link": settings.get_happ_download_link("windows"), + 'windows': { + 'name': 'Windows', + 'icon': '💻', + 'link': settings.get_happ_download_link('windows'), }, } # Filter out platforms without links - available_platforms = { - k: v for k, v in platforms.items() if v["link"] - } + available_platforms = {k: v for k, v in platforms.items() if v['link']} return { - "platforms": available_platforms, - "happ_enabled": bool(available_platforms), + 'platforms': available_platforms, + 'happ_enabled': bool(available_platforms), } -@router.get("/app-config") +@router.get('/app-config') async def get_app_config( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get app configuration for connection with deep links.""" - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) subscription_url = None subscription_crypto_link = None @@ -2512,7 +2544,7 @@ async def get_app_config( # Load config from RemnaWave (if configured) or local file config = await _load_app_config_async() - platforms_raw = config.get("platforms", {}) + platforms_raw = config.get('platforms', {}) if not isinstance(platforms_raw, dict): platforms_raw = {} @@ -2529,19 +2561,19 @@ async def get_app_config( continue app_data = { - "id": app.get("id"), - "name": app.get("name"), - "isFeatured": app.get("isFeatured", False), - "installationStep": app.get("installationStep"), - "addSubscriptionStep": app.get("addSubscriptionStep"), - "connectAndUseStep": app.get("connectAndUseStep"), - "additionalBeforeAddSubscriptionStep": app.get("additionalBeforeAddSubscriptionStep"), - "additionalAfterAddSubscriptionStep": app.get("additionalAfterAddSubscriptionStep"), + 'id': app.get('id'), + 'name': app.get('name'), + 'isFeatured': app.get('isFeatured', False), + 'installationStep': app.get('installationStep'), + 'addSubscriptionStep': app.get('addSubscriptionStep'), + 'connectAndUseStep': app.get('connectAndUseStep'), + 'additionalBeforeAddSubscriptionStep': app.get('additionalBeforeAddSubscriptionStep'), + 'additionalAfterAddSubscriptionStep': app.get('additionalAfterAddSubscriptionStep'), } # Add deep link if subscription exists if subscription_url or subscription_crypto_link: - app_data["deepLink"] = _create_deep_link(app, subscription_url, subscription_crypto_link) + app_data['deepLink'] = _create_deep_link(app, subscription_url, subscription_crypto_link) platform_apps.append(app_data) @@ -2550,51 +2582,52 @@ async def get_app_config( # Platform display names for UI platform_names = { - "ios": {"ru": "iPhone/iPad", "en": "iPhone/iPad"}, - "android": {"ru": "Android", "en": "Android"}, - "macos": {"ru": "macOS", "en": "macOS"}, - "windows": {"ru": "Windows", "en": "Windows"}, - "linux": {"ru": "Linux", "en": "Linux"}, - "androidTV": {"ru": "Android TV", "en": "Android TV"}, - "appleTV": {"ru": "Apple TV", "en": "Apple TV"}, + 'ios': {'ru': 'iPhone/iPad', 'en': 'iPhone/iPad'}, + 'android': {'ru': 'Android', 'en': 'Android'}, + 'macos': {'ru': 'macOS', 'en': 'macOS'}, + 'windows': {'ru': 'Windows', 'en': 'Windows'}, + 'linux': {'ru': 'Linux', 'en': 'Linux'}, + 'androidTV': {'ru': 'Android TV', 'en': 'Android TV'}, + 'appleTV': {'ru': 'Apple TV', 'en': 'Apple TV'}, } hide_link = settings.should_hide_subscription_link() return { - "platforms": platforms, - "platformNames": platform_names, - "hasSubscription": bool(subscription_url or subscription_crypto_link), - "subscriptionUrl": subscription_url if not hide_link else None, - "subscriptionCryptoLink": subscription_crypto_link if not hide_link else None, - "hideLink": hide_link, - "branding": config.get("config", {}).get("branding", {}), + 'platforms': platforms, + 'platformNames': platform_names, + 'hasSubscription': bool(subscription_url or subscription_crypto_link), + 'subscriptionUrl': subscription_url if not hide_link else None, + 'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None, + 'hideLink': hide_link, + 'branding': config.get('config', {}).get('branding', {}), } # ============ Device Management ============ -@router.get("/devices") + +@router.get('/devices') async def get_devices( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get list of connected devices.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) if not user.remnawave_uuid: return { - "devices": [], - "total": 0, - "device_limit": user.subscription.device_limit or 1, + 'devices': [], + 'total': 0, + 'device_limit': user.subscription.device_limit or 1, } try: @@ -2605,99 +2638,98 @@ async def get_devices( devices_list = response.get('devices', []) formatted_devices = [] for device in devices_list: - hwid = device.get("hwid") or device.get("deviceId") or device.get("id") - platform = device.get("platform") or device.get("platformType") or "Unknown" - model = device.get("deviceModel") or device.get("model") or device.get("name") or "Unknown" - created_at = device.get("updatedAt") or device.get("lastSeen") or device.get("createdAt") + hwid = device.get('hwid') or device.get('deviceId') or device.get('id') + platform = device.get('platform') or device.get('platformType') or 'Unknown' + model = device.get('deviceModel') or device.get('model') or device.get('name') or 'Unknown' + created_at = device.get('updatedAt') or device.get('lastSeen') or device.get('createdAt') - formatted_devices.append({ - "hwid": hwid, - "platform": platform, - "device_model": model, - "created_at": created_at, - }) + formatted_devices.append( + { + 'hwid': hwid, + 'platform': platform, + 'device_model': model, + 'created_at': created_at, + } + ) return { - "devices": formatted_devices, - "total": response.get('total', len(formatted_devices)), - "device_limit": user.subscription.device_limit or 1, + 'devices': formatted_devices, + 'total': response.get('total', len(formatted_devices)), + 'device_limit': user.subscription.device_limit or 1, } except Exception as e: - logger.error(f"Error fetching devices: {e}") + logger.error(f'Error fetching devices: {e}') return { - "devices": [], - "total": 0, - "device_limit": user.subscription.device_limit or 1, + 'devices': [], + 'total': 0, + 'device_limit': user.subscription.device_limit or 1, } -@router.delete("/devices/{hwid}") +@router.delete('/devices/{hwid}') async def delete_device( hwid: str, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Delete a specific device by HWID.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) if not user.remnawave_uuid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User UUID not found", + detail='User UUID not found', ) try: service = RemnaWaveService() async with service.get_api_client() as api: - delete_data = { - "userUuid": user.remnawave_uuid, - "hwid": hwid - } + delete_data = {'userUuid': user.remnawave_uuid, 'hwid': hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) return { - "success": True, - "message": "Device deleted successfully", - "deleted_hwid": hwid, + 'success': True, + 'message': 'Device deleted successfully', + 'deleted_hwid': hwid, } except Exception as e: - logger.error(f"Error deleting device: {e}") + logger.error(f'Error deleting device: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete device", + detail='Failed to delete device', ) -@router.delete("/devices") +@router.delete('/devices') async def delete_all_devices( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Delete all connected devices.""" from app.services.remnawave_service import RemnaWaveService - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) if not user.remnawave_uuid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User UUID not found", + detail='User UUID not found', ) try: @@ -2708,17 +2740,17 @@ async def delete_all_devices( if not response or 'response' not in response: return { - "success": True, - "message": "No devices to delete", - "deleted_count": 0, + 'success': True, + 'message': 'No devices to delete', + 'deleted_count': 0, } devices_list = response['response'].get('devices', []) if not devices_list: return { - "success": True, - "message": "No devices to delete", - "deleted_count": 0, + 'success': True, + 'message': 'No devices to delete', + 'deleted_count': 0, } deleted_count = 0 @@ -2726,56 +2758,54 @@ async def delete_all_devices( device_hwid = device.get('hwid') if device_hwid: try: - delete_data = { - "userUuid": user.remnawave_uuid, - "hwid": device_hwid - } + delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) deleted_count += 1 except Exception as device_error: - logger.error(f"Error deleting device {device_hwid}: {device_error}") + logger.error(f'Error deleting device {device_hwid}: {device_error}') return { - "success": True, - "message": f"Deleted {deleted_count} devices", - "deleted_count": deleted_count, + 'success': True, + 'message': f'Deleted {deleted_count} devices', + 'deleted_count': deleted_count, } except Exception as e: - logger.error(f"Error deleting all devices: {e}") + logger.error(f'Error deleting all devices: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete devices", + detail='Failed to delete devices', ) # ============ Tariff Switch ============ -@router.post("/tariff/switch/preview") + +@router.post('/tariff/switch/preview') async def preview_tariff_switch( request: TariffPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Preview tariff switch - shows cost calculation.""" if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariffs mode is not enabled", + detail='Tariffs mode is not enabled', ) - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription or not user.subscription.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No active subscription with tariff", + detail='No active subscription with tariff', ) - if user.subscription.status not in ("active", "trial"): + if user.subscription.status not in ('active', 'trial'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Subscription is not active", + detail='Subscription is not active', ) current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) @@ -2784,22 +2814,22 @@ async def preview_tariff_switch( if not new_tariff or not new_tariff.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found or inactive", + detail='Tariff not found or inactive', ) if user.subscription.tariff_id == request.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Already on this tariff", + detail='Already on this tariff', ) # Check tariff availability for user's promo group - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Tariff not available for your promo group", + detail='Tariff not available for your promo group', ) # Calculate remaining days @@ -2866,50 +2896,50 @@ async def preview_tariff_switch( missing = max(0, upgrade_cost - balance) if not has_enough else 0 return { - "can_switch": has_enough, - "current_tariff_id": current_tariff.id if current_tariff else None, - "current_tariff_name": current_tariff.name if current_tariff else None, - "new_tariff_id": new_tariff.id, - "new_tariff_name": new_tariff.name, - "remaining_days": remaining_days, - "upgrade_cost_kopeks": upgrade_cost, - "upgrade_cost_label": settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", - "balance_kopeks": balance, - "balance_label": settings.format_price(balance), - "has_enough_balance": has_enough, - "missing_amount_kopeks": missing, - "missing_amount_label": settings.format_price(missing) if missing > 0 else "", - "is_upgrade": is_upgrade, + 'can_switch': has_enough, + 'current_tariff_id': current_tariff.id if current_tariff else None, + 'current_tariff_name': current_tariff.name if current_tariff else None, + 'new_tariff_id': new_tariff.id, + 'new_tariff_name': new_tariff.name, + 'remaining_days': remaining_days, + 'upgrade_cost_kopeks': upgrade_cost, + 'upgrade_cost_label': settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно', + 'balance_kopeks': balance, + 'balance_label': settings.format_price(balance), + 'has_enough_balance': has_enough, + 'missing_amount_kopeks': missing, + 'missing_amount_label': settings.format_price(missing) if missing > 0 else '', + 'is_upgrade': is_upgrade, } -@router.post("/tariff/switch") +@router.post('/tariff/switch') async def switch_tariff( request: TariffPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Switch to a different tariff without changing end date.""" from datetime import timedelta if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tariffs mode is not enabled", + detail='Tariffs mode is not enabled', ) - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription or not user.subscription.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No active subscription with tariff", + detail='No active subscription with tariff', ) - if user.subscription.status not in ("active", "trial"): + if user.subscription.status not in ('active', 'trial'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Subscription is not active", + detail='Subscription is not active', ) current_tariff = await get_tariff_by_id(db, user.subscription.tariff_id) @@ -2918,22 +2948,22 @@ async def switch_tariff( if not new_tariff or not new_tariff.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Tariff not found or inactive", + detail='Tariff not found or inactive', ) if user.subscription.tariff_id == request.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Already on this tariff", + detail='Already on this tariff', ) # Check tariff availability - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Tariff not available", + detail='Tariff not available', ) # Calculate remaining days @@ -2954,7 +2984,7 @@ async def switch_tariff( if daily_price <= 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Daily tariff has invalid price", + detail='Daily tariff has invalid price', ) upgrade_cost = daily_price new_period_days = 1 # Daily tariff starts with 1 day @@ -3002,9 +3032,9 @@ async def switch_tariff( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": f"Insufficient funds. Missing {settings.format_price(missing)}", - "missing_amount": missing, + 'code': 'insufficient_funds', + 'message': f'Insufficient funds. Missing {settings.format_price(missing)}', + 'missing_amount': missing, }, ) @@ -3019,7 +3049,7 @@ async def switch_tariff( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to charge balance", + detail='Failed to charge balance', ) # Create transaction @@ -3032,15 +3062,17 @@ async def switch_tariff( ) # Update subscription - old_tariff_name = current_tariff.name if current_tariff else "Unknown" + old_tariff_name = current_tariff.name if current_tariff else 'Unknown' user.subscription.tariff_id = new_tariff.id user.subscription.traffic_limit_gb = new_tariff.traffic_limit_gb user.subscription.device_limit = new_tariff.device_limit user.subscription.connected_squads = new_tariff.allowed_squads or [] # Reset purchased traffic and delete TrafficPurchase records on tariff switch - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == user.subscription.id)) user.subscription.purchased_traffic_gb = 0 user.subscription.traffic_reset_at = None @@ -3060,59 +3092,60 @@ async def switch_tariff( # Sync with RemnaWave try: subscription_service = SubscriptionService() - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user(db, user.subscription) else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f"Failed to sync tariff switch with RemnaWave: {e}") + logger.error(f'Failed to sync tariff switch with RemnaWave: {e}') await db.refresh(user) await db.refresh(user.subscription) return { - "success": True, - "message": f"Switched from '{old_tariff_name}' to '{new_tariff.name}'", - "subscription": _subscription_to_response(user.subscription), - "old_tariff_name": old_tariff_name, - "new_tariff_id": new_tariff.id, - "new_tariff_name": new_tariff.name, - "charged_kopeks": upgrade_cost, - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'success': True, + 'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'", + 'subscription': _subscription_to_response(user.subscription), + 'old_tariff_name': old_tariff_name, + 'new_tariff_id': new_tariff.id, + 'new_tariff_name': new_tariff.name, + 'charged_kopeks': upgrade_cost, + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } # ============ Daily Subscription Pause ============ -@router.post("/pause") + +@router.post('/pause') async def toggle_subscription_pause( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Toggle pause/resume for daily subscription.""" from datetime import timedelta - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) tariff_id = getattr(user.subscription, 'tariff_id', None) if not tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Subscription has no tariff", + detail='Subscription has no tariff', ) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not getattr(tariff, 'is_daily', False): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Pause is only available for daily tariffs", + detail='Pause is only available for daily tariffs', ) # Toggle pause state @@ -3127,15 +3160,16 @@ async def toggle_subscription_pause( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_balance", - "message": "Insufficient balance to resume daily subscription", - "required": daily_price, - "balance": user.balance_kopeks, + 'code': 'insufficient_balance', + 'message': 'Insufficient balance to resume daily subscription', + 'required': daily_price, + 'balance': user.balance_kopeks, }, ) # Restore ACTIVE status if was DISABLED from app.database.models import SubscriptionStatus + if user.subscription.status == SubscriptionStatus.DISABLED.value: user.subscription.status = SubscriptionStatus.ACTIVE.value user.subscription.last_daily_charge_at = datetime.utcnow() @@ -3152,45 +3186,46 @@ async def toggle_subscription_pause( if user.remnawave_uuid: await subscription_service.enable_remnawave_user(user.remnawave_uuid) except Exception as e: - logger.error(f"Error syncing with RemnaWave on resume: {e}") + logger.error(f'Error syncing with RemnaWave on resume: {e}') if new_paused_state: - message = "Daily subscription paused" + message = 'Daily subscription paused' else: - message = "Daily subscription resumed" + message = 'Daily subscription resumed' return { - "success": True, - "message": message, - "is_paused": new_paused_state, - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'success': True, + 'message': message, + 'is_paused': new_paused_state, + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } # ============ Traffic Switch (Change Traffic Package) ============ -@router.put("/traffic") + +@router.put('/traffic') async def switch_traffic_package( request: TrafficPurchaseRequest, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), -) -> Dict[str, Any]: +) -> dict[str, Any]: """Switch to a different traffic package (change limit).""" - from app.utils.pricing_utils import calculate_prorated_price, apply_percentage_discount + from app.utils.pricing_utils import calculate_prorated_price - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No subscription found", + detail='No subscription found', ) if user.subscription.is_trial: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Traffic management is only available for paid subscriptions", + detail='Traffic management is only available for paid subscriptions', ) current_traffic = user.subscription.traffic_limit_gb or 0 @@ -3199,23 +3234,23 @@ async def switch_traffic_package( if current_traffic == new_traffic: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Already on this traffic package", + detail='Already on this traffic package', ) # Get available packages packages = settings.get_traffic_packages() - current_pkg = next((p for p in packages if p["gb"] == current_traffic and p.get("enabled", True)), None) - new_pkg = next((p for p in packages if p["gb"] == new_traffic and p.get("enabled", True)), None) + current_pkg = next((p for p in packages if p['gb'] == current_traffic and p.get('enabled', True)), None) + new_pkg = next((p for p in packages if p['gb'] == new_traffic and p.get('enabled', True)), None) if not new_pkg: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid traffic package", + detail='Invalid traffic package', ) # Calculate price difference (only charge for upgrade) - current_price = current_pkg["price"] if current_pkg else 0 - new_price = new_pkg["price"] + current_price = current_pkg['price'] if current_pkg else 0 + new_price = new_pkg['price'] if new_price > current_price: # Upgrade - charge difference @@ -3223,11 +3258,17 @@ async def switch_traffic_package( # Apply promo discount traffic_discount_percent = 0 - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) if promo_group: apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) if apply_to_addons: - traffic_discount_percent = max(0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0))) + traffic_discount_percent = max( + 0, min(100, int(getattr(promo_group, 'traffic_discount_percent', 0) or 0)) + ) if traffic_discount_percent > 0: price_diff = int(price_diff * (100 - traffic_discount_percent) / 100) @@ -3238,16 +3279,16 @@ async def switch_traffic_package( if user.balance_kopeks < final_price: raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail=f"Insufficient balance. Need {final_price / 100:.2f} RUB", + detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB', ) # Charge balance - description = f"Traffic upgrade from {current_traffic}GB to {new_traffic}GB" + description = f'Traffic upgrade from {current_traffic}GB to {new_traffic}GB' success = await subtract_user_balance(db, user, final_price, description) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to charge balance", + detail='Failed to charge balance', ) # Create transaction @@ -3274,24 +3315,24 @@ async def switch_traffic_package( # Sync with RemnaWave try: subscription_service = SubscriptionService() - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user(db, user.subscription) else: await subscription_service.create_remnawave_user(db, user.subscription) except Exception as e: - logger.error(f"Failed to sync traffic switch with RemnaWave: {e}") + logger.error(f'Failed to sync traffic switch with RemnaWave: {e}') await db.refresh(user) await db.refresh(user.subscription) return { - "success": True, - "message": f"Traffic changed from {current_traffic}GB to {new_traffic}GB", - "old_traffic_gb": current_traffic, - "new_traffic_gb": new_traffic, - "charged_kopeks": charged, - "balance_kopeks": user.balance_kopeks, - "balance_label": settings.format_price(user.balance_kopeks), + 'success': True, + 'message': f'Traffic changed from {current_traffic}GB to {new_traffic}GB', + 'old_traffic_gb': current_traffic, + 'new_traffic_gb': new_traffic, + 'charged_kopeks': charged, + 'balance_kopeks': user.balance_kopeks, + 'balance_label': settings.format_price(user.balance_kopeks), } @@ -3303,7 +3344,7 @@ TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds -@router.post("/refresh-traffic") +@router.post('/refresh-traffic') async def refresh_traffic( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -3315,7 +3356,7 @@ async def refresh_traffic( if not user.subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No active subscription", + detail='No active subscription', ) # Используем user.id для rate limit и кеша (работает и для email-пользователей) @@ -3324,29 +3365,29 @@ async def refresh_traffic( # Check rate limit is_limited = await RateLimitCache.is_rate_limited( user_cache_id, - "traffic_refresh", + 'traffic_refresh', TRAFFIC_REFRESH_RATE_LIMIT, TRAFFIC_REFRESH_RATE_WINDOW, ) if is_limited: # Check if we have cached data - traffic_cache_key = cache_key("traffic", user_cache_id) + traffic_cache_key = cache_key('traffic', user_cache_id) cached_data = await cache.get(traffic_cache_key) if cached_data: return { - "success": True, - "cached": True, - "rate_limited": True, - "retry_after_seconds": TRAFFIC_REFRESH_RATE_WINDOW, + 'success': True, + 'cached': True, + 'rate_limited': True, + 'retry_after_seconds': TRAFFIC_REFRESH_RATE_WINDOW, **cached_data, } raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.", - headers={"Retry-After": str(TRAFFIC_REFRESH_RATE_WINDOW)}, + detail=f'Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.', + headers={'Retry-After': str(TRAFFIC_REFRESH_RATE_WINDOW)}, ) # Fetch traffic from RemnaWave @@ -3364,27 +3405,27 @@ async def refresh_traffic( if not traffic_stats: # Return current database values if RemnaWave unavailable traffic_data = { - "traffic_used_bytes": int((user.subscription.traffic_used_gb or 0) * (1024**3)), - "traffic_used_gb": round(user.subscription.traffic_used_gb or 0, 2), - "traffic_limit_bytes": int((user.subscription.traffic_limit_gb or 0) * (1024**3)), - "traffic_limit_gb": user.subscription.traffic_limit_gb or 0, - "traffic_used_percent": round( + 'traffic_used_bytes': int((user.subscription.traffic_used_gb or 0) * (1024**3)), + 'traffic_used_gb': round(user.subscription.traffic_used_gb or 0, 2), + 'traffic_limit_bytes': int((user.subscription.traffic_limit_gb or 0) * (1024**3)), + 'traffic_limit_gb': user.subscription.traffic_limit_gb or 0, + 'traffic_used_percent': round( ((user.subscription.traffic_used_gb or 0) / (user.subscription.traffic_limit_gb or 1)) * 100 if user.subscription.traffic_limit_gb else 0, 1, ), - "is_unlimited": (user.subscription.traffic_limit_gb or 0) == 0, + 'is_unlimited': (user.subscription.traffic_limit_gb or 0) == 0, } return { - "success": True, - "cached": False, - "source": "database", + 'success': True, + 'cached': False, + 'source': 'database', **traffic_data, } # Update subscription with fresh data - used_gb = traffic_stats.get("used_traffic_gb", 0) + used_gb = traffic_stats.get('used_traffic_gb', 0) if abs((user.subscription.traffic_used_gb or 0) - used_gb) > 0.01: user.subscription.traffic_used_gb = used_gb user.subscription.updated_at = datetime.utcnow() @@ -3398,30 +3439,30 @@ async def refresh_traffic( percent = 0 traffic_data = { - "traffic_used_bytes": traffic_stats.get("used_traffic_bytes", 0), - "traffic_used_gb": round(used_gb, 2), - "traffic_limit_bytes": traffic_stats.get("traffic_limit_bytes", 0), - "traffic_limit_gb": limit_gb, - "traffic_used_percent": round(percent, 1), - "is_unlimited": limit_gb == 0, - "lifetime_used_bytes": traffic_stats.get("lifetime_used_traffic_bytes", 0), - "lifetime_used_gb": round(traffic_stats.get("lifetime_used_traffic_gb", 0), 2), + 'traffic_used_bytes': traffic_stats.get('used_traffic_bytes', 0), + 'traffic_used_gb': round(used_gb, 2), + 'traffic_limit_bytes': traffic_stats.get('traffic_limit_bytes', 0), + 'traffic_limit_gb': limit_gb, + 'traffic_used_percent': round(percent, 1), + 'is_unlimited': limit_gb == 0, + 'lifetime_used_bytes': traffic_stats.get('lifetime_used_traffic_bytes', 0), + 'lifetime_used_gb': round(traffic_stats.get('lifetime_used_traffic_gb', 0), 2), } # Cache the result - traffic_cache_key = cache_key("traffic", user_cache_id) + traffic_cache_key = cache_key('traffic', user_cache_id) await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL) return { - "success": True, - "cached": False, - "source": "remnawave", + 'success': True, + 'cached': False, + 'source': 'remnawave', **traffic_data, } except Exception as e: - logger.error(f"Error refreshing traffic for user {user.id}: {e}") + logger.error(f'Error refreshing traffic for user {user.id}: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to refresh traffic data", + detail='Failed to refresh traffic data', ) diff --git a/app/cabinet/routes/ticket_notifications.py b/app/cabinet/routes/ticket_notifications.py index a8420d44..61e43e37 100644 --- a/app/cabinet/routes/ticket_notifications.py +++ b/app/cabinet/routes/ticket_notifications.py @@ -2,34 +2,34 @@ import logging from datetime import datetime -from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User from app.database.crud.ticket_notification import TicketNotificationCRUD +from app.database.models import User -from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user +from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user logger = logging.getLogger(__name__) -router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"]) -admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"]) +router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications']) +admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications']) # Schemas class TicketNotificationResponse(BaseModel): """Single ticket notification.""" + id: int ticket_id: int notification_type: str - message: Optional[str] = None + message: str | None = None is_read: bool created_at: datetime - read_at: Optional[datetime] = None + read_at: datetime | None = None class Config: from_attributes = True @@ -37,19 +37,21 @@ class TicketNotificationResponse(BaseModel): class TicketNotificationListResponse(BaseModel): """List of ticket notifications.""" - items: List[TicketNotificationResponse] + + items: list[TicketNotificationResponse] unread_count: int class UnreadCountResponse(BaseModel): """Unread notifications count.""" + unread_count: int # User endpoints -@router.get("", response_model=TicketNotificationListResponse) +@router.get('', response_model=TicketNotificationListResponse) async def get_user_notifications( - unread_only: bool = Query(False, description="Only return unread notifications"), + unread_only: bool = Query(False, description='Only return unread notifications'), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), user: User = Depends(get_current_cabinet_user), @@ -67,7 +69,7 @@ async def get_user_notifications( ) -@router.get("/unread-count", response_model=UnreadCountResponse) +@router.get('/unread-count', response_model=UnreadCountResponse) async def get_user_unread_count( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -77,7 +79,7 @@ async def get_user_unread_count( return UnreadCountResponse(unread_count=count) -@router.post("/{notification_id}/read") +@router.post('/{notification_id}/read') async def mark_notification_as_read( notification_id: int, user: User = Depends(get_current_cabinet_user), @@ -89,7 +91,7 @@ async def mark_notification_as_read( if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found", + detail='Notification not found', ) # Check ownership: notification must belong to user and not be an admin notification @@ -100,36 +102,34 @@ async def mark_notification_as_read( ) await TicketNotificationCRUD.mark_as_read(db, notification_id) - return {"success": True} + return {'success': True} -@router.post("/read-all") +@router.post('/read-all') async def mark_all_notifications_as_read( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Mark all notifications as read for current user.""" count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id) - return {"success": True, "marked_count": count} + return {'success': True, 'marked_count': count} -@router.post("/ticket/{ticket_id}/read") +@router.post('/ticket/{ticket_id}/read') async def mark_ticket_notifications_as_read( ticket_id: int, user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): """Mark all notifications for a specific ticket as read.""" - count = await TicketNotificationCRUD.mark_ticket_notifications_as_read( - db, ticket_id, user.id, is_admin=False - ) - return {"success": True, "marked_count": count} + count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, user.id, is_admin=False) + return {'success': True, 'marked_count': count} # Admin endpoints -@admin_router.get("", response_model=TicketNotificationListResponse) +@admin_router.get('', response_model=TicketNotificationListResponse) async def get_admin_notifications( - unread_only: bool = Query(False, description="Only return unread notifications"), + unread_only: bool = Query(False, description='Only return unread notifications'), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), admin: User = Depends(get_current_admin_user), @@ -147,7 +147,7 @@ async def get_admin_notifications( ) -@admin_router.get("/unread-count", response_model=UnreadCountResponse) +@admin_router.get('/unread-count', response_model=UnreadCountResponse) async def get_admin_unread_count( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), @@ -157,7 +157,7 @@ async def get_admin_unread_count( return UnreadCountResponse(unread_count=count) -@admin_router.post("/{notification_id}/read") +@admin_router.post('/{notification_id}/read') async def mark_admin_notification_as_read( notification_id: int, admin: User = Depends(get_current_admin_user), @@ -169,38 +169,36 @@ async def mark_admin_notification_as_read( if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found", + detail='Notification not found', ) # Check that this is actually an admin notification if not notification.is_for_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="This is not an admin notification", + detail='This is not an admin notification', ) await TicketNotificationCRUD.mark_as_read(db, notification_id) - return {"success": True} + return {'success': True} -@admin_router.post("/read-all") +@admin_router.post('/read-all') async def mark_all_admin_notifications_as_read( admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Mark all admin notifications as read.""" count = await TicketNotificationCRUD.mark_all_as_read_admin(db) - return {"success": True, "marked_count": count} + return {'success': True, 'marked_count': count} -@admin_router.post("/ticket/{ticket_id}/read") +@admin_router.post('/ticket/{ticket_id}/read') async def mark_admin_ticket_notifications_as_read( ticket_id: int, admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): """Mark all admin notifications for a specific ticket as read.""" - count = await TicketNotificationCRUD.mark_ticket_notifications_as_read( - db, ticket_id, admin.id, is_admin=True - ) - return {"success": True, "marked_count": count} + count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True) + return {'success': True, 'marked_count': count} diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 419132fc..b28e6f9b 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -3,39 +3,39 @@ import logging import math from datetime import datetime -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, desc from sqlalchemy.orm import selectinload -from app.database.models import User, Ticket, TicketMessage -from app.config import settings -from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply -from app.database.crud.ticket_notification import TicketNotificationCRUD from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply +from app.config import settings +from app.database.crud.ticket_notification import TicketNotificationCRUD +from app.database.models import Ticket, TicketMessage, User +from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.tickets import ( - TicketResponse, + TicketCreateRequest, TicketDetailResponse, TicketListResponse, - TicketMessageResponse, - TicketCreateRequest, TicketMessageCreateRequest, + TicketMessageResponse, + TicketResponse, ) + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"]) +router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets']) def _message_to_response(message: TicketMessage) -> TicketMessageResponse: """Convert TicketMessage to response.""" return TicketMessageResponse( id=message.id, - message_text=message.message_text or "", + message_text=message.message_text or '', is_from_admin=message.is_from_admin, has_media=bool(message.media_file_id), media_type=message.media_type, @@ -56,9 +56,9 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti return TicketResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, @@ -67,11 +67,11 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti ) -@router.get("", response_model=TicketListResponse) +@router.get('', response_model=TicketListResponse) async def get_tickets( - page: int = Query(1, ge=1, description="Page number"), - per_page: int = Query(20, ge=1, le=100, description="Items per page"), - status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), + page: int = Query(1, ge=1, description='Page number'), + per_page: int = Query(20, ge=1, le=100, description='Items per page'), + status_filter: str | None = Query(None, alias='status', description='Filter by status'), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -80,15 +80,11 @@ async def get_tickets( if not settings.is_support_tickets_enabled(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Support tickets are disabled", + detail='Support tickets are disabled', ) # Base query - query = ( - select(Ticket) - .where(Ticket.user_id == user.id) - .options(selectinload(Ticket.messages)) - ) + query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages)) # Filter by status if status_filter: @@ -121,7 +117,7 @@ async def get_tickets( ) -@router.post("", response_model=TicketDetailResponse) +@router.post('', response_model=TicketDetailResponse) async def create_ticket( request: TicketCreateRequest, user: User = Depends(get_current_cabinet_user), @@ -132,15 +128,15 @@ async def create_ticket( if not settings.is_support_tickets_enabled(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Support tickets are disabled", + detail='Support tickets are disabled', ) # Create ticket ticket = Ticket( user_id=user.id, title=request.title, - status="open", - priority="normal", + status='open', + priority='normal', created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) @@ -162,13 +158,13 @@ async def create_ticket( await db.commit() # Refresh to get relationships - await db.refresh(ticket, ["messages"]) + await db.refresh(ticket, ['messages']) # Уведомить админов о новом тикете (Telegram) try: await notify_admins_about_new_ticket(ticket, db) except Exception as e: - logger.error(f"Error notifying admins about new ticket from cabinet: {e}") + logger.error(f'Error notifying admins about new ticket from cabinet: {e}') # Уведомить админов в кабинете try: @@ -177,7 +173,7 @@ async def create_ticket( # Отправить WebSocket уведомление await notify_admins_new_ticket(ticket.id, ticket.title, user.id) except Exception as e: - logger.error(f"Error creating cabinet notification for new ticket: {e}") + logger.error(f'Error creating cabinet notification for new ticket: {e}') messages = [_message_to_response(m) for m in ticket.messages] @@ -185,16 +181,16 @@ async def create_ticket( id=ticket.id, title=ticket.title, status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at, closed_at=ticket.closed_at, - is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False, messages=messages, ) -@router.get("/{ticket_id}", response_model=TicketDetailResponse) +@router.get('/{ticket_id}', response_model=TicketDetailResponse) async def get_ticket( ticket_id: int, user: User = Depends(get_current_cabinet_user), @@ -202,9 +198,7 @@ async def get_ticket( ): """Get ticket with all messages.""" query = ( - select(Ticket) - .where(Ticket.id == ticket_id, Ticket.user_id == user.id) - .options(selectinload(Ticket.messages)) + select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages)) ) result = await db.execute(query) @@ -213,7 +207,7 @@ async def get_ticket( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) messages = sorted(ticket.messages or [], key=lambda m: m.created_at) @@ -221,18 +215,18 @@ async def get_ticket( return TicketDetailResponse( id=ticket.id, - title=ticket.title or f"Ticket #{ticket.id}", + title=ticket.title or f'Ticket #{ticket.id}', status=ticket.status, - priority=ticket.priority or "normal", + priority=ticket.priority or 'normal', created_at=ticket.created_at, updated_at=ticket.updated_at or ticket.created_at, closed_at=ticket.closed_at, - is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, "is_reply_blocked") else False, + is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False, messages=messages_response, ) -@router.post("/{ticket_id}/messages", response_model=TicketMessageResponse) +@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse) async def add_ticket_message( ticket_id: int, request: TicketMessageCreateRequest, @@ -248,21 +242,21 @@ async def add_ticket_message( if not ticket: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Ticket not found", + detail='Ticket not found', ) # Check if ticket is closed - if ticket.status == "closed": + if ticket.status == 'closed': raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot add message to closed ticket", + detail='Cannot add message to closed ticket', ) # Check if replies are blocked - if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_blocked: + if hasattr(ticket, 'is_reply_blocked') and ticket.is_reply_blocked: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Replies to this ticket are blocked", + detail='Replies to this ticket are blocked', ) # Create message with optional media @@ -279,8 +273,8 @@ async def add_ticket_message( db.add(message) # Update ticket status and timestamp - if ticket.status == "answered": - ticket.status = "pending" + if ticket.status == 'answered': + ticket.status = 'pending' ticket.updated_at = datetime.utcnow() await db.commit() @@ -290,7 +284,7 @@ async def add_ticket_message( try: await notify_admins_about_ticket_reply(ticket, request.message, db) except Exception as e: - logger.error(f"Error notifying admins about ticket reply from cabinet: {e}") + logger.error(f'Error notifying admins about ticket reply from cabinet: {e}') # Уведомить админов в кабинете try: @@ -299,8 +293,8 @@ async def add_ticket_message( ) if notification: # Отправить WebSocket уведомление - await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id) + await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id) except Exception as e: - logger.error(f"Error creating cabinet notification for user reply: {e}") + logger.error(f'Error creating cabinet notification for user reply: {e}') return _message_to_response(message) diff --git a/app/cabinet/routes/websocket.py b/app/cabinet/routes/websocket.py index ae883c56..4778a93e 100644 --- a/app/cabinet/routes/websocket.py +++ b/app/cabinet/routes/websocket.py @@ -5,14 +5,14 @@ from __future__ import annotations import asyncio import json import logging -from typing import Any, Dict, Set from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from app.database.database import AsyncSessionLocal -from app.database.crud.user import get_user_by_id -from app.config import settings from app.cabinet.auth.jwt_handler import get_token_payload +from app.config import settings +from app.database.crud.user import get_user_by_id +from app.database.database import AsyncSessionLocal + logger = logging.getLogger(__name__) @@ -24,9 +24,9 @@ class CabinetConnectionManager: def __init__(self): # user_id -> set of websocket connections - self._user_connections: Dict[int, Set[WebSocket]] = {} + self._user_connections: dict[int, set[WebSocket]] = {} # admin user_ids -> set of websocket connections - self._admin_connections: Dict[int, Set[WebSocket]] = {} + self._admin_connections: dict[int, set[WebSocket]] = {} self._lock = asyncio.Lock() async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None: @@ -42,8 +42,10 @@ class CabinetConnectionManager: self._admin_connections[user_id].add(websocket) logger.debug( - "Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d", - user_id, is_admin, len(self._user_connections) + 'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d', + user_id, + is_admin, + len(self._user_connections), ) async def disconnect(self, websocket: WebSocket, user_id: int) -> None: @@ -59,7 +61,7 @@ class CabinetConnectionManager: if not self._admin_connections[user_id]: del self._admin_connections[user_id] - logger.debug("Cabinet WS disconnected: user_id=%d", user_id) + logger.debug('Cabinet WS disconnected: user_id=%d', user_id) async def send_to_user(self, user_id: int, message: dict) -> None: """Отправить сообщение конкретному пользователю.""" @@ -77,7 +79,7 @@ class CabinetConnectionManager: try: await ws.send_text(data) except Exception as e: - logger.warning("Failed to send to user %d: %s", user_id, e) + logger.warning('Failed to send to user %d: %s', user_id, e) disconnected.add(ws) # Cleanup disconnected @@ -93,20 +95,17 @@ class CabinetConnectionManager: if not self._admin_connections: return # Create a snapshot: list of (user_id, list of websockets) - admin_snapshot = [ - (user_id, list(connections)) - for user_id, connections in self._admin_connections.items() - ] + admin_snapshot = [(user_id, list(connections)) for user_id, connections in self._admin_connections.items()] data = json.dumps(message, default=str, ensure_ascii=False) - disconnected_by_user: Dict[int, Set[WebSocket]] = {} + disconnected_by_user: dict[int, set[WebSocket]] = {} for user_id, connections in admin_snapshot: for ws in connections: try: await ws.send_text(data) except Exception as e: - logger.warning("Failed to send to admin %d: %s", user_id, e) + logger.warning('Failed to send to admin %d: %s', user_id, e) if user_id not in disconnected_by_user: disconnected_by_user[user_id] = set() disconnected_by_user[user_id].add(ws) @@ -133,58 +132,55 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]: if not token: return None, False - payload = get_token_payload(token, expected_type="access") + payload = get_token_payload(token, expected_type='access') if not payload: return None, False try: - user_id = int(payload.get("sub")) + user_id = int(payload.get('sub')) except (TypeError, ValueError): return None, False async with AsyncSessionLocal() as db: user = await get_user_by_id(db, user_id) - if not user or user.status != "active": + if not user or user.status != 'active': return None, False - is_admin = settings.is_admin( - telegram_id=user.telegram_id, - email=user.email if user.email_verified else None - ) + is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None) return user_id, is_admin -@router.websocket("/ws") +@router.websocket('/ws') async def cabinet_websocket_endpoint(websocket: WebSocket): """WebSocket endpoint для real-time уведомлений кабинета.""" - client_host = websocket.client.host if websocket.client else "unknown" + client_host = websocket.client.host if websocket.client else 'unknown' # Получаем токен из query params - token = websocket.query_params.get("token") + token = websocket.query_params.get('token') if not token: - logger.debug("Cabinet WS: No token from %s", client_host) + logger.debug('Cabinet WS: No token from %s', client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() - await websocket.close(code=1008, reason="Unauthorized: No token") + await websocket.close(code=1008, reason='Unauthorized: No token') return # Верифицируем токен user_id, is_admin = await verify_cabinet_ws_token(token) if not user_id: - logger.debug("Cabinet WS: Invalid token from %s", client_host) + logger.debug('Cabinet WS: Invalid token from %s', client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() - await websocket.close(code=1008, reason="Unauthorized: Invalid token") + await websocket.close(code=1008, reason='Unauthorized: Invalid token') return # Принимаем соединение try: await websocket.accept() - logger.debug("Cabinet WS accepted: user_id=%d, is_admin=%s", user_id, is_admin) + logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin) except Exception as e: - logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e) + logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e) return # Регистрируем подключение @@ -192,11 +188,13 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): try: # Приветственное сообщение - await websocket.send_json({ - "type": "connected", - "user_id": user_id, - "is_admin": is_admin, - }) + await websocket.send_json( + { + 'type': 'connected', + 'user_id': user_id, + 'is_admin': is_admin, + } + ) # Обрабатываем входящие сообщения while True: @@ -205,21 +203,21 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): message = json.loads(data) # Ping/pong для keepalive - if message.get("type") == "ping": - await websocket.send_json({"type": "pong"}) + if message.get('type') == 'ping': + await websocket.send_json({'type': 'pong'}) except json.JSONDecodeError: - logger.warning("Cabinet WS: Invalid JSON from user %d", user_id) + logger.warning('Cabinet WS: Invalid JSON from user %d', user_id) except WebSocketDisconnect: break except Exception as e: - logger.exception("Cabinet WS error for user %d: %s", user_id, e) + logger.exception('Cabinet WS error for user %d: %s', user_id, e) break except WebSocketDisconnect: - logger.debug("Cabinet WS disconnected: user_id=%d", user_id) + logger.debug('Cabinet WS disconnected: user_id=%d', user_id) except Exception as e: - logger.exception("Cabinet WS error: %s", e) + logger.exception('Cabinet WS error: %s', e) finally: await cabinet_ws_manager.disconnect(websocket, user_id) @@ -227,31 +225,38 @@ async def cabinet_websocket_endpoint(websocket: WebSocket): # Функции для отправки уведомлений (используются из других модулей) async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None: """Уведомить пользователя об ответе в тикете.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "ticket.admin_reply", - "ticket_id": ticket_id, - "message": message, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'ticket.admin_reply', + 'ticket_id': ticket_id, + 'message': message, + }, + ) async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None: """Уведомить админов о новом тикете.""" - await cabinet_ws_manager.send_to_admins({ - "type": "ticket.new", - "ticket_id": ticket_id, - "title": title, - "user_id": user_id, - }) + await cabinet_ws_manager.send_to_admins( + { + 'type': 'ticket.new', + 'ticket_id': ticket_id, + 'title': title, + 'user_id': user_id, + } + ) async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None: """Уведомить админов об ответе пользователя.""" - await cabinet_ws_manager.send_to_admins({ - "type": "ticket.user_reply", - "ticket_id": ticket_id, - "message": message, - "user_id": user_id, - }) + await cabinet_ws_manager.send_to_admins( + { + 'type': 'ticket.user_reply', + 'ticket_id': ticket_id, + 'message': message, + 'user_id': user_id, + } + ) # ============================================================================ @@ -263,34 +268,40 @@ async def notify_user_balance_topup( user_id: int, amount_kopeks: int, new_balance_kopeks: int, - description: str = "", + description: str = '', ) -> None: """Уведомить пользователя о пополнении баланса.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "balance.topup", - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "new_balance_kopeks": new_balance_kopeks, - "new_balance_rubles": new_balance_kopeks / 100, - "description": description, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'balance.topup', + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'new_balance_kopeks': new_balance_kopeks, + 'new_balance_rubles': new_balance_kopeks / 100, + 'description': description, + }, + ) async def notify_user_balance_change( user_id: int, amount_kopeks: int, new_balance_kopeks: int, - description: str = "", + description: str = '', ) -> None: """Уведомить пользователя об изменении баланса.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "balance.change", - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "new_balance_kopeks": new_balance_kopeks, - "new_balance_rubles": new_balance_kopeks / 100, - "description": description, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'balance.change', + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'new_balance_kopeks': new_balance_kopeks, + 'new_balance_rubles': new_balance_kopeks / 100, + 'description': description, + }, + ) # ============================================================================ @@ -301,14 +312,17 @@ async def notify_user_balance_change( async def notify_user_subscription_activated( user_id: int, expires_at: str, - tariff_name: str = "", + tariff_name: str = '', ) -> None: """Уведомить пользователя об активации подписки.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.activated", - "expires_at": expires_at, - "tariff_name": tariff_name, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.activated', + 'expires_at': expires_at, + 'tariff_name': tariff_name, + }, + ) async def notify_user_subscription_expiring( @@ -317,18 +331,24 @@ async def notify_user_subscription_expiring( expires_at: str, ) -> None: """Уведомить пользователя о скором истечении подписки.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.expiring", - "days_left": days_left, - "expires_at": expires_at, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.expiring', + 'days_left': days_left, + 'expires_at': expires_at, + }, + ) async def notify_user_subscription_expired(user_id: int) -> None: """Уведомить пользователя об истечении подписки.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.expired", - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.expired', + }, + ) async def notify_user_subscription_renewed( @@ -337,12 +357,15 @@ async def notify_user_subscription_renewed( amount_kopeks: int = 0, ) -> None: """Уведомить пользователя о продлении подписки.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.renewed", - "new_expires_at": new_expires_at, - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.renewed', + 'new_expires_at': new_expires_at, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + }, + ) # ============================================================================ @@ -356,23 +379,29 @@ async def notify_user_autopay_success( new_expires_at: str, ) -> None: """Уведомить пользователя об успешном автопродлении.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "autopay.success", - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "new_expires_at": new_expires_at, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'autopay.success', + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'new_expires_at': new_expires_at, + }, + ) async def notify_user_autopay_failed( user_id: int, - reason: str = "", + reason: str = '', ) -> None: """Уведомить пользователя о неудачном автопродлении.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "autopay.failed", - "reason": reason, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'autopay.failed', + 'reason': reason, + }, + ) async def notify_user_autopay_insufficient_funds( @@ -381,13 +410,16 @@ async def notify_user_autopay_insufficient_funds( balance_kopeks: int, ) -> None: """Уведомить о недостатке средств для автопродления.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "autopay.insufficient_funds", - "required_kopeks": required_kopeks, - "required_rubles": required_kopeks / 100, - "balance_kopeks": balance_kopeks, - "balance_rubles": balance_kopeks / 100, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'autopay.insufficient_funds', + 'required_kopeks': required_kopeks, + 'required_rubles': required_kopeks / 100, + 'balance_kopeks': balance_kopeks, + 'balance_rubles': balance_kopeks / 100, + }, + ) # ============================================================================ @@ -395,27 +427,36 @@ async def notify_user_autopay_insufficient_funds( # ============================================================================ -async def notify_user_ban(user_id: int, reason: str = "") -> None: +async def notify_user_ban(user_id: int, reason: str = '') -> None: """Уведомить пользователя о блокировке.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "account.banned", - "reason": reason, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'account.banned', + 'reason': reason, + }, + ) async def notify_user_unban(user_id: int) -> None: """Уведомить пользователя о разблокировке.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "account.unbanned", - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'account.unbanned', + }, + ) async def notify_user_warning(user_id: int, message: str) -> None: """Уведомить пользователя о предупреждении.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "account.warning", - "message": message, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'account.warning', + 'message': message, + }, + ) # ============================================================================ @@ -426,26 +467,32 @@ async def notify_user_warning(user_id: int, message: str) -> None: async def notify_user_referral_bonus( user_id: int, bonus_kopeks: int, - referral_name: str = "", + referral_name: str = '', ) -> None: """Уведомить пользователя о реферальном бонусе.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "referral.bonus", - "bonus_kopeks": bonus_kopeks, - "bonus_rubles": bonus_kopeks / 100, - "referral_name": referral_name, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'referral.bonus', + 'bonus_kopeks': bonus_kopeks, + 'bonus_rubles': bonus_kopeks / 100, + 'referral_name': referral_name, + }, + ) async def notify_user_referral_registered( user_id: int, - referral_name: str = "", + referral_name: str = '', ) -> None: """Уведомить пользователя о регистрации нового реферала.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "referral.registered", - "referral_name": referral_name, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'referral.registered', + 'referral_name': referral_name, + }, + ) # ============================================================================ @@ -459,31 +506,40 @@ async def notify_user_daily_debit( new_balance_kopeks: int, ) -> None: """Уведомить о ежедневном списании.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.daily_debit", - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "new_balance_kopeks": new_balance_kopeks, - "new_balance_rubles": new_balance_kopeks / 100, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.daily_debit', + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'new_balance_kopeks': new_balance_kopeks, + 'new_balance_rubles': new_balance_kopeks / 100, + }, + ) async def notify_user_traffic_reset(user_id: int) -> None: """Уведомить о сбросе трафика.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "subscription.traffic_reset", - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'subscription.traffic_reset', + }, + ) async def notify_user_payment_received( user_id: int, amount_kopeks: int, - payment_method: str = "", + payment_method: str = '', ) -> None: """Уведомить о полученном платеже.""" - await cabinet_ws_manager.send_to_user(user_id, { - "type": "payment.received", - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "payment_method": payment_method, - }) + await cabinet_ws_manager.send_to_user( + user_id, + { + 'type': 'payment.received', + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'payment_method': payment_method, + }, + ) diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index 7d9320c7..4aa31450 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -1,41 +1,43 @@ """ API роуты колеса удачи для пользователей. """ + import logging import math import time + import httpx from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel -from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings -from app.database.models import User -from app.database.crud.wheel import ( - get_or_create_wheel_config, - get_wheel_prizes, - get_user_spins_today, - get_user_spin_history, -) -from app.services.wheel_service import wheel_service from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user from app.cabinet.schemas.wheel import ( - WheelConfigResponse, - WheelPrizeDisplay, SpinAvailabilityResponse, + SpinHistoryItem, + SpinHistoryResponse, SpinRequest, SpinResultResponse, - SpinHistoryResponse, - SpinHistoryItem, + WheelConfigResponse, + WheelPrizeDisplay, ) +from app.config import settings +from app.database.crud.wheel import ( + get_or_create_wheel_config, + get_user_spin_history, + get_user_spins_today, + get_wheel_prizes, +) +from app.database.models import User +from app.services.wheel_service import wheel_service + logger = logging.getLogger(__name__) -router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"]) +router = APIRouter(prefix='/wheel', tags=['Fortune Wheel']) -@router.get("/config", response_model=WheelConfigResponse) +@router.get('/config', response_model=WheelConfigResponse) async def get_wheel_config( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -78,7 +80,7 @@ async def get_wheel_config( ) -@router.get("/availability", response_model=SpinAvailabilityResponse) +@router.get('/availability', response_model=SpinAvailabilityResponse) async def check_spin_availability( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -99,7 +101,7 @@ async def check_spin_availability( ) -@router.post("/spin", response_model=SpinResultResponse) +@router.post('/spin', response_model=SpinResultResponse) async def spin_wheel( request: SpinRequest, user: User = Depends(get_current_cabinet_user), @@ -130,7 +132,7 @@ async def spin_wheel( ) -@router.get("/history", response_model=SpinHistoryResponse) +@router.get('/history', response_model=SpinHistoryResponse) async def get_spin_history( page: int = 1, per_page: int = 20, @@ -138,8 +140,7 @@ async def get_spin_history( db: AsyncSession = Depends(get_cabinet_db), ): """Получить историю спинов пользователя.""" - if page < 1: - page = 1 + page = max(page, 1) if per_page < 1 or per_page > 100: per_page = 20 @@ -150,24 +151,26 @@ async def get_spin_history( items = [] for spin in spins: # Получаем emoji и color из приза, если он есть - emoji = "🎁" - color = "#3B82F6" + emoji = '🎁' + color = '#3B82F6' if spin.prize: emoji = spin.prize.emoji color = spin.prize.color - items.append(SpinHistoryItem( - id=spin.id, - payment_type=spin.payment_type, - payment_amount=spin.payment_amount, - prize_type=spin.prize_type, - prize_value=spin.prize_value, - prize_display_name=spin.prize_display_name, - emoji=emoji, - color=color, - prize_value_kopeks=spin.prize_value_kopeks, - created_at=spin.created_at, - )) + items.append( + SpinHistoryItem( + id=spin.id, + payment_type=spin.payment_type, + payment_amount=spin.payment_amount, + prize_type=spin.prize_type, + prize_value=spin.prize_value, + prize_display_name=spin.prize_display_name, + emoji=emoji, + color=color, + prize_value_kopeks=spin.prize_value_kopeks, + created_at=spin.created_at, + ) + ) pages = math.ceil(total / per_page) if total > 0 else 1 @@ -182,11 +185,12 @@ async def get_spin_history( class StarsInvoiceResponse(BaseModel): """Ответ с ссылкой на Stars invoice.""" + invoice_url: str stars_amount: int -@router.post("/stars-invoice", response_model=StarsInvoiceResponse) +@router.post('/stars-invoice', response_model=StarsInvoiceResponse) async def create_stars_invoice( user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), @@ -200,13 +204,13 @@ async def create_stars_invoice( if not config.is_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Колесо удачи недоступно", + detail='Колесо удачи недоступно', ) if not config.spin_cost_stars_enabled or not config.spin_cost_stars: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Оплата Stars не включена", + detail='Оплата Stars не включена', ) # Проверяем лимит спинов @@ -214,7 +218,7 @@ async def create_stars_invoice( if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Достигнут дневной лимит спинов", + detail='Достигнут дневной лимит спинов', ) # Проверяем наличие призов @@ -222,41 +226,41 @@ async def create_stars_invoice( if not prizes: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Призы не настроены", + detail='Призы не настроены', ) stars_amount = config.spin_cost_stars - payload = f"wheel_spin_{user.id}_{int(time.time())}" + payload = f'wheel_spin_{user.id}_{int(time.time())}' # Создаем invoice через Telegram Bot API try: bot_token = settings.BOT_TOKEN - api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink" + api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' async with httpx.AsyncClient() as client: response = await client.post( api_url, json={ - "title": "Колесо удачи", - "description": f"Спин колеса удачи ({stars_amount} ⭐)", - "payload": payload, - "provider_token": "", # Пустой для Stars - "currency": "XTR", - "prices": [{"label": "Спин колеса", "amount": stars_amount}], + 'title': 'Колесо удачи', + 'description': f'Спин колеса удачи ({stars_amount} ⭐)', + 'payload': payload, + 'provider_token': '', # Пустой для Stars + 'currency': 'XTR', + 'prices': [{'label': 'Спин колеса', 'amount': stars_amount}], }, ) result = response.json() - if not result.get("ok"): - logger.error(f"Telegram API error: {result}") + if not result.get('ok'): + logger.error(f'Telegram API error: {result}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Ошибка создания инвойса", + detail='Ошибка создания инвойса', ) - invoice_url = result["result"] - logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}") + invoice_url = result['result'] + logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}') return StarsInvoiceResponse( invoice_url=invoice_url, @@ -264,8 +268,8 @@ async def create_stars_invoice( ) except httpx.HTTPError as e: - logger.error(f"HTTP error creating invoice: {e}") + logger.error(f'HTTP error creating invoice: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Ошибка соединения с Telegram", + detail='Ошибка соединения с Telegram', ) diff --git a/app/cabinet/schemas/__init__.py b/app/cabinet/schemas/__init__.py index 8bca5b54..a69ecd2c 100644 --- a/app/cabinet/schemas/__init__.py +++ b/app/cabinet/schemas/__init__.py @@ -1,88 +1,89 @@ """Cabinet Pydantic schemas.""" from .auth import ( - TelegramAuthRequest, - TelegramWidgetAuthRequest, + AuthResponse, + EmailLoginRequest, EmailRegisterRequest, EmailVerifyRequest, - EmailLoginRequest, - RefreshTokenRequest, PasswordForgotRequest, PasswordResetRequest, + RefreshTokenRequest, + TelegramAuthRequest, + TelegramWidgetAuthRequest, TokenResponse, UserResponse, - AuthResponse, -) -from .subscription import ( - SubscriptionResponse, - TrafficPurchaseInfo, - RenewalOptionResponse, - RenewalRequest, - TrafficPackageResponse, - TrafficPurchaseRequest, - DevicePurchaseRequest, - AutopayUpdateRequest, ) from .balance import ( BalanceResponse, - TransactionResponse, - TransactionListResponse, PaymentMethodResponse, TopUpRequest, TopUpResponse, + TransactionListResponse, + TransactionResponse, ) from .referral import ( + ReferralEarningResponse, ReferralInfoResponse, ReferralListResponse, - ReferralEarningResponse, ReferralTermsResponse, ) +from .subscription import ( + AutopayUpdateRequest, + DevicePurchaseRequest, + RenewalOptionResponse, + RenewalRequest, + SubscriptionResponse, + TrafficPackageResponse, + TrafficPurchaseInfo, + TrafficPurchaseRequest, +) from .tickets import ( - TicketResponse, - TicketListResponse, - TicketMessageResponse, TicketCreateRequest, + TicketListResponse, TicketMessageCreateRequest, + TicketMessageResponse, + TicketResponse, ) + __all__ = [ - # Auth - "TelegramAuthRequest", - "TelegramWidgetAuthRequest", - "EmailRegisterRequest", - "EmailVerifyRequest", - "EmailLoginRequest", - "RefreshTokenRequest", - "PasswordForgotRequest", - "PasswordResetRequest", - "TokenResponse", - "UserResponse", - "AuthResponse", - # Subscription - "SubscriptionResponse", - "TrafficPurchaseInfo", - "RenewalOptionResponse", - "RenewalRequest", - "TrafficPackageResponse", - "TrafficPurchaseRequest", - "DevicePurchaseRequest", - "AutopayUpdateRequest", + 'AuthResponse', + 'AutopayUpdateRequest', # Balance - "BalanceResponse", - "TransactionResponse", - "TransactionListResponse", - "PaymentMethodResponse", - "TopUpRequest", - "TopUpResponse", + 'BalanceResponse', + 'DevicePurchaseRequest', + 'EmailLoginRequest', + 'EmailRegisterRequest', + 'EmailVerifyRequest', + 'PasswordForgotRequest', + 'PasswordResetRequest', + 'PaymentMethodResponse', + 'ReferralEarningResponse', # Referral - "ReferralInfoResponse", - "ReferralListResponse", - "ReferralEarningResponse", - "ReferralTermsResponse", + 'ReferralInfoResponse', + 'ReferralListResponse', + 'ReferralTermsResponse', + 'RefreshTokenRequest', + 'RenewalOptionResponse', + 'RenewalRequest', + # Subscription + 'SubscriptionResponse', + # Auth + 'TelegramAuthRequest', + 'TelegramWidgetAuthRequest', + 'TicketCreateRequest', + 'TicketListResponse', + 'TicketMessageCreateRequest', + 'TicketMessageResponse', # Tickets - "TicketResponse", - "TicketListResponse", - "TicketMessageResponse", - "TicketCreateRequest", - "TicketMessageCreateRequest", + 'TicketResponse', + 'TokenResponse', + 'TopUpRequest', + 'TopUpResponse', + 'TrafficPackageResponse', + 'TrafficPurchaseInfo', + 'TrafficPurchaseRequest', + 'TransactionListResponse', + 'TransactionResponse', + 'UserResponse', ] diff --git a/app/cabinet/schemas/auth.py b/app/cabinet/schemas/auth.py index b18e6887..ca44089c 100644 --- a/app/cabinet/schemas/auth.py +++ b/app/cabinet/schemas/auth.py @@ -1,82 +1,92 @@ """Authentication schemas for cabinet.""" from datetime import datetime -from typing import Optional, Dict, Any + from pydantic import BaseModel, EmailStr, Field class TelegramAuthRequest(BaseModel): """Request for Telegram WebApp initData authentication.""" - init_data: str = Field(..., description="Telegram WebApp initData string") + + init_data: str = Field(..., description='Telegram WebApp initData string') class TelegramWidgetAuthRequest(BaseModel): """Request for Telegram Login Widget authentication.""" - id: int = Field(..., description="Telegram user ID") + + id: int = Field(..., description='Telegram user ID') first_name: str = Field(..., description="User's first name") - last_name: Optional[str] = Field(None, description="User's last name") - username: Optional[str] = Field(None, description="User's username") - photo_url: Optional[str] = Field(None, description="User's photo URL") - auth_date: int = Field(..., description="Unix timestamp of authentication") - hash: str = Field(..., description="Authentication hash") + last_name: str | None = Field(None, description="User's last name") + username: str | None = Field(None, description="User's username") + photo_url: str | None = Field(None, description="User's photo URL") + auth_date: int = Field(..., description='Unix timestamp of authentication') + hash: str = Field(..., description='Authentication hash') class EmailRegisterRequest(BaseModel): """Request to register/link email to existing Telegram account.""" - email: EmailStr = Field(..., description="Email address") - password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)") + + email: EmailStr = Field(..., description='Email address') + password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)') class EmailVerifyRequest(BaseModel): """Request to verify email with token.""" - token: str = Field(..., description="Email verification token") + + token: str = Field(..., description='Email verification token') class EmailLoginRequest(BaseModel): """Request to login with email and password.""" - email: EmailStr = Field(..., description="Email address") - password: str = Field(..., description="Password") + + email: EmailStr = Field(..., description='Email address') + password: str = Field(..., description='Password') class RefreshTokenRequest(BaseModel): """Request to refresh access token.""" - refresh_token: str = Field(..., description="Refresh token") + + refresh_token: str = Field(..., description='Refresh token') class PasswordForgotRequest(BaseModel): """Request to initiate password reset.""" - email: EmailStr = Field(..., description="Email address") + + email: EmailStr = Field(..., description='Email address') class PasswordResetRequest(BaseModel): """Request to reset password with token.""" - token: str = Field(..., description="Password reset token") - password: str = Field(..., min_length=8, max_length=128, description="New password (min 8 chars)") + + token: str = Field(..., description='Password reset token') + password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)') class TokenResponse(BaseModel): """Token pair response.""" + access_token: str refresh_token: str - token_type: str = "bearer" - expires_in: int = Field(..., description="Access token expiration in seconds") + token_type: str = 'bearer' + expires_in: int = Field(..., description='Access token expiration in seconds') class UserResponse(BaseModel): """User data response.""" + id: int - telegram_id: Optional[int] = None # Nullable для email-only пользователей - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - email: Optional[str] = None + telegram_id: int | None = None # Nullable для email-only пользователей + username: str | None = None + first_name: str | None = None + last_name: str | None = None + email: str | None = None email_verified: bool = False balance_kopeks: int = 0 balance_rubles: float = 0.0 - referral_code: Optional[str] = None - language: str = "ru" + referral_code: str | None = None + language: str = 'ru' created_at: datetime - auth_type: str = "telegram" # "telegram" или "email" + auth_type: str = 'telegram' # "telegram" или "email" class Config: from_attributes = True @@ -84,16 +94,18 @@ class UserResponse(BaseModel): class EmailRegisterStandaloneRequest(BaseModel): """Request to register new account with email (no Telegram required).""" - email: EmailStr = Field(..., description="Email address") - password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 chars)") - first_name: Optional[str] = Field(None, max_length=64, description="First name") - language: str = Field("ru", description="Preferred language") + + email: EmailStr = Field(..., description='Email address') + password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)') + first_name: str | None = Field(None, max_length=64, description='First name') + language: str = Field('ru', description='Preferred language') class AuthResponse(BaseModel): """Full authentication response with tokens and user.""" + access_token: str refresh_token: str - token_type: str = "bearer" + token_type: str = 'bearer' expires_in: int user: UserResponse diff --git a/app/cabinet/schemas/balance.py b/app/cabinet/schemas/balance.py index 141c180f..140cfd00 100644 --- a/app/cabinet/schemas/balance.py +++ b/app/cabinet/schemas/balance.py @@ -1,27 +1,30 @@ """Balance and payment schemas for cabinet.""" from datetime import datetime -from typing import Optional, List, Dict, Any +from typing import Any + from pydantic import BaseModel, Field class BalanceResponse(BaseModel): """User balance data.""" + balance_kopeks: int balance_rubles: float class TransactionResponse(BaseModel): """Transaction history item.""" + id: int type: str amount_kopeks: int amount_rubles: float - description: Optional[str] = None - payment_method: Optional[str] = None + description: str | None = None + payment_method: str | None = None is_completed: bool created_at: datetime - completed_at: Optional[datetime] = None + completed_at: datetime | None = None class Config: from_attributes = True @@ -29,7 +32,8 @@ class TransactionResponse(BaseModel): class TransactionListResponse(BaseModel): """Paginated transaction list.""" - items: List[TransactionResponse] + + items: list[TransactionResponse] total: int page: int per_page: int @@ -38,46 +42,52 @@ class TransactionListResponse(BaseModel): class PaymentOptionResponse(BaseModel): """Payment method option (e.g. Platega sub-methods).""" + id: str name: str - description: Optional[str] = None + description: str | None = None class PaymentMethodResponse(BaseModel): """Available payment method.""" + id: str name: str - description: Optional[str] = None + description: str | None = None min_amount_kopeks: int max_amount_kopeks: int is_available: bool = True - options: Optional[List[Dict[str, Any]]] = None + options: list[dict[str, Any]] | None = None class TopUpRequest(BaseModel): """Request to create payment for balance top-up.""" - amount_kopeks: int = Field(..., ge=1000, description="Amount in kopeks (min 10 rubles)") - payment_method: str = Field(..., description="Payment method ID") - payment_option: Optional[str] = Field(None, description="Payment option (e.g. Platega method code)") + + amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)') + payment_method: str = Field(..., description='Payment method ID') + payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)') class TopUpResponse(BaseModel): """Response with payment info.""" + payment_id: str payment_url: str amount_kopeks: int amount_rubles: float status: str - expires_at: Optional[datetime] = None + expires_at: datetime | None = None class StarsInvoiceRequest(BaseModel): """Request to create Telegram Stars invoice for balance top-up.""" - amount_kopeks: int = Field(..., ge=100, description="Amount in kopeks (min 1 ruble)") + + amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)') class StarsInvoiceResponse(BaseModel): """Response with Telegram Stars invoice link.""" + invoice_url: str stars_amount: int amount_kopeks: int @@ -85,6 +95,7 @@ class StarsInvoiceResponse(BaseModel): class PendingPaymentResponse(BaseModel): """Pending payment details for manual verification.""" + id: int method: str method_display: str @@ -97,11 +108,11 @@ class PendingPaymentResponse(BaseModel): is_paid: bool is_checkable: bool created_at: datetime - expires_at: Optional[datetime] = None - payment_url: Optional[str] = None - user_id: Optional[int] = None - user_telegram_id: Optional[int] = None - user_username: Optional[str] = None + expires_at: datetime | None = None + payment_url: str | None = None + user_id: int | None = None + user_telegram_id: int | None = None + user_username: str | None = None class Config: from_attributes = True @@ -109,7 +120,8 @@ class PendingPaymentResponse(BaseModel): class PendingPaymentListResponse(BaseModel): """Paginated list of pending payments.""" - items: List[PendingPaymentResponse] + + items: list[PendingPaymentResponse] total: int page: int per_page: int @@ -118,9 +130,10 @@ class PendingPaymentListResponse(BaseModel): class ManualCheckResponse(BaseModel): """Response after manual payment status check.""" + success: bool message: str - payment: Optional[PendingPaymentResponse] = None + payment: PendingPaymentResponse | None = None status_changed: bool = False - old_status: Optional[str] = None - new_status: Optional[str] = None + old_status: str | None = None + new_status: str | None = None diff --git a/app/cabinet/schemas/ban_system.py b/app/cabinet/schemas/ban_system.py index defdb1f9..44576482 100644 --- a/app/cabinet/schemas/ban_system.py +++ b/app/cabinet/schemas/ban_system.py @@ -1,22 +1,27 @@ """Schemas for Ban System integration in cabinet.""" from datetime import datetime -from typing import List, Optional, Dict, Any +from typing import Any + from pydantic import BaseModel, Field # === Status === + class BanSystemStatusResponse(BaseModel): """Ban System integration status.""" + enabled: bool configured: bool # === Stats === + class BanSystemStatsResponse(BaseModel): """Overall Ban System statistics.""" + total_users: int = 0 active_users: int = 0 users_over_limit: int = 0 @@ -28,48 +33,53 @@ class BanSystemStatsResponse(BaseModel): agents_online: int = 0 agents_total: int = 0 panel_connected: bool = False - uptime_seconds: Optional[int] = None + uptime_seconds: int | None = None # === Users === + class BanUserIPInfo(BaseModel): """User IP address information.""" + ip: str - first_seen: Optional[datetime] = None - last_seen: Optional[datetime] = None - node: Optional[str] = None + first_seen: datetime | None = None + last_seen: datetime | None = None + node: str | None = None request_count: int = 0 - country_code: Optional[str] = None - country_name: Optional[str] = None - city: Optional[str] = None + country_code: str | None = None + country_name: str | None = None + city: str | None = None class BanUserRequestLog(BaseModel): """User request log entry.""" + timestamp: datetime source_ip: str - destination: Optional[str] = None - dest_port: Optional[int] = None - protocol: Optional[str] = None - action: Optional[str] = None - node: Optional[str] = None + destination: str | None = None + dest_port: int | None = None + protocol: str | None = None + action: str | None = None + node: str | None = None class BanUserListItem(BaseModel): """User in the list.""" + email: str unique_ip_count: int = 0 total_requests: int = 0 - limit: Optional[int] = None + limit: int | None = None is_over_limit: bool = False blocked_count: int = 0 - last_seen: Optional[datetime] = None + last_seen: datetime | None = None class BanUsersListResponse(BaseModel): """Paginated list of users.""" - users: List[BanUserListItem] = [] + + users: list[BanUserListItem] = [] total: int = 0 offset: int = 0 limit: int = 50 @@ -77,83 +87,95 @@ class BanUsersListResponse(BaseModel): class BanUserDetailResponse(BaseModel): """Detailed user information.""" + email: str unique_ip_count: int = 0 total_requests: int = 0 - limit: Optional[int] = None + limit: int | None = None is_over_limit: bool = False blocked_count: int = 0 - ips: List[BanUserIPInfo] = [] - recent_requests: List[BanUserRequestLog] = [] - network_type: Optional[str] = None # wifi, mobile, mixed + ips: list[BanUserIPInfo] = [] + recent_requests: list[BanUserRequestLog] = [] + network_type: str | None = None # wifi, mobile, mixed # === Punishments (Bans) === + class BanPunishmentItem(BaseModel): """Punishment/ban entry.""" - id: Optional[int] = None + + id: int | None = None user_id: str - uuid: Optional[str] = None + uuid: str | None = None username: str - reason: Optional[str] = None + reason: str | None = None punished_at: datetime - enable_at: Optional[datetime] = None + enable_at: datetime | None = None ip_count: int = 0 limit: int = 0 enabled: bool = False - enabled_at: Optional[datetime] = None - node_name: Optional[str] = None + enabled_at: datetime | None = None + node_name: str | None = None class BanPunishmentsListResponse(BaseModel): """List of active punishments.""" - punishments: List[BanPunishmentItem] = [] + + punishments: list[BanPunishmentItem] = [] total: int = 0 class BanHistoryResponse(BaseModel): """Punishment history.""" - items: List[BanPunishmentItem] = [] + + items: list[BanPunishmentItem] = [] total: int = 0 class BanUserRequest(BaseModel): """Request to ban a user.""" + username: str = Field(..., min_length=1) minutes: int = Field(default=30, ge=1) - reason: Optional[str] = Field(None, max_length=500) + reason: str | None = Field(None, max_length=500) class UnbanResponse(BaseModel): """Unban response.""" + success: bool message: str # === Nodes === + class BanNodeItem(BaseModel): """Node information.""" + name: str - address: Optional[str] = None + address: str | None = None is_connected: bool = False - last_seen: Optional[datetime] = None + last_seen: datetime | None = None users_count: int = 0 - agent_stats: Optional[Dict[str, Any]] = None + agent_stats: dict[str, Any] | None = None class BanNodesListResponse(BaseModel): """List of nodes.""" - nodes: List[BanNodeItem] = [] + + nodes: list[BanNodeItem] = [] total: int = 0 online: int = 0 # === Agents === + class BanAgentItem(BaseModel): """Monitoring agent information.""" + node_name: str sent_total: int = 0 dropped_total: int = 0 @@ -166,13 +188,14 @@ class BanAgentItem(BaseModel): dedup_skipped: int = 0 filter_checked: int = 0 filter_filtered: int = 0 - health: str = "unknown" # healthy, warning, critical + health: str = 'unknown' # healthy, warning, critical is_online: bool = False - last_report: Optional[datetime] = None + last_report: datetime | None = None class BanAgentsSummary(BaseModel): """Agents summary statistics.""" + total_agents: int = 0 online_agents: int = 0 total_sent: int = 0 @@ -185,16 +208,19 @@ class BanAgentsSummary(BaseModel): class BanAgentsListResponse(BaseModel): """List of agents.""" - agents: List[BanAgentItem] = [] - summary: Optional[BanAgentsSummary] = None + + agents: list[BanAgentItem] = [] + summary: BanAgentsSummary | None = None total: int = 0 online: int = 0 # === Traffic === + class BanTrafficStats(BaseModel): """Traffic statistics.""" + total_bytes: int = 0 upload_bytes: int = 0 download_bytes: int = 0 @@ -204,22 +230,24 @@ class BanTrafficStats(BaseModel): class BanTrafficUserItem(BaseModel): """User traffic information.""" + username: str - email: Optional[str] = None + email: str | None = None total_bytes: int = 0 upload_bytes: int = 0 download_bytes: int = 0 - limit_bytes: Optional[int] = None + limit_bytes: int | None = None is_over_limit: bool = False class BanTrafficViolationItem(BaseModel): """Traffic limit violation entry.""" - id: Optional[int] = None + + id: int | None = None username: str - email: Optional[str] = None + email: str | None = None violation_type: str - description: Optional[str] = None + description: str | None = None bytes_used: int = 0 bytes_limit: int = 0 detected_at: datetime @@ -228,100 +256,117 @@ class BanTrafficViolationItem(BaseModel): class BanTrafficViolationsResponse(BaseModel): """List of traffic violations.""" - violations: List[BanTrafficViolationItem] = [] + + violations: list[BanTrafficViolationItem] = [] total: int = 0 class BanTrafficTopItem(BaseModel): """Top user by traffic.""" + username: str bytes_total: int = 0 - bytes_limit: Optional[int] = None + bytes_limit: int | None = None over_limit: bool = False class BanTrafficResponse(BaseModel): """Full traffic statistics response.""" + enabled: bool = False - stats: Optional[Dict[str, Any]] = None - top_users: List[BanTrafficTopItem] = [] - recent_violations: List[BanTrafficViolationItem] = [] + stats: dict[str, Any] | None = None + top_users: list[BanTrafficTopItem] = [] + recent_violations: list[BanTrafficViolationItem] = [] # === Settings === + class BanSettingDefinition(BaseModel): """Setting definition with value.""" + key: str value: Any type: str # bool, int, str, list - min_value: Optional[int] = None - max_value: Optional[int] = None + min_value: int | None = None + max_value: int | None = None editable: bool = True - description: Optional[str] = None - category: Optional[str] = None + description: str | None = None + category: str | None = None class BanSettingsResponse(BaseModel): """All settings response.""" - settings: List[BanSettingDefinition] = [] + + settings: list[BanSettingDefinition] = [] class BanSettingUpdateRequest(BaseModel): """Request to update a setting.""" + value: Any class BanWhitelistRequest(BaseModel): """Request to add/remove from whitelist.""" + username: str = Field(..., min_length=1) # === Reports === + class BanReportTopViolator(BaseModel): """Top violator in report.""" + username: str count: int = 0 class BanReportResponse(BaseModel): """Period report response.""" + period_hours: int = 24 current_users: int = 0 current_ips: int = 0 - punishment_stats: Optional[Dict[str, Any]] = None - top_violators: List[BanReportTopViolator] = [] + punishment_stats: dict[str, Any] | None = None + top_violators: list[BanReportTopViolator] = [] # === Health === + class BanHealthComponent(BaseModel): """Health component status.""" + name: str status: str # healthy, degraded, unhealthy - message: Optional[str] = None - details: Optional[Dict[str, Any]] = None + message: str | None = None + details: dict[str, Any] | None = None class BanHealthResponse(BaseModel): """Health status response.""" + status: str # healthy, degraded, unhealthy - uptime: Optional[int] = None - components: List[BanHealthComponent] = [] + uptime: int | None = None + components: list[BanHealthComponent] = [] class BanHealthDetailedResponse(BaseModel): """Detailed health response.""" + status: str - uptime: Optional[int] = None - components: Dict[str, Any] = {} + uptime: int | None = None + components: dict[str, Any] = {} # === Agent History === + class BanAgentHistoryItem(BaseModel): """Agent history item.""" + timestamp: datetime sent_total: int = 0 dropped_total: int = 0 @@ -331,10 +376,11 @@ class BanAgentHistoryItem(BaseModel): class BanAgentHistoryResponse(BaseModel): """Agent history response.""" + node: str hours: int = 24 records: int = 0 - delta: Optional[Dict[str, Any]] = None - first: Optional[Dict[str, Any]] = None - last: Optional[Dict[str, Any]] = None - history: List[BanAgentHistoryItem] = [] + delta: dict[str, Any] | None = None + first: dict[str, Any] | None = None + last: dict[str, Any] | None = None + history: list[BanAgentHistoryItem] = [] diff --git a/app/cabinet/schemas/broadcasts.py b/app/cabinet/schemas/broadcasts.py index d01c3853..b0675538 100644 --- a/app/cabinet/schemas/broadcasts.py +++ b/app/cabinet/schemas/broadcasts.py @@ -1,23 +1,25 @@ """Pydantic schemas for cabinet broadcasts.""" from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field # ============ Filters ============ + class BroadcastFilter(BaseModel): """Single broadcast filter.""" + key: str label: str - count: Optional[int] = None - group: Optional[str] = None # basic, subscription, traffic, registration, source, activity + count: int | None = None + group: str | None = None # basic, subscription, traffic, registration, source, activity class TariffFilter(BaseModel): """Tariff-based filter.""" + key: str # tariff_1, tariff_2, ... label: str # tariff name tariff_id: int @@ -26,15 +28,18 @@ class TariffFilter(BaseModel): class BroadcastFiltersResponse(BaseModel): """Response with all available filters.""" - filters: List[BroadcastFilter] # basic filters - tariff_filters: List[TariffFilter] # tariff filters - custom_filters: List[BroadcastFilter] # custom filters + + filters: list[BroadcastFilter] # basic filters + tariff_filters: list[TariffFilter] # tariff filters + custom_filters: list[BroadcastFilter] # custom filters # ============ Tariffs ============ + class TariffForBroadcast(BaseModel): """Tariff info for broadcast filtering.""" + id: int name: str filter_key: str # tariff_{id} @@ -43,13 +48,16 @@ class TariffForBroadcast(BaseModel): class BroadcastTariffsResponse(BaseModel): """Response with tariffs for filtering.""" - tariffs: List[TariffForBroadcast] + + tariffs: list[TariffForBroadcast] # ============ Buttons ============ + class BroadcastButton(BaseModel): """Single broadcast button.""" + key: str label: str default: bool = False @@ -57,47 +65,54 @@ class BroadcastButton(BaseModel): class BroadcastButtonsResponse(BaseModel): """Response with available buttons.""" - buttons: List[BroadcastButton] + + buttons: list[BroadcastButton] # ============ Media ============ + class BroadcastMediaRequest(BaseModel): """Media attachment for broadcast.""" - type: str = Field(..., pattern=r"^(photo|video|document)$") + + type: str = Field(..., pattern=r'^(photo|video|document)$') file_id: str - caption: Optional[str] = None + caption: str | None = None # ============ Create ============ + class BroadcastCreateRequest(BaseModel): """Request to create a broadcast.""" + target: str message_text: str = Field(..., min_length=1, max_length=4000) - selected_buttons: List[str] = Field(default_factory=lambda: ["home"]) - media: Optional[BroadcastMediaRequest] = None + selected_buttons: list[str] = Field(default_factory=lambda: ['home']) + media: BroadcastMediaRequest | None = None # ============ Response ============ + class BroadcastResponse(BaseModel): """Broadcast response.""" + 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 + media_type: str | None = None + media_file_id: str | None = None + media_caption: str | None = None total_count: int sent_count: int failed_count: int status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling - admin_id: Optional[int] = None - admin_name: Optional[str] = None + admin_id: int | None = None + admin_name: str | None = None created_at: datetime - completed_at: Optional[datetime] = None + completed_at: datetime | None = None progress_percent: float = 0.0 class Config: @@ -106,7 +121,8 @@ class BroadcastResponse(BaseModel): class BroadcastListResponse(BaseModel): """Paginated list of broadcasts.""" - items: List[BroadcastResponse] + + items: list[BroadcastResponse] total: int limit: int offset: int @@ -114,12 +130,15 @@ class BroadcastListResponse(BaseModel): # ============ Preview ============ + class BroadcastPreviewRequest(BaseModel): """Request to preview broadcast recipients count.""" + target: str class BroadcastPreviewResponse(BaseModel): """Preview response with recipients count.""" + target: str count: int diff --git a/app/cabinet/schemas/campaigns.py b/app/cabinet/schemas/campaigns.py index 435c5592..c0c0530d 100644 --- a/app/cabinet/schemas/campaigns.py +++ b/app/cabinet/schemas/campaigns.py @@ -1,21 +1,24 @@ """Schemas for advertising campaigns management in cabinet.""" from datetime import datetime -from typing import List, Optional, Literal +from typing import Literal + from pydantic import BaseModel, Field -CampaignBonusType = Literal["balance", "subscription", "none", "tariff"] +CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff'] class TariffInfo(BaseModel): """Tariff info for campaign.""" + id: int name: str class CampaignListItem(BaseModel): """Campaign item for list view.""" + id: int name: str start_parameter: str @@ -32,12 +35,14 @@ class CampaignListItem(BaseModel): class CampaignListResponse(BaseModel): """Response with list of campaigns.""" - campaigns: List[CampaignListItem] + + campaigns: list[CampaignListItem] total: int class CampaignDetailResponse(BaseModel): """Detailed campaign response.""" + id: int name: str start_parameter: str @@ -47,20 +52,20 @@ class CampaignDetailResponse(BaseModel): balance_bonus_kopeks: int = 0 balance_bonus_rubles: float = 0.0 # Subscription bonus - 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) + subscription_duration_days: int | None = None + subscription_traffic_gb: int | None = None + subscription_device_limit: int | None = None + subscription_squads: list[str] = Field(default_factory=list) # Tariff bonus - tariff_id: Optional[int] = None - tariff_duration_days: Optional[int] = None - tariff: Optional[TariffInfo] = None + tariff_id: int | None = None + tariff_duration_days: int | None = None + tariff: TariffInfo | None = None # Meta - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime - updated_at: Optional[datetime] = None + updated_at: datetime | None = None # Deep link - deep_link: Optional[str] = None + deep_link: str | None = None class Config: from_attributes = True @@ -68,42 +73,45 @@ class CampaignDetailResponse(BaseModel): class CampaignCreateRequest(BaseModel): """Request to create a campaign.""" + name: str = Field(..., min_length=1, max_length=255) - start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$") + start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$') bonus_type: CampaignBonusType is_active: bool = True # Balance bonus balance_bonus_kopeks: int = Field(0, ge=0) # Subscription bonus - subscription_duration_days: Optional[int] = Field(None, ge=1) - subscription_traffic_gb: Optional[int] = Field(None, ge=0) - subscription_device_limit: Optional[int] = Field(None, ge=1) - subscription_squads: List[str] = Field(default_factory=list) + subscription_duration_days: int | None = Field(None, ge=1) + subscription_traffic_gb: int | None = Field(None, ge=0) + subscription_device_limit: int | None = Field(None, ge=1) + subscription_squads: list[str] = Field(default_factory=list) # Tariff bonus - tariff_id: Optional[int] = None - tariff_duration_days: Optional[int] = Field(None, ge=1) + tariff_id: int | None = None + tariff_duration_days: int | None = Field(None, ge=1) class CampaignUpdateRequest(BaseModel): """Request to update a campaign.""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - start_parameter: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$") - bonus_type: Optional[CampaignBonusType] = None - is_active: Optional[bool] = None + + name: str | None = Field(None, min_length=1, max_length=255) + start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$') + bonus_type: CampaignBonusType | None = None + is_active: bool | None = None # Balance bonus - balance_bonus_kopeks: Optional[int] = Field(None, ge=0) + balance_bonus_kopeks: int | None = Field(None, ge=0) # Subscription bonus - subscription_duration_days: Optional[int] = Field(None, ge=1) - subscription_traffic_gb: Optional[int] = Field(None, ge=0) - subscription_device_limit: Optional[int] = Field(None, ge=1) - subscription_squads: Optional[List[str]] = None + subscription_duration_days: int | None = Field(None, ge=1) + subscription_traffic_gb: int | None = Field(None, ge=0) + subscription_device_limit: int | None = Field(None, ge=1) + subscription_squads: list[str] | None = None # Tariff bonus - tariff_id: Optional[int] = None - tariff_duration_days: Optional[int] = Field(None, ge=1) + tariff_id: int | None = None + tariff_duration_days: int | None = Field(None, ge=1) class CampaignToggleResponse(BaseModel): """Response after toggling campaign.""" + id: int is_active: bool message: str @@ -111,6 +119,7 @@ class CampaignToggleResponse(BaseModel): class CampaignStatisticsResponse(BaseModel): """Detailed campaign statistics.""" + id: int name: str start_parameter: str @@ -121,7 +130,7 @@ class CampaignStatisticsResponse(BaseModel): balance_issued_kopeks: int = 0 balance_issued_rubles: float = 0.0 subscription_issued: int = 0 - last_registration: Optional[datetime] = None + last_registration: datetime | None = None # Revenue stats total_revenue_kopeks: int = 0 total_revenue_rubles: float = 0.0 @@ -137,21 +146,22 @@ class CampaignStatisticsResponse(BaseModel): conversion_rate: float = 0.0 trial_conversion_rate: float = 0.0 # Deep link - deep_link: Optional[str] = None + deep_link: str | None = None class CampaignRegistrationItem(BaseModel): """Campaign registration item.""" + id: int user_id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None bonus_type: str balance_bonus_kopeks: int = 0 - subscription_duration_days: Optional[int] = None - tariff_id: Optional[int] = None - tariff_duration_days: Optional[int] = None + subscription_duration_days: int | None = None + tariff_id: int | None = None + tariff_duration_days: int | None = None created_at: datetime # User stats user_balance_kopeks: int = 0 @@ -164,7 +174,8 @@ class CampaignRegistrationItem(BaseModel): class CampaignRegistrationsResponse(BaseModel): """Response with campaign registrations.""" - registrations: List[CampaignRegistrationItem] + + registrations: list[CampaignRegistrationItem] total: int page: int per_page: int @@ -172,6 +183,7 @@ class CampaignRegistrationsResponse(BaseModel): class CampaignsOverviewResponse(BaseModel): """Overview of all campaigns.""" + total: int active: int inactive: int @@ -184,7 +196,8 @@ class CampaignsOverviewResponse(BaseModel): class ServerSquadInfo(BaseModel): """Server squad info for campaign selection.""" + id: int squad_uuid: str display_name: str - country_code: Optional[str] = None + country_code: str | None = None diff --git a/app/cabinet/schemas/referral.py b/app/cabinet/schemas/referral.py index a6bf3609..db0c61f8 100644 --- a/app/cabinet/schemas/referral.py +++ b/app/cabinet/schemas/referral.py @@ -1,12 +1,13 @@ """Referral program schemas for cabinet.""" from datetime import datetime -from typing import Optional, List + from pydantic import BaseModel class ReferralInfoResponse(BaseModel): """Referral program info for current user.""" + referral_code: str referral_link: str total_referrals: int @@ -18,9 +19,10 @@ class ReferralInfoResponse(BaseModel): class ReferralItemResponse(BaseModel): """Single referral info.""" + id: int - username: Optional[str] = None - first_name: Optional[str] = None + username: str | None = None + first_name: str | None = None created_at: datetime has_subscription: bool has_paid: bool @@ -28,7 +30,8 @@ class ReferralItemResponse(BaseModel): class ReferralListResponse(BaseModel): """Paginated referral list.""" - items: List[ReferralItemResponse] + + items: list[ReferralItemResponse] total: int page: int per_page: int @@ -37,12 +40,13 @@ class ReferralListResponse(BaseModel): class ReferralEarningResponse(BaseModel): """Referral earning history item.""" + id: int amount_kopeks: int amount_rubles: float reason: str - referral_username: Optional[str] = None - referral_first_name: Optional[str] = None + referral_username: str | None = None + referral_first_name: str | None = None created_at: datetime class Config: @@ -51,7 +55,8 @@ class ReferralEarningResponse(BaseModel): class ReferralEarningsListResponse(BaseModel): """Paginated referral earnings list.""" - items: List[ReferralEarningResponse] + + items: list[ReferralEarningResponse] total: int total_amount_kopeks: int total_amount_rubles: float @@ -62,6 +67,7 @@ class ReferralEarningsListResponse(BaseModel): class ReferralTermsResponse(BaseModel): """Referral program terms.""" + is_enabled: bool commission_percent: int minimum_topup_kopeks: int diff --git a/app/cabinet/schemas/remnawave.py b/app/cabinet/schemas/remnawave.py index 9bd06a6a..80aed84f 100644 --- a/app/cabinet/schemas/remnawave.py +++ b/app/cabinet/schemas/remnawave.py @@ -1,33 +1,38 @@ """Schemas for RemnaWave management in cabinet admin panel.""" -from datetime import datetime, time -from typing import Any, Dict, List, Literal, Optional +from datetime import datetime +from typing import Any, Literal from pydantic import BaseModel, Field # ============ Status & Connection ============ + class ConnectionStatus(BaseModel): """RemnaWave API connection status.""" + status: str message: str - api_url: Optional[str] = None - status_code: Optional[int] = None - system_info: Optional[Dict[str, Any]] = None + api_url: str | None = None + status_code: int | None = None + system_info: dict[str, Any] | None = None class RemnaWaveStatusResponse(BaseModel): """RemnaWave configuration and connection status.""" + is_configured: bool - configuration_error: Optional[str] = None - connection: Optional[ConnectionStatus] = None + configuration_error: str | None = None + connection: ConnectionStatus | None = None # ============ System Statistics ============ + class SystemSummary(BaseModel): """System summary statistics.""" + users_online: int total_users: int active_connections: int @@ -40,6 +45,7 @@ class SystemSummary(BaseModel): class ServerInfo(BaseModel): """Server hardware info.""" + cpu_cores: int cpu_physical_cores: int memory_total: int @@ -51,6 +57,7 @@ class ServerInfo(BaseModel): class Bandwidth(BaseModel): """Realtime bandwidth statistics.""" + realtime_download: int realtime_upload: int realtime_total: int @@ -58,13 +65,15 @@ class Bandwidth(BaseModel): class TrafficPeriod(BaseModel): """Traffic statistics for a period.""" + current: int previous: int - difference: Optional[str] = None + difference: str | None = None class TrafficPeriods(BaseModel): """Traffic statistics for multiple periods.""" + last_2_days: TrafficPeriod last_7_days: TrafficPeriod last_30_days: TrafficPeriod @@ -74,190 +83,212 @@ class TrafficPeriods(BaseModel): class SystemStatsResponse(BaseModel): """Full system statistics response.""" + system: SystemSummary - users_by_status: Dict[str, int] + users_by_status: dict[str, int] server_info: ServerInfo bandwidth: Bandwidth traffic_periods: TrafficPeriods - 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 + nodes_realtime: list[dict[str, Any]] = Field(default_factory=list) + nodes_weekly: list[dict[str, Any]] = Field(default_factory=list) + last_updated: datetime | None = None # ============ Nodes ============ + class NodeInfo(BaseModel): """Node information.""" + uuid: str name: str address: str - country_code: Optional[str] = None + country_code: str | None = 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 - last_status_change: Optional[datetime] = None - last_status_message: Optional[str] = None - xray_uptime: Optional[str] = None + users_online: int | None = None + traffic_used_bytes: int | None = None + traffic_limit_bytes: int | None = None + last_status_change: datetime | None = None + last_status_message: str | None = None + xray_uptime: str | None = None is_traffic_tracking_active: bool = False - traffic_reset_day: Optional[int] = None - notify_percent: Optional[int] = None + traffic_reset_day: int | None = None + notify_percent: int | None = None consumption_multiplier: float = 1.0 - cpu_count: Optional[int] = None - cpu_model: Optional[str] = None - total_ram: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - provider_uuid: Optional[str] = None + cpu_count: int | None = None + cpu_model: str | None = None + total_ram: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + provider_uuid: str | None = None class NodesListResponse(BaseModel): """List of nodes response.""" - items: List[NodeInfo] + + items: list[NodeInfo] total: int class NodesOverview(BaseModel): """Nodes overview statistics.""" + total: int online: int offline: int disabled: int total_users_online: int - nodes: List[NodeInfo] + nodes: list[NodeInfo] class NodeStatisticsResponse(BaseModel): """Node statistics with usage history.""" + node: NodeInfo - realtime: Optional[Dict[str, Any]] = None - usage_history: List[Dict[str, Any]] = Field(default_factory=list) - last_updated: Optional[datetime] = None + realtime: dict[str, Any] | None = None + usage_history: list[dict[str, Any]] = Field(default_factory=list) + last_updated: datetime | None = None class NodeUsageResponse(BaseModel): """Node usage history response.""" - items: List[Dict[str, Any]] = Field(default_factory=list) + + items: list[dict[str, Any]] = Field(default_factory=list) class NodeActionRequest(BaseModel): """Request to perform node action.""" - action: Literal["enable", "disable", "restart"] + + action: Literal['enable', 'disable', 'restart'] class NodeActionResponse(BaseModel): """Response after node action.""" + success: bool - message: Optional[str] = None - is_disabled: Optional[bool] = None + message: str | None = None + is_disabled: bool | None = None # ============ Squads (Internal Squads) ============ + class SquadInfo(BaseModel): """Internal Squad information from RemnaWave.""" + uuid: str name: str members_count: int inbounds_count: int - inbounds: List[Dict[str, Any]] = Field(default_factory=list) + inbounds: list[dict[str, Any]] = Field(default_factory=list) class SquadWithLocalInfo(BaseModel): """Squad with local database info.""" + uuid: str name: str members_count: int inbounds_count: int - inbounds: List[Dict[str, Any]] = Field(default_factory=list) + inbounds: list[dict[str, Any]] = Field(default_factory=list) # Local DB info - local_id: Optional[int] = None - display_name: Optional[str] = None - country_code: Optional[str] = None - is_available: Optional[bool] = None - is_trial_eligible: Optional[bool] = None - price_kopeks: Optional[int] = None - max_users: Optional[int] = None - current_users: Optional[int] = None + local_id: int | None = None + display_name: str | None = None + country_code: str | None = None + is_available: bool | None = None + is_trial_eligible: bool | None = None + price_kopeks: int | None = None + max_users: int | None = None + current_users: int | None = None is_synced: bool = False class SquadsListResponse(BaseModel): """List of squads response.""" - items: List[SquadWithLocalInfo] + + items: list[SquadWithLocalInfo] total: int class SquadDetailResponse(BaseModel): """Detailed squad response.""" + uuid: str name: str members_count: int inbounds_count: int - inbounds: List[Dict[str, Any]] = Field(default_factory=list) + inbounds: list[dict[str, Any]] = Field(default_factory=list) # Local DB info if synced - local_id: Optional[int] = None - display_name: Optional[str] = None - country_code: Optional[str] = None - description: Optional[str] = None - is_available: Optional[bool] = None - is_trial_eligible: Optional[bool] = None - price_kopeks: Optional[int] = None - max_users: Optional[int] = None - current_users: Optional[int] = None - sort_order: Optional[int] = None + local_id: int | None = None + display_name: str | None = None + country_code: str | None = None + description: str | None = None + is_available: bool | None = None + is_trial_eligible: bool | None = None + price_kopeks: int | None = None + max_users: int | None = None + current_users: int | None = None + sort_order: int | None = None is_synced: bool = False active_subscriptions: int = 0 class SquadCreateRequest(BaseModel): """Request to create a new squad.""" + name: str = Field(..., min_length=1, max_length=255) - inbound_uuids: List[str] = Field(default_factory=list) + inbound_uuids: list[str] = Field(default_factory=list) class SquadUpdateRequest(BaseModel): """Request to update a squad.""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - inbound_uuids: Optional[List[str]] = None + + name: str | None = Field(None, min_length=1, max_length=255) + inbound_uuids: list[str] | None = None class SquadActionRequest(BaseModel): """Request to perform squad action.""" - action: Literal["add_all_users", "remove_all_users", "delete", "rename", "update_inbounds"] - name: Optional[str] = None - inbound_uuids: Optional[List[str]] = None + + action: Literal['add_all_users', 'remove_all_users', 'delete', 'rename', 'update_inbounds'] + name: str | None = None + inbound_uuids: list[str] | None = None class SquadOperationResponse(BaseModel): """Response after squad operation.""" + success: bool - message: Optional[str] = None - data: Optional[Dict[str, Any]] = None + message: str | None = None + data: dict[str, Any] | None = None # ============ Migration ============ + class MigrationPreviewResponse(BaseModel): """Preview of squad migration.""" + squad_uuid: str squad_name: str current_users: int - max_users: Optional[int] = None + max_users: int | None = None users_to_migrate: int class MigrationRequest(BaseModel): """Request to migrate users between squads.""" + source_uuid: str target_uuid: str class MigrationStats(BaseModel): """Migration statistics.""" + source_uuid: str target_uuid: str total: int = 0 @@ -270,83 +301,96 @@ class MigrationStats(BaseModel): class MigrationResponse(BaseModel): """Response after migration.""" + success: bool - message: Optional[str] = None - error: Optional[str] = None - data: Optional[MigrationStats] = None + message: str | None = None + error: str | None = None + data: MigrationStats | None = None # ============ Inbounds ============ + class InboundInfo(BaseModel): """Inbound information.""" + uuid: str tag: str - type: Optional[str] = None - network: Optional[str] = None - security: Optional[str] = None + type: str | None = None + network: str | None = None + security: str | None = None class InboundsListResponse(BaseModel): """List of inbounds response.""" - items: List[Dict[str, Any]] = Field(default_factory=list) + + items: list[dict[str, Any]] = Field(default_factory=list) total: int = 0 # ============ Auto Sync ============ + class AutoSyncTime(BaseModel): """Scheduled sync time.""" + hour: int minute: int class AutoSyncStatus(BaseModel): """Auto sync status.""" + enabled: bool - times: List[str] = Field(default_factory=list) # HH:MM format - next_run: Optional[datetime] = None + times: list[str] = Field(default_factory=list) # HH:MM format + next_run: datetime | None = None is_running: bool = False - last_run_started_at: Optional[datetime] = None - last_run_finished_at: Optional[datetime] = None - last_run_success: Optional[bool] = None - last_run_reason: Optional[str] = None - last_run_error: Optional[str] = None - last_user_stats: Optional[Dict[str, Any]] = None - last_server_stats: Optional[Dict[str, Any]] = None + last_run_started_at: datetime | None = None + last_run_finished_at: datetime | None = None + last_run_success: bool | None = None + last_run_reason: str | None = None + last_run_error: str | None = None + last_user_stats: dict[str, Any] | None = None + last_server_stats: dict[str, Any] | None = None class AutoSyncToggleRequest(BaseModel): """Request to toggle auto sync.""" + enabled: bool class AutoSyncRunResponse(BaseModel): """Response after running sync.""" + started: bool - success: Optional[bool] = None - error: Optional[str] = None - user_stats: Optional[Dict[str, Any]] = None - server_stats: Optional[Dict[str, Any]] = None - reason: Optional[str] = None + success: bool | None = None + error: str | None = None + user_stats: dict[str, Any] | None = None + server_stats: dict[str, Any] | None = None + reason: str | None = None # ============ Manual Sync ============ + class SyncMode(BaseModel): """Sync mode options.""" - mode: Literal["all", "new_only", "update_only"] = "all" + + mode: Literal['all', 'new_only', 'update_only'] = 'all' class SyncResponse(BaseModel): """Response after sync operation.""" + success: bool - message: Optional[str] = None - data: Optional[Dict[str, Any]] = None + message: str | None = None + data: dict[str, Any] | None = None class SyncRecommendations(BaseModel): """Sync recommendations.""" + success: bool - message: Optional[str] = None - data: Optional[Dict[str, Any]] = None + message: str | None = None + data: dict[str, Any] | None = None diff --git a/app/cabinet/schemas/servers.py b/app/cabinet/schemas/servers.py index 19819fb4..913af643 100644 --- a/app/cabinet/schemas/servers.py +++ b/app/cabinet/schemas/servers.py @@ -1,12 +1,13 @@ """Schemas for server management in cabinet.""" from datetime import datetime -from typing import List, Optional + from pydantic import BaseModel, Field class PromoGroupInfo(BaseModel): """Promo group info for server.""" + id: int name: str is_selected: bool = False @@ -14,16 +15,17 @@ class PromoGroupInfo(BaseModel): class ServerListItem(BaseModel): """Server item for list view.""" + id: int squad_uuid: str display_name: str - original_name: Optional[str] = None - country_code: Optional[str] = None + original_name: str | None = None + country_code: str | None = None is_available: bool is_trial_eligible: bool price_kopeks: int price_rubles: float - max_users: Optional[int] = None + max_users: int | None = None current_users: int sort_order: int is_full: bool @@ -36,32 +38,34 @@ class ServerListItem(BaseModel): class ServerListResponse(BaseModel): """Response with list of servers.""" - servers: List[ServerListItem] + + servers: list[ServerListItem] total: int class ServerDetailResponse(BaseModel): """Detailed server response.""" + id: int squad_uuid: str display_name: str - original_name: Optional[str] = None - country_code: Optional[str] = None - description: Optional[str] = None + original_name: str | None = None + country_code: str | None = None + description: str | None = None is_available: bool is_trial_eligible: bool price_kopeks: int price_rubles: float - max_users: Optional[int] = None + max_users: int | None = None current_users: int sort_order: int is_full: bool availability_status: str - promo_groups: List[PromoGroupInfo] + promo_groups: list[PromoGroupInfo] active_subscriptions: int - tariffs_using: List[str] # Names of tariffs using this server + tariffs_using: list[str] # Names of tariffs using this server created_at: datetime - updated_at: Optional[datetime] = None + updated_at: datetime | None = None class Config: from_attributes = True @@ -69,19 +73,21 @@ class ServerDetailResponse(BaseModel): class ServerUpdateRequest(BaseModel): """Request to update a server.""" - display_name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - country_code: Optional[str] = Field(None, max_length=5) - is_available: Optional[bool] = None - is_trial_eligible: Optional[bool] = None - price_kopeks: Optional[int] = Field(None, ge=0) - max_users: Optional[int] = Field(None, ge=0) - sort_order: Optional[int] = Field(None, ge=0) - promo_group_ids: Optional[List[int]] = None + + display_name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = None + country_code: str | None = Field(None, max_length=5) + is_available: bool | None = None + is_trial_eligible: bool | None = None + price_kopeks: int | None = Field(None, ge=0) + max_users: int | None = Field(None, ge=0) + sort_order: int | None = Field(None, ge=0) + promo_group_ids: list[int] | None = None class ServerToggleResponse(BaseModel): """Response after toggling server.""" + id: int is_available: bool message: str @@ -89,6 +95,7 @@ class ServerToggleResponse(BaseModel): class ServerTrialToggleResponse(BaseModel): """Response after toggling trial eligibility.""" + id: int is_trial_eligible: bool message: str @@ -96,18 +103,20 @@ class ServerTrialToggleResponse(BaseModel): class ServerStatsResponse(BaseModel): """Server statistics.""" + id: int display_name: str squad_uuid: str current_users: int - max_users: Optional[int] + max_users: int | None active_subscriptions: int trial_subscriptions: int - usage_percent: Optional[float] = None + usage_percent: float | None = None class ServerSyncResponse(BaseModel): """Response after syncing with RemnaWave.""" + created: int updated: int removed: int @@ -116,4 +125,5 @@ class ServerSyncResponse(BaseModel): class ServerSyncRequest(BaseModel): """Request to sync servers.""" + force: bool = False # Force sync even if recently synced diff --git a/app/cabinet/schemas/subscription.py b/app/cabinet/schemas/subscription.py index 339826f6..8fd62175 100644 --- a/app/cabinet/schemas/subscription.py +++ b/app/cabinet/schemas/subscription.py @@ -1,19 +1,21 @@ """Subscription schemas for cabinet.""" from datetime import datetime -from typing import Optional, List + from pydantic import BaseModel, Field class ServerInfo(BaseModel): """Server info for display.""" + uuid: str name: str - country_code: Optional[str] = None + country_code: str | None = None class TrafficPurchaseInfo(BaseModel): """Purchased traffic package info.""" + id: int traffic_gb: int expires_at: datetime @@ -24,6 +26,7 @@ class TrafficPurchaseInfo(BaseModel): class SubscriptionResponse(BaseModel): """User subscription data.""" + id: int status: str is_trial: bool @@ -32,27 +35,27 @@ class SubscriptionResponse(BaseModel): days_left: int hours_left: int = 0 minutes_left: int = 0 - time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м" + time_left_display: str = '' # Human readable format like "2д 5ч" or "5ч 30м" traffic_limit_gb: int traffic_used_gb: float traffic_used_percent: float device_limit: int - connected_squads: List[str] = [] - servers: List[ServerInfo] = [] # Server display info + connected_squads: list[str] = [] + servers: list[ServerInfo] = [] # Server display info autopay_enabled: bool autopay_days_before: int - subscription_url: Optional[str] = None + subscription_url: str | None = None hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают) is_active: bool is_expired: bool - traffic_purchases: List[TrafficPurchaseInfo] = [] + traffic_purchases: list[TrafficPurchaseInfo] = [] # Daily tariff fields is_daily: bool = False is_daily_paused: bool = False - daily_price_kopeks: Optional[int] = None - next_daily_charge_at: Optional[datetime] = None # When next daily charge will happen - tariff_id: Optional[int] = None - tariff_name: Optional[str] = None + daily_price_kopeks: int | None = None + next_daily_charge_at: datetime | None = None # When next daily charge will happen + tariff_id: int | None = None + tariff_name: str | None = None class Config: from_attributes = True @@ -60,20 +63,23 @@ class SubscriptionResponse(BaseModel): class RenewalOptionResponse(BaseModel): """Available subscription renewal option.""" + period_days: int price_kopeks: int price_rubles: float discount_percent: int = 0 - original_price_kopeks: Optional[int] = None + original_price_kopeks: int | None = None class RenewalRequest(BaseModel): """Request to renew subscription.""" - period_days: int = Field(..., description="Renewal period in days") + + period_days: int = Field(..., description='Renewal period in days') class TrafficPackageResponse(BaseModel): """Available traffic package.""" + gb: int price_kopeks: int price_rubles: float @@ -82,22 +88,26 @@ class TrafficPackageResponse(BaseModel): class TrafficPurchaseRequest(BaseModel): """Request to purchase additional traffic.""" - gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)") + + gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)') class DevicePurchaseRequest(BaseModel): """Request to purchase additional device slots.""" - devices: int = Field(..., ge=1, description="Number of additional devices") + + devices: int = Field(..., ge=1, description='Number of additional devices') class AutopayUpdateRequest(BaseModel): """Request to update autopay settings.""" + enabled: bool - days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge") + days_before: int | None = Field(None, ge=1, le=30, description='Days before expiration to charge') class TrialInfoResponse(BaseModel): """Trial subscription info.""" + is_available: bool duration_days: int traffic_limit_gb: int @@ -105,29 +115,34 @@ class TrialInfoResponse(BaseModel): requires_payment: bool = False price_kopeks: int = 0 price_rubles: float = 0.0 - reason_unavailable: Optional[str] = None + reason_unavailable: str | None = None # ============ Purchase Options Schemas ============ + class PurchaseSelectionRequest(BaseModel): """User's selection for subscription purchase.""" - period_id: Optional[str] = Field(None, description="Period ID like 'days:30'") - period_days: Optional[int] = Field(None, description="Period in days") - traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)") - servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs") - devices: Optional[int] = Field(None, description="Device limit") + + period_id: str | None = Field(None, description="Period ID like 'days:30'") + period_days: int | None = Field(None, description='Period in days') + traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)') + servers: list[str] | None = Field(default_factory=list, description='Server UUIDs') + devices: int | None = Field(None, description='Device limit') class PurchasePreviewRequest(BaseModel): """Request to preview purchase pricing.""" + selection: PurchaseSelectionRequest # ============ Tariff Purchase Schemas ============ + class TariffPurchaseRequest(BaseModel): """Request to purchase a tariff.""" - tariff_id: int = Field(..., description="Tariff ID to purchase") - period_days: int = Field(..., description="Period in days") - traffic_gb: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)") + + tariff_id: int = Field(..., description='Tariff ID to purchase') + period_days: int = Field(..., description='Period in days') + traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)') diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py index 69bb052e..ac850827 100644 --- a/app/cabinet/schemas/tariffs.py +++ b/app/cabinet/schemas/tariffs.py @@ -1,15 +1,16 @@ """Schemas for tariff management in cabinet.""" from datetime import datetime -from typing import List, Optional, Dict + from pydantic import BaseModel, Field class PeriodPrice(BaseModel): """Price for a specific period.""" - days: int = Field(..., ge=1, description="Period in days") - price_kopeks: int = Field(..., ge=0, description="Price in kopeks") - price_rubles: Optional[float] = None + + days: int = Field(..., ge=1, description='Period in days') + price_kopeks: int = Field(..., ge=0, description='Price in kopeks') + price_rubles: float | None = None def __init__(self, **data): super().__init__(**data) @@ -19,21 +20,24 @@ class PeriodPrice(BaseModel): class ServerTrafficLimit(BaseModel): """Traffic limit for a specific server.""" - traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit") + + traffic_limit_gb: int = Field(0, ge=0, description='0 = use default tariff limit') class ServerInfo(BaseModel): """Server info for tariff.""" + id: int squad_uuid: str display_name: str - country_code: Optional[str] = None + country_code: str | None = None is_selected: bool = False - traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера + traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера class PromoGroupInfo(BaseModel): """Promo group info for tariff.""" + id: int name: str is_selected: bool = False @@ -41,9 +45,10 @@ class PromoGroupInfo(BaseModel): class TariffListItem(BaseModel): """Tariff item for list view.""" + id: int name: str - description: Optional[str] = None + description: str | None = None is_active: bool is_trial_available: bool is_daily: bool = False @@ -63,32 +68,34 @@ class TariffListItem(BaseModel): class TariffListResponse(BaseModel): """Response with list of tariffs.""" - tariffs: List[TariffListItem] + + tariffs: list[TariffListItem] total: int class TariffDetailResponse(BaseModel): """Detailed tariff response.""" + id: int name: str - description: Optional[str] = None + description: str | None = None is_active: bool is_trial_available: bool allow_traffic_topup: bool = True traffic_topup_enabled: bool = False - traffic_topup_packages: Dict[str, int] = Field(default_factory=dict) + traffic_topup_packages: dict[str, int] = Field(default_factory=dict) max_topup_traffic_gb: int = 0 traffic_limit_gb: int device_limit: int - device_price_kopeks: Optional[int] = None - max_device_limit: Optional[int] = None + device_price_kopeks: int | None = None + max_device_limit: int | None = None tier_level: int display_order: int - period_prices: List[PeriodPrice] - allowed_squads: List[str] # UUIDs - server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}} - servers: List[ServerInfo] - promo_groups: List[PromoGroupInfo] + period_prices: list[PeriodPrice] + allowed_squads: list[str] # UUIDs + server_traffic_limits: dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}} + servers: list[ServerInfo] + promo_groups: list[PromoGroupInfo] subscriptions_count: int # Произвольное количество дней custom_days_enabled: bool = False @@ -104,9 +111,9 @@ class TariffDetailResponse(BaseModel): is_daily: bool = False daily_price_kopeks: int = 0 # Режим сброса трафика - traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка created_at: datetime - updated_at: Optional[datetime] = None + updated_at: datetime | None = None class Config: from_attributes = True @@ -114,22 +121,25 @@ class TariffDetailResponse(BaseModel): class TariffCreateRequest(BaseModel): """Request to create a tariff.""" + name: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = None + description: str | None = None is_active: bool = True allow_traffic_topup: bool = True traffic_topup_enabled: bool = False - traffic_topup_packages: Dict[str, int] = Field(default_factory=dict) + traffic_topup_packages: dict[str, int] = Field(default_factory=dict) max_topup_traffic_gb: int = Field(0, ge=0) - traffic_limit_gb: int = Field(0, ge=0, description="0 = unlimited") + traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited') device_limit: int = Field(1, ge=1) - device_price_kopeks: Optional[int] = Field(None, ge=0) - max_device_limit: Optional[int] = Field(None, ge=1) + device_price_kopeks: int | None = Field(None, ge=0) + max_device_limit: int | None = Field(None, ge=1) tier_level: int = Field(1, ge=1, le=10) - period_prices: List[PeriodPrice] = Field(default_factory=list) - allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs") - server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits") - promo_group_ids: List[int] = Field(default_factory=list) + period_prices: list[PeriodPrice] = Field(default_factory=list) + allowed_squads: list[str] = Field(default_factory=list, description='Server UUIDs') + server_traffic_limits: dict[str, ServerTrafficLimit] = Field( + default_factory=dict, description='Per-server traffic limits' + ) + promo_group_ids: list[int] = Field(default_factory=list) # Произвольное количество дней custom_days_enabled: bool = False price_per_day_kopeks: int = Field(0, ge=0) @@ -144,47 +154,49 @@ class TariffCreateRequest(BaseModel): is_daily: bool = False daily_price_kopeks: int = Field(0, ge=0) # Режим сброса трафика - traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка class TariffUpdateRequest(BaseModel): """Request to update a tariff.""" - name: Optional[str] = Field(None, min_length=1, max_length=255) - description: Optional[str] = None - is_active: Optional[bool] = None - allow_traffic_topup: Optional[bool] = None - traffic_topup_enabled: Optional[bool] = None - traffic_topup_packages: Optional[Dict[str, int]] = None - max_topup_traffic_gb: Optional[int] = Field(None, ge=0) - traffic_limit_gb: Optional[int] = Field(None, ge=0) - device_limit: Optional[int] = Field(None, ge=1) - device_price_kopeks: Optional[int] = Field(None, ge=0) - max_device_limit: Optional[int] = Field(None, ge=1) - tier_level: Optional[int] = Field(None, ge=1, le=10) - display_order: Optional[int] = Field(None, ge=0) - period_prices: Optional[List[PeriodPrice]] = None - allowed_squads: Optional[List[str]] = None - server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None - promo_group_ids: Optional[List[int]] = None + + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = None + is_active: bool | None = None + allow_traffic_topup: bool | None = None + traffic_topup_enabled: bool | None = None + traffic_topup_packages: dict[str, int] | None = None + max_topup_traffic_gb: int | None = Field(None, ge=0) + traffic_limit_gb: int | None = Field(None, ge=0) + device_limit: int | None = Field(None, ge=1) + device_price_kopeks: int | None = Field(None, ge=0) + max_device_limit: int | None = Field(None, ge=1) + tier_level: int | None = Field(None, ge=1, le=10) + display_order: int | None = Field(None, ge=0) + period_prices: list[PeriodPrice] | None = None + allowed_squads: list[str] | None = None + server_traffic_limits: dict[str, ServerTrafficLimit] | None = None + promo_group_ids: list[int] | None = None # Произвольное количество дней - custom_days_enabled: Optional[bool] = None - price_per_day_kopeks: Optional[int] = Field(None, ge=0) - min_days: Optional[int] = Field(None, ge=1) - max_days: Optional[int] = Field(None, ge=1) + custom_days_enabled: bool | None = None + price_per_day_kopeks: int | None = Field(None, ge=0) + min_days: int | None = Field(None, ge=1) + max_days: int | None = Field(None, ge=1) # Произвольный трафик при покупке - custom_traffic_enabled: Optional[bool] = None - traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0) - min_traffic_gb: Optional[int] = Field(None, ge=1) - max_traffic_gb: Optional[int] = Field(None, ge=1) + custom_traffic_enabled: bool | None = None + traffic_price_per_gb_kopeks: int | None = Field(None, ge=0) + min_traffic_gb: int | None = Field(None, ge=1) + max_traffic_gb: int | None = Field(None, ge=1) # Дневной тариф - is_daily: Optional[bool] = None - daily_price_kopeks: Optional[int] = Field(None, ge=0) + is_daily: bool | None = None + daily_price_kopeks: int | None = Field(None, ge=0) # Режим сброса трафика - traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка class TariffToggleResponse(BaseModel): """Response after toggling tariff.""" + id: int is_active: bool message: str @@ -192,6 +204,7 @@ class TariffToggleResponse(BaseModel): class TariffTrialResponse(BaseModel): """Response after setting trial tariff.""" + id: int is_trial_available: bool message: str @@ -199,6 +212,7 @@ class TariffTrialResponse(BaseModel): class TariffStatsResponse(BaseModel): """Tariff statistics.""" + id: int name: str subscriptions_count: int diff --git a/app/cabinet/schemas/tickets.py b/app/cabinet/schemas/tickets.py index 90ede395..f3b240dd 100644 --- a/app/cabinet/schemas/tickets.py +++ b/app/cabinet/schemas/tickets.py @@ -1,19 +1,20 @@ """Support tickets schemas for cabinet.""" from datetime import datetime -from typing import Optional, List + from pydantic import BaseModel, Field class TicketMessageResponse(BaseModel): """Ticket message data.""" + id: int message_text: str is_from_admin: bool has_media: bool = False - media_type: Optional[str] = None - media_file_id: Optional[str] = None - media_caption: Optional[str] = None + media_type: str | None = None + media_file_id: str | None = None + media_caption: str | None = None created_at: datetime class Config: @@ -22,15 +23,16 @@ class TicketMessageResponse(BaseModel): class TicketResponse(BaseModel): """Ticket data.""" + id: int title: str status: str priority: str created_at: datetime updated_at: datetime - closed_at: Optional[datetime] = None + closed_at: datetime | None = None messages_count: int = 0 - last_message: Optional[TicketMessageResponse] = None + last_message: TicketMessageResponse | None = None class Config: from_attributes = True @@ -38,15 +40,16 @@ class TicketResponse(BaseModel): class TicketDetailResponse(BaseModel): """Ticket with all messages.""" + id: int title: str status: str priority: str created_at: datetime updated_at: datetime - closed_at: Optional[datetime] = None + closed_at: datetime | None = None is_reply_blocked: bool = False - messages: List[TicketMessageResponse] = [] + messages: list[TicketMessageResponse] = [] class Config: from_attributes = True @@ -54,7 +57,8 @@ class TicketDetailResponse(BaseModel): class TicketListResponse(BaseModel): """Paginated ticket list.""" - items: List[TicketResponse] + + items: list[TicketResponse] total: int page: int per_page: int @@ -63,16 +67,18 @@ class TicketListResponse(BaseModel): class TicketCreateRequest(BaseModel): """Request to create a new ticket.""" - title: str = Field(..., min_length=3, max_length=255, description="Ticket title") - message: str = Field(..., min_length=10, max_length=4000, description="Initial message") - media_type: Optional[str] = Field(None, description="Media type: photo, video, document") - media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media") - media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption") + + title: str = Field(..., min_length=3, max_length=255, description='Ticket title') + message: str = Field(..., min_length=10, max_length=4000, description='Initial message') + media_type: str | None = Field(None, description='Media type: photo, video, document') + media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media') + media_caption: str | None = Field(None, max_length=1000, description='Media caption') class TicketMessageCreateRequest(BaseModel): """Request to add message to ticket.""" - message: str = Field(..., min_length=1, max_length=4000, description="Message text") - media_type: Optional[str] = Field(None, description="Media type: photo, video, document") - media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media") - media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption") + + message: str = Field(..., min_length=1, max_length=4000, description='Message text') + media_type: str | None = Field(None, description='Media type: photo, video, document') + media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media') + media_caption: str | None = Field(None, max_length=1000, description='Media caption') diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 76bbe581..dc6e160c 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -1,51 +1,57 @@ """Schemas for Admin Users management in cabinet.""" from datetime import datetime -from typing import List, Optional, Dict, Any -from pydantic import BaseModel, Field from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field class UserStatusEnum(str, Enum): """User status enum.""" - ACTIVE = "active" - BLOCKED = "blocked" - DELETED = "deleted" + + ACTIVE = 'active' + BLOCKED = 'blocked' + DELETED = 'deleted' class SubscriptionStatusEnum(str, Enum): """Subscription status enum.""" - TRIAL = "trial" - ACTIVE = "active" - EXPIRED = "expired" - DISABLED = "disabled" - PENDING = "pending" + + TRIAL = 'trial' + ACTIVE = 'active' + EXPIRED = 'expired' + DISABLED = 'disabled' + PENDING = 'pending' class SortByEnum(str, Enum): """Sort options for users list.""" - CREATED_AT = "created_at" - BALANCE = "balance" - TRAFFIC = "traffic" - LAST_ACTIVITY = "last_activity" - TOTAL_SPENT = "total_spent" - PURCHASE_COUNT = "purchase_count" + + CREATED_AT = 'created_at' + BALANCE = 'balance' + TRAFFIC = 'traffic' + LAST_ACTIVITY = 'last_activity' + TOTAL_SPENT = 'total_spent' + PURCHASE_COUNT = 'purchase_count' # === User Subscription Info === + class UserSubscriptionInfo(BaseModel): """User subscription information.""" + id: int status: str is_trial: bool - start_date: Optional[datetime] = None - end_date: Optional[datetime] = None + start_date: datetime | None = None + end_date: datetime | None = None traffic_limit_gb: int = 0 traffic_used_gb: float = 0.0 device_limit: int = 1 - tariff_id: Optional[int] = None - tariff_name: Optional[str] = None + tariff_id: int | None = None + tariff_name: str | None = None autopay_enabled: bool = False is_active: bool = False days_remaining: int = 0 @@ -53,6 +59,7 @@ class UserSubscriptionInfo(BaseModel): class UserPromoGroupInfo(BaseModel): """User promo group info.""" + id: int name: str is_default: bool = False @@ -60,29 +67,31 @@ class UserPromoGroupInfo(BaseModel): # === User List === + class UserListItem(BaseModel): """User item in list.""" + id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None full_name: str status: str balance_kopeks: int balance_rubles: float created_at: datetime - last_activity: Optional[datetime] = None + last_activity: datetime | None = None # Subscription summary has_subscription: bool = False - subscription_status: Optional[str] = None + subscription_status: str | None = None subscription_is_trial: bool = False - subscription_end_date: Optional[datetime] = None + subscription_end_date: datetime | None = None # Promo group - promo_group_id: Optional[int] = None - promo_group_name: Optional[str] = None + promo_group_id: int | None = None + promo_group_name: str | None = None # Stats total_spent_kopeks: int = 0 @@ -96,7 +105,8 @@ class UserListItem(BaseModel): class UsersListResponse(BaseModel): """Paginated list of users.""" - users: List[UserListItem] + + users: list[UserListItem] total: int offset: int = 0 limit: int = 50 @@ -104,35 +114,39 @@ class UsersListResponse(BaseModel): # === User Detail === + class UserTransactionItem(BaseModel): """User transaction.""" + id: int type: str amount_kopeks: int amount_rubles: float - description: Optional[str] = None - payment_method: Optional[str] = None + description: str | None = None + payment_method: str | None = None is_completed: bool = True created_at: datetime class UserReferralInfo(BaseModel): """User referral info.""" + referral_code: str referrals_count: int = 0 total_earnings_kopeks: int = 0 - commission_percent: Optional[int] = None - referred_by_id: Optional[int] = None - referred_by_username: Optional[str] = None + commission_percent: int | None = None + referred_by_id: int | None = None + referred_by_username: str | None = None class UserDetailResponse(BaseModel): """Detailed user information.""" + id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None full_name: str status: str language: str @@ -140,20 +154,20 @@ class UserDetailResponse(BaseModel): balance_rubles: float # Email (cabinet) - email: Optional[str] = None + email: str | None = None email_verified: bool = False # Dates created_at: datetime - updated_at: Optional[datetime] = None - last_activity: Optional[datetime] = None - cabinet_last_login: Optional[datetime] = None + updated_at: datetime | None = None + last_activity: datetime | None = None + cabinet_last_login: datetime | None = None # Subscription - subscription: Optional[UserSubscriptionInfo] = None + subscription: UserSubscriptionInfo | None = None # Promo group - promo_group: Optional[UserPromoGroupInfo] = None + promo_group: UserPromoGroupInfo | None = None # Referral referral: UserReferralInfo @@ -168,28 +182,31 @@ class UserDetailResponse(BaseModel): # Restrictions restriction_topup: bool = False restriction_subscription: bool = False - restriction_reason: Optional[str] = None + restriction_reason: str | None = None # Promo offer promo_offer_discount_percent: int = 0 - promo_offer_discount_source: Optional[str] = None - promo_offer_discount_expires_at: Optional[datetime] = None + promo_offer_discount_source: str | None = None + promo_offer_discount_expires_at: datetime | None = None # Recent transactions - recent_transactions: List[UserTransactionItem] = [] + recent_transactions: list[UserTransactionItem] = [] # === User Actions === + class UpdateBalanceRequest(BaseModel): """Request to update user balance.""" - amount_kopeks: int = Field(..., description="Amount in kopeks (positive to add, negative to subtract)") - description: str = Field(default="Admin balance adjustment", max_length=500) - create_transaction: bool = Field(default=True, description="Create transaction record") + + amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)') + description: str = Field(default='Admin balance adjustment', max_length=500) + create_transaction: bool = Field(default=True, description='Create transaction record') class UpdateBalanceResponse(BaseModel): """Response after balance update.""" + success: bool old_balance_kopeks: int new_balance_kopeks: int @@ -198,44 +215,50 @@ class UpdateBalanceResponse(BaseModel): class UpdateSubscriptionRequest(BaseModel): """Request to update user subscription.""" - action: str = Field(..., description="Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel") + + action: str = Field( + ..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel' + ) # For extend action - days: Optional[int] = Field(None, ge=1, le=3650, description="Days to extend") + days: int | None = Field(None, ge=1, le=3650, description='Days to extend') # For set_end_date action - end_date: Optional[datetime] = Field(None, description="New end date") + end_date: datetime | None = Field(None, description='New end date') # For change_tariff action - tariff_id: Optional[int] = Field(None, description="New tariff ID") + tariff_id: int | None = Field(None, description='New tariff ID') # For set_traffic action - traffic_limit_gb: Optional[int] = Field(None, ge=0, description="New traffic limit in GB") - traffic_used_gb: Optional[float] = Field(None, ge=0, description="Set traffic used in GB") + traffic_limit_gb: int | None = Field(None, ge=0, description='New traffic limit in GB') + traffic_used_gb: float | None = Field(None, ge=0, description='Set traffic used in GB') # For toggle_autopay - autopay_enabled: Optional[bool] = Field(None, description="Enable/disable autopay") + autopay_enabled: bool | None = Field(None, description='Enable/disable autopay') # For create new subscription - is_trial: Optional[bool] = Field(None, description="Is trial subscription") - device_limit: Optional[int] = Field(None, ge=1, description="Device limit") + is_trial: bool | None = Field(None, description='Is trial subscription') + device_limit: int | None = Field(None, ge=1, description='Device limit') class UpdateSubscriptionResponse(BaseModel): """Response after subscription update.""" + success: bool message: str - subscription: Optional[UserSubscriptionInfo] = None + subscription: UserSubscriptionInfo | None = None class UpdateUserStatusRequest(BaseModel): """Request to update user status.""" + status: UserStatusEnum - reason: Optional[str] = Field(None, max_length=500, description="Reason for status change") + reason: str | None = Field(None, max_length=500, description='Reason for status change') class UpdateUserStatusResponse(BaseModel): """Response after status update.""" + success: bool old_status: str new_status: str @@ -244,50 +267,58 @@ class UpdateUserStatusResponse(BaseModel): class UpdateRestrictionsRequest(BaseModel): """Request to update user restrictions.""" - restriction_topup: Optional[bool] = Field(None, description="Block balance top-up") - restriction_subscription: Optional[bool] = Field(None, description="Block subscription purchase/renewal") - restriction_reason: Optional[str] = Field(None, max_length=500, description="Reason for restrictions") + + restriction_topup: bool | None = Field(None, description='Block balance top-up') + restriction_subscription: bool | None = Field(None, description='Block subscription purchase/renewal') + restriction_reason: str | None = Field(None, max_length=500, description='Reason for restrictions') class UpdateRestrictionsResponse(BaseModel): """Response after restrictions update.""" + success: bool restriction_topup: bool restriction_subscription: bool - restriction_reason: Optional[str] = None + restriction_reason: str | None = None message: str class UpdatePromoGroupRequest(BaseModel): """Request to update user promo group.""" - promo_group_id: Optional[int] = Field(None, description="New promo group ID (null to remove)") + + promo_group_id: int | None = Field(None, description='New promo group ID (null to remove)') class UpdatePromoGroupResponse(BaseModel): """Response after promo group update.""" + success: bool - old_promo_group_id: Optional[int] = None - new_promo_group_id: Optional[int] = None - promo_group_name: Optional[str] = None + old_promo_group_id: int | None = None + new_promo_group_id: int | None = None + promo_group_name: str | None = None message: str class DeleteUserRequest(BaseModel): """Request to delete user.""" - soft_delete: bool = Field(default=True, description="Soft delete (mark as deleted) or hard delete") - reason: Optional[str] = Field(None, max_length=500, description="Reason for deletion") + + soft_delete: bool = Field(default=True, description='Soft delete (mark as deleted) or hard delete') + reason: str | None = Field(None, max_length=500, description='Reason for deletion') class DeleteUserResponse(BaseModel): """Response after user deletion.""" + success: bool message: str # === Statistics === + class UsersStatsResponse(BaseModel): """Users statistics.""" + total_users: int = 0 active_users: int = 0 blocked_users: int = 0 @@ -315,20 +346,23 @@ class UsersStatsResponse(BaseModel): # === Search === + class UserSearchRequest(BaseModel): """Request for user search.""" + query: str = Field(..., min_length=1, max_length=255) - search_by: List[str] = Field( - default=["telegram_id", "username", "first_name", "last_name", "email"], - description="Fields to search in" + search_by: list[str] = Field( + default=['telegram_id', 'username', 'first_name', 'last_name', 'email'], description='Fields to search in' ) limit: int = Field(default=20, ge=1, le=100) # === Tariffs for User === + class PeriodPriceInfo(BaseModel): """Period price info.""" + days: int price_kopeks: int price_rubles: float @@ -336,9 +370,10 @@ class PeriodPriceInfo(BaseModel): class UserAvailableTariffItem(BaseModel): """Tariff available for user.""" + id: int name: str - description: Optional[str] = None + description: str | None = None is_active: bool = True is_trial_available: bool = False traffic_limit_gb: int = 0 @@ -347,7 +382,7 @@ class UserAvailableTariffItem(BaseModel): display_order: int = 0 # Pricing - period_prices: List[PeriodPriceInfo] = [] + period_prices: list[PeriodPriceInfo] = [] is_daily: bool = False daily_price_kopeks: int = 0 @@ -364,92 +399,102 @@ class UserAvailableTariffItem(BaseModel): class UserAvailableTariffsResponse(BaseModel): """List of tariffs available for user.""" + user_id: int - promo_group_id: Optional[int] = None - promo_group_name: Optional[str] = None - tariffs: List[UserAvailableTariffItem] = [] + promo_group_id: int | None = None + promo_group_name: str | None = None + tariffs: list[UserAvailableTariffItem] = [] total: int = 0 # Current subscription tariff - current_tariff_id: Optional[int] = None - current_tariff_name: Optional[str] = None + current_tariff_id: int | None = None + current_tariff_name: str | None = None # === Panel Sync === + class PanelUserInfo(BaseModel): """User info from panel.""" - uuid: Optional[str] = None - short_uuid: Optional[str] = None - username: Optional[str] = None - status: Optional[str] = None - expire_at: Optional[datetime] = None + + uuid: str | None = None + short_uuid: str | None = None + username: str | None = None + status: str | None = None + expire_at: datetime | None = None traffic_limit_gb: float = 0 traffic_used_gb: float = 0 device_limit: int = 1 - subscription_url: Optional[str] = None - active_squads: List[str] = [] + subscription_url: str | None = None + active_squads: list[str] = [] class SyncFromPanelRequest(BaseModel): """Request to sync user from panel.""" - update_subscription: bool = Field(default=True, description="Update subscription data") - update_traffic: bool = Field(default=True, description="Update traffic usage") - create_if_missing: bool = Field(default=False, description="Create subscription if user exists in panel but not in bot") + + update_subscription: bool = Field(default=True, description='Update subscription data') + update_traffic: bool = Field(default=True, description='Update traffic usage') + create_if_missing: bool = Field( + default=False, description='Create subscription if user exists in panel but not in bot' + ) class SyncFromPanelResponse(BaseModel): """Response after syncing from panel.""" + success: bool message: str - panel_user: Optional[PanelUserInfo] = None - changes: Dict[str, Any] = {} - errors: List[str] = [] + panel_user: PanelUserInfo | None = None + changes: dict[str, Any] = {} + errors: list[str] = [] class SyncToPanelRequest(BaseModel): """Request to sync user to panel.""" - create_if_missing: bool = Field(default=True, description="Create user in panel if not exists") - update_status: bool = Field(default=True, description="Update user status in panel") - update_traffic_limit: bool = Field(default=True, description="Update traffic limit in panel") - update_expire_date: bool = Field(default=True, description="Update expire date in panel") - update_squads: bool = Field(default=True, description="Update connected squads in panel") + + create_if_missing: bool = Field(default=True, description='Create user in panel if not exists') + update_status: bool = Field(default=True, description='Update user status in panel') + update_traffic_limit: bool = Field(default=True, description='Update traffic limit in panel') + update_expire_date: bool = Field(default=True, description='Update expire date in panel') + update_squads: bool = Field(default=True, description='Update connected squads in panel') class SyncToPanelResponse(BaseModel): """Response after syncing to panel.""" + success: bool message: str - action: str = "" # created, updated, no_changes - panel_uuid: Optional[str] = None - changes: Dict[str, Any] = {} - errors: List[str] = [] + action: str = '' # created, updated, no_changes + panel_uuid: str | None = None + changes: dict[str, Any] = {} + errors: list[str] = [] class PanelSyncStatusResponse(BaseModel): """Panel sync status for user.""" + user_id: int - telegram_id: Optional[int] = None - remnawave_uuid: Optional[str] = None - last_sync: Optional[datetime] = None + telegram_id: int | None = None + remnawave_uuid: str | None = None + last_sync: datetime | None = None # Bot data - bot_subscription_status: Optional[str] = None - bot_subscription_end_date: Optional[datetime] = None + bot_subscription_status: str | None = None + bot_subscription_end_date: datetime | None = None bot_traffic_limit_gb: int = 0 bot_traffic_used_gb: float = 0 bot_device_limit: int = 0 - bot_squads: List[str] = [] + bot_squads: list[str] = [] # Panel data (if available) panel_found: bool = False - panel_status: Optional[str] = None - panel_expire_at: Optional[datetime] = None + panel_status: str | None = None + panel_expire_at: datetime | None = None panel_traffic_limit_gb: float = 0 panel_traffic_used_gb: float = 0 panel_device_limit: int = 0 - panel_squads: List[str] = [] + panel_squads: list[str] = [] # Differences has_differences: bool = False - differences: List[str] = [] + differences: list[str] = [] diff --git a/app/cabinet/schemas/wheel.py b/app/cabinet/schemas/wheel.py index 612da3e5..616863f7 100644 --- a/app/cabinet/schemas/wheel.py +++ b/app/cabinet/schemas/wheel.py @@ -1,27 +1,29 @@ """Схемы для колеса удачи (Fortune Wheel).""" from datetime import datetime -from typing import Optional, List -from pydantic import BaseModel, Field from enum import Enum +from pydantic import BaseModel, Field + # ==================== ENUMS ==================== class WheelPaymentType(str, Enum): """Способы оплаты спина.""" - TELEGRAM_STARS = "telegram_stars" - SUBSCRIPTION_DAYS = "subscription_days" + + TELEGRAM_STARS = 'telegram_stars' + SUBSCRIPTION_DAYS = 'subscription_days' class WheelPrizeType(str, Enum): """Типы призов.""" - SUBSCRIPTION_DAYS = "subscription_days" - BALANCE_BONUS = "balance_bonus" - TRAFFIC_GB = "traffic_gb" - PROMOCODE = "promocode" - NOTHING = "nothing" + + SUBSCRIPTION_DAYS = 'subscription_days' + BALANCE_BONUS = 'balance_bonus' + TRAFFIC_GB = 'traffic_gb' + PROMOCODE = 'promocode' + NOTHING = 'nothing' # ==================== USER SCHEMAS ==================== @@ -29,6 +31,7 @@ class WheelPrizeType(str, Enum): class WheelPrizeDisplay(BaseModel): """Отображение приза для пользователя.""" + id: int display_name: str emoji: str @@ -41,17 +44,18 @@ class WheelPrizeDisplay(BaseModel): class WheelConfigResponse(BaseModel): """Конфигурация колеса для пользователя.""" + is_enabled: bool name: str - spin_cost_stars: Optional[int] = None - spin_cost_days: Optional[int] = None + spin_cost_stars: int | None = None + spin_cost_days: int | None = None spin_cost_stars_enabled: bool spin_cost_days_enabled: bool - prizes: List[WheelPrizeDisplay] + prizes: list[WheelPrizeDisplay] daily_limit: int user_spins_today: int can_spin: bool - can_spin_reason: Optional[str] = None + can_spin_reason: str | None = None can_pay_stars: bool = False can_pay_days: bool = False user_balance_kopeks: int = 0 @@ -60,8 +64,9 @@ class WheelConfigResponse(BaseModel): class SpinAvailabilityResponse(BaseModel): """Доступность спина.""" + can_spin: bool - reason: Optional[str] = None + reason: str | None = None spins_remaining_today: int can_pay_stars: bool can_pay_days: bool @@ -73,34 +78,37 @@ class SpinAvailabilityResponse(BaseModel): class SpinRequest(BaseModel): """Запрос на спин.""" + payment_type: WheelPaymentType class SpinResultResponse(BaseModel): """Результат спина.""" + success: bool - prize_id: Optional[int] = None - prize_type: Optional[str] = None + prize_id: int | None = None + prize_type: str | None = None prize_value: int = 0 - prize_display_name: str = "" - emoji: str = "🎁" - color: str = "#3B82F6" + prize_display_name: str = '' + emoji: str = '🎁' + color: str = '#3B82F6' rotation_degrees: float = 0.0 - message: str = "" - promocode: Optional[str] = None - error: Optional[str] = None + message: str = '' + promocode: str | None = None + error: str | None = None class SpinHistoryItem(BaseModel): """Элемент истории спинов.""" + id: int payment_type: str payment_amount: int prize_type: str prize_value: int prize_display_name: str - emoji: str = "🎁" - color: str = "#3B82F6" + emoji: str = '🎁' + color: str = '#3B82F6' prize_value_kopeks: int created_at: datetime @@ -110,7 +118,8 @@ class SpinHistoryItem(BaseModel): class SpinHistoryResponse(BaseModel): """История спинов с пагинацией.""" - items: List[SpinHistoryItem] + + items: list[SpinHistoryItem] total: int page: int per_page: int @@ -122,6 +131,7 @@ class SpinHistoryResponse(BaseModel): class WheelPrizeAdminResponse(BaseModel): """Полная информация о призе для админа.""" + id: int config_id: int prize_type: str @@ -131,13 +141,13 @@ class WheelPrizeAdminResponse(BaseModel): color: str prize_value_kopeks: int sort_order: int - manual_probability: Optional[float] = None + manual_probability: float | None = None is_active: bool promo_balance_bonus_kopeks: int = 0 promo_subscription_days: int = 0 promo_traffic_gb: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None class Config: from_attributes = True @@ -145,6 +155,7 @@ class WheelPrizeAdminResponse(BaseModel): class AdminWheelConfigResponse(BaseModel): """Полная конфигурация колеса для админа.""" + id: int is_enabled: bool name: str @@ -157,9 +168,9 @@ class AdminWheelConfigResponse(BaseModel): min_subscription_days_for_day_payment: int promo_prefix: str promo_validity_days: int - prizes: List[WheelPrizeAdminResponse] - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + prizes: list[WheelPrizeAdminResponse] + created_at: datetime | None = None + updated_at: datetime | None = None class Config: from_attributes = True @@ -167,29 +178,31 @@ class AdminWheelConfigResponse(BaseModel): class UpdateWheelConfigRequest(BaseModel): """Запрос на обновление конфига колеса.""" - is_enabled: Optional[bool] = None - name: Optional[str] = Field(None, min_length=1, max_length=255) - spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000) - spin_cost_days: Optional[int] = Field(None, ge=1, le=30) - spin_cost_stars_enabled: Optional[bool] = None - spin_cost_days_enabled: Optional[bool] = None - rtp_percent: Optional[int] = Field(None, ge=0, le=100) - daily_spin_limit: Optional[int] = Field(None, ge=0, le=100) - min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30) - promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20) - promo_validity_days: Optional[int] = Field(None, ge=1, le=365) + + is_enabled: bool | None = None + name: str | None = Field(None, min_length=1, max_length=255) + spin_cost_stars: int | None = Field(None, ge=1, le=1000) + spin_cost_days: int | None = Field(None, ge=1, le=30) + spin_cost_stars_enabled: bool | None = None + spin_cost_days_enabled: bool | None = None + rtp_percent: int | None = Field(None, ge=0, le=100) + daily_spin_limit: int | None = Field(None, ge=0, le=100) + min_subscription_days_for_day_payment: int | None = Field(None, ge=1, le=30) + promo_prefix: str | None = Field(None, min_length=1, max_length=20) + promo_validity_days: int | None = Field(None, ge=1, le=365) class CreatePrizeRequest(BaseModel): """Запрос на создание приза.""" + prize_type: WheelPrizeType prize_value: int = Field(..., ge=0) display_name: str = Field(..., min_length=1, max_length=100) - emoji: str = Field(default="🎁", max_length=10) - color: str = Field(default="#3B82F6", pattern=r'^#[0-9A-Fa-f]{6}$') + emoji: str = Field(default='🎁', max_length=10) + color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$') prize_value_kopeks: int = Field(..., ge=0) sort_order: int = Field(default=0, ge=0) - manual_probability: Optional[float] = Field(None, ge=0, le=1) + manual_probability: float | None = Field(None, ge=0, le=1) is_active: bool = True promo_balance_bonus_kopeks: int = Field(default=0, ge=0) promo_subscription_days: int = Field(default=0, ge=0) @@ -198,30 +211,33 @@ class CreatePrizeRequest(BaseModel): class UpdatePrizeRequest(BaseModel): """Запрос на обновление приза.""" - prize_type: Optional[WheelPrizeType] = None - prize_value: Optional[int] = Field(None, ge=0) - display_name: Optional[str] = Field(None, min_length=1, max_length=100) - emoji: Optional[str] = Field(None, max_length=10) - color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') - prize_value_kopeks: Optional[int] = Field(None, ge=0) - sort_order: Optional[int] = Field(None, ge=0) - manual_probability: Optional[float] = Field(None, ge=0, le=1) - is_active: Optional[bool] = None - promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0) - promo_subscription_days: Optional[int] = Field(None, ge=0) - promo_traffic_gb: Optional[int] = Field(None, ge=0) + + prize_type: WheelPrizeType | None = None + prize_value: int | None = Field(None, ge=0) + display_name: str | None = Field(None, min_length=1, max_length=100) + emoji: str | None = Field(None, max_length=10) + color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') + prize_value_kopeks: int | None = Field(None, ge=0) + sort_order: int | None = Field(None, ge=0) + manual_probability: float | None = Field(None, ge=0, le=1) + is_active: bool | None = None + promo_balance_bonus_kopeks: int | None = Field(None, ge=0) + promo_subscription_days: int | None = Field(None, ge=0) + promo_traffic_gb: int | None = Field(None, ge=0) class ReorderPrizesRequest(BaseModel): """Запрос на переупорядочивание призов.""" - prize_ids: List[int] + + prize_ids: list[int] class AdminSpinItem(BaseModel): """Спин для админки.""" + id: int user_id: int - username: Optional[str] = None + username: str | None = None payment_type: str payment_amount: int payment_value_kopeks: int @@ -238,7 +254,8 @@ class AdminSpinItem(BaseModel): class AdminSpinsResponse(BaseModel): """Список спинов для админки с пагинацией.""" - items: List[AdminSpinItem] + + items: list[AdminSpinItem] total: int page: int per_page: int @@ -247,13 +264,14 @@ class AdminSpinsResponse(BaseModel): class WheelStatisticsResponse(BaseModel): """Статистика колеса.""" + total_spins: int total_revenue_kopeks: int total_payout_kopeks: int actual_rtp_percent: float configured_rtp_percent: int spins_by_payment_type: dict - prizes_distribution: List[dict] - top_wins: List[dict] - period_from: Optional[str] = None - period_to: Optional[str] = None + prizes_distribution: list[dict] + top_wins: list[dict] + period_from: str | None = None + period_to: str | None = None diff --git a/app/cabinet/services/__init__.py b/app/cabinet/services/__init__.py index 9153fe9e..93f74b91 100644 --- a/app/cabinet/services/__init__.py +++ b/app/cabinet/services/__init__.py @@ -2,4 +2,5 @@ from .email_service import EmailService, email_service -__all__ = ["EmailService", "email_service"] + +__all__ = ['EmailService', 'email_service'] diff --git a/app/cabinet/services/email_service.py b/app/cabinet/services/email_service.py index 2a1a4571..e59386cd 100644 --- a/app/cabinet/services/email_service.py +++ b/app/cabinet/services/email_service.py @@ -2,12 +2,12 @@ import logging import smtplib -from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from typing import Optional +from email.mime.text import MIMEText from app.config import settings + logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ class EmailService: to_email: str, subject: str, body_html: str, - body_text: Optional[str] = None, + body_text: str | None = None, ) -> bool: """ Send an email. @@ -60,27 +60,28 @@ class EmailService: True if email was sent successfully, False otherwise """ if not self.is_configured(): - logger.warning("SMTP is not configured, cannot send email") + logger.warning('SMTP is not configured, cannot send email') return False try: - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = f"{self.from_name} <{self.from_email}>" - msg["To"] = to_email + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f'{self.from_name} <{self.from_email}>' + msg['To'] = to_email # Plain text version if body_text is None: # Simple HTML to text conversion import re - body_text = re.sub(r"<[^>]+>", "", body_html) - body_text = body_text.replace(" ", " ") - body_text = body_text.replace("&", "&") - body_text = body_text.replace("<", "<") - body_text = body_text.replace(">", ">") - part1 = MIMEText(body_text, "plain", "utf-8") - part2 = MIMEText(body_html, "html", "utf-8") + body_text = re.sub(r'<[^>]+>', '', body_html) + body_text = body_text.replace(' ', ' ') + body_text = body_text.replace('&', '&') + body_text = body_text.replace('<', '<') + body_text = body_text.replace('>', '>') + + part1 = MIMEText(body_text, 'plain', 'utf-8') + part2 = MIMEText(body_html, 'html', 'utf-8') msg.attach(part1) msg.attach(part2) @@ -88,11 +89,11 @@ class EmailService: with self._get_smtp_connection() as smtp: smtp.sendmail(self.from_email, to_email, msg.as_string()) - logger.info(f"Email sent successfully to {to_email}") + logger.info(f'Email sent successfully to {to_email}') return True except Exception as e: - logger.error(f"Failed to send email to {to_email}: {e}") + logger.error(f'Failed to send email to {to_email}: {e}') return False def send_verification_email( @@ -100,7 +101,7 @@ class EmailService: to_email: str, verification_token: str, verification_url: str, - username: Optional[str] = None, + username: str | None = None, ) -> bool: """ Send email verification email. @@ -114,10 +115,10 @@ class EmailService: Returns: True if email was sent successfully, False otherwise """ - full_url = f"{verification_url}?token={verification_token}" - greeting = f"Hello{', ' + username if username else ''}!" + full_url = f'{verification_url}?token={verification_token}' + greeting = f'Hello{", " + username if username else ""}!' - subject = "Verify your email address" + subject = 'Verify your email address' body_html = f""" @@ -162,7 +163,7 @@ class EmailService: to_email: str, reset_token: str, reset_url: str, - username: Optional[str] = None, + username: str | None = None, ) -> bool: """ Send password reset email. @@ -176,10 +177,10 @@ class EmailService: Returns: True if email was sent successfully, False otherwise """ - full_url = f"{reset_url}?token={reset_token}" - greeting = f"Hello{', ' + username if username else ''}!" + full_url = f'{reset_url}?token={reset_token}' + greeting = f'Hello{", " + username if username else ""}!' - subject = "Reset your password" + subject = 'Reset your password' body_html = f""" diff --git a/app/cabinet/services/email_templates.py b/app/cabinet/services/email_templates.py index 51214bae..c8fe2d95 100644 --- a/app/cabinet/services/email_templates.py +++ b/app/cabinet/services/email_templates.py @@ -4,7 +4,7 @@ Email notification templates for different notification types. Supports multiple languages: ru, en, zh, ua """ -from typing import Any, Dict, Optional +from typing import Any from app.config import settings @@ -13,15 +13,15 @@ class EmailNotificationTemplates: """HTML email templates for user notifications.""" def __init__(self): - self.service_name = settings.SMTP_FROM_NAME or "VPN Service" - self.cabinet_url = getattr(settings, "CABINET_URL", "") + self.service_name = settings.SMTP_FROM_NAME or 'VPN Service' + self.cabinet_url = getattr(settings, 'CABINET_URL', '') def get_template( self, - notification_type: "NotificationType", + notification_type: 'NotificationType', language: str, - context: Dict[str, Any], - ) -> Optional[Dict[str, str]]: + context: dict[str, Any], + ) -> dict[str, str] | None: """ Get email template for notification type. @@ -165,15 +165,15 @@ class EmailNotificationTemplates: def _get_cabinet_button(self, language: str) -> str: """Get cabinet link button HTML.""" if not self.cabinet_url: - return "" + return '' texts = { - "ru": "Открыть личный кабинет", - "en": "Open Dashboard", - "zh": "打开控制面板", - "ua": "Відкрити особистий кабінет", + 'ru': 'Открыть личный кабинет', + 'en': 'Open Dashboard', + 'zh': '打开控制面板', + 'ua': 'Відкрити особистий кабінет', } - text = texts.get(language, texts["en"]) + text = texts.get(language, texts['en']) return f'

{text}

' @@ -181,20 +181,20 @@ class EmailNotificationTemplates: # Balance Templates # ============================================================================ - def _balance_topup_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _balance_topup_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for balance top-up notification.""" - amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") - balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") + amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽') + balance = context.get('formatted_balance', f'{context.get("new_balance_rubles", 0):.2f} ₽') subjects = { - "ru": f"Баланс пополнен на {amount}", - "en": f"Balance topped up by {amount}", - "zh": f"余额已充值 {amount}", - "ua": f"Баланс поповнено на {amount}", + 'ru': f'Баланс пополнен на {amount}', + 'en': f'Balance topped up by {amount}', + 'zh': f'余额已充值 {amount}', + 'ua': f'Баланс поповнено на {amount}', } bodies = { - "ru": f""" + 'ru': f"""

Баланс успешно пополнен!

Сумма пополнения: +{amount}

@@ -203,7 +203,7 @@ class EmailNotificationTemplates:

Спасибо за использование нашего сервиса!

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Balance Successfully Topped Up!

Top-up amount: +{amount}

@@ -212,7 +212,7 @@ class EmailNotificationTemplates:

Thank you for using our service!

{self._get_cabinet_button(language)} """, - "zh": f""" + 'zh': f"""

充值成功!

充值金额: +{amount}

@@ -221,7 +221,7 @@ class EmailNotificationTemplates:

感谢使用我们的服务!

{self._get_cabinet_button(language)} """, - "ua": f""" + 'ua': f"""

Баланс успішно поповнено!

Сума поповнення: +{amount}

@@ -233,25 +233,25 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _balance_change_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _balance_change_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for balance change notification.""" - amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") - balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") - description = context.get("description", "") + amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽') + balance = context.get('formatted_balance', f'{context.get("new_balance_rubles", 0):.2f} ₽') + description = context.get('description', '') subjects = { - "ru": "Изменение баланса", - "en": "Balance Changed", - "zh": "余额变动", - "ua": "Зміна балансу", + 'ru': 'Изменение баланса', + 'en': 'Balance Changed', + 'zh': '余额变动', + 'ua': 'Зміна балансу', } bodies = { - "ru": f""" + 'ru': f"""

Изменение баланса

Сумма: {amount}

@@ -260,7 +260,7 @@ class EmailNotificationTemplates:
{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Balance Changed

Amount: {amount}

@@ -272,28 +272,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Subscription Templates # ============================================================================ - def _subscription_expiring_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _subscription_expiring_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription expiring notification.""" - days_left = context.get("days_left", 0) - expires_at = context.get("expires_at", "") + days_left = context.get('days_left', 0) + expires_at = context.get('expires_at', '') subjects = { - "ru": f"Подписка истекает через {days_left} дн.", - "en": f"Subscription expires in {days_left} day(s)", - "zh": f"订阅将在 {days_left} 天后到期", - "ua": f"Підписка закінчується через {days_left} дн.", + 'ru': f'Подписка истекает через {days_left} дн.', + 'en': f'Subscription expires in {days_left} day(s)', + 'zh': f'订阅将在 {days_left} 天后到期', + 'ua': f'Підписка закінчується через {days_left} дн.', } bodies = { - "ru": f""" + 'ru': f"""

Подписка скоро истекает

Ваша подписка истекает через {days_left} дн.

@@ -302,7 +302,7 @@ class EmailNotificationTemplates:

Продлите подписку, чтобы не потерять доступ к сервису.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Subscription Expiring Soon

Your subscription expires in {days_left} day(s).

@@ -311,7 +311,7 @@ class EmailNotificationTemplates:

Renew your subscription to maintain access to our service.

{self._get_cabinet_button(language)} """, - "zh": f""" + 'zh': f"""

订阅即将到期

您的订阅将在 {days_left} 天后到期。

@@ -320,7 +320,7 @@ class EmailNotificationTemplates:

请续订以保持对服务的访问。

{self._get_cabinet_button(language)} """, - "ua": f""" + 'ua': f"""

Підписка скоро закінчується

Ваша підписка закінчується через {days_left} дн.

@@ -332,21 +332,21 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _subscription_expired_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _subscription_expired_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription expired notification.""" subjects = { - "ru": "Подписка истекла", - "en": "Subscription Expired", - "zh": "订阅已到期", - "ua": "Підписка закінчилась", + 'ru': 'Подписка истекла', + 'en': 'Subscription Expired', + 'zh': '订阅已到期', + 'ua': 'Підписка закінчилась', } bodies = { - "ru": f""" + 'ru': f"""

Подписка истекла

Ваша подписка истекла. Доступ к VPN отключён.

@@ -354,7 +354,7 @@ class EmailNotificationTemplates:

Оформите новую подписку, чтобы продолжить использование сервиса.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Subscription Expired

Your subscription has expired. VPN access has been disabled.

@@ -362,7 +362,7 @@ class EmailNotificationTemplates:

Purchase a new subscription to continue using our service.

{self._get_cabinet_button(language)} """, - "zh": f""" + 'zh': f"""

订阅已到期

您的订阅已到期。VPN访问已被禁用。

@@ -370,7 +370,7 @@ class EmailNotificationTemplates:

请购买新订阅以继续使用我们的服务。

{self._get_cabinet_button(language)} """, - "ua": f""" + 'ua': f"""

Підписка закінчилась

Ваша підписка закінчилась. Доступ до VPN вимкнено.

@@ -381,23 +381,23 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _subscription_renewed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _subscription_renewed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription renewed notification.""" - new_expires_at = context.get("new_expires_at", "") + new_expires_at = context.get('new_expires_at', '') subjects = { - "ru": "Подписка продлена", - "en": "Subscription Renewed", - "zh": "订阅已续订", - "ua": "Підписку продовжено", + 'ru': 'Подписка продлена', + 'en': 'Subscription Renewed', + 'zh': '订阅已续订', + 'ua': 'Підписку продовжено', } bodies = { - "ru": f""" + 'ru': f"""

Подписка успешно продлена!

Ваша подписка была успешно продлена.

@@ -406,7 +406,7 @@ class EmailNotificationTemplates:

Спасибо за использование нашего сервиса!

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Subscription Successfully Renewed!

Your subscription has been successfully renewed.

@@ -418,23 +418,23 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _subscription_activated_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _subscription_activated_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for subscription activated notification.""" - expires_at = context.get("expires_at", "") + expires_at = context.get('expires_at', '') subjects = { - "ru": "Подписка активирована", - "en": "Subscription Activated", - "zh": "订阅已激活", - "ua": "Підписку активовано", + 'ru': 'Подписка активирована', + 'en': 'Subscription Activated', + 'zh': '订阅已激活', + 'ua': 'Підписку активовано', } bodies = { - "ru": f""" + 'ru': f"""

Подписка активирована!

Ваша VPN подписка успешно активирована.

@@ -443,7 +443,7 @@ class EmailNotificationTemplates:

Теперь вы можете пользоваться VPN сервисом.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Subscription Activated!

Your VPN subscription has been successfully activated.

@@ -455,28 +455,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Autopay Templates # ============================================================================ - def _autopay_success_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _autopay_success_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for successful autopay notification.""" - amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") - new_expires_at = context.get("new_expires_at", "") + amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽') + new_expires_at = context.get('new_expires_at', '') subjects = { - "ru": "Автопродление выполнено", - "en": "Auto-renewal Successful", - "zh": "自动续订成功", - "ua": "Автопродовження виконано", + 'ru': 'Автопродление выполнено', + 'en': 'Auto-renewal Successful', + 'zh': '自动续订成功', + 'ua': 'Автопродовження виконано', } bodies = { - "ru": f""" + 'ru': f"""

Автопродление выполнено

Ваша подписка была автоматически продлена.

@@ -485,7 +485,7 @@ class EmailNotificationTemplates:
{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Auto-renewal Successful

Your subscription has been automatically renewed.

@@ -497,23 +497,23 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _autopay_failed_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for failed autopay notification.""" - reason = context.get("reason", "") + reason = context.get('reason', '') subjects = { - "ru": "Ошибка автопродления", - "en": "Auto-renewal Failed", - "zh": "自动续订失败", - "ua": "Помилка автопродовження", + 'ru': 'Ошибка автопродления', + 'en': 'Auto-renewal Failed', + 'zh': '自动续订失败', + 'ua': 'Помилка автопродовження', } bodies = { - "ru": f""" + 'ru': f"""

Ошибка автопродления

Не удалось автоматически продлить подписку.

@@ -522,7 +522,7 @@ class EmailNotificationTemplates:

Пожалуйста, пополните баланс и продлите подписку вручную.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Auto-renewal Failed

Failed to automatically renew your subscription.

@@ -534,24 +534,24 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _autopay_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _autopay_insufficient_funds_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for autopay insufficient funds notification.""" - required = context.get("required_amount", "") - balance = context.get("current_balance", "") + required = context.get('required_amount', '') + balance = context.get('current_balance', '') subjects = { - "ru": "Недостаточно средств для автопродления", - "en": "Insufficient Funds for Auto-renewal", - "zh": "余额不足无法自动续订", - "ua": "Недостатньо коштів для автопродовження", + 'ru': 'Недостаточно средств для автопродления', + 'en': 'Insufficient Funds for Auto-renewal', + 'zh': '余额不足无法自动续订', + 'ua': 'Недостатньо коштів для автопродовження', } bodies = { - "ru": f""" + 'ru': f"""

Недостаточно средств

Недостаточно средств на балансе для автопродления подписки.

@@ -561,7 +561,7 @@ class EmailNotificationTemplates:

Пополните баланс, чтобы подписка была продлена автоматически.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Insufficient Funds

Insufficient balance for subscription auto-renewal.

@@ -574,28 +574,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Daily Subscription Templates # ============================================================================ - def _daily_debit_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _daily_debit_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for daily subscription debit notification.""" - amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") - balance = context.get("formatted_balance", f"{context.get('new_balance_rubles', 0):.2f} ₽") + amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽') + balance = context.get('formatted_balance', f'{context.get("new_balance_rubles", 0):.2f} ₽') subjects = { - "ru": f"Списание за подписку: {amount}", - "en": f"Subscription charge: {amount}", - "zh": f"订阅扣费: {amount}", - "ua": f"Списання за підписку: {amount}", + 'ru': f'Списание за подписку: {amount}', + 'en': f'Subscription charge: {amount}', + 'zh': f'订阅扣费: {amount}', + 'ua': f'Списання за підписку: {amount}', } bodies = { - "ru": f""" + 'ru': f"""

Ежедневное списание

С вашего баланса списано: {amount}

@@ -603,7 +603,7 @@ class EmailNotificationTemplates:
{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Daily Charge

Charged from your balance: {amount}

@@ -614,21 +614,21 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _daily_insufficient_funds_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _daily_insufficient_funds_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for daily subscription insufficient funds.""" subjects = { - "ru": "Недостаточно средств для продления", - "en": "Insufficient Funds", - "zh": "余额不足", - "ua": "Недостатньо коштів", + 'ru': 'Недостаточно средств для продления', + 'en': 'Insufficient Funds', + 'zh': '余额不足', + 'ua': 'Недостатньо коштів', } bodies = { - "ru": f""" + 'ru': f"""

Недостаточно средств

На балансе недостаточно средств для продления подписки.

@@ -637,7 +637,7 @@ class EmailNotificationTemplates:

Пополните баланс, чтобы продолжить использование сервиса.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Insufficient Funds

Insufficient balance to continue subscription.

@@ -649,28 +649,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _traffic_reset_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _traffic_reset_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for traffic reset notification.""" subjects = { - "ru": "Трафик обновлён", - "en": "Traffic Reset", - "zh": "流量已重置", - "ua": "Трафік оновлено", + 'ru': 'Трафик обновлён', + 'en': 'Traffic Reset', + 'zh': '流量已重置', + 'ua': 'Трафік оновлено', } bodies = { - "ru": f""" + 'ru': f"""

Трафик обновлён

Ваш трафик был сброшен. Вы можете продолжить использование VPN.

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Traffic Reset

Your traffic has been reset. You can continue using the VPN.

@@ -680,27 +680,27 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Account Status Templates # ============================================================================ - def _ban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for ban notification.""" - reason = context.get("reason", "") + reason = context.get('reason', '') subjects = { - "ru": "Аккаунт заблокирован", - "en": "Account Suspended", - "zh": "账户已被封禁", - "ua": "Обліковий запис заблоковано", + 'ru': 'Аккаунт заблокирован', + 'en': 'Account Suspended', + 'zh': '账户已被封禁', + 'ua': 'Обліковий запис заблоковано', } bodies = { - "ru": f""" + 'ru': f"""

Аккаунт заблокирован

Ваш аккаунт был заблокирован.

@@ -708,7 +708,7 @@ class EmailNotificationTemplates:

Если вы считаете, что это ошибка, обратитесь в поддержку.

""", - "en": f""" + 'en': f"""

Account Suspended

Your account has been suspended.

@@ -719,21 +719,21 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _unban_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _unban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for unban notification.""" subjects = { - "ru": "Аккаунт разблокирован", - "en": "Account Reactivated", - "zh": "账户已解封", - "ua": "Обліковий запис розблоковано", + 'ru': 'Аккаунт разблокирован', + 'en': 'Account Reactivated', + 'zh': '账户已解封', + 'ua': 'Обліковий запис розблоковано', } bodies = { - "ru": f""" + 'ru': f"""

Аккаунт разблокирован

Ваш аккаунт был разблокирован.

@@ -741,7 +741,7 @@ class EmailNotificationTemplates:
{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Account Reactivated

Your account has been reactivated.

@@ -752,29 +752,29 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _warning_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for warning notification.""" - message = context.get("message", "") + message = context.get('message', '') subjects = { - "ru": "Предупреждение", - "en": "Warning", - "zh": "警告", - "ua": "Попередження", + 'ru': 'Предупреждение', + 'en': 'Warning', + 'zh': '警告', + 'ua': 'Попередження', } bodies = { - "ru": f""" + 'ru': f"""

Предупреждение

{f'

{message}

' if message else '

Вы получили предупреждение от администрации.

'}
""", - "en": f""" + 'en': f"""

Warning

{f'

{message}

' if message else '

You have received a warning from the administration.

'} @@ -783,28 +783,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Referral Templates # ============================================================================ - def _referral_bonus_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for referral bonus notification.""" - bonus = context.get("formatted_bonus", f"{context.get('bonus_rubles', 0):.2f} ₽") - referral_name = context.get("referral_name", "") + bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f} ₽') + referral_name = context.get('referral_name', '') subjects = { - "ru": f"Реферальный бонус: +{bonus}", - "en": f"Referral bonus: +{bonus}", - "zh": f"推荐奖励: +{bonus}", - "ua": f"Реферальний бонус: +{bonus}", + 'ru': f'Реферальный бонус: +{bonus}', + 'en': f'Referral bonus: +{bonus}', + 'zh': f'推荐奖励: +{bonus}', + 'ua': f'Реферальний бонус: +{bonus}', } bodies = { - "ru": f""" + 'ru': f"""

Реферальный бонус!

Вы получили реферальный бонус: +{bonus}

@@ -813,7 +813,7 @@ class EmailNotificationTemplates:

Продолжайте приглашать друзей и зарабатывайте больше!

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Referral Bonus!

You received a referral bonus: +{bonus}

@@ -825,23 +825,23 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } - def _referral_registered_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for new referral registered notification.""" - referral_name = context.get("referral_name", "") + referral_name = context.get('referral_name', '') subjects = { - "ru": "Новый реферал зарегистрирован", - "en": "New Referral Registered", - "zh": "新推荐用户已注册", - "ua": "Новий реферал зареєстрований", + 'ru': 'Новый реферал зарегистрирован', + 'en': 'New Referral Registered', + 'zh': '新推荐用户已注册', + 'ua': 'Новий реферал зареєстрований', } bodies = { - "ru": f""" + 'ru': f"""

Новый реферал!

По вашей ссылке зарегистрировался новый пользователь{f': {referral_name}' if referral_name else ''}.

@@ -849,7 +849,7 @@ class EmailNotificationTemplates:

Вы будете получать бонусы с его пополнений!

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

New Referral!

A new user registered using your link{f': {referral_name}' if referral_name else ''}.

@@ -860,28 +860,28 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } # ============================================================================ # Payment Templates # ============================================================================ - def _payment_received_template(self, language: str, context: Dict[str, Any]) -> Dict[str, str]: + def _payment_received_template(self, language: str, context: dict[str, Any]) -> dict[str, str]: """Template for payment received notification.""" - amount = context.get("formatted_amount", f"{context.get('amount_rubles', 0):.2f} ₽") - payment_method = context.get("payment_method", "") + amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽') + payment_method = context.get('payment_method', '') subjects = { - "ru": f"Платёж получен: {amount}", - "en": f"Payment received: {amount}", - "zh": f"收到付款: {amount}", - "ua": f"Платіж отримано: {amount}", + 'ru': f'Платёж получен: {amount}', + 'en': f'Payment received: {amount}', + 'zh': f'收到付款: {amount}', + 'ua': f'Платіж отримано: {amount}', } bodies = { - "ru": f""" + 'ru': f"""

Платёж успешно обработан

Сумма: +{amount}

@@ -890,7 +890,7 @@ class EmailNotificationTemplates:

Спасибо за оплату!

{self._get_cabinet_button(language)} """, - "en": f""" + 'en': f"""

Payment Successfully Processed

Amount: +{amount}

@@ -902,8 +902,8 @@ class EmailNotificationTemplates: } return { - "subject": subjects.get(language, subjects["en"]), - "body_html": self._get_base_template(bodies.get(language, bodies["en"])), + 'subject': subjects.get(language, subjects['en']), + 'body_html': self._get_base_template(bodies.get(language, bodies['en'])), } diff --git a/app/config.py b/app/config.py index ae7e3987..695aac9b 100644 --- a/app/config.py +++ b/app/config.py @@ -1,18 +1,18 @@ import hashlib import hmac +import html import logging import math import os import re -import html from collections import defaultdict from datetime import time -from typing import Dict, List, Optional, Union +from pathlib import Path from urllib.parse import urlparse from zoneinfo import ZoneInfo + +from pydantic import Field, field_validator from pydantic_settings import BaseSettings -from pydantic import field_validator, Field -from pathlib import Path DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [ @@ -20,27 +20,25 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [ # Примеры: "tme", "joingroup", "support", "admin" ] -USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$") +USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$') logger = logging.getLogger(__name__) class Settings(BaseSettings): - BOT_TOKEN: str - BOT_USERNAME: Optional[str] = None - ADMIN_IDS: str = "" - ADMIN_EMAILS: str = "" # Comma-separated admin emails for email-only users + BOT_USERNAME: str | None = None + ADMIN_IDS: str = '' + ADMIN_EMAILS: str = '' # Comma-separated admin emails for email-only users # Test email account for development/testing (bypasses email verification and SMTP) - TEST_EMAIL: str = "" # e.g., test@example.com - TEST_EMAIL_PASSWORD: str = "" # Password for test account + TEST_EMAIL: str = '' # e.g., test@example.com + TEST_EMAIL_PASSWORD: str = '' # Password for test account - SUPPORT_USERNAME: str = "@support" - SUPPORT_MENU_ENABLED: bool = True - SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both + SUPPORT_USERNAME: str = '@support' SUPPORT_MENU_ENABLED: bool = True + SUPPORT_SYSTEM_MODE: str = 'both' # one of: tickets, contact, both # SLA for support tickets SUPPORT_TICKET_SLA_ENABLED: bool = True SUPPORT_TICKET_SLA_MINUTES: int = 5 @@ -49,14 +47,14 @@ class Settings(BaseSettings): # MiniApp tickets settings MINIAPP_TICKETS_ENABLED: bool = True # Enable/disable tickets section in miniapp - MINIAPP_SUPPORT_TYPE: str = "tickets" # one of: tickets, profile, url - MINIAPP_SUPPORT_URL: str = "" # Custom URL to redirect when tickets disabled (only for url type) + MINIAPP_SUPPORT_TYPE: str = 'tickets' # one of: tickets, profile, url + MINIAPP_SUPPORT_URL: str = '' # Custom URL to redirect when tickets disabled (only for url type) ADMIN_NOTIFICATIONS_ENABLED: bool = False - ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None - ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None - ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None - ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None + ADMIN_NOTIFICATIONS_CHAT_ID: str | None = None + ADMIN_NOTIFICATIONS_TOPIC_ID: int | None = None + ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: int | None = None + ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: int | None = None # Настройки очереди чеков NaloGO NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды) @@ -64,88 +62,88 @@ class Settings(BaseSettings): NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека ADMIN_REPORTS_ENABLED: bool = False - ADMIN_REPORTS_CHAT_ID: Optional[str] = None - ADMIN_REPORTS_TOPIC_ID: Optional[int] = None - ADMIN_REPORTS_SEND_TIME: Optional[str] = None + ADMIN_REPORTS_CHAT_ID: str | None = None + ADMIN_REPORTS_TOPIC_ID: int | None = None + ADMIN_REPORTS_SEND_TIME: str | None = None - CHANNEL_SUB_ID: Optional[str] = None - CHANNEL_LINK: Optional[str] = None + CHANNEL_SUB_ID: str | None = None + CHANNEL_LINK: str | None = None CHANNEL_IS_REQUIRED_SUB: bool = False CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True CHANNEL_REQUIRED_FOR_ALL: bool = False - DATABASE_URL: Optional[str] = None - - POSTGRES_HOST: str = "postgres" - POSTGRES_PORT: int = 5432 - POSTGRES_DB: str = "remnawave_bot" - POSTGRES_USER: str = "remnawave_user" - POSTGRES_PASSWORD: str = "secure_password_123" - - SQLITE_PATH: str = "./data/bot.db" - LOCALES_PATH: str = "./locales" + DATABASE_URL: str | None = None - TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC")) - - DATABASE_MODE: str = "auto" - - REDIS_URL: str = "redis://localhost:6379/0" + POSTGRES_HOST: str = 'postgres' + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = 'remnawave_bot' + POSTGRES_USER: str = 'remnawave_user' + POSTGRES_PASSWORD: str = 'secure_password_123' + + SQLITE_PATH: str = './data/bot.db' + LOCALES_PATH: str = './locales' + + TIMEZONE: str = Field(default_factory=lambda: os.getenv('TZ', 'UTC')) + + DATABASE_MODE: str = 'auto' + + REDIS_URL: str = 'redis://localhost:6379/0' CART_TTL_SECONDS: int = 3600 # Время жизни корзины пользователя в Redis (1 час) - REMNAWAVE_API_URL: Optional[str] = None - REMNAWAVE_API_KEY: Optional[str] = None - REMNAWAVE_SECRET_KEY: Optional[str] = None + REMNAWAVE_API_URL: str | None = None + REMNAWAVE_API_KEY: str | None = None + REMNAWAVE_SECRET_KEY: str | None = None - REMNAWAVE_USERNAME: Optional[str] = None - REMNAWAVE_PASSWORD: Optional[str] = None - REMNAWAVE_CADDY_TOKEN: Optional[str] = None - REMNAWAVE_AUTH_TYPE: str = "api_key" # api_key, basic, bearer, cookies, caddy - REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}" - REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}" - REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable" + REMNAWAVE_USERNAME: str | None = None + REMNAWAVE_PASSWORD: str | None = None + REMNAWAVE_CADDY_TOKEN: str | None = None + REMNAWAVE_AUTH_TYPE: str = 'api_key' # api_key, basic, bearer, cookies, caddy + REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = 'Bot user: {full_name} {username}' + REMNAWAVE_USER_USERNAME_TEMPLATE: str = 'user_{telegram_id}' + REMNAWAVE_USER_DELETE_MODE: str = 'delete' # "delete" или "disable" REMNAWAVE_AUTO_SYNC_ENABLED: bool = False - REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00" - CABINET_REMNA_SUB_CONFIG: Optional[str] = None # UUID конфига страницы подписки из RemnaWave - + REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00' + CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave + TRIAL_DURATION_DAYS: int = 3 TRIAL_TRAFFIC_LIMIT_GB: int = 10 TRIAL_DEVICE_LIMIT: int = 2 TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 - TRIAL_USER_TAG: Optional[str] = None + TRIAL_USER_TAG: str | None = None DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 - DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH" + DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH' RESET_TRAFFIC_ON_PAYMENT: bool = False MAX_DEVICES_LIMIT: int = 20 - - TRIAL_WARNING_HOURS: int = 2 - ENABLE_NOTIFICATIONS: bool = True - NOTIFICATION_RETRY_ATTEMPTS: int = 3 - + + TRIAL_WARNING_HOURS: int = 2 + ENABLE_NOTIFICATIONS: bool = True + NOTIFICATION_RETRY_ATTEMPTS: int = 3 + MONITORING_LOGS_RETENTION_DAYS: int = 30 NOTIFICATION_CACHE_HOURS: int = 24 - SERVER_STATUS_MODE: str = "disabled" - SERVER_STATUS_EXTERNAL_URL: Optional[str] = None - SERVER_STATUS_METRICS_URL: Optional[str] = None - SERVER_STATUS_METRICS_USERNAME: Optional[str] = None - SERVER_STATUS_METRICS_PASSWORD: Optional[str] = None + SERVER_STATUS_MODE: str = 'disabled' + SERVER_STATUS_EXTERNAL_URL: str | None = None + SERVER_STATUS_METRICS_URL: str | None = None + SERVER_STATUS_METRICS_USERNAME: str | None = None + SERVER_STATUS_METRICS_PASSWORD: str | None = None SERVER_STATUS_METRICS_VERIFY_SSL: bool = True SERVER_STATUS_REQUEST_TIMEOUT: int = 10 SERVER_STATUS_ITEMS_PER_PAGE: int = 10 - + BASE_SUBSCRIPTION_PRICE: int = 50000 - AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360" - AVAILABLE_RENEWAL_PERIODS: str = "30,90,180" + AVAILABLE_SUBSCRIPTION_PERIODS: str = '14,30,60,90,180,360' + AVAILABLE_RENEWAL_PERIODS: str = '30,90,180' PRICE_14_DAYS: int = 50000 PRICE_30_DAYS: int = 99000 PRICE_60_DAYS: int = 189000 PRICE_90_DAYS: int = 269000 PRICE_180_DAYS: int = 499000 PRICE_360_DAYS: int = 899000 - PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None + PAID_SUBSCRIPTION_USER_TAG: str | None = None PRICE_TRAFFIC_5GB: int = 2000 PRICE_TRAFFIC_10GB: int = 3500 @@ -157,32 +155,32 @@ class Settings(BaseSettings): PRICE_TRAFFIC_1000GB: int = 19500 PRICE_TRAFFIC_UNLIMITED: int = 20000 - TRAFFIC_PACKAGES_CONFIG: str = "" + TRAFFIC_PACKAGES_CONFIG: str = '' PRICE_PER_DEVICE: int = 5000 DEVICES_SELECTION_ENABLED: bool = True - DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None + DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None # Настройки модема MODEM_ENABLED: bool = False MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц - MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20") + MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20") BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False - BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = "" + BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = '' # Режим выбора трафика: # - selectable: пользователь выбирает трафик при покупке и может докупать # - fixed: фиксированный лимит, без выбора и без докупки # - fixed_with_topup: фиксированный лимит при покупке, но докупка разрешена (при продлении сброс до лимита) - TRAFFIC_SELECTION_MODE: str = "selectable" + TRAFFIC_SELECTION_MODE: str = 'selectable' FIXED_TRAFFIC_LIMIT_GB: int = 100 BUY_TRAFFIC_BUTTON_VISIBLE: bool = True # Режим продаж подписок: # - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно) # - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами) - SALES_MODE: str = "classic" + SALES_MODE: str = 'classic' # ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала) # Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads) @@ -192,17 +190,17 @@ class Settings(BaseSettings): # Настройки докупки трафика TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика # Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG) - TRAFFIC_TOPUP_PACKAGES_CONFIG: str = "" - + TRAFFIC_TOPUP_PACKAGES_CONFIG: str = '' + # Настройки сброса трафика # Режимы расчета цены сброса: # "period" - фиксированная цена = стоимость периода 30 дней (старое поведение) # "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика) # "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется) - TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased" + TRAFFIC_RESET_PRICE_MODE: str = 'traffic_with_purchased' # Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена) TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30] - + REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000 REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000 REFERRAL_INVITER_BONUS_KOPEKS: int = 10000 @@ -217,7 +215,7 @@ class Settings(BaseSettings): REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽) REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой) - REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: Optional[int] = None # Топик для уведомлений + REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений # Настройки анализа на подозрительность REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽) @@ -234,7 +232,7 @@ class Settings(BaseSettings): REFERRAL_CONTESTS_ENABLED: bool = False BLACKLIST_CHECK_ENABLED: bool = False - BLACKLIST_GITHUB_URL: Optional[str] = None + BLACKLIST_GITHUB_URL: str | None = None BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24 BLACKLIST_IGNORE_ADMINS: bool = True @@ -243,7 +241,7 @@ class Settings(BaseSettings): SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1 SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит - SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None + SIMPLE_SUBSCRIPTION_SQUAD_UUID: str | None = None # Настройки конструктора меню (API) MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API @@ -252,7 +250,7 @@ class Settings(BaseSettings): TRAFFIC_MONITORING_ENABLED: bool = False # Глобальный переключатель (для обратной совместимости) TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки (для обратной совместимости) TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (для обратной совместимости) - SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: int | None = None # Новый мониторинг трафика v2 # Быстрая проверка (текущий использованный трафик) @@ -262,13 +260,13 @@ class Settings(BaseSettings): # Суточная проверка (трафик за 24 часа) TRAFFIC_DAILY_CHECK_ENABLED: bool = False - TRAFFIC_DAILY_CHECK_TIME: str = "00:00" # Время суточной проверки (HH:MM) + TRAFFIC_DAILY_CHECK_TIME: str = '00:00' # Время суточной проверки (HH:MM) TRAFFIC_DAILY_THRESHOLD_GB: float = 50.0 # Порог суточного трафика в ГБ # Фильтрация по серверам (UUID нод через запятую) - TRAFFIC_MONITORED_NODES: str = "" # Только эти ноды (пусто = все) - TRAFFIC_IGNORED_NODES: str = "" # Исключить эти ноды - TRAFFIC_EXCLUDED_USER_UUIDS: str = "" # Исключить пользователей (UUID через запятую) + TRAFFIC_MONITORED_NODES: str = '' # Только эти ноды (пусто = все) + TRAFFIC_IGNORED_NODES: str = '' # Исключить эти ноды + TRAFFIC_EXCLUDED_USER_UUIDS: str = '' # Исключить пользователей (UUID через запятую) # Параллельность и кулдаун TRAFFIC_CHECK_BATCH_SIZE: int = 1000 # Размер батча для получения пользователей @@ -279,15 +277,15 @@ class Settings(BaseSettings): DAILY_SUBSCRIPTIONS_ENABLED: bool = True # Включить автоматическое списание для суточных тарифов DAILY_SUBSCRIPTIONS_CHECK_INTERVAL_MINUTES: int = 30 # Интервал проверки в минутах - AUTOPAY_WARNING_DAYS: str = "3,1" + AUTOPAY_WARNING_DAYS: str = '3,1' ENABLE_AUTOPAY: bool = False DEFAULT_AUTOPAY_ENABLED: bool = False DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 - MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 - SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000 - + MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 + SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000 + MONITORING_INTERVAL: int = 60 INACTIVE_USER_DELETE_MONTHS: int = 3 @@ -296,33 +294,33 @@ class Settings(BaseSettings): MAINTENANCE_AUTO_ENABLE: bool = True MAINTENANCE_MONITORING_ENABLED: bool = True MAINTENANCE_RETRY_ATTEMPTS: int = 1 - MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже." - + MAINTENANCE_MESSAGE: str = '🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.' + TELEGRAM_STARS_ENABLED: bool = True TELEGRAM_STARS_RATE_RUB: float = 1.3 - TELEGRAM_STARS_DISPLAY_NAME: str = "Telegram Stars" - + TELEGRAM_STARS_DISPLAY_NAME: str = 'Telegram Stars' + TRIBUTE_ENABLED: bool = False - TRIBUTE_API_KEY: Optional[str] = None - TRIBUTE_DONATE_LINK: Optional[str] = None - TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook" - TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0" + TRIBUTE_API_KEY: str | None = None + TRIBUTE_DONATE_LINK: str | None = None + TRIBUTE_WEBHOOK_PATH: str = '/tribute-webhook' + TRIBUTE_WEBHOOK_HOST: str = '0.0.0.0' TRIBUTE_WEBHOOK_PORT: int = 8081 YOOKASSA_ENABLED: bool = False - YOOKASSA_DISPLAY_NAME: str = "YooKassa" - YOOKASSA_SHOP_ID: Optional[str] = None - YOOKASSA_SECRET_KEY: Optional[str] = None - YOOKASSA_RETURN_URL: Optional[str] = None - YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None + YOOKASSA_DISPLAY_NAME: str = 'YooKassa' + YOOKASSA_SHOP_ID: str | None = None + YOOKASSA_SECRET_KEY: str | None = None + YOOKASSA_RETURN_URL: str | None = None + YOOKASSA_DEFAULT_RECEIPT_EMAIL: str | None = None YOOKASSA_VAT_CODE: int = 1 - YOOKASSA_SBP_ENABLED: bool = False - YOOKASSA_PAYMENT_MODE: str = "full_payment" - YOOKASSA_PAYMENT_SUBJECT: str = "service" - YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook" - YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0" + YOOKASSA_SBP_ENABLED: bool = False + YOOKASSA_PAYMENT_MODE: str = 'full_payment' + YOOKASSA_PAYMENT_SUBJECT: str = 'service' + YOOKASSA_WEBHOOK_PATH: str = '/yookassa-webhook' + YOOKASSA_WEBHOOK_HOST: str = '0.0.0.0' YOOKASSA_WEBHOOK_PORT: int = 8082 - YOOKASSA_TRUSTED_PROXY_NETWORKS: str = "" + YOOKASSA_TRUSTED_PROXY_NETWORKS: str = '' YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000 YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000 YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False @@ -332,10 +330,10 @@ class Settings(BaseSettings): PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 NALOGO_ENABLED: bool = False - NALOGO_INN: Optional[str] = None - NALOGO_PASSWORD: Optional[str] = None - NALOGO_DEVICE_ID: Optional[str] = None - NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json" + NALOGO_INN: str | None = None + NALOGO_PASSWORD: str | None = None + NALOGO_DEVICE_ID: str | None = None + NALOGO_STORAGE_PATH: str = './nalogo_tokens.json' AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False @@ -348,430 +346,426 @@ class Settings(BaseSettings): # Отключение превью ссылок в сообщениях бота DISABLE_WEB_PAGE_PREVIEW: bool = False ACTIVATE_BUTTON_VISIBLE: bool = False - ACTIVATE_BUTTON_TEXT: str = "активировать" - PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса" - PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки" - PAYMENT_SERVICE_NAME: str = "Интернет-сервис" - PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}" - PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}" + ACTIVATE_BUTTON_TEXT: str = 'активировать' + PAYMENT_BALANCE_DESCRIPTION: str = 'Пополнение баланса' + PAYMENT_SUBSCRIPTION_DESCRIPTION: str = 'Оплата подписки' + PAYMENT_SERVICE_NAME: str = 'Интернет-сервис' + PAYMENT_BALANCE_TEMPLATE: str = '{service_name} - {description}' + PAYMENT_SUBSCRIPTION_TEMPLATE: str = '{service_name} - {description}' CRYPTOBOT_ENABLED: bool = False - CRYPTOBOT_DISPLAY_NAME: str = "CryptoBot" - CRYPTOBOT_API_TOKEN: Optional[str] = None - CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None - CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot" + CRYPTOBOT_DISPLAY_NAME: str = 'CryptoBot' + CRYPTOBOT_API_TOKEN: str | None = None + CRYPTOBOT_WEBHOOK_SECRET: str | None = None + CRYPTOBOT_BASE_URL: str = 'https://pay.crypt.bot' CRYPTOBOT_TESTNET: bool = False - CRYPTOBOT_WEBHOOK_PATH: str = "/cryptobot-webhook" + CRYPTOBOT_WEBHOOK_PATH: str = '/cryptobot-webhook' CRYPTOBOT_WEBHOOK_PORT: int = 8083 - CRYPTOBOT_DEFAULT_ASSET: str = "USDT" - CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH" + CRYPTOBOT_DEFAULT_ASSET: str = 'USDT' + CRYPTOBOT_ASSETS: str = 'USDT,TON,BTC,ETH' CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24 HELEKET_ENABLED: bool = False - HELEKET_DISPLAY_NAME: str = "Heleket Crypto" - HELEKET_MERCHANT_ID: Optional[str] = None - HELEKET_API_KEY: Optional[str] = None - HELEKET_BASE_URL: str = "https://api.heleket.com/v1" - HELEKET_DEFAULT_CURRENCY: str = "USDT" - HELEKET_DEFAULT_NETWORK: Optional[str] = None + HELEKET_DISPLAY_NAME: str = 'Heleket Crypto' + HELEKET_MERCHANT_ID: str | None = None + HELEKET_API_KEY: str | None = None + HELEKET_BASE_URL: str = 'https://api.heleket.com/v1' + HELEKET_DEFAULT_CURRENCY: str = 'USDT' + HELEKET_DEFAULT_NETWORK: str | None = None HELEKET_INVOICE_LIFETIME: int = 3600 HELEKET_MARKUP_PERCENT: float = 0.0 - HELEKET_WEBHOOK_PATH: str = "/heleket-webhook" - HELEKET_WEBHOOK_HOST: str = "0.0.0.0" + HELEKET_WEBHOOK_PATH: str = '/heleket-webhook' + HELEKET_WEBHOOK_HOST: str = '0.0.0.0' HELEKET_WEBHOOK_PORT: int = 8086 - HELEKET_CALLBACK_URL: Optional[str] = None - HELEKET_RETURN_URL: Optional[str] = None - HELEKET_SUCCESS_URL: Optional[str] = None + HELEKET_CALLBACK_URL: str | None = None + HELEKET_RETURN_URL: str | None = None + HELEKET_SUCCESS_URL: str | None = None MULENPAY_ENABLED: bool = False - MULENPAY_API_KEY: Optional[str] = None - MULENPAY_SECRET_KEY: Optional[str] = None - MULENPAY_SHOP_ID: Optional[int] = None - MULENPAY_BASE_URL: str = "https://mulenpay.ru/api" - MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook" - MULENPAY_DISPLAY_NAME: str = "Mulen Pay" - MULENPAY_DESCRIPTION: str = "Пополнение баланса" - MULENPAY_LANGUAGE: str = "ru" + MULENPAY_API_KEY: str | None = None + MULENPAY_SECRET_KEY: str | None = None + MULENPAY_SHOP_ID: int | None = None + MULENPAY_BASE_URL: str = 'https://mulenpay.ru/api' + MULENPAY_WEBHOOK_PATH: str = '/mulenpay-webhook' + MULENPAY_DISPLAY_NAME: str = 'Mulen Pay' + MULENPAY_DESCRIPTION: str = 'Пополнение баланса' + MULENPAY_LANGUAGE: str = 'ru' MULENPAY_VAT_CODE: int = 0 - DISPLAY_NAME_BANNED_KEYWORDS: str = "\n".join( - DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS - ) + DISPLAY_NAME_BANNED_KEYWORDS: str = '\n'.join(DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS) MULENPAY_PAYMENT_SUBJECT: int = 4 MULENPAY_PAYMENT_MODE: int = 4 MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000 MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000 - MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None + MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None PAL24_ENABLED: bool = False - PAL24_DISPLAY_NAME: str = "PAL24" - PAL24_API_TOKEN: Optional[str] = None - PAL24_SHOP_ID: Optional[str] = None - PAL24_SIGNATURE_TOKEN: Optional[str] = None - PAL24_BASE_URL: str = "https://pal24.pro/api/v1/" - PAL24_WEBHOOK_PATH: str = "/pal24-webhook" + PAL24_DISPLAY_NAME: str = 'PAL24' + PAL24_API_TOKEN: str | None = None + PAL24_SHOP_ID: str | None = None + PAL24_SIGNATURE_TOKEN: str | None = None + PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/' + PAL24_WEBHOOK_PATH: str = '/pal24-webhook' PAL24_WEBHOOK_PORT: int = 8084 - PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса" + PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса' PAL24_MIN_AMOUNT_KOPEKS: int = 10000 PAL24_MAX_AMOUNT_KOPEKS: int = 100000000 PAL24_REQUEST_TIMEOUT: int = 30 - PAL24_SBP_BUTTON_TEXT: Optional[str] = None - PAL24_CARD_BUTTON_TEXT: Optional[str] = None + PAL24_SBP_BUTTON_TEXT: str | None = None + PAL24_CARD_BUTTON_TEXT: str | None = None PAL24_SBP_BUTTON_VISIBLE: bool = True PAL24_CARD_BUTTON_VISIBLE: bool = True PLATEGA_ENABLED: bool = False - PLATEGA_MERCHANT_ID: Optional[str] = None - PLATEGA_SECRET: Optional[str] = None - PLATEGA_DISPLAY_NAME: str = "Platega" - PLATEGA_BASE_URL: str = "https://app.platega.io" - PLATEGA_RETURN_URL: Optional[str] = None - PLATEGA_FAILED_URL: Optional[str] = None - PLATEGA_CURRENCY: str = "RUB" - PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13" + PLATEGA_MERCHANT_ID: str | None = None + PLATEGA_SECRET: str | None = None + PLATEGA_DISPLAY_NAME: str = 'Platega' + PLATEGA_BASE_URL: str = 'https://app.platega.io' + PLATEGA_RETURN_URL: str | None = None + PLATEGA_FAILED_URL: str | None = None + PLATEGA_CURRENCY: str = 'RUB' + PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13' PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000 PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000 - PLATEGA_WEBHOOK_PATH: str = "/platega-webhook" - PLATEGA_WEBHOOK_HOST: str = "0.0.0.0" + PLATEGA_WEBHOOK_PATH: str = '/platega-webhook' + PLATEGA_WEBHOOK_HOST: str = '0.0.0.0' PLATEGA_WEBHOOK_PORT: int = 8086 WATA_ENABLED: bool = False - WATA_DISPLAY_NAME: str = "Wata" - WATA_BASE_URL: str = "https://api.wata.pro/api/h2h" - WATA_ACCESS_TOKEN: Optional[str] = None - WATA_TERMINAL_PUBLIC_ID: Optional[str] = None - WATA_PAYMENT_DESCRIPTION: str = "Пополнение баланса" - WATA_PAYMENT_TYPE: str = "OneTime" - WATA_SUCCESS_REDIRECT_URL: Optional[str] = None - WATA_FAIL_REDIRECT_URL: Optional[str] = None - WATA_LINK_TTL_MINUTES: Optional[int] = None + WATA_DISPLAY_NAME: str = 'Wata' + WATA_BASE_URL: str = 'https://api.wata.pro/api/h2h' + WATA_ACCESS_TOKEN: str | None = None + WATA_TERMINAL_PUBLIC_ID: str | None = None + WATA_PAYMENT_DESCRIPTION: str = 'Пополнение баланса' + WATA_PAYMENT_TYPE: str = 'OneTime' + WATA_SUCCESS_REDIRECT_URL: str | None = None + WATA_FAIL_REDIRECT_URL: str | None = None + WATA_LINK_TTL_MINUTES: int | None = None WATA_MIN_AMOUNT_KOPEKS: int = 10000 WATA_MAX_AMOUNT_KOPEKS: int = 100000000 WATA_REQUEST_TIMEOUT: int = 30 - WATA_WEBHOOK_PATH: str = "/wata-webhook" - WATA_WEBHOOK_HOST: str = "0.0.0.0" + WATA_WEBHOOK_PATH: str = '/wata-webhook' + WATA_WEBHOOK_HOST: str = '0.0.0.0' WATA_WEBHOOK_PORT: int = 8085 - WATA_PUBLIC_KEY_URL: Optional[str] = None + WATA_PUBLIC_KEY_URL: str | None = None WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600 # CloudPayments CLOUDPAYMENTS_ENABLED: bool = False - CLOUDPAYMENTS_DISPLAY_NAME: str = "CloudPayments" - CLOUDPAYMENTS_PUBLIC_ID: Optional[str] = None - CLOUDPAYMENTS_API_SECRET: Optional[str] = None - CLOUDPAYMENTS_API_URL: str = "https://api.cloudpayments.ru" - CLOUDPAYMENTS_WIDGET_URL: str = "https://widget.cloudpayments.ru/show" - CLOUDPAYMENTS_DESCRIPTION: str = "Пополнение баланса" - CLOUDPAYMENTS_CURRENCY: str = "RUB" + CLOUDPAYMENTS_DISPLAY_NAME: str = 'CloudPayments' + CLOUDPAYMENTS_PUBLIC_ID: str | None = None + CLOUDPAYMENTS_API_SECRET: str | None = None + CLOUDPAYMENTS_API_URL: str = 'https://api.cloudpayments.ru' + CLOUDPAYMENTS_WIDGET_URL: str = 'https://widget.cloudpayments.ru/show' + CLOUDPAYMENTS_DESCRIPTION: str = 'Пополнение баланса' + CLOUDPAYMENTS_CURRENCY: str = 'RUB' CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: int = 5000 CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: int = 10000000 - CLOUDPAYMENTS_WEBHOOK_PATH: str = "/cloudpayments-webhook" - CLOUDPAYMENTS_WEBHOOK_HOST: str = "0.0.0.0" + CLOUDPAYMENTS_WEBHOOK_PATH: str = '/cloudpayments-webhook' + CLOUDPAYMENTS_WEBHOOK_HOST: str = '0.0.0.0' CLOUDPAYMENTS_WEBHOOK_PORT: int = 8087 - CLOUDPAYMENTS_RETURN_URL: Optional[str] = None - CLOUDPAYMENTS_SKIN: str = "mini" # mini, classic, modern + CLOUDPAYMENTS_RETURN_URL: str | None = None + CLOUDPAYMENTS_SKIN: str = 'mini' # mini, classic, modern CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False CLOUDPAYMENTS_TEST_MODE: bool = False # Freekassa FREEKASSA_ENABLED: bool = False - FREEKASSA_SHOP_ID: Optional[int] = None - FREEKASSA_API_KEY: Optional[str] = None - FREEKASSA_SECRET_WORD_1: Optional[str] = None # Для формы оплаты - FREEKASSA_SECRET_WORD_2: Optional[str] = None # Для webhook - FREEKASSA_DISPLAY_NAME: str = "Freekassa" - FREEKASSA_CURRENCY: str = "RUB" + FREEKASSA_SHOP_ID: int | None = None + FREEKASSA_API_KEY: str | None = None + FREEKASSA_SECRET_WORD_1: str | None = None # Для формы оплаты + FREEKASSA_SECRET_WORD_2: str | None = None # Для webhook + FREEKASSA_DISPLAY_NAME: str = 'Freekassa' + FREEKASSA_CURRENCY: str = 'RUB' FREEKASSA_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб FREEKASSA_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб FREEKASSA_PAYMENT_TIMEOUT_SECONDS: int = 3600 - FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook" - FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0" + FREEKASSA_WEBHOOK_PATH: str = '/freekassa-webhook' + FREEKASSA_WEBHOOK_HOST: str = '0.0.0.0' FREEKASSA_WEBHOOK_PORT: int = 8088 # Способ оплаты: None = форма выбора, 42 = обычный СБП, 44 = NSPK СБП - FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None + FREEKASSA_PAYMENT_SYSTEM_ID: int | None = None # Использовать API для создания заказов (нужно для NSPK СБП) FREEKASSA_USE_API: bool = False # Публичный IP сервера для Freekassa API (если не задан - определяется автоматически) - SERVER_PUBLIC_IP: Optional[str] = None + SERVER_PUBLIC_IP: str | None = None # KassaAI (api.fk.life) - отдельная платёжка KASSA_AI_ENABLED: bool = False - KASSA_AI_SHOP_ID: Optional[int] = None - KASSA_AI_API_KEY: Optional[str] = None - KASSA_AI_SECRET_WORD_2: Optional[str] = None # Для webhook - KASSA_AI_DISPLAY_NAME: str = "KassaAI" - KASSA_AI_CURRENCY: str = "RUB" + KASSA_AI_SHOP_ID: int | None = None + KASSA_AI_API_KEY: str | None = None + KASSA_AI_SECRET_WORD_2: str | None = None # Для webhook + KASSA_AI_DISPLAY_NAME: str = 'KassaAI' + KASSA_AI_CURRENCY: str = 'RUB' KASSA_AI_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб KASSA_AI_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб - KASSA_AI_WEBHOOK_PATH: str = "/kassa-ai-webhook" - KASSA_AI_WEBHOOK_HOST: str = "0.0.0.0" + KASSA_AI_WEBHOOK_PATH: str = '/kassa-ai-webhook' + KASSA_AI_WEBHOOK_HOST: str = '0.0.0.0' KASSA_AI_WEBHOOK_PORT: int = 8089 # Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay KASSA_AI_PAYMENT_SYSTEM_ID: int = 44 - MAIN_MENU_MODE: str = "default" - CONNECT_BUTTON_MODE: str = "guide" - MINIAPP_CUSTOM_URL: str = "" - MINIAPP_STATIC_PATH: str = "miniapp" - MINIAPP_PURCHASE_URL: str = "" - MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN" - MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN" - MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" - MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" + MAIN_MENU_MODE: str = 'default' + CONNECT_BUTTON_MODE: str = 'guide' + MINIAPP_CUSTOM_URL: str = '' + MINIAPP_STATIC_PATH: str = 'miniapp' + MINIAPP_PURCHASE_URL: str = '' + MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN' + MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN' + MINIAPP_SERVICE_DESCRIPTION_EN: str = 'Secure & Fast Connection' + MINIAPP_SERVICE_DESCRIPTION_RU: 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 + HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: str | None = None + HAPP_DOWNLOAD_LINK_IOS: str | None = None + HAPP_DOWNLOAD_LINK_ANDROID: str | None = None + HAPP_DOWNLOAD_LINK_MACOS: str | None = None + HAPP_DOWNLOAD_LINK_WINDOWS: str | None = None + HAPP_DOWNLOAD_LINK_PC: str | None = None HIDE_SUBSCRIPTION_LINK: bool = False ENABLE_LOGO_MODE: bool = True - LOGO_FILE: str = "vpn_logo.png" + LOGO_FILE: str = 'vpn_logo.png' SKIP_RULES_ACCEPT: bool = False SKIP_REFERRAL_CODE: bool = False - DEFAULT_LANGUAGE: str = "ru" - AVAILABLE_LANGUAGES: str = "ru,en" + DEFAULT_LANGUAGE: str = 'ru' + AVAILABLE_LANGUAGES: str = 'ru,en' LANGUAGE_SELECTION_ENABLED: bool = True # Округление цен при отображении (≤50 коп вниз, >50 коп вверх) PRICE_ROUNDING_ENABLED: bool = True - LOG_LEVEL: str = "INFO" - LOG_FILE: str = "logs/bot.log" + LOG_LEVEL: str = 'INFO' + LOG_FILE: str = 'logs/bot.log' # === Log Rotation Settings === LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение - LOG_ROTATION_TIME: str = "00:00" # Время ротации (HH:MM) + LOG_ROTATION_TIME: str = '00:00' # Время ротации (HH:MM) LOG_ROTATION_KEEP_DAYS: int = 7 # Хранить архивы N дней LOG_ROTATION_COMPRESS: bool = True # Сжимать архивы gzip LOG_ROTATION_SEND_TO_TELEGRAM: bool = False # Отправлять в канал - LOG_ROTATION_CHAT_ID: Optional[str] = None # Канал для логов (или BACKUP_SEND_CHAT_ID) - LOG_ROTATION_TOPIC_ID: Optional[int] = None # Топик в канале + LOG_ROTATION_CHAT_ID: str | None = None # Канал для логов (или BACKUP_SEND_CHAT_ID) + LOG_ROTATION_TOPIC_ID: int | None = None # Топик в канале # Пути к лог-файлам (при LOG_ROTATION_ENABLED=true) - LOG_DIR: str = "logs" - LOG_INFO_FILE: str = "info.log" - LOG_WARNING_FILE: str = "warning.log" - LOG_ERROR_FILE: str = "error.log" - LOG_PAYMENTS_FILE: str = "payments.log" + LOG_DIR: str = 'logs' + LOG_INFO_FILE: str = 'info.log' + LOG_WARNING_FILE: str = 'warning.log' + LOG_ERROR_FILE: str = 'error.log' + LOG_PAYMENTS_FILE: str = 'payments.log' # === Ban Notification Messages === # Сообщение о блокировке за превышение лимита устройств # Переменные: {ip_count}, {limit}, {ban_minutes}, {node_info} BAN_MSG_PUNISHMENT: str = ( - "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" - "━━━━━━━━━━━━━━━━━━━━━\n\n" - "❌ Причина: Превышен лимит устройств\n" - "{node_info}\n" - "📊 Детали нарушения:\n" - "├ 📱 Устройств подключено: {ip_count}\n" - "├ 📋 Разрешено по тарифу: {limit}\n" - "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" - "━━━━━━━━━━━━━━━━━━━━━\n" - "💡 Что делать:\n" - "1. Отключите лишние устройства от VPN\n" - "2. Дождитесь окончания блокировки\n" - "3. Подключитесь заново\n\n" - "🔄 Доступ восстановится автоматически" + '🚫 АККАУНТ ЗАБЛОКИРОВАН\n' + '━━━━━━━━━━━━━━━━━━━━━\n\n' + '❌ Причина: Превышен лимит устройств\n' + '{node_info}\n' + '📊 Детали нарушения:\n' + '├ 📱 Устройств подключено: {ip_count}\n' + '├ 📋 Разрешено по тарифу: {limit}\n' + '└ ⏱ Время блокировки: {ban_minutes} мин\n\n' + '━━━━━━━━━━━━━━━━━━━━━\n' + '💡 Что делать:\n' + '1. Отключите лишние устройства от VPN\n' + '2. Дождитесь окончания блокировки\n' + '3. Подключитесь заново\n\n' + '🔄 Доступ восстановится автоматически' ) # Сообщение о разблокировке BAN_MSG_ENABLED: str = ( - "✅ АККАУНТ РАЗБЛОКИРОВАН\n" - "━━━━━━━━━━━━━━━━━━━━━\n\n" - "🎉 Ваш аккаунт успешно разблокирован!\n\n" - "Теперь вы можете снова пользоваться VPN.\n\n" - "━━━━━━━━━━━━━━━━━━━━━\n" - "⚠️ Рекомендации:\n" - "• Следите за количеством устройств\n" - "• Отключайте VPN когда не используете\n" - "• Не превышайте лимит по тарифу" + '✅ АККАУНТ РАЗБЛОКИРОВАН\n' + '━━━━━━━━━━━━━━━━━━━━━\n\n' + '🎉 Ваш аккаунт успешно разблокирован!\n\n' + 'Теперь вы можете снова пользоваться VPN.\n\n' + '━━━━━━━━━━━━━━━━━━━━━\n' + '⚠️ Рекомендации:\n' + '• Следите за количеством устройств\n' + '• Отключайте VPN когда не используете\n' + '• Не превышайте лимит по тарифу' ) # Сообщение о блокировке за WiFi # Переменные: {ban_minutes}, {network_info}, {node_info} BAN_MSG_WIFI: str = ( - "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" - "━━━━━━━━━━━━━━━━━━━━━\n\n" - "❌ Причина: Использование WiFi сети\n" - "{node_info}\n" - "📊 Детали:\n" - "├ 📶 Тип подключения: WiFi\n" - "{network_info}" - "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" - "━━━━━━━━━━━━━━━━━━━━━\n" - "💡 Что делать:\n" - "1. Отключитесь от WiFi\n" - "2. Используйте мобильный интернет\n" - "3. Дождитесь окончания блокировки\n\n" - "🔄 Доступ восстановится автоматически" + '🚫 АККАУНТ ЗАБЛОКИРОВАН\n' + '━━━━━━━━━━━━━━━━━━━━━\n\n' + '❌ Причина: Использование WiFi сети\n' + '{node_info}\n' + '📊 Детали:\n' + '├ 📶 Тип подключения: WiFi\n' + '{network_info}' + '└ ⏱ Время блокировки: {ban_minutes} мин\n\n' + '━━━━━━━━━━━━━━━━━━━━━\n' + '💡 Что делать:\n' + '1. Отключитесь от WiFi\n' + '2. Используйте мобильный интернет\n' + '3. Дождитесь окончания блокировки\n\n' + '🔄 Доступ восстановится автоматически' ) # Сообщение о блокировке за мобильную сеть # Переменные: {ban_minutes}, {network_info}, {node_info} BAN_MSG_MOBILE: str = ( - "🚫 АККАУНТ ЗАБЛОКИРОВАН\n" - "━━━━━━━━━━━━━━━━━━━━━\n\n" - "❌ Причина: Использование мобильной сети\n" - "{node_info}\n" - "📊 Детали:\n" - "├ 📱 Тип подключения: Мобильная сеть\n" - "{network_info}" - "└ ⏱ Время блокировки: {ban_minutes} мин\n\n" - "━━━━━━━━━━━━━━━━━━━━━\n" - "💡 Что делать:\n" - "1. Подключитесь к WiFi\n" - "2. Дождитесь окончания блокировки\n" - "3. Используйте VPN только через WiFi\n\n" - "🔄 Доступ восстановится автоматически" + '🚫 АККАУНТ ЗАБЛОКИРОВАН\n' + '━━━━━━━━━━━━━━━━━━━━━\n\n' + '❌ Причина: Использование мобильной сети\n' + '{node_info}\n' + '📊 Детали:\n' + '├ 📱 Тип подключения: Мобильная сеть\n' + '{network_info}' + '└ ⏱ Время блокировки: {ban_minutes} мин\n\n' + '━━━━━━━━━━━━━━━━━━━━━\n' + '💡 Что делать:\n' + '1. Подключитесь к WiFi\n' + '2. Дождитесь окончания блокировки\n' + '3. Используйте VPN только через WiFi\n\n' + '🔄 Доступ восстановится автоматически' ) # Сообщение-предупреждение # Переменные: {warning_message} BAN_MSG_WARNING: str = ( - "⚠️ ПРЕДУПРЕЖДЕНИЕ\n" - "━━━━━━━━━━━━━━━━━━━━━\n\n" - "{warning_message}\n\n" - "━━━━━━━━━━━━━━━━━━━━━\n" - "❗ При повторном нарушении аккаунт будет заблокирован" + '⚠️ ПРЕДУПРЕЖДЕНИЕ\n' + '━━━━━━━━━━━━━━━━━━━━━\n\n' + '{warning_message}\n\n' + '━━━━━━━━━━━━━━━━━━━━━\n' + '❗ При повторном нарушении аккаунт будет заблокирован' ) DEBUG: bool = False - WEBHOOK_URL: Optional[str] = None - WEBHOOK_PATH: str = "/webhook" - WEBHOOK_SECRET_TOKEN: Optional[str] = None + WEBHOOK_URL: str | None = None + WEBHOOK_PATH: str = '/webhook' + WEBHOOK_SECRET_TOKEN: str | None = None WEBHOOK_DROP_PENDING_UPDATES: bool = True WEBHOOK_MAX_QUEUE_SIZE: int = 1024 WEBHOOK_WORKERS: int = 4 WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1 WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0 - BOT_RUN_MODE: str = "polling" + BOT_RUN_MODE: str = 'polling' WEB_API_ENABLED: bool = False - WEB_API_HOST: str = "0.0.0.0" + 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_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_TITLE: str = 'Remnawave Bot Admin API' + WEB_API_VERSION: str = '1.0.0' + WEB_API_DEFAULT_TOKEN: str | None = 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" + + APP_CONFIG_PATH: str = 'app-config.json' ENABLE_DEEP_LINKS: bool = True - APP_CONFIG_CACHE_TTL: int = 3600 + APP_CONFIG_CACHE_TTL: int = 3600 VERSION_CHECK_ENABLED: bool = True - VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot" + VERSION_CHECK_REPO: str = 'fr1ngg/remnawave-bedolaga-telegram-bot' VERSION_CHECK_INTERVAL_HOURS: int = 1 BACKUP_AUTO_ENABLED: bool = True BACKUP_INTERVAL_HOURS: int = 24 - BACKUP_TIME: str = "03:00" + BACKUP_TIME: str = '03:00' BACKUP_MAX_KEEP: int = 7 BACKUP_COMPRESSION: bool = True BACKUP_INCLUDE_LOGS: bool = False - BACKUP_LOCATION: str = "/app/data/backups" + BACKUP_LOCATION: str = '/app/data/backups' BACKUP_SEND_ENABLED: bool = False - BACKUP_SEND_CHAT_ID: Optional[str] = None - BACKUP_SEND_TOPIC_ID: Optional[int] = None - BACKUP_ARCHIVE_PASSWORD: Optional[str] = None + BACKUP_SEND_CHAT_ID: str | None = None + BACKUP_SEND_TOPIC_ID: int | None = None + BACKUP_ARCHIVE_PASSWORD: str | None = None - EXTERNAL_ADMIN_TOKEN: Optional[str] = None - EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None + EXTERNAL_ADMIN_TOKEN: str | None = None + EXTERNAL_ADMIN_TOKEN_BOT_ID: int | None = None # Cabinet (Personal Account) settings CABINET_ENABLED: bool = False - CABINET_JWT_SECRET: Optional[str] = None + CABINET_JWT_SECRET: str | None = None CABINET_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 CABINET_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - CABINET_ALLOWED_ORIGINS: str = "" + CABINET_ALLOWED_ORIGINS: str = '' CABINET_EMAIL_VERIFICATION_ENABLED: bool = True CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24 CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1 # SMTP settings for cabinet email - SMTP_HOST: Optional[str] = None + SMTP_HOST: str | None = None SMTP_PORT: int = 587 - SMTP_USER: Optional[str] = None - SMTP_PASSWORD: Optional[str] = None - SMTP_FROM_EMAIL: Optional[str] = None - SMTP_FROM_NAME: str = "VPN Service" + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + SMTP_FROM_EMAIL: str | None = None + SMTP_FROM_NAME: str = 'VPN Service' SMTP_USE_TLS: bool = True # Ban System Integration (BedolagaBan monitoring) BAN_SYSTEM_ENABLED: bool = False - BAN_SYSTEM_API_URL: Optional[str] = None # e.g., http://ban-server:8000 - BAN_SYSTEM_API_TOKEN: Optional[str] = None + BAN_SYSTEM_API_URL: str | None = None # e.g., http://ban-server:8000 + BAN_SYSTEM_API_TOKEN: str | None = None BAN_SYSTEM_REQUEST_TIMEOUT: int = 30 @field_validator('MAIN_MENU_MODE', mode='before') @classmethod - def normalize_main_menu_mode(cls, value: Optional[str]) -> str: + def normalize_main_menu_mode(cls, value: str | None) -> str: if not value: - return "default" + return 'default' normalized = str(value).strip().lower() aliases = { - "classic": "default", - "default": "default", - "full": "default", - "standard": "default", - "text": "text", - "text_only": "text", - "textual": "text", - "minimal": "text", + 'classic': 'default', + 'default': 'default', + 'full': 'default', + 'standard': 'default', + 'text': 'text', + 'text_only': 'text', + 'textual': 'text', + 'minimal': 'text', } mode = aliases.get(normalized, normalized) - if mode not in {"default", "text"}: - raise ValueError("MAIN_MENU_MODE must be one of: default, text") + if mode not in {'default', 'text'}: + raise ValueError('MAIN_MENU_MODE must be one of: default, text') return mode @field_validator('SERVER_STATUS_MODE', mode='before') @classmethod - def normalize_server_status_mode(cls, value: Optional[str]) -> str: + def normalize_server_status_mode(cls, value: str | None) -> str: if not value: - return "disabled" + return 'disabled' normalized = str(value).strip().lower() aliases = { - "off": "disabled", - "none": "disabled", - "disabled": "disabled", - "external": "external_link", - "link": "external_link", - "url": "external_link", - "external_link": "external_link", - "miniapp": "external_link_miniapp", - "mini_app": "external_link_miniapp", - "mini-app": "external_link_miniapp", - "webapp": "external_link_miniapp", - "web_app": "external_link_miniapp", - "web-app": "external_link_miniapp", - "external_link_miniapp": "external_link_miniapp", - "xray": "xray", - "xraychecker": "xray", - "xray_metrics": "xray", - "metrics": "xray", + 'off': 'disabled', + 'none': 'disabled', + 'disabled': 'disabled', + 'external': 'external_link', + 'link': 'external_link', + 'url': 'external_link', + 'external_link': 'external_link', + 'miniapp': 'external_link_miniapp', + 'mini_app': 'external_link_miniapp', + 'mini-app': 'external_link_miniapp', + 'webapp': 'external_link_miniapp', + 'web_app': 'external_link_miniapp', + 'web-app': 'external_link_miniapp', + 'external_link_miniapp': 'external_link_miniapp', + 'xray': 'xray', + 'xraychecker': 'xray', + 'xray_metrics': 'xray', + 'metrics': 'xray', } mode = aliases.get(normalized, normalized) - if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}: - raise ValueError( - "SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray" - ) + if mode not in {'disabled', 'external_link', 'external_link_miniapp', 'xray'}: + raise ValueError('SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray') return mode @field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before') @classmethod - def ensure_positive_server_status_page_size(cls, value: Optional[int]) -> int: + def ensure_positive_server_status_page_size(cls, value: int | None) -> int: try: if value is None: return 10 @@ -782,7 +776,7 @@ class Settings(BaseSettings): @field_validator('SERVER_STATUS_REQUEST_TIMEOUT', mode='before') @classmethod - def ensure_positive_server_status_timeout(cls, value: Optional[int]) -> int: + def ensure_positive_server_status_timeout(cls, value: int | None) -> int: try: if value is None: return 10 @@ -790,57 +784,55 @@ class Settings(BaseSettings): return max(1, value_int) except (TypeError, ValueError): return 10 - + @field_validator('LOG_FILE', mode='before') @classmethod def ensure_log_dir(cls, v): log_path = Path(v) log_path.parent.mkdir(parents=True, exist_ok=True) return str(log_path) - + def get_database_url(self) -> str: if self.DATABASE_URL and self.DATABASE_URL.strip(): return self.DATABASE_URL - + mode = self.DATABASE_MODE.lower() - - if mode == "sqlite": + + if mode == 'sqlite': return self._get_sqlite_url() - elif mode == "postgresql": + if mode == 'postgresql': return self._get_postgresql_url() - elif mode == "auto": - if os.getenv("DOCKER_ENV") == "true" or os.path.exists("/.dockerenv"): + if mode == 'auto': + if os.getenv('DOCKER_ENV') == 'true' or os.path.exists('/.dockerenv'): return self._get_postgresql_url() - else: - return self._get_sqlite_url() - else: - return self._get_auto_database_url() - + return self._get_sqlite_url() + return self._get_auto_database_url() + def _get_sqlite_url(self) -> str: sqlite_path = Path(self.SQLITE_PATH) sqlite_path.parent.mkdir(parents=True, exist_ok=True) - return f"sqlite+aiosqlite:///{sqlite_path.absolute()}" - + return f'sqlite+aiosqlite:///{sqlite_path.absolute()}' + def _get_postgresql_url(self) -> str: - return (f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" - f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}") - + return ( + f'postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}' + f'@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}' + ) + def _get_auto_database_url(self) -> str: - if (os.getenv("DOCKER_ENV") == "true" or - os.path.exists("/.dockerenv")): + if os.getenv('DOCKER_ENV') == 'true' or os.path.exists('/.dockerenv'): return self._get_postgresql_url() - else: - return self._get_sqlite_url() - + return self._get_sqlite_url() + def is_postgresql(self) -> bool: """Проверяет, используется ли PostgreSQL""" - return "postgresql" in self.get_database_url() - + return 'postgresql' in self.get_database_url() + def is_sqlite(self) -> bool: """Проверяет, используется ли SQLite""" - return "sqlite" in self.get_database_url() - - def is_admin(self, telegram_id: Optional[int] = None, email: Optional[str] = None) -> bool: + return 'sqlite' in self.get_database_url() + + def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool: """ Check if user is admin by telegram_id or email. @@ -857,7 +849,7 @@ class Settings(BaseSettings): return True return False - def get_admin_ids(self) -> List[int]: + def get_admin_ids(self) -> list[int]: try: admin_ids = self.ADMIN_IDS @@ -871,7 +863,7 @@ class Settings(BaseSettings): except (ValueError, AttributeError): return [] - def get_admin_emails(self) -> List[str]: + def get_admin_emails(self) -> list[str]: """Get list of admin emails for email-only users.""" try: admin_emails = self.ADMIN_EMAILS @@ -886,14 +878,14 @@ class Settings(BaseSettings): except (ValueError, AttributeError): return [] - def get_test_email(self) -> Optional[str]: + def get_test_email(self) -> str | None: """Get test email for development/testing.""" - email = (self.TEST_EMAIL or "").strip().lower() + email = (self.TEST_EMAIL or '').strip().lower() return email if email else None - def get_test_email_password(self) -> Optional[str]: + def get_test_email_password(self) -> str | None: """Get test email password.""" - password = (self.TEST_EMAIL_PASSWORD or "").strip() + password = (self.TEST_EMAIL_PASSWORD or '').strip() return password if password else None def is_test_email(self, email: str) -> bool: @@ -912,73 +904,76 @@ class Settings(BaseSettings): return False return password == test_password - def get_remnawave_auth_params(self) -> Dict[str, Optional[str]]: + def get_remnawave_auth_params(self) -> dict[str, str | None]: return { - "base_url": self.REMNAWAVE_API_URL, - "api_key": self.REMNAWAVE_API_KEY, - "secret_key": self.REMNAWAVE_SECRET_KEY, - "username": self.REMNAWAVE_USERNAME, - "password": self.REMNAWAVE_PASSWORD, - "caddy_token": self.REMNAWAVE_CADDY_TOKEN, - "auth_type": self.REMNAWAVE_AUTH_TYPE + 'base_url': self.REMNAWAVE_API_URL, + 'api_key': self.REMNAWAVE_API_KEY, + 'secret_key': self.REMNAWAVE_SECRET_KEY, + 'username': self.REMNAWAVE_USERNAME, + 'password': self.REMNAWAVE_PASSWORD, + 'caddy_token': self.REMNAWAVE_CADDY_TOKEN, + 'auth_type': self.REMNAWAVE_AUTH_TYPE, } def get_pal24_sbp_button_text(self, fallback: str) -> str: - value = (self.PAL24_SBP_BUTTON_TEXT or "").strip() + value = (self.PAL24_SBP_BUTTON_TEXT or '').strip() return value or fallback def get_pal24_card_button_text(self, fallback: str) -> str: - value = (self.PAL24_CARD_BUTTON_TEXT or "").strip() + value = (self.PAL24_CARD_BUTTON_TEXT or '').strip() return value or fallback - + def is_pal24_sbp_button_visible(self) -> bool: return self.PAL24_SBP_BUTTON_VISIBLE - + def is_pal24_card_button_visible(self) -> bool: return self.PAL24_CARD_BUTTON_VISIBLE - + def get_remnawave_user_delete_mode(self) -> str: """Возвращает режим удаления пользователей: 'delete' или 'disable'""" mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip() - return mode if mode in ["delete", "disable"] else "delete" + return mode if mode in ['delete', 'disable'] else 'delete' def format_remnawave_user_description( self, *, full_name: str, - username: Optional[str], - telegram_id: Optional[int], - email: Optional[str] = None, - user_id: Optional[int] = None + username: str | None, + telegram_id: int | None, + email: str | None = None, + user_id: int | None = None, ) -> str: """ Форматирует описание пользователя для RemnaWave. Поддерживает как Telegram-пользователей, так и email-only пользователей. """ - template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}" - template_for_formatting = template.replace("@{username}", "{username}") + template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or 'Bot user: {full_name} {username}' + template_for_formatting = template.replace('@{username}', '{username}') - username_clean = (username or "").lstrip("@") + username_clean = (username or '').lstrip('@') # Формируем идентификатор для description identifier_parts = [] if telegram_id: - identifier_parts.append(f"TG: {telegram_id}") + identifier_parts.append(f'TG: {telegram_id}') if email: - identifier_parts.append(f"Email: {email}") + identifier_parts.append(f'Email: {email}') if user_id and not identifier_parts: - identifier_parts.append(f"ID: {user_id}") + identifier_parts.append(f'ID: {user_id}') - values = defaultdict(str, { - "full_name": full_name, - "username": f"@{username_clean}" if username_clean else "", - "username_clean": username_clean, - "telegram_id": str(telegram_id) if telegram_id else "", - "email": email or "", - "user_id": str(user_id) if user_id else "", - "identifier": " | ".join(identifier_parts) - }) + values = defaultdict( + str, + { + 'full_name': full_name, + 'username': f'@{username_clean}' if username_clean else '', + 'username_clean': username_clean, + 'telegram_id': str(telegram_id) if telegram_id else '', + 'email': email or '', + 'user_id': str(user_id) if user_id else '', + 'identifier': ' | '.join(identifier_parts), + }, + ) description = template_for_formatting.format_map(values) @@ -993,66 +988,69 @@ class Settings(BaseSettings): self, *, full_name: str, - username: Optional[str], - telegram_id: Optional[int], - email: Optional[str] = None, - user_id: Optional[int] = None + username: str | None, + telegram_id: int | None, + email: str | None = None, + user_id: int | None = None, ) -> str: """ Форматирует username для RemnaWave. Для email-пользователей (telegram_id=None) использует email prefix + user_id. """ - template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}" + template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or 'user_{telegram_id}' - username_clean = (username or "").lstrip("@") - full_name_value = full_name or "" + username_clean = (username or '').lstrip('@') + full_name_value = full_name or '' # Для email-пользователей формируем уникальный identifier if telegram_id: identifier = str(telegram_id) elif email: email_prefix = email.split('@')[0][:10] - identifier = f"email_{email_prefix}_{user_id}" if user_id else f"email_{email_prefix}" + identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}' elif user_id: - identifier = f"id_{user_id}" + identifier = f'id_{user_id}' else: - identifier = "unknown" + identifier = 'unknown' - values = defaultdict(str, { - "full_name": full_name_value, - "username": username_clean, - "username_clean": username_clean, - "telegram_id": str(telegram_id) if telegram_id else identifier, - "identifier": identifier, - "email": email.split('@')[0] if email else "", - "user_id": str(user_id) if user_id else "", - }) + values = defaultdict( + str, + { + 'full_name': full_name_value, + 'username': username_clean, + 'username_clean': username_clean, + 'telegram_id': str(telegram_id) if telegram_id else identifier, + 'identifier': identifier, + 'email': email.split('@')[0] if email else '', + 'user_id': str(user_id) if user_id else '', + }, + ) raw_username = template.format_map(values).strip() - sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username) - sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-") + sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username) + sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-') if not sanitized_username: - sanitized_username = f"user_{identifier}" + sanitized_username = f'user_{identifier}' return sanitized_username[:64] @staticmethod - def parse_daily_time_list(raw_value: Optional[str]) -> List[time]: + def parse_daily_time_list(raw_value: str | None) -> list[time]: if not raw_value: return [] - segments = re.split(r"[\s,;]+", raw_value.strip()) + segments = re.split(r'[\s,;]+', raw_value.strip()) seen: set[tuple[int, int]] = set() - parsed: List[time] = [] + parsed: list[time] = [] for segment in segments: if not segment: continue try: - hours_str, minutes_str = segment.split(":", 1) + hours_str, minutes_str = segment.split(':', 1) hours = int(hours_str) minutes = int(minutes_str) except (ValueError, AttributeError): @@ -1071,55 +1069,55 @@ class Settings(BaseSettings): parsed.sort() return parsed - def get_remnawave_auto_sync_times(self) -> List[time]: + def get_remnawave_auto_sync_times(self) -> list[time]: return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES) - def get_traffic_monitored_nodes(self) -> List[str]: + def get_traffic_monitored_nodes(self) -> list[str]: """Возвращает список UUID нод для мониторинга (пусто = все)""" if not self.TRAFFIC_MONITORED_NODES: return [] # Убираем комментарии (все после #) - value = self.TRAFFIC_MONITORED_NODES.split("#")[0].strip() + value = self.TRAFFIC_MONITORED_NODES.split('#')[0].strip() if not value: return [] - return [n.strip() for n in value.split(",") if n.strip()] + return [n.strip() for n in value.split(',') if n.strip()] - def get_traffic_ignored_nodes(self) -> List[str]: + def get_traffic_ignored_nodes(self) -> list[str]: """Возвращает список UUID нод для исключения из мониторинга""" if not self.TRAFFIC_IGNORED_NODES: return [] # Убираем комментарии (все после #) - value = self.TRAFFIC_IGNORED_NODES.split("#")[0].strip() + value = self.TRAFFIC_IGNORED_NODES.split('#')[0].strip() if not value: return [] - return [n.strip() for n in value.split(",") if n.strip()] + return [n.strip() for n in value.split(',') if n.strip()] - def get_traffic_excluded_user_uuids(self) -> List[str]: + def get_traffic_excluded_user_uuids(self) -> list[str]: """Возвращает список UUID пользователей для исключения из мониторинга (например, тунельные/служебные)""" if not self.TRAFFIC_EXCLUDED_USER_UUIDS: return [] # Убираем комментарии (все после #) - value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split("#")[0].strip() + value = self.TRAFFIC_EXCLUDED_USER_UUIDS.split('#')[0].strip() if not value: return [] - return [uuid.strip().lower() for uuid in value.split(",") if uuid.strip()] + return [uuid.strip().lower() for uuid in value.split(',') if uuid.strip()] - def get_traffic_daily_check_time(self) -> Optional[time]: + def get_traffic_daily_check_time(self) -> time | None: """Возвращает время суточной проверки трафика""" times = self.parse_daily_time_list(self.TRAFFIC_DAILY_CHECK_TIME) return times[0] if times else None - def get_display_name_banned_keywords(self) -> List[str]: + def get_display_name_banned_keywords(self) -> list[str]: raw_value = self.DISPLAY_NAME_BANNED_KEYWORDS if raw_value is None: return [] if isinstance(raw_value, str): - candidates = re.split(r"[\n,]+", raw_value) + candidates = re.split(r'[\n,]+', raw_value) else: candidates = list(raw_value) - unique: List[str] = [] + unique: list[str] = [] seen: set[str] = set() for candidate in candidates: normalized = str(candidate).strip().lower() @@ -1131,8 +1129,8 @@ class Settings(BaseSettings): unique.append(normalized) return unique - - def get_autopay_warning_days(self) -> List[int]: + + def get_autopay_warning_days(self) -> list[int]: try: days = self.AUTOPAY_WARNING_DAYS if isinstance(days, str): @@ -1144,30 +1142,30 @@ class Settings(BaseSettings): return [3, 1] def is_autopay_enabled_by_default(self) -> bool: - value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True) + value = getattr(self, 'DEFAULT_AUTOPAY_ENABLED', True) if isinstance(value, str): normalized = value.strip().lower() - return normalized in {"1", "true", "yes", "on"} + return normalized in {'1', 'true', 'yes', 'on'} return bool(value) def is_auto_purchase_after_topup_enabled(self) -> bool: - value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False) + value = getattr(self, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', False) if isinstance(value, str): normalized = value.strip().lower() - return normalized in {"1", "true", "yes", "on"} + return normalized in {'1', 'true', 'yes', 'on'} return bool(value) def is_auto_activate_after_topup_enabled(self) -> bool: """Умная автоактивация после пополнения баланса (без корзины).""" - value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False) + value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False) if isinstance(value, str): normalized = value.strip().lower() - return normalized in {"1", "true", "yes", "on"} + return normalized in {'1', 'true', 'yes', 'on'} return bool(value) @@ -1175,15 +1173,15 @@ class Settings(BaseSettings): """Показывать ли кнопки быстрого выбора суммы пополнения.""" return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS - def get_available_languages(self) -> List[str]: - defaults = ["ru", "en", "ua", "zh"] + def get_available_languages(self) -> list[str]: + defaults = ['ru', 'en', 'ua', 'zh'] try: langs = self.AVAILABLE_LANGUAGES except AttributeError: return defaults - candidates: List[str] + candidates: list[str] if isinstance(langs, str): if not langs.strip(): @@ -1194,7 +1192,7 @@ class Settings(BaseSettings): else: return defaults - cleaned: List[str] = [] + cleaned: list[str] = [] seen: set[str] = set() for code in candidates: @@ -1212,9 +1210,9 @@ class Settings(BaseSettings): return cleaned or defaults def is_language_selection_enabled(self) -> bool: - return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True)) + return bool(getattr(self, 'LANGUAGE_SELECTION_ENABLED', True)) - def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str: + def format_price(self, price_kopeks: int, round_kopeks: bool | None = None) -> str: """ Форматирует цену в копейках для отображения пользователю. @@ -1229,7 +1227,7 @@ class Settings(BaseSettings): # Используем настройку если не передано явно should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED - sign = "-" if price_kopeks < 0 else "" + sign = '-' if price_kopeks < 0 else '' abs_kopeks = abs(price_kopeks) rubles, kopeks = divmod(abs_kopeks, 100) @@ -1237,41 +1235,39 @@ class Settings(BaseSettings): # Округление: ≤50 коп вниз, >50 коп вверх if kopeks > 50: rubles += 1 - return f"{sign}{rubles} ₽" + return f'{sign}{rubles} ₽' # Без округления - показываем точное значение if kopeks: - value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".") - return f"{value} ₽" + value = f'{sign}{rubles}.{kopeks:02d}'.rstrip('0').rstrip('.') + return f'{value} ₽' - return f"{sign}{rubles} ₽" + return f'{sign}{rubles} ₽' - def get_reports_chat_id(self) -> Optional[str]: + def get_reports_chat_id(self) -> str | None: if self.ADMIN_REPORTS_CHAT_ID: return self.ADMIN_REPORTS_CHAT_ID return self.ADMIN_NOTIFICATIONS_CHAT_ID - def get_reports_topic_id(self) -> Optional[int]: + def get_reports_topic_id(self) -> int | None: return self.ADMIN_REPORTS_TOPIC_ID or None - def get_reports_send_time(self) -> Optional[time]: + def get_reports_send_time(self) -> time | None: value = self.ADMIN_REPORTS_SEND_TIME if not value: return None try: - hours_str, minutes_str = value.strip().split(":", 1) + hours_str, minutes_str = value.strip().split(':', 1) hours = int(hours_str) minutes = int(minutes_str) if not (0 <= hours <= 23 and 0 <= minutes <= 59): raise ValueError return time(hour=hours, minute=minutes) except (ValueError, AttributeError): - logging.getLogger(__name__).warning( - "Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value - ) + logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value) return None - + def kopeks_to_rubles(self, kopeks: int) -> float: return kopeks / 100 @@ -1279,7 +1275,7 @@ class Settings(BaseSettings): return int(rubles * 100) @staticmethod - def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]: + def _normalize_user_tag(value: str | None, setting_name: str) -> str | None: if value is None: return None @@ -1289,7 +1285,7 @@ class Settings(BaseSettings): if len(cleaned) > 16: logger.warning( - "Некорректная длина %s: максимум 16 символов, получено %s", + 'Некорректная длина %s: максимум 16 символов, получено %s', setting_name, len(cleaned), ) @@ -1297,7 +1293,7 @@ class Settings(BaseSettings): if not USER_TAG_PATTERN.fullmatch(cleaned): logger.warning( - "Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание", + 'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание', setting_name, ) return None @@ -1307,50 +1303,50 @@ class Settings(BaseSettings): def get_trial_warning_hours(self) -> int: return self.TRIAL_WARNING_HOURS - def get_trial_user_tag(self) -> Optional[str]: - return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG") + def get_trial_user_tag(self) -> str | None: + return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG') - def get_paid_subscription_user_tag(self) -> Optional[str]: + def get_paid_subscription_user_tag(self) -> str | None: return self._normalize_user_tag( self.PAID_SUBSCRIPTION_USER_TAG, - "PAID_SUBSCRIPTION_USER_TAG", + 'PAID_SUBSCRIPTION_USER_TAG', ) - def get_bot_username(self) -> Optional[str]: - username = getattr(self, "BOT_USERNAME", None) + def get_bot_username(self) -> str | None: + username = getattr(self, 'BOT_USERNAME', None) if not username: return None - normalized = str(username).strip().lstrip("@") + normalized = str(username).strip().lstrip('@') return normalized or None def is_notifications_enabled(self) -> bool: return self.ENABLE_NOTIFICATIONS def get_main_menu_mode(self) -> str: - return getattr(self, "MAIN_MENU_MODE", "default") + return getattr(self, 'MAIN_MENU_MODE', 'default') def is_text_main_menu_mode(self) -> bool: - return self.get_main_menu_mode() == "text" + return self.get_main_menu_mode() == 'text' - def get_main_menu_miniapp_url(self) -> Optional[str]: + def get_main_menu_miniapp_url(self) -> str | None: for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]: - value = (candidate or "").strip() + value = (candidate or '').strip() if value: return value return None - + def get_app_config_path(self) -> str: if os.path.isabs(self.APP_CONFIG_PATH): return self.APP_CONFIG_PATH - + project_root = Path(__file__).parent.parent return str(project_root / self.APP_CONFIG_PATH) - + def is_deep_links_enabled(self) -> bool: return self.ENABLE_DEEP_LINKS - def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]: - def _clean(value: Optional[str]) -> Optional[str]: + def get_miniapp_branding(self) -> dict[str, dict[str, str | None]]: + def _clean(value: str | None) -> str | None: if value is None: return None value_str = str(value).strip() @@ -1361,135 +1357,135 @@ class Settings(BaseSettings): desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN) desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU) - default_name = name_en or name_ru or "RemnaWave VPN" - default_description = desc_en or desc_ru or "Secure & Fast Connection" + default_name = name_en or name_ru or 'RemnaWave VPN' + default_description = desc_en or desc_ru or 'Secure & Fast Connection' return { - "service_name": { - "default": default_name, - "en": name_en, - "ru": name_ru, + 'service_name': { + 'default': default_name, + 'en': name_en, + 'ru': name_ru, }, - "service_description": { - "default": default_description, - "en": desc_en, - "ru": desc_ru, + 'service_description': { + 'default': default_description, + 'en': desc_en, + 'ru': desc_ru, }, } - + def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL def build_external_admin_token(self, bot_username: str) -> str: """Генерирует детерминированный и криптографически стойкий токен внешней админки.""" - normalized = (bot_username or "").strip().lstrip("@").lower() + normalized = (bot_username or '').strip().lstrip('@').lower() if not normalized: - raise ValueError("Bot username is required to build external admin token") + raise ValueError('Bot username is required to build external admin token') - secret = (self.BOT_TOKEN or "").strip() + secret = (self.BOT_TOKEN or '').strip() if not secret: - raise ValueError("Bot token is required to build external admin token") + raise ValueError('Bot token is required to build external admin token') digest = hmac.new( - key=secret.encode("utf-8"), - msg=f"remnawave.external_admin::{normalized}".encode("utf-8"), + key=secret.encode('utf-8'), + msg=f'remnawave.external_admin::{normalized}'.encode(), digestmod=hashlib.sha256, ).hexdigest() return digest[:48] - def get_external_admin_token(self) -> Optional[str]: - token = (self.EXTERNAL_ADMIN_TOKEN or "").strip() + def get_external_admin_token(self) -> str | None: + token = (self.EXTERNAL_ADMIN_TOKEN or '').strip() return token or None - def get_external_admin_bot_id(self) -> Optional[int]: + def get_external_admin_bot_id(self) -> int | None: try: return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений logging.getLogger(__name__).warning( - "Некорректный идентификатор бота для внешней админки: %s", + 'Некорректный идентификатор бота для внешней админки: %s', self.EXTERNAL_ADMIN_TOKEN_BOT_ID, ) return None - + def is_traffic_selectable(self) -> bool: - return self.TRAFFIC_SELECTION_MODE.lower() == "selectable" - + return self.TRAFFIC_SELECTION_MODE.lower() == 'selectable' + def is_traffic_fixed(self) -> bool: """Возвращает True если выбор трафика отключён (fixed или fixed_with_topup)""" - return self.TRAFFIC_SELECTION_MODE.lower() in ("fixed", "fixed_with_topup") + return self.TRAFFIC_SELECTION_MODE.lower() in ('fixed', 'fixed_with_topup') def is_traffic_topup_blocked(self) -> bool: """Возвращает True если докупка трафика полностью заблокирована (только fixed)""" - return self.TRAFFIC_SELECTION_MODE.lower() == "fixed" + return self.TRAFFIC_SELECTION_MODE.lower() == 'fixed' def get_fixed_traffic_limit(self) -> int: return self.FIXED_TRAFFIC_LIMIT_GB def is_traffic_topup_enabled(self) -> bool: return self.TRAFFIC_TOPUP_ENABLED - - def get_traffic_topup_packages(self) -> List[Dict]: + + def get_traffic_topup_packages(self) -> list[dict]: """Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG.""" config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip() - + if not config_str: # Если не настроены отдельные пакеты для докупки - используем основные return self.get_traffic_packages() - + packages = [] for package_config in config_str.split(','): package_config = package_config.strip() if not package_config: continue - + parts = package_config.split(':') if len(parts) >= 2: try: gb = int(parts[0]) price = int(parts[1]) enabled = parts[2].lower() == 'true' if len(parts) > 2 else True - packages.append({"gb": gb, "price": price, "enabled": enabled}) + packages.append({'gb': gb, 'price': price, 'enabled': enabled}) except (ValueError, IndexError): continue - + return packages if packages else self.get_traffic_packages() - - def get_traffic_topup_price(self, gb: Optional[int]) -> int: + + def get_traffic_topup_price(self, gb: int | None) -> int: """Возвращает цену докупки для указанного количества ГБ.""" packages = self.get_traffic_topup_packages() - enabled_packages = [pkg for pkg in packages if pkg["enabled"]] - + enabled_packages = [pkg for pkg in packages if pkg['enabled']] + if not enabled_packages: return 0 - + # Ищем точное совпадение for pkg in enabled_packages: - if pkg["gb"] == gb: - return pkg["price"] - + if pkg['gb'] == gb: + return pkg['price'] + # Если не нашли - возвращаем 0 return 0 - + def get_traffic_reset_price_mode(self) -> str: return self.TRAFFIC_RESET_PRICE_MODE.lower() - + def get_traffic_reset_base_price(self) -> int: return self.TRAFFIC_RESET_BASE_PRICE def is_devices_selection_enabled(self) -> bool: return self.DEVICES_SELECTION_ENABLED - def get_devices_selection_disabled_amount(self) -> Optional[int]: + def get_devices_selection_disabled_amount(self) -> int | None: raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT - if raw_value in (None, ""): + if raw_value in (None, ''): return None try: value = int(raw_value) except (TypeError, ValueError): logger.warning( - "Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s", + 'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s', raw_value, ) return None @@ -1499,7 +1495,7 @@ class Settings(BaseSettings): return value - def get_disabled_mode_device_limit(self) -> Optional[int]: + def get_disabled_mode_device_limit(self) -> int | None: return self.get_devices_selection_disabled_amount() def is_modem_enabled(self) -> bool: @@ -1507,39 +1503,39 @@ class Settings(BaseSettings): def is_tariffs_mode(self) -> bool: """Проверяет, включен ли режим продаж 'Тарифы'.""" - return self.SALES_MODE == "tariffs" + return self.SALES_MODE == 'tariffs' def is_classic_mode(self) -> bool: """Проверяет, включен ли классический режим продаж.""" - return self.SALES_MODE != "tariffs" + return self.SALES_MODE != 'tariffs' def get_sales_mode(self) -> str: """Возвращает текущий режим продаж.""" - return self.SALES_MODE if self.SALES_MODE in ("classic", "tariffs") else "classic" + return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic' def get_trial_tariff_id(self) -> int: """Возвращает ID тарифа для триала (0 = использовать стандартные настройки).""" - return self.TRIAL_TARIFF_ID if self.TRIAL_TARIFF_ID > 0 else 0 + return max(0, self.TRIAL_TARIFF_ID) def get_modem_price_per_month(self) -> int: try: value = int(self.MODEM_PRICE_PER_MONTH) except (TypeError, ValueError): logger.warning( - "Некорректное значение MODEM_PRICE_PER_MONTH: %s", + 'Некорректное значение MODEM_PRICE_PER_MONTH: %s', self.MODEM_PRICE_PER_MONTH, ) return 10000 return max(0, value) - def get_modem_period_discounts(self) -> Dict[int, int]: + def get_modem_period_discounts(self) -> dict[int, int]: """Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}""" try: - config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip() + config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip() if not config_str: return {} - discounts: Dict[int, int] = {} + discounts: dict[int, int] = {} for part in config_str.split(','): part = part.strip() if not part: @@ -1568,13 +1564,13 @@ class Settings(BaseSettings): return 0 discounts = self.get_modem_period_discounts() - + # Ищем точное совпадение или ближайшее меньшее applicable_discount = 0 for discount_months, discount_percent in sorted(discounts.items()): if months >= discount_months: applicable_discount = discount_percent - + return applicable_discount def is_trial_paid_activation_enabled(self) -> bool: @@ -1590,7 +1586,7 @@ class Settings(BaseSettings): value = int(self.TRIAL_ACTIVATION_PRICE) except (TypeError, ValueError): logger.warning( - "Некорректное значение TRIAL_ACTIVATION_PRICE: %s", + 'Некорректное значение TRIAL_ACTIVATION_PRICE: %s', self.TRIAL_ACTIVATION_PRICE, ) return 0 @@ -1599,49 +1595,40 @@ class Settings(BaseSettings): return 0 return value - + def is_yookassa_enabled(self) -> bool: - return (self.YOOKASSA_ENABLED and - self.YOOKASSA_SHOP_ID is not None and - self.YOOKASSA_SECRET_KEY is not None) + return self.YOOKASSA_ENABLED and self.YOOKASSA_SHOP_ID is not None and self.YOOKASSA_SECRET_KEY is not None def get_yookassa_display_name(self) -> str: - name = (self.YOOKASSA_DISPLAY_NAME or "").strip() - return name if name else "YooKassa" + name = (self.YOOKASSA_DISPLAY_NAME or '').strip() + return name if name else 'YooKassa' def is_nalogo_enabled(self) -> bool: - return (self.NALOGO_ENABLED and - self.NALOGO_INN is not None and - self.NALOGO_PASSWORD is not None) + return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None def is_support_topup_enabled(self) -> bool: return bool(self.SUPPORT_TOPUP_ENABLED) - + def get_yookassa_return_url(self) -> str: if self.YOOKASSA_RETURN_URL: return self.YOOKASSA_RETURN_URL - elif self.WEBHOOK_URL: - return f"{self.WEBHOOK_URL}/payment-success" - return "https://t.me/" + if self.WEBHOOK_URL: + return f'{self.WEBHOOK_URL}/payment-success' + return 'https://t.me/' def is_cryptobot_enabled(self) -> bool: - return (self.CRYPTOBOT_ENABLED and - self.CRYPTOBOT_API_TOKEN is not None) + return self.CRYPTOBOT_ENABLED and self.CRYPTOBOT_API_TOKEN is not None def get_cryptobot_display_name(self) -> str: - name = (self.CRYPTOBOT_DISPLAY_NAME or "").strip() - return name if name else "CryptoBot" + name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip() + return name if name else 'CryptoBot' def is_heleket_enabled(self) -> bool: - return ( - self.HELEKET_ENABLED - and self.HELEKET_MERCHANT_ID is not None - and self.HELEKET_API_KEY is not None - ) + return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None def get_heleket_display_name(self) -> str: - name = (self.HELEKET_DISPLAY_NAME or "").strip() - return name if name else "Heleket Crypto" + name = (self.HELEKET_DISPLAY_NAME or '').strip() + return name if name else 'Heleket Crypto' def is_mulenpay_enabled(self) -> bool: return ( @@ -1652,82 +1639,74 @@ class Settings(BaseSettings): ) def get_mulenpay_display_name(self) -> str: - name = (self.MULENPAY_DISPLAY_NAME or "").strip() + name = (self.MULENPAY_DISPLAY_NAME or '').strip() if not name: - return "Mulen Pay" + return 'Mulen Pay' return name def get_mulenpay_display_name_html(self) -> str: return html.escape(self.get_mulenpay_display_name()) - def get_mulenpay_expected_origin(self) -> Optional[str]: - override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip() + def get_mulenpay_expected_origin(self) -> str | None: + override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or '').strip() if override: return override - base_url = (self.MULENPAY_BASE_URL or "").strip() + base_url = (self.MULENPAY_BASE_URL or '').strip() if not base_url: return None parsed = urlparse(base_url) if parsed.scheme and parsed.netloc: - return f"{parsed.scheme}://{parsed.netloc}" + return f'{parsed.scheme}://{parsed.netloc}' return None def is_pal24_enabled(self) -> bool: - return ( - self.PAL24_ENABLED - and self.PAL24_API_TOKEN is not None - and self.PAL24_SHOP_ID is not None - ) + return self.PAL24_ENABLED and self.PAL24_API_TOKEN is not None and self.PAL24_SHOP_ID is not None def get_pal24_display_name(self) -> str: - name = (self.PAL24_DISPLAY_NAME or "").strip() - return name if name else "PAL24" + name = (self.PAL24_DISPLAY_NAME or '').strip() + return name if name else 'PAL24' def is_platega_enabled(self) -> bool: - return ( - self.PLATEGA_ENABLED - and self.PLATEGA_MERCHANT_ID is not None - and self.PLATEGA_SECRET is not None - ) + return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None def get_platega_display_name(self) -> str: - name = (self.PLATEGA_DISPLAY_NAME or "").strip() + name = (self.PLATEGA_DISPLAY_NAME or '').strip() if not name: - return "Platega" + return 'Platega' return name def get_platega_display_name_html(self) -> str: return html.escape(self.get_platega_display_name()) - def get_platega_return_url(self) -> Optional[str]: + def get_platega_return_url(self) -> str | None: if self.PLATEGA_RETURN_URL: return self.PLATEGA_RETURN_URL if self.WEBHOOK_URL: - return f"{self.WEBHOOK_URL}/payment-success" + return f'{self.WEBHOOK_URL}/payment-success' return None - def get_platega_failed_url(self) -> Optional[str]: + def get_platega_failed_url(self) -> str | None: if self.PLATEGA_FAILED_URL: return self.PLATEGA_FAILED_URL if self.WEBHOOK_URL: - return f"{self.WEBHOOK_URL}/payment-failed" + return f'{self.WEBHOOK_URL}/payment-failed' return None - def get_platega_active_methods(self) -> List[int]: - raw_value = str(self.PLATEGA_ACTIVE_METHODS or "") - normalized = raw_value.replace(";", ",") + def get_platega_active_methods(self) -> list[int]: + raw_value = str(self.PLATEGA_ACTIVE_METHODS or '') + normalized = raw_value.replace(';', ',') methods: list[int] = [] seen: set[int] = set() - for part in normalized.split(","): + for part in normalized.split(','): part = part.strip() if not part: continue try: method_code = int(part) except ValueError: - logger.warning("Некорректный код метода Platega: %s", part) + logger.warning('Некорректный код метода Platega: %s', part) continue if method_code in {2, 10, 11, 12, 13} and method_code not in seen: methods.append(method_code) @@ -1739,39 +1718,35 @@ class Settings(BaseSettings): return methods @staticmethod - def get_platega_method_definitions() -> Dict[int, Dict[str, str]]: + def get_platega_method_definitions() -> dict[int, dict[str, str]]: return { - 2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"}, - 10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"}, - 11: {"name": "Банковские карты", "title": "💳 Банковские карты"}, - 12: {"name": "Международные карты", "title": "🌍 Международные карты"}, - 13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"}, + 2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'}, + 10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'}, + 11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'}, + 12: {'name': 'Международные карты', 'title': '🌍 Международные карты'}, + 13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'}, } def get_platega_method_display_name(self, method_code: int) -> str: definitions = self.get_platega_method_definitions() info = definitions.get(method_code) - if info and info.get("name"): - return info["name"] - return f"Метод {method_code}" + if info and info.get('name'): + return info['name'] + return f'Метод {method_code}' def get_platega_method_display_title(self, method_code: int) -> str: definitions = self.get_platega_method_definitions() info = definitions.get(method_code) if not info: - return f"Platega {method_code}" - return info.get("title") or info.get("name") or f"Platega {method_code}" + return f'Platega {method_code}' + return info.get('title') or info.get('name') or f'Platega {method_code}' def is_wata_enabled(self) -> bool: - return ( - self.WATA_ENABLED - and self.WATA_ACCESS_TOKEN is not None - and self.WATA_TERMINAL_PUBLIC_ID is not None - ) + return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None def get_wata_display_name(self) -> str: - name = (self.WATA_DISPLAY_NAME or "").strip() - return name if name else "Wata" + name = (self.WATA_DISPLAY_NAME or '').strip() + return name if name else 'Wata' def is_cloudpayments_enabled(self) -> bool: return ( @@ -1781,8 +1756,8 @@ class Settings(BaseSettings): ) def get_cloudpayments_display_name(self) -> str: - name = (self.CLOUDPAYMENTS_DISPLAY_NAME or "").strip() - return name if name else "CloudPayments" + name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip() + return name if name else 'CloudPayments' def is_freekassa_enabled(self) -> bool: return ( @@ -1794,8 +1769,8 @@ class Settings(BaseSettings): ) def get_freekassa_display_name(self) -> str: - name = (self.FREEKASSA_DISPLAY_NAME or "").strip() - return name if name else "Freekassa" + name = (self.FREEKASSA_DISPLAY_NAME or '').strip() + return name if name else 'Freekassa' def get_freekassa_display_name_html(self) -> str: return html.escape(self.get_freekassa_display_name()) @@ -1809,8 +1784,8 @@ class Settings(BaseSettings): ) def get_kassa_ai_display_name(self) -> str: - name = (self.KASSA_AI_DISPLAY_NAME or "").strip() - return name if name else "KassaAI" + name = (self.KASSA_AI_DISPLAY_NAME or '').strip() + return name if name else 'KassaAI' def get_kassa_ai_display_name_html(self) -> str: return html.escape(self.get_kassa_ai_display_name()) @@ -1826,7 +1801,7 @@ class Settings(BaseSettings): if minutes <= 0: logger.warning( - "Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.", + 'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.', self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES, ) return 10 @@ -1835,18 +1810,18 @@ class Settings(BaseSettings): def get_cryptobot_base_url(self) -> str: if self.CRYPTOBOT_TESTNET: - return "https://testnet-pay.crypt.bot" + return 'https://testnet-pay.crypt.bot' return self.CRYPTOBOT_BASE_URL - - def get_cryptobot_assets(self) -> List[str]: + + def get_cryptobot_assets(self) -> list[str]: try: assets = self.CRYPTOBOT_ASSETS.strip() if not assets: - return ["USDT", "TON"] + return ['USDT', 'TON'] return [asset.strip() for asset in assets.split(',') if asset.strip()] except (ValueError, AttributeError): - return ["USDT", "TON"] - + return ['USDT', 'TON'] + def get_cryptobot_invoice_expires_seconds(self) -> int: return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600 @@ -1863,15 +1838,15 @@ class Settings(BaseSettings): value = 3600 return max(300, min(43200, value)) - def get_heleket_callback_url(self) -> Optional[str]: + def get_heleket_callback_url(self) -> str | None: if self.HELEKET_CALLBACK_URL: return self.HELEKET_CALLBACK_URL if self.WEBHOOK_URL: - return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}" + return f'{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}' return None def is_happ_cryptolink_mode(self) -> bool: - return self.CONNECT_BUTTON_MODE == "happ_cryptolink" + 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 @@ -1884,43 +1859,40 @@ class Settings(BaseSettings): return self.HIDE_SUBSCRIPTION_LINK def is_contests_enabled(self) -> bool: - if getattr(self, "CONTESTS_ENABLED", False): + if getattr(self, 'CONTESTS_ENABLED', False): return True # legacy fallback - return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False)) + return bool(getattr(self, 'REFERRAL_CONTESTS_ENABLED', False)) def is_referral_contests_enabled(self) -> bool: # kept for backward compatibility return self.is_contests_enabled() - def get_happ_cryptolink_redirect_template(self) -> Optional[str]: - template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip() + def get_happ_cryptolink_redirect_template(self) -> str | None: + template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or '').strip() return template or None - def get_happ_download_link(self, platform: str) -> Optional[str]: + def get_happ_download_link(self, platform: str) -> str | None: platform_key = platform.lower() - if platform_key == "pc": - platform_key = "windows" + 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() - ), + '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 - + def get_maintenance_message(self) -> str: return self.MAINTENANCE_MESSAGE - + def get_maintenance_check_interval(self) -> int: return self.MAINTENANCE_CHECK_INTERVAL @@ -1934,13 +1906,13 @@ class Settings(BaseSettings): def is_base_promo_group_period_discount_enabled(self) -> bool: return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED - def get_base_promo_group_period_discounts(self) -> Dict[int, int]: + def get_base_promo_group_period_discounts(self) -> dict[int, int]: try: - config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip() + config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or '').strip() if not config_str: return {} - discounts: Dict[int, int] = {} + discounts: dict[int, int] = {} for part in config_str.split(','): part = part.strip() if not part: @@ -1963,7 +1935,7 @@ class Settings(BaseSettings): except Exception: return {} - def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int: + def get_base_promo_group_period_discount(self, period_days: int | None) -> int: if not period_days or not self.is_base_promo_group_period_discount_enabled(): return 0 @@ -1976,7 +1948,7 @@ class Settings(BaseSettings): def is_maintenance_monitoring_enabled(self) -> bool: return self.MAINTENANCE_MONITORING_ENABLED - def get_available_subscription_periods(self) -> List[int]: + def get_available_subscription_periods(self) -> list[int]: """ Возвращает доступные периоды подписки. Использует AVAILABLE_SUBSCRIPTION_PERIODS для фильтрации. @@ -1999,11 +1971,11 @@ class Settings(BaseSettings): # Возвращаем только разрешённые периоды (без фильтрации по цене, # т.к. в режиме classic цена складывается из серверов/трафика/устройств) - periods = sorted(list(allowed_periods)) + periods = sorted(allowed_periods) return periods if periods else [30, 90, 180] - def get_available_renewal_periods(self) -> List[int]: + def get_available_renewal_periods(self) -> list[int]: """ Возвращает доступные периоды продления. Использует AVAILABLE_RENEWAL_PERIODS для фильтрации. @@ -2024,11 +1996,11 @@ class Settings(BaseSettings): allowed_periods = {30, 60, 90, 180, 360} # Возвращаем только разрешённые периоды (без фильтрации по цене) - periods = sorted(list(allowed_periods)) + periods = sorted(allowed_periods) return periods if periods else [30, 90, 180] - def get_configured_subscription_periods(self) -> List[int]: + def get_configured_subscription_periods(self) -> list[int]: """ Возвращает настроенные периоды подписки из AVAILABLE_SUBSCRIPTION_PERIODS. БЕЗ фильтрации по ценам - используется для админки. @@ -2047,7 +2019,7 @@ class Settings(BaseSettings): except (ValueError, AttributeError): return [14, 30, 60, 90, 180, 360] - def get_configured_renewal_periods(self) -> List[int]: + def get_configured_renewal_periods(self) -> list[int]: """ Возвращает настроенные периоды продления из AVAILABLE_RENEWAL_PERIODS. БЕЗ фильтрации по ценам - используется для админки. @@ -2066,62 +2038,55 @@ class Settings(BaseSettings): except (ValueError, AttributeError): return [30, 60, 90, 180, 360] - def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str: + def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str: # Базовое описание - description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}" - + description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}' + # Если передан user_id, добавляем его if telegram_user_id is not None: - description += f" (ID {telegram_user_id})" - + description += f' (ID {telegram_user_id})' + # Формируем финальную строку по шаблону - return self.PAYMENT_BALANCE_TEMPLATE.format( - service_name=self.PAYMENT_SERVICE_NAME, - description=description - ) - + return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description) + def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str: return self.PAYMENT_SUBSCRIPTION_TEMPLATE.format( service_name=self.PAYMENT_SERVICE_NAME, - description=f"{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней" + description=f'{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней', ) - + def get_custom_payment_description(self, description: str) -> str: - return self.PAYMENT_BALANCE_TEMPLATE.format( - service_name=self.PAYMENT_SERVICE_NAME, - description=description - ) + return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description) def get_stars_rate(self) -> float: return self.TELEGRAM_STARS_RATE_RUB def get_telegram_stars_display_name(self) -> str: - name = (self.TELEGRAM_STARS_DISPLAY_NAME or "").strip() - return name if name else "Telegram Stars" + name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip() + return name if name else 'Telegram Stars' def stars_to_rubles(self, stars: int) -> float: return stars * self.get_stars_rate() - + def rubles_to_stars(self, rubles: float) -> int: rate = self.get_stars_rate() if rate <= 0: - raise ValueError("Stars rate must be positive") + raise ValueError('Stars rate must be positive') return max(1, math.ceil(rubles / rate)) - def get_admin_notifications_chat_id(self) -> Optional[int]: + def get_admin_notifications_chat_id(self) -> int | None: if not self.ADMIN_NOTIFICATIONS_CHAT_ID: return None - + try: return int(self.ADMIN_NOTIFICATIONS_CHAT_ID) except (ValueError, TypeError): return None - - def is_admin_notifications_enabled(self) -> bool: - return (self.ADMIN_NOTIFICATIONS_ENABLED and - self.get_admin_notifications_chat_id() is not None) - def get_backup_send_chat_id(self) -> Optional[int]: + def is_admin_notifications_enabled(self) -> bool: + return self.ADMIN_NOTIFICATIONS_ENABLED and self.get_admin_notifications_chat_id() is not None + + def get_backup_send_chat_id(self) -> int | None: if not self.BACKUP_SEND_CHAT_ID: return None @@ -2131,11 +2096,10 @@ class Settings(BaseSettings): return None def is_backup_send_enabled(self) -> bool: - return (self.BACKUP_SEND_ENABLED and - self.get_backup_send_chat_id() is not None) + return self.BACKUP_SEND_ENABLED and self.get_backup_send_chat_id() is not None - def get_backup_archive_password(self) -> Optional[str]: - password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip() + def get_backup_archive_password(self) -> str | None: + password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip() return password if password else None # === Log Rotation Methods === @@ -2144,7 +2108,7 @@ class Settings(BaseSettings): """Проверить, включена ли новая система ротации логов.""" return self.LOG_ROTATION_ENABLED - def get_log_rotation_chat_id(self) -> Optional[int]: + def get_log_rotation_chat_id(self) -> int | None: """Получить ID канала для отправки логов. Если LOG_ROTATION_CHAT_ID не задан, использует BACKUP_SEND_CHAT_ID. @@ -2158,7 +2122,7 @@ class Settings(BaseSettings): except (ValueError, TypeError): return None - def get_log_rotation_topic_id(self) -> Optional[int]: + def get_log_rotation_topic_id(self) -> int | None: """Получить ID топика для отправки логов. Если LOG_ROTATION_TOPIC_ID не задан, использует BACKUP_SEND_TOPIC_ID. @@ -2168,84 +2132,81 @@ class Settings(BaseSettings): return topic_id return self.BACKUP_SEND_TOPIC_ID - def get_referral_settings(self) -> Dict: + def get_referral_settings(self) -> dict: return { - "program_enabled": self.is_referral_program_enabled(), - "minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS, - "first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, - "inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS, - "commission_percent": self.REFERRAL_COMMISSION_PERCENT, - "notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED, - "withdrawal_enabled": self.REFERRAL_WITHDRAWAL_ENABLED, - "withdrawal_min_amount_kopeks": self.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS, - "withdrawal_cooldown_days": self.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS, + 'program_enabled': self.is_referral_program_enabled(), + 'minimum_topup_kopeks': self.REFERRAL_MINIMUM_TOPUP_KOPEKS, + 'first_topup_bonus_kopeks': self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, + 'inviter_bonus_kopeks': self.REFERRAL_INVITER_BONUS_KOPEKS, + 'commission_percent': self.REFERRAL_COMMISSION_PERCENT, + 'notifications_enabled': self.REFERRAL_NOTIFICATIONS_ENABLED, + 'withdrawal_enabled': self.REFERRAL_WITHDRAWAL_ENABLED, + 'withdrawal_min_amount_kopeks': self.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS, + 'withdrawal_cooldown_days': self.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS, } def is_referral_withdrawal_enabled(self) -> bool: """Проверяет, включена ли функция вывода реферального баланса.""" return self.is_referral_program_enabled() and self.REFERRAL_WITHDRAWAL_ENABLED - + def is_referral_program_enabled(self) -> bool: return bool(self.REFERRAL_PROGRAM_ENABLED) - + def is_referral_notifications_enabled(self) -> bool: return self.REFERRAL_NOTIFICATIONS_ENABLED - - def get_traffic_packages(self) -> List[Dict]: + + def get_traffic_packages(self) -> list[dict]: import logging + logger = logging.getLogger(__name__) - + try: packages = [] config_str = self.TRAFFIC_PACKAGES_CONFIG.strip() - + logger.debug(f"CONFIG STRING: '{config_str}'") if not config_str: - logger.debug("CONFIG EMPTY, USING FALLBACK") + logger.debug('CONFIG EMPTY, USING FALLBACK') return self._get_fallback_traffic_packages() - logger.debug("PARSING CONFIG...") - + logger.debug('PARSING CONFIG...') + for package_config in config_str.split(','): package_config = package_config.strip() if not package_config: continue - + parts = package_config.split(':') if len(parts) != 3: continue - + try: gb = int(parts[0]) price = int(parts[1]) enabled = parts[2].lower() == 'true' - - packages.append({ - "gb": gb, - "price": price, - "enabled": enabled - }) + + packages.append({'gb': gb, 'price': price, 'enabled': enabled}) except ValueError: continue - - logger.debug(f"PARSED {len(packages)} packages from config") + + logger.debug(f'PARSED {len(packages)} packages from config') return packages if packages else self._get_fallback_traffic_packages() - + except Exception as e: - logger.info(f"ERROR PARSING CONFIG: {e}") + logger.info(f'ERROR PARSING CONFIG: {e}') return self._get_fallback_traffic_packages() def is_version_check_enabled(self) -> bool: return self.VERSION_CHECK_ENABLED - + def get_version_check_repo(self) -> str: return self.VERSION_CHECK_REPO - + def get_version_check_interval(self) -> int: return self.VERSION_CHECK_INTERVAL_HOURS - - def _get_fallback_traffic_packages(self) -> List[Dict]: + + def _get_fallback_traffic_packages(self) -> list[dict]: try: if self.TRAFFIC_PACKAGES_CONFIG.strip(): packages = [] @@ -2253,44 +2214,40 @@ class Settings(BaseSettings): package_config = package_config.strip() if not package_config: continue - + parts = package_config.split(':') if len(parts) != 3: continue - + try: gb = int(parts[0]) price = int(parts[1]) enabled = parts[2].lower() == 'true' - - packages.append({ - "gb": gb, - "price": price, - "enabled": enabled - }) + + packages.append({'gb': gb, 'price': price, 'enabled': enabled}) except ValueError: continue - + if packages: return packages - except Exception as e: + except Exception: pass - + return [ - {"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True}, - {"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True}, - {"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True}, - {"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True}, - {"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True}, - {"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True}, - {"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True}, - {"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True}, - {"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True}, + {'gb': 5, 'price': self.PRICE_TRAFFIC_5GB, 'enabled': True}, + {'gb': 10, 'price': self.PRICE_TRAFFIC_10GB, 'enabled': True}, + {'gb': 25, 'price': self.PRICE_TRAFFIC_25GB, 'enabled': True}, + {'gb': 50, 'price': self.PRICE_TRAFFIC_50GB, 'enabled': True}, + {'gb': 100, 'price': self.PRICE_TRAFFIC_100GB, 'enabled': True}, + {'gb': 250, 'price': self.PRICE_TRAFFIC_250GB, 'enabled': True}, + {'gb': 500, 'price': self.PRICE_TRAFFIC_500GB, 'enabled': True}, + {'gb': 1000, 'price': self.PRICE_TRAFFIC_1000GB, 'enabled': True}, + {'gb': 0, 'price': self.PRICE_TRAFFIC_UNLIMITED, 'enabled': True}, ] - - def get_traffic_price(self, gb: Optional[int]) -> int: + + def get_traffic_price(self, gb: int | None) -> int: packages = self.get_traffic_packages() - enabled_packages = [pkg for pkg in packages if pkg["enabled"]] + enabled_packages = [pkg for pkg in packages if pkg['enabled']] if not enabled_packages: return 0 @@ -2299,57 +2256,57 @@ class Settings(BaseSettings): gb = 0 for package in enabled_packages: - if package["gb"] == gb: - return package["price"] + if package['gb'] == gb: + return package['price'] - unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None) + unlimited_package = next((pkg for pkg in enabled_packages if pkg['gb'] == 0), None) if gb <= 0: - return unlimited_package["price"] if unlimited_package else 0 + return unlimited_package['price'] if unlimited_package else 0 - finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0] + finite_packages = [pkg for pkg in enabled_packages if pkg['gb'] > 0] if not finite_packages: - return unlimited_package["price"] if unlimited_package else 0 + return unlimited_package['price'] if unlimited_package else 0 - max_package = max(finite_packages, key=lambda x: x["gb"]) + max_package = max(finite_packages, key=lambda x: x['gb']) - if gb >= max_package["gb"]: - return unlimited_package["price"] if unlimited_package else max_package["price"] + if gb >= max_package['gb']: + return unlimited_package['price'] if unlimited_package else max_package['price'] - suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb] + suitable_packages = [pkg for pkg in finite_packages if pkg['gb'] >= gb] if suitable_packages: - nearest_package = min(suitable_packages, key=lambda x: x["gb"]) - return nearest_package["price"] + nearest_package = min(suitable_packages, key=lambda x: x['gb']) + return nearest_package['price'] - return unlimited_package["price"] if unlimited_package else 0 + return unlimited_package['price'] if unlimited_package else 0 def _clean_support_contact(self) -> str: - return (self.SUPPORT_USERNAME or "").strip() + return (self.SUPPORT_USERNAME or '').strip() - def get_support_contact_url(self) -> Optional[str]: + def get_support_contact_url(self) -> str | None: contact = self._clean_support_contact() if not contact: return None - if contact.startswith(("http://", "https://", "tg://")): + if contact.startswith(('http://', 'https://', 'tg://')): return contact - contact_without_prefix = contact.lstrip("@") + contact_without_prefix = contact.lstrip('@') - if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")): - return f"https://{contact_without_prefix}" + if contact_without_prefix.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')): + return f'https://{contact_without_prefix}' - if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): - return f"https://{contact}" + if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')): + return f'https://{contact}' - if "." in contact_without_prefix: - return f"https://{contact_without_prefix}" + if '.' in contact_without_prefix: + return f'https://{contact_without_prefix}' if contact_without_prefix: - return f"https://t.me/{contact_without_prefix}" + return f'https://t.me/{contact_without_prefix}' return None @@ -2357,26 +2314,26 @@ class Settings(BaseSettings): contact = self._clean_support_contact() if not contact: - return "" + return '' - if contact.startswith("@"): + if contact.startswith('@'): return contact - if contact.startswith(("http://", "https://", "tg://")): + if contact.startswith(('http://', 'https://', 'tg://')): return contact - if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")): + if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')): url = self.get_support_contact_url() return url if url else contact - contact_without_prefix = contact.lstrip("@") + contact_without_prefix = contact.lstrip('@') - if "." in contact_without_prefix: + if '.' in contact_without_prefix: url = self.get_support_contact_url() return url if url else contact - if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix): - return f"@{contact_without_prefix}" + if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix): + return f'@{contact_without_prefix}' return contact @@ -2387,24 +2344,24 @@ class Settings(BaseSettings): return self.SERVER_STATUS_MODE def is_server_status_enabled(self) -> bool: - return self.get_server_status_mode() != "disabled" + return self.get_server_status_mode() != 'disabled' - def get_server_status_external_url(self) -> Optional[str]: - url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip() + def get_server_status_external_url(self) -> str | None: + url = (self.SERVER_STATUS_EXTERNAL_URL or '').strip() return url or None - def get_server_status_metrics_url(self) -> Optional[str]: - url = (self.SERVER_STATUS_METRICS_URL or "").strip() + def get_server_status_metrics_url(self) -> str | None: + url = (self.SERVER_STATUS_METRICS_URL or '').strip() return url or None - def get_server_status_metrics_auth(self) -> Optional[tuple[str, str]]: - username = (self.SERVER_STATUS_METRICS_USERNAME or "").strip() + def get_server_status_metrics_auth(self) -> tuple[str, str] | None: + username = (self.SERVER_STATUS_METRICS_USERNAME or '').strip() password_raw = self.SERVER_STATUS_METRICS_PASSWORD if not username: return None - password = "" if password_raw is None else str(password_raw) + password = '' if password_raw is None else str(password_raw) return username, password def get_server_status_items_per_page(self) -> int: @@ -2417,29 +2374,29 @@ class Settings(BaseSettings): return bool(self.WEB_API_ENABLED) def get_web_api_allowed_origins(self) -> list[str]: - raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",") + raw = (self.WEB_API_ALLOWED_ORIGINS or '').split(',') origins = [origin.strip() for origin in raw if origin.strip()] - return origins or ["*"] + return origins or ['*'] - def get_web_api_docs_config(self) -> Dict[str, Optional[str]]: + def get_web_api_docs_config(self) -> dict[str, str | None]: if self.WEB_API_DOCS_ENABLED: return { - "docs_url": "/docs", - "redoc_url": "/redoc", - "openapi_url": "/openapi.json", + 'docs_url': '/docs', + 'redoc_url': '/redoc', + 'openapi_url': '/openapi.json', } - return {"docs_url": None, "redoc_url": None, "openapi_url": None} + 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" + mode = (self.SUPPORT_SYSTEM_MODE or 'both').strip().lower() + return mode if mode in {'tickets', 'contact', 'both'} else 'both' def is_support_tickets_enabled(self) -> bool: - return self.get_support_system_mode() in {"tickets", "both"} + return self.get_support_system_mode() in {'tickets', 'both'} def is_support_contact_enabled(self) -> bool: - return self.get_support_system_mode() in {"contact", "both"} + return self.get_support_system_mode() in {'contact', 'both'} # MiniApp tickets settings def is_miniapp_tickets_enabled(self) -> bool: @@ -2448,25 +2405,25 @@ class Settings(BaseSettings): def get_miniapp_support_type(self) -> str: """Get miniapp support type: tickets, profile, or url.""" - support_type = (self.MINIAPP_SUPPORT_TYPE or "tickets").strip().lower() - return support_type if support_type in {"tickets", "profile", "url"} else "tickets" + support_type = (self.MINIAPP_SUPPORT_TYPE or 'tickets').strip().lower() + return support_type if support_type in {'tickets', 'profile', 'url'} else 'tickets' def get_miniapp_support_url(self) -> str: """Get custom support URL for miniapp (when type is 'url').""" - return (self.MINIAPP_SUPPORT_URL or "").strip() + return (self.MINIAPP_SUPPORT_URL or '').strip() def get_bot_run_mode(self) -> str: - mode = (self.BOT_RUN_MODE or "polling").strip().lower() - if mode not in {"polling", "webhook", "both"}: - return "polling" + mode = (self.BOT_RUN_MODE or 'polling').strip().lower() + if mode not in {'polling', 'webhook', 'both'}: + return 'polling' return mode def get_telegram_webhook_path(self) -> str: - raw_path = (self.WEBHOOK_PATH or "/webhook").strip() + raw_path = (self.WEBHOOK_PATH or '/webhook').strip() if not raw_path: - raw_path = "/webhook" - if not raw_path.startswith("/"): - raw_path = "/" + raw_path + raw_path = '/webhook' + if not raw_path.startswith('/'): + raw_path = '/' + raw_path return raw_path def get_webhook_queue_maxsize(self) -> int: @@ -2497,17 +2454,17 @@ class Settings(BaseSettings): timeout = 30.0 return max(1.0, timeout) - def get_telegram_webhook_url(self) -> Optional[str]: - base_url = (self.WEBHOOK_URL or "").strip() + def get_telegram_webhook_url(self) -> str | None: + base_url = (self.WEBHOOK_URL or '').strip() if not base_url: return None path = self.get_telegram_webhook_path() - return f"{base_url.rstrip('/')}{path}" + return f'{base_url.rstrip("/")}{path}' def get_miniapp_static_path(self) -> Path: - raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip() + raw_path = (self.MINIAPP_STATIC_PATH or 'miniapp').strip() if not raw_path: - raw_path = "miniapp" + raw_path = 'miniapp' return Path(raw_path) # Cabinet methods @@ -2525,10 +2482,10 @@ class Settings(BaseSettings): def get_cabinet_refresh_token_expire_days(self) -> int: return max(1, self.CABINET_REFRESH_TOKEN_EXPIRE_DAYS) - def get_cabinet_allowed_origins(self) -> List[str]: + def get_cabinet_allowed_origins(self) -> list[str]: if not self.CABINET_ALLOWED_ORIGINS: return [] - return [o.strip() for o in self.CABINET_ALLOWED_ORIGINS.split(",") if o.strip()] + return [o.strip() for o in self.CABINET_ALLOWED_ORIGINS.split(',') if o.strip()] def is_cabinet_email_verification_enabled(self) -> bool: return bool(self.CABINET_EMAIL_VERIFICATION_ENABLED) @@ -2542,7 +2499,7 @@ class Settings(BaseSettings): def is_smtp_configured(self) -> bool: return bool(self.SMTP_HOST and self.SMTP_USER and self.SMTP_PASSWORD) - def get_smtp_from_email(self) -> Optional[str]: + def get_smtp_from_email(self) -> str | None: if self.SMTP_FROM_EMAIL: return self.SMTP_FROM_EMAIL return self.SMTP_USER @@ -2554,52 +2511,46 @@ class Settings(BaseSettings): def is_ban_system_configured(self) -> bool: return bool(self.BAN_SYSTEM_API_URL and self.BAN_SYSTEM_API_TOKEN) - def get_ban_system_api_url(self) -> Optional[str]: + def get_ban_system_api_url(self) -> str | None: if self.BAN_SYSTEM_API_URL: return self.BAN_SYSTEM_API_URL.rstrip('/') return None - def get_ban_system_api_token(self) -> Optional[str]: + def get_ban_system_api_token(self) -> str | None: return self.BAN_SYSTEM_API_TOKEN def get_ban_system_request_timeout(self) -> int: return max(1, self.BAN_SYSTEM_REQUEST_TIMEOUT) - model_config = { - "env_file": ".env", - "env_file_encoding": "utf-8", - "extra": "ignore" - } + model_config = {'env_file': '.env', 'env_file_encoding': 'utf-8', 'extra': 'ignore'} - @field_validator("TIMEZONE") + @field_validator('TIMEZONE') @classmethod def validate_timezone(cls, value: str) -> str: try: ZoneInfo(value) except Exception as exc: # pragma: no cover - defensive validation - raise ValueError( - f"Некорректный идентификатор часового пояса: {value}" - ) from exc + raise ValueError(f'Некорректный идентификатор часового пояса: {value}') from exc return value settings = Settings() ENV_OVERRIDE_KEYS = set(settings.model_fields_set) -_PERIOD_PRICE_FIELDS: Dict[int, str] = { - 14: "PRICE_14_DAYS", - 30: "PRICE_30_DAYS", - 60: "PRICE_60_DAYS", - 90: "PRICE_90_DAYS", - 180: "PRICE_180_DAYS", - 360: "PRICE_360_DAYS", +_PERIOD_PRICE_FIELDS: dict[int, str] = { + 14: 'PRICE_14_DAYS', + 30: 'PRICE_30_DAYS', + 60: 'PRICE_60_DAYS', + 90: 'PRICE_90_DAYS', + 180: 'PRICE_180_DAYS', + 360: 'PRICE_360_DAYS', } # Хранилище периодов/цен из БД (приоритет над .env) -_DB_PERIOD_PRICES: Optional[Dict[int, int]] = None +_DB_PERIOD_PRICES: dict[int, int] | None = None -def set_period_prices_from_db(period_prices: Dict[int, int]) -> None: +def set_period_prices_from_db(period_prices: dict[int, int]) -> None: """ Устанавливает периоды/цены из БД. Вызывается после синхронизации тарифов при запуске бота. @@ -2609,7 +2560,7 @@ def set_period_prices_from_db(period_prices: Dict[int, int]) -> None: refresh_period_prices() -def get_db_period_prices() -> Optional[Dict[int, int]]: +def get_db_period_prices() -> dict[int, int] | None: """Возвращает периоды/цены из БД если они загружены.""" return _DB_PERIOD_PRICES @@ -2627,26 +2578,27 @@ def refresh_period_prices() -> None: else: # Fallback на .env PERIOD_PRICES.update( - { - days: getattr(settings, field_name, 0) - for days, field_name in _PERIOD_PRICE_FIELDS.items() - } + {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()} ) -PERIOD_PRICES: Dict[int, int] = {} +PERIOD_PRICES: dict[int, int] = {} refresh_period_prices() -def get_traffic_prices() -> Dict[int, int]: + +def get_traffic_prices() -> dict[int, int]: packages = settings.get_traffic_packages() - return {package["gb"]: package["price"] for package in packages} + return {package['gb']: package['price'] for package in packages} + TRAFFIC_PRICES = get_traffic_prices() + def refresh_traffic_prices(): global TRAFFIC_PRICES TRAFFIC_PRICES = get_traffic_prices() + refresh_traffic_prices() settings._original_database_url = settings.DATABASE_URL diff --git a/app/database/__init__.py b/app/database/__init__.py index 808b1c21..80732d3d 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -11,13 +11,14 @@ from .database import ( init_db, ) + __all__ = [ - "DatabaseManager", - "batch_ops", - "close_db", - "db_manager", - "get_db", - "get_db_read_only", - "get_pool_metrics", - "init_db", + 'DatabaseManager', + 'batch_ops', + 'close_db', + 'db_manager', + 'get_db', + 'get_db_read_only', + 'get_pool_metrics', + 'init_db', ] diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py index 65e1afca..9ada95ae 100644 --- a/app/database/crud/campaign.py +++ b/app/database/crud/campaign.py @@ -1,8 +1,7 @@ import logging from datetime import datetime -from typing import Dict, List, Optional -from sqlalchemy import and_, func, select, update, delete +from sqlalchemy import and_, delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -17,6 +16,7 @@ from app.database.models import ( User, ) + logger = logging.getLogger(__name__) @@ -26,15 +26,15 @@ async def create_campaign( name: str, start_parameter: str, bonus_type: str, - created_by: Optional[int] = None, + created_by: int | None = None, balance_bonus_kopeks: int = 0, - subscription_duration_days: Optional[int] = None, - subscription_traffic_gb: Optional[int] = None, - subscription_device_limit: Optional[int] = None, - subscription_squads: Optional[List[str]] = None, + subscription_duration_days: int | None = None, + subscription_traffic_gb: int | None = None, + subscription_device_limit: int | None = None, + subscription_squads: list[str] | None = None, # Поля для типа "tariff" - tariff_id: Optional[int] = None, - tariff_duration_days: Optional[int] = None, + tariff_id: int | None = None, + tariff_duration_days: int | None = None, is_active: bool = True, ) -> AdvertisingCampaign: campaign = AdvertisingCampaign( @@ -57,7 +57,7 @@ async def create_campaign( await db.refresh(campaign) logger.info( - "📣 Создана рекламная кампания %s (start=%s, bonus=%s)", + '📣 Создана рекламная кампания %s (start=%s, bonus=%s)', campaign.name, campaign.start_parameter, campaign.bonus_type, @@ -65,9 +65,7 @@ async def create_campaign( return campaign -async def get_campaign_by_id( - db: AsyncSession, campaign_id: int -) -> Optional[AdvertisingCampaign]: +async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingCampaign | None: result = await db.execute( select(AdvertisingCampaign) .options( @@ -84,10 +82,8 @@ async def get_campaign_by_start_parameter( start_parameter: str, *, only_active: bool = False, -) -> Optional[AdvertisingCampaign]: - stmt = select(AdvertisingCampaign).where( - AdvertisingCampaign.start_parameter == start_parameter - ) +) -> AdvertisingCampaign | None: + stmt = select(AdvertisingCampaign).where(AdvertisingCampaign.start_parameter == start_parameter) if only_active: stmt = stmt.where(AdvertisingCampaign.is_active.is_(True)) @@ -101,7 +97,7 @@ async def get_campaigns_list( offset: int = 0, limit: int = 20, include_inactive: bool = True, -) -> List[AdvertisingCampaign]: +) -> list[AdvertisingCampaign]: stmt = ( select(AdvertisingCampaign) .options( @@ -119,9 +115,7 @@ async def get_campaigns_list( return result.scalars().all() -async def get_campaigns_count( - db: AsyncSession, *, is_active: Optional[bool] = None -) -> int: +async def get_campaigns_count(db: AsyncSession, *, is_active: bool | None = None) -> int: stmt = select(func.count(AdvertisingCampaign.id)) if is_active is not None: stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active)) @@ -136,17 +130,17 @@ async def update_campaign( **kwargs, ) -> AdvertisingCampaign: allowed_fields = { - "name", - "start_parameter", - "bonus_type", - "balance_bonus_kopeks", - "subscription_duration_days", - "subscription_traffic_gb", - "subscription_device_limit", - "subscription_squads", - "tariff_id", - "tariff_duration_days", - "is_active", + 'name', + 'start_parameter', + 'bonus_type', + 'balance_bonus_kopeks', + 'subscription_duration_days', + 'subscription_traffic_gb', + 'subscription_device_limit', + 'subscription_squads', + 'tariff_id', + 'tariff_duration_days', + 'is_active', } update_data = {} @@ -157,33 +151,27 @@ async def update_campaign( if not update_data: return campaign - update_data["updated_at"] = datetime.utcnow() + update_data['updated_at'] = datetime.utcnow() - await db.execute( - update(AdvertisingCampaign) - .where(AdvertisingCampaign.id == campaign.id) - .values(**update_data) - ) + await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data)) await db.commit() await db.refresh(campaign) - logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data) + logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data) return campaign async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool: - await db.execute( - delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id) - ) + await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)) await db.commit() - logger.info("🗑️ Удалена рекламная кампания %s", campaign.name) + logger.info('🗑️ Удалена рекламная кампания %s', campaign.name) return True async def get_campaign_registration_by_user( db: AsyncSession, user_id: int, -) -> Optional[AdvertisingCampaignRegistration]: +) -> AdvertisingCampaignRegistration | None: result = await db.execute( select(AdvertisingCampaignRegistration) .options(selectinload(AdvertisingCampaignRegistration.campaign)) @@ -200,9 +188,9 @@ async def record_campaign_registration( user_id: int, bonus_type: str, balance_bonus_kopeks: int = 0, - subscription_duration_days: Optional[int] = None, - tariff_id: Optional[int] = None, - tariff_duration_days: Optional[int] = None, + subscription_duration_days: int | None = None, + tariff_id: int | None = None, + tariff_duration_days: int | None = None, ) -> AdvertisingCampaignRegistration: existing = await db.execute( select(AdvertisingCampaignRegistration).where( @@ -229,14 +217,14 @@ async def record_campaign_registration( await db.commit() await db.refresh(registration) - logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id) + logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id) return registration async def get_campaign_statistics( db: AsyncSession, campaign_id: int, -) -> Dict[str, Optional[int]]: +) -> dict[str, int | None]: registrations_query = select(AdvertisingCampaignRegistration.user_id).where( AdvertisingCampaignRegistration.campaign_id == campaign_id ) @@ -245,9 +233,7 @@ async def get_campaign_statistics( result = await db.execute( select( func.count(AdvertisingCampaignRegistration.id), - func.coalesce( - func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 - ), + func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0), func.max(AdvertisingCampaignRegistration.created_at), ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id) ) @@ -259,7 +245,7 @@ async def get_campaign_statistics( select(func.count(AdvertisingCampaignRegistration.id)).where( and_( AdvertisingCampaignRegistration.campaign_id == campaign_id, - AdvertisingCampaignRegistration.bonus_type == "subscription", + AdvertisingCampaignRegistration.bonus_type == 'subscription', ) ) ) @@ -312,11 +298,7 @@ async def get_campaign_statistics( SubscriptionConversion.first_payment_amount_kopeks, SubscriptionConversion.converted_at, ) - .where( - SubscriptionConversion.user_id.in_( - select(registrations_subquery.c.user_id) - ) - ) + .where(SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))) .order_by(SubscriptionConversion.converted_at) ) conversion_entries = conversions_rows.all() @@ -339,8 +321,8 @@ async def get_campaign_statistics( subscription_payments_total = 0 paid_users_from_transactions = set() conversion_user_ids = set() - first_payment_amount_by_user: Dict[int, int] = {} - first_payment_time_by_user: Dict[int, Optional[datetime]] = {} + first_payment_amount_by_user: dict[int, int] = {} + first_payment_time_by_user: dict[int, datetime | None] = {} for user_id, amount_kopeks, converted_at in conversion_entries: conversion_user_ids.add(user_id) @@ -358,13 +340,8 @@ async def get_campaign_statistics( first_payment_time_by_user[user_id] = created_at else: existing_time = first_payment_time_by_user.get(user_id) - if existing_time is None and created_at is not None: - first_payment_amount_by_user[user_id] = amount_value - first_payment_time_by_user[user_id] = created_at - elif ( - existing_time is not None - and created_at is not None - and created_at < existing_time + if (existing_time is None and created_at is not None) or ( + existing_time is not None and created_at is not None and created_at < existing_time ): first_payment_amount_by_user[user_id] = amount_value first_payment_time_by_user[user_id] = created_at @@ -376,15 +353,11 @@ async def get_campaign_statistics( paid_users_count = max(len(paid_user_ids), paid_users_from_flag) conversion_count = conversion_count or len(paid_user_ids) - if conversion_count < len(paid_user_ids): - conversion_count = len(paid_user_ids) + conversion_count = max(conversion_count, len(paid_user_ids)) avg_first_payment = 0 if first_payment_amount_by_user: - avg_first_payment = int( - sum(first_payment_amount_by_user.values()) - / len(first_payment_amount_by_user) - ) + avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user)) conversion_rate = 0.0 if count: @@ -459,50 +432,44 @@ async def get_campaign_statistics( avg_revenue_per_user = int(total_revenue / count) return { - "registrations": count, - "balance_issued": total_balance, - "subscription_issued": subscription_bonuses_issued, - "last_registration": last_registration, - "total_revenue_kopeks": total_revenue, - "trial_users_count": trial_users_count, - "active_trials_count": active_trials_count, - "conversion_count": conversion_count, - "paid_users_count": paid_users_count, - "conversion_rate": conversion_rate, - "trial_conversion_rate": trial_conversion_rate, - "avg_revenue_per_user_kopeks": avg_revenue_per_user, - "avg_first_payment_kopeks": avg_first_payment, + 'registrations': count, + 'balance_issued': total_balance, + 'subscription_issued': subscription_bonuses_issued, + 'last_registration': last_registration, + 'total_revenue_kopeks': total_revenue, + 'trial_users_count': trial_users_count, + 'active_trials_count': active_trials_count, + 'conversion_count': conversion_count, + 'paid_users_count': paid_users_count, + 'conversion_rate': conversion_rate, + 'trial_conversion_rate': trial_conversion_rate, + 'avg_revenue_per_user_kopeks': avg_revenue_per_user, + 'avg_first_payment_kopeks': avg_first_payment, } -async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]: +async def get_campaigns_overview(db: AsyncSession) -> dict[str, int]: total = await get_campaigns_count(db) active = await get_campaigns_count(db, is_active=True) inactive = await get_campaigns_count(db, is_active=False) - registrations_result = await db.execute( - select(func.count(AdvertisingCampaignRegistration.id)) - ) + registrations_result = await db.execute(select(func.count(AdvertisingCampaignRegistration.id))) balance_result = await db.execute( - select( - func.coalesce( - func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0 - ) - ) + select(func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0)) ) subscription_result = await db.execute( select(func.count(AdvertisingCampaignRegistration.id)).where( - AdvertisingCampaignRegistration.bonus_type == "subscription" + AdvertisingCampaignRegistration.bonus_type == 'subscription' ) ) return { - "total": total, - "active": active, - "inactive": inactive, - "registrations": registrations_result.scalar() or 0, - "balance_total": balance_result.scalar() or 0, - "subscription_total": subscription_result.scalar() or 0, + 'total': total, + 'active': active, + 'inactive': inactive, + 'registrations': registrations_result.scalar() or 0, + 'balance_total': balance_result.scalar() or 0, + 'subscription_total': subscription_result.scalar() or 0, } diff --git a/app/database/crud/cloudpayments.py b/app/database/crud/cloudpayments.py index 4484ee20..7f40d29f 100644 --- a/app/database/crud/cloudpayments.py +++ b/app/database/crud/cloudpayments.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any -from sqlalchemy import select, update +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import CloudPaymentsPayment + logger = logging.getLogger(__name__) @@ -20,11 +21,11 @@ async def create_cloudpayments_payment( user_id: int, invoice_id: str, amount_kopeks: int, - description: Optional[str] = None, - currency: str = "RUB", - payment_url: Optional[str] = None, - email: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + description: str | None = None, + currency: str = 'RUB', + payment_url: str | None = None, + email: str | None = None, + metadata: dict[str, Any] | None = None, test_mode: bool = False, ) -> CloudPaymentsPayment: """ @@ -51,7 +52,7 @@ async def create_cloudpayments_payment( amount_kopeks=amount_kopeks, currency=currency, description=description, - status="pending", + status='pending', is_paid=False, payment_url=payment_url, email=email, @@ -64,7 +65,7 @@ async def create_cloudpayments_payment( await db.refresh(payment) logger.debug( - "Created CloudPayments payment: id=%s, invoice=%s, amount=%s", + 'Created CloudPayments payment: id=%s, invoice=%s, amount=%s', payment.id, invoice_id, amount_kopeks, @@ -76,36 +77,28 @@ async def create_cloudpayments_payment( async def get_cloudpayments_payment_by_invoice_id( db: AsyncSession, invoice_id: str, -) -> Optional[CloudPaymentsPayment]: +) -> CloudPaymentsPayment | None: """Get CloudPayments payment by invoice ID.""" - result = await db.execute( - select(CloudPaymentsPayment).where( - CloudPaymentsPayment.invoice_id == invoice_id - ) - ) + result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.invoice_id == invoice_id)) return result.scalars().first() async def get_cloudpayments_payment_by_id( db: AsyncSession, payment_id: int, -) -> Optional[CloudPaymentsPayment]: +) -> CloudPaymentsPayment | None: """Get CloudPayments payment by internal ID.""" - result = await db.execute( - select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id) - ) + result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id)) return result.scalars().first() async def get_cloudpayments_payment_by_transaction_id( db: AsyncSession, transaction_id_cp: int, -) -> Optional[CloudPaymentsPayment]: +) -> CloudPaymentsPayment | None: """Get CloudPayments payment by CloudPayments transaction ID.""" result = await db.execute( - select(CloudPaymentsPayment).where( - CloudPaymentsPayment.transaction_id_cp == transaction_id_cp - ) + select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp) ) return result.scalars().first() @@ -114,7 +107,7 @@ async def update_cloudpayments_payment( db: AsyncSession, payment_id: int, **kwargs: Any, -) -> Optional[CloudPaymentsPayment]: +) -> CloudPaymentsPayment | None: """ Update CloudPayments payment record. @@ -145,15 +138,15 @@ async def mark_cloudpayments_payment_as_paid( db: AsyncSession, payment_id: int, *, - transaction_id_cp: Optional[int] = None, - token: Optional[str] = None, - card_first_six: Optional[str] = None, - card_last_four: Optional[str] = None, - card_type: Optional[str] = None, - card_exp_date: Optional[str] = None, - email: Optional[str] = None, - callback_payload: Optional[Dict[str, Any]] = None, -) -> Optional[CloudPaymentsPayment]: + transaction_id_cp: int | None = None, + token: str | None = None, + card_first_six: str | None = None, + card_last_four: str | None = None, + card_type: str | None = None, + card_exp_date: str | None = None, + email: str | None = None, + callback_payload: dict[str, Any] | None = None, +) -> CloudPaymentsPayment | None: """ Mark CloudPayments payment as paid. @@ -176,7 +169,7 @@ async def mark_cloudpayments_payment_as_paid( if not payment: return None - payment.status = "completed" + payment.status = 'completed' payment.is_paid = True payment.paid_at = datetime.utcnow() @@ -202,7 +195,7 @@ async def mark_cloudpayments_payment_as_paid( await db.refresh(payment) logger.info( - "Marked CloudPayments payment as paid: id=%s, invoice=%s", + 'Marked CloudPayments payment as paid: id=%s, invoice=%s', payment.id, payment.invoice_id, ) @@ -214,7 +207,7 @@ async def link_cloudpayments_payment_to_transaction( db: AsyncSession, payment_id: int, transaction_id: int, -) -> Optional[CloudPaymentsPayment]: +) -> CloudPaymentsPayment | None: """Link CloudPayments payment to internal transaction.""" payment = await get_cloudpayments_payment_by_id(db, payment_id) if not payment: diff --git a/app/database/crud/contest.py b/app/database/crud/contest.py index 06074749..511bf6ee 100644 --- a/app/database/crud/contest.py +++ b/app/database/crud/contest.py @@ -1,32 +1,29 @@ import logging +from collections.abc import Sequence from datetime import datetime -from typing import List, Optional, Sequence, Tuple -from sqlalchemy import and_, delete, desc, func, select +from sqlalchemy import and_, delete, desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User +from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User + logger = logging.getLogger(__name__) # Templates -async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]: - result = await db.execute( - select(ContestTemplate).where(ContestTemplate.id == template_id) - ) +async def get_template_by_id(db: AsyncSession, template_id: int) -> ContestTemplate | None: + result = await db.execute(select(ContestTemplate).where(ContestTemplate.id == template_id)) return result.scalar_one_or_none() -async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]: - result = await db.execute( - select(ContestTemplate).where(ContestTemplate.slug == slug) - ) +async def get_template_by_slug(db: AsyncSession, slug: str) -> ContestTemplate | None: + result = await db.execute(select(ContestTemplate).where(ContestTemplate.slug == slug)) return result.scalar_one_or_none() -async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]: +async def list_templates(db: AsyncSession, enabled_only: bool = True) -> list[ContestTemplate]: query = select(ContestTemplate).order_by(ContestTemplate.id) if enabled_only: query = query.where(ContestTemplate.is_enabled.is_(True)) @@ -39,16 +36,16 @@ async def upsert_template( *, slug: str, name: str, - description: str = "", - prize_type: str = "days", - prize_value: str = "1", + description: str = '', + prize_type: str = 'days', + prize_value: str = '1', max_winners: int = 1, attempts_per_user: int = 1, times_per_day: int = 1, - schedule_times: Optional[str] = None, + schedule_times: str | None = None, cooldown_hours: int = 24, - payload: Optional[dict] = None, - is_enabled: Optional[bool] = None, + payload: dict | None = None, + is_enabled: bool | None = None, ) -> ContestTemplate: template = await get_template_by_slug(db, slug) if not template: @@ -98,7 +95,7 @@ async def create_round( template_id=template.id, starts_at=starts_at, ends_at=ends_at, - status="active", + status='active', payload=payload, max_winners=template.max_winners, attempts_per_user=template.attempts_per_user, @@ -109,14 +106,14 @@ async def create_round( return round_obj -async def get_active_rounds(db: AsyncSession) -> List[ContestRound]: +async def get_active_rounds(db: AsyncSession) -> list[ContestRound]: now = datetime.utcnow() result = await db.execute( select(ContestRound) .options(selectinload(ContestRound.template)) .where( and_( - ContestRound.status == "active", + ContestRound.status == 'active', ContestRound.starts_at <= now, ContestRound.ends_at >= now, ) @@ -126,7 +123,7 @@ async def get_active_rounds(db: AsyncSession) -> List[ContestRound]: return list(result.scalars().all()) -async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]: +async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None: now = datetime.utcnow() result = await db.execute( select(ContestRound) @@ -134,7 +131,7 @@ async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Op .where( and_( ContestRound.template_id == template_id, - ContestRound.status == "active", + ContestRound.status == 'active', ContestRound.starts_at <= now, ContestRound.ends_at >= now, ) @@ -145,7 +142,7 @@ async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Op async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound: - round_obj.status = "finished" + round_obj.status = 'finished' await db.commit() await db.refresh(round_obj) return round_obj @@ -159,7 +156,7 @@ async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> C # Attempts -async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]: +async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None: result = await db.execute( select(ContestAttempt).where( and_( @@ -176,7 +173,7 @@ async def create_attempt( *, round_id: int, user_id: int, - answer: Optional[str], + answer: str | None, is_winner: bool, ) -> ContestAttempt: attempt = ContestAttempt( @@ -195,7 +192,7 @@ async def update_attempt( db: AsyncSession, attempt: ContestAttempt, *, - answer: Optional[str] = None, + answer: str | None = None, is_winner: bool = False, ) -> ContestAttempt: """Update existing attempt with answer and winner status.""" @@ -214,7 +211,7 @@ async def clear_attempts(db: AsyncSession, round_id: int) -> int: return deleted_count -async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]: +async def list_winners(db: AsyncSession, round_id: int) -> Sequence[tuple[User, ContestAttempt]]: result = await db.execute( select(User, ContestAttempt) .join(ContestAttempt, ContestAttempt.user_id == User.id) diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index 0d5eecef..869d77ed 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -1,12 +1,13 @@ import logging from datetime import datetime -from typing import Optional, List -from sqlalchemy import select, and_ + +from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import CryptoBotPayment + logger = logging.getLogger(__name__) @@ -16,14 +17,13 @@ async def create_cryptobot_payment( invoice_id: str, amount: str, asset: str, - status: str = "active", - description: Optional[str] = None, - payload: Optional[str] = None, - bot_invoice_url: Optional[str] = None, - mini_app_invoice_url: Optional[str] = None, - web_app_invoice_url: Optional[str] = None + status: str = 'active', + description: str | None = None, + payload: str | None = None, + bot_invoice_url: str | None = None, + mini_app_invoice_url: str | None = None, + web_app_invoice_url: str | None = None, ) -> CryptoBotPayment: - payment = CryptoBotPayment( user_id=user_id, invoice_id=invoice_id, @@ -34,22 +34,18 @@ async def create_cryptobot_payment( payload=payload, bot_invoice_url=bot_invoice_url, mini_app_invoice_url=mini_app_invoice_url, - web_app_invoice_url=web_app_invoice_url + web_app_invoice_url=web_app_invoice_url, ) - + db.add(payment) await db.commit() await db.refresh(payment) - - logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}") + + logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}') return payment -async def get_cryptobot_payment_by_invoice_id( - db: AsyncSession, - invoice_id: str -) -> Optional[CryptoBotPayment]: - +async def get_cryptobot_payment_by_invoice_id(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None: result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) @@ -58,72 +54,55 @@ async def get_cryptobot_payment_by_invoice_id( return result.scalar_one_or_none() -async def get_cryptobot_payment_by_id( - db: AsyncSession, - payment_id: int -) -> Optional[CryptoBotPayment]: - +async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None: result = await db.execute( - select(CryptoBotPayment) - .options(selectinload(CryptoBotPayment.user)) - .where(CryptoBotPayment.id == payment_id) + select(CryptoBotPayment).options(selectinload(CryptoBotPayment.user)).where(CryptoBotPayment.id == payment_id) ) return result.scalar_one_or_none() async def update_cryptobot_payment_status( - db: AsyncSession, - invoice_id: str, - status: str, - paid_at: Optional[datetime] = None -) -> Optional[CryptoBotPayment]: - + db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None +) -> CryptoBotPayment | None: payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id) - + if not payment: return None - + payment.status = status payment.updated_at = datetime.utcnow() - - if status == "paid" and paid_at: + + if status == 'paid' and paid_at: payment.paid_at = paid_at - + await db.commit() await db.refresh(payment) - - logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}") + + logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}') return payment async def link_cryptobot_payment_to_transaction( - db: AsyncSession, - invoice_id: str, - transaction_id: int -) -> Optional[CryptoBotPayment]: - + db: AsyncSession, invoice_id: str, transaction_id: int +) -> CryptoBotPayment | None: payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id) - + if not payment: return None - + payment.transaction_id = transaction_id payment.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(payment) - - logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}") + + logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}') return payment async def get_user_cryptobot_payments( - db: AsyncSession, - user_id: int, - limit: int = 50, - offset: int = 0 -) -> List[CryptoBotPayment]: - + db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0 +) -> list[CryptoBotPayment]: result = await db.execute( select(CryptoBotPayment) .where(CryptoBotPayment.user_id == user_id) @@ -134,23 +113,15 @@ async def get_user_cryptobot_payments( return result.scalars().all() -async def get_pending_cryptobot_payments( - db: AsyncSession, - older_than_hours: int = 24 -) -> List[CryptoBotPayment]: - +async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]: from datetime import timedelta + cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) - + result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) - .where( - and_( - CryptoBotPayment.status == "active", - CryptoBotPayment.created_at < cutoff_time - ) - ) + .where(and_(CryptoBotPayment.status == 'active', CryptoBotPayment.created_at < cutoff_time)) .order_by(CryptoBotPayment.created_at) ) return result.scalars().all() diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index ac08cd53..6d841666 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging from datetime import datetime, timedelta -from typing import List, Optional from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +10,7 @@ from sqlalchemy.orm import selectinload from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import DiscountOffer + logger = logging.getLogger(__name__) @@ -18,13 +18,13 @@ async def upsert_discount_offer( db: AsyncSession, *, user_id: int, - subscription_id: Optional[int], + subscription_id: int | None, notification_type: str, discount_percent: int, bonus_amount_kopeks: int, valid_hours: int, - effect_type: str = "percent_discount", - extra_data: Optional[dict] = None, + effect_type: str = 'percent_discount', + extra_data: dict | None = None, ) -> DiscountOffer: """Create or refresh a discount offer for a user.""" @@ -35,7 +35,7 @@ async def upsert_discount_offer( .where( DiscountOffer.user_id == user_id, DiscountOffer.notification_type == notification_type, - DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.is_active == True, ) .order_by(DiscountOffer.created_at.desc()) ) @@ -67,7 +67,7 @@ async def upsert_discount_offer( return offer -async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> DiscountOffer | None: result = await db.execute( select(DiscountOffer) .options( @@ -84,10 +84,10 @@ async def list_discount_offers( *, offset: int = 0, limit: int = 50, - user_id: Optional[int] = None, - notification_type: Optional[str] = None, - is_active: Optional[bool] = None, -) -> List[DiscountOffer]: + user_id: int | None = None, + notification_type: str | None = None, + is_active: bool | None = None, +) -> list[DiscountOffer]: stmt = ( select(DiscountOffer) .options( @@ -113,7 +113,7 @@ async def list_discount_offers( async def list_active_discount_offers_for_user( db: AsyncSession, user_id: int, -) -> List[DiscountOffer]: +) -> list[DiscountOffer]: """Return active (not yet claimed) offers for a user.""" now = datetime.utcnow() @@ -125,7 +125,7 @@ async def list_active_discount_offers_for_user( ) .where( DiscountOffer.user_id == user_id, - DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.is_active == True, DiscountOffer.expires_at > now, ) .order_by(DiscountOffer.expires_at.asc()) @@ -138,9 +138,9 @@ async def list_active_discount_offers_for_user( async def count_discount_offers( db: AsyncSession, *, - user_id: Optional[int] = None, - notification_type: Optional[str] = None, - is_active: Optional[bool] = None, + user_id: int | None = None, + notification_type: str | None = None, + is_active: bool | None = None, ) -> int: stmt = select(func.count(DiscountOffer.id)) @@ -159,7 +159,7 @@ async def mark_offer_claimed( db: AsyncSession, offer: DiscountOffer, *, - details: Optional[dict] = None, + details: dict | None = None, ) -> DiscountOffer: offer.claimed_at = datetime.utcnow() offer.is_active = False @@ -171,7 +171,7 @@ async def mark_offer_claimed( db, user_id=offer.user_id, offer_id=offer.id, - action="claimed", + action='claimed', source=offer.notification_type, percent=offer.discount_percent, effect_type=offer.effect_type, @@ -179,7 +179,7 @@ async def mark_offer_claimed( ) except Exception as exc: # pragma: no cover - defensive logging logger.warning( - "Failed to record promo offer claim log for offer %s: %s", + 'Failed to record promo offer claim log for offer %s: %s', offer.id, exc, ) @@ -187,7 +187,7 @@ async def mark_offer_claimed( await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer claim log failure: %s", + 'Failed to rollback session after promo offer claim log failure: %s', rollback_error, ) @@ -198,7 +198,7 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: now = datetime.utcnow() result = await db.execute( select(DiscountOffer).where( - DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.is_active == True, DiscountOffer.expires_at < now, ) ) @@ -213,41 +213,41 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: count += 1 log_payloads.append( { - "user_id": offer.user_id, - "offer_id": offer.id, - "source": offer.notification_type, - "percent": offer.discount_percent, - "effect_type": offer.effect_type, + 'user_id': offer.user_id, + 'offer_id': offer.id, + 'source': offer.notification_type, + 'percent': offer.discount_percent, + 'effect_type': offer.effect_type, } ) await db.commit() for payload in log_payloads: - if not payload.get("user_id"): + if not payload.get('user_id'): continue try: await log_promo_offer_action( db, - user_id=payload["user_id"], - offer_id=payload["offer_id"], - action="disabled", - source=payload.get("source"), - percent=payload.get("percent"), - effect_type=payload.get("effect_type"), - details={"reason": "offer_expired"}, + user_id=payload['user_id'], + offer_id=payload['offer_id'], + action='disabled', + source=payload.get('source'), + percent=payload.get('percent'), + effect_type=payload.get('effect_type'), + details={'reason': 'offer_expired'}, ) except Exception as exc: # pragma: no cover - defensive logging logger.warning( - "Failed to record promo offer disable log for offer %s: %s", - payload.get("offer_id"), + 'Failed to record promo offer disable log for offer %s: %s', + payload.get('offer_id'), exc, ) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer disable log failure: %s", + 'Failed to rollback session after promo offer disable log failure: %s', rollback_error, ) @@ -257,8 +257,8 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: async def get_latest_claimed_offer_for_user( db: AsyncSession, user_id: int, - source: Optional[str] = None, -) -> Optional[DiscountOffer]: + source: str | None = None, +) -> DiscountOffer | None: stmt = ( select(DiscountOffer) .where( diff --git a/app/database/crud/faq.py b/app/database/crud/faq.py index efff8c4a..a42d2520 100644 --- a/app/database/crud/faq.py +++ b/app/database/crud/faq.py @@ -1,19 +1,18 @@ import logging +from collections.abc import Iterable from datetime import datetime -from typing import Iterable, Optional from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import FaqPage, FaqSetting + logger = logging.getLogger(__name__) -async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]: - result = await db.execute( - select(FaqSetting).where(FaqSetting.language == language) - ) +async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None: + result = await db.execute(select(FaqSetting).where(FaqSetting.language == language)) return result.scalar_one_or_none() @@ -34,9 +33,9 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq await db.refresh(setting) logger.info( - "✅ Статус FAQ для языка %s обновлен: %s", + '✅ Статус FAQ для языка %s обновлен: %s', language, - "enabled" if setting.is_enabled else "disabled", + 'enabled' if setting.is_enabled else 'disabled', ) return setting @@ -64,7 +63,7 @@ async def get_faq_pages( return pages -async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]: +async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> FaqPage | None: result = await db.execute(select(FaqPage).where(FaqPage.id == page_id)) return result.scalar_one_or_none() @@ -75,13 +74,11 @@ async def create_faq_page( language: str, title: str, content: str, - display_order: Optional[int] = None, + display_order: int | None = None, is_active: bool = True, ) -> FaqPage: if display_order is None: - result = await db.execute( - select(func.max(FaqPage.display_order)).where(FaqPage.language == language) - ) + result = await db.execute(select(func.max(FaqPage.display_order)).where(FaqPage.language == language)) max_order = result.scalar() or 0 display_order = max_order + 1 @@ -97,7 +94,7 @@ async def create_faq_page( await db.commit() await db.refresh(page) - logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language) + logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language) return page @@ -106,10 +103,10 @@ async def update_faq_page( db: AsyncSession, page: FaqPage, *, - title: Optional[str] = None, - content: Optional[str] = None, - display_order: Optional[int] = None, - is_active: Optional[bool] = None, + title: str | None = None, + content: str | None = None, + display_order: int | None = None, + is_active: bool | None = None, ) -> FaqPage: if title is not None: page.title = title @@ -125,7 +122,7 @@ async def update_faq_page( await db.commit() await db.refresh(page) - logger.info("✅ Страница FAQ %s обновлена", page.id) + logger.info('✅ Страница FAQ %s обновлена', page.id) return page @@ -133,7 +130,7 @@ async def update_faq_page( async def delete_faq_page(db: AsyncSession, page_id: int) -> None: await db.execute(delete(FaqPage).where(FaqPage.id == page_id)) await db.commit() - logger.info("🗑️ Страница FAQ %s удалена", page_id) + logger.info('🗑️ Страница FAQ %s удалена', page_id) async def bulk_update_order( @@ -142,9 +139,6 @@ async def bulk_update_order( ) -> None: for page_id, order in pages: await db.execute( - update(FaqPage) - .where(FaqPage.id == page_id) - .values(display_order=order, updated_at=datetime.utcnow()) + update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow()) ) await db.commit() - diff --git a/app/database/crud/freekassa.py b/app/database/crud/freekassa.py index 0236a317..aad9889c 100644 --- a/app/database/crud/freekassa.py +++ b/app/database/crud/freekassa.py @@ -3,13 +3,13 @@ import json import logging from datetime import datetime -from typing import Optional, List from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import FreekassaPayment + logger = logging.getLogger(__name__) @@ -19,11 +19,11 @@ async def create_freekassa_payment( user_id: int, order_id: str, amount_kopeks: int, - currency: str = "RUB", - description: Optional[str] = None, - payment_url: Optional[str] = None, - expires_at: Optional[datetime] = None, - metadata_json: Optional[str] = None, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + expires_at: datetime | None = None, + metadata_json: str | None = None, ) -> FreekassaPayment: """Создает запись о платеже Freekassa.""" payment = FreekassaPayment( @@ -35,45 +35,31 @@ async def create_freekassa_payment( payment_url=payment_url, expires_at=expires_at, metadata_json=json.loads(metadata_json) if metadata_json else None, - status="pending", + status='pending', is_paid=False, ) db.add(payment) await db.commit() await db.refresh(payment) - logger.info(f"Создан платеж Freekassa: order_id={order_id}, user_id={user_id}") + logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}') return payment -async def get_freekassa_payment_by_order_id( - db: AsyncSession, order_id: str -) -> Optional[FreekassaPayment]: +async def get_freekassa_payment_by_order_id(db: AsyncSession, order_id: str) -> FreekassaPayment | None: """Получает платеж по order_id.""" - result = await db.execute( - select(FreekassaPayment).where(FreekassaPayment.order_id == order_id) - ) + result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.order_id == order_id)) return result.scalar_one_or_none() -async def get_freekassa_payment_by_fk_order_id( - db: AsyncSession, freekassa_order_id: str -) -> Optional[FreekassaPayment]: +async def get_freekassa_payment_by_fk_order_id(db: AsyncSession, freekassa_order_id: str) -> FreekassaPayment | None: """Получает платеж по ID от Freekassa (intid).""" - result = await db.execute( - select(FreekassaPayment).where( - FreekassaPayment.freekassa_order_id == freekassa_order_id - ) - ) + result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.freekassa_order_id == freekassa_order_id)) return result.scalar_one_or_none() -async def get_freekassa_payment_by_id( - db: AsyncSession, payment_id: int -) -> Optional[FreekassaPayment]: +async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> FreekassaPayment | None: """Получает платеж по ID.""" - result = await db.execute( - select(FreekassaPayment).where(FreekassaPayment.id == payment_id) - ) + result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id)) return result.scalar_one_or_none() @@ -83,10 +69,10 @@ async def update_freekassa_payment_status( *, status: str, is_paid: bool = False, - freekassa_order_id: Optional[str] = None, - payment_system_id: Optional[int] = None, - callback_payload: Optional[dict] = None, - transaction_id: Optional[int] = None, + freekassa_order_id: str | None = None, + payment_system_id: int | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, ) -> FreekassaPayment: """Обновляет статус платежа.""" payment.status = status @@ -106,21 +92,16 @@ async def update_freekassa_payment_status( await db.commit() await db.refresh(payment) - logger.info( - f"Обновлен статус платежа Freekassa: order_id={payment.order_id}, " - f"status={status}, is_paid={is_paid}" - ) + logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}') return payment -async def get_pending_freekassa_payments( - db: AsyncSession, user_id: int -) -> List[FreekassaPayment]: +async def get_pending_freekassa_payments(db: AsyncSession, user_id: int) -> list[FreekassaPayment]: """Получает незавершенные платежи пользователя.""" result = await db.execute( select(FreekassaPayment).where( FreekassaPayment.user_id == user_id, - FreekassaPayment.status == "pending", + FreekassaPayment.status == 'pending', FreekassaPayment.is_paid == False, ) ) @@ -132,7 +113,7 @@ async def get_user_freekassa_payments( user_id: int, limit: int = 10, offset: int = 0, -) -> List[FreekassaPayment]: +) -> list[FreekassaPayment]: """Получает платежи пользователя с пагинацией.""" result = await db.execute( select(FreekassaPayment) @@ -146,12 +127,12 @@ async def get_user_freekassa_payments( async def get_expired_pending_payments( db: AsyncSession, -) -> List[FreekassaPayment]: +) -> list[FreekassaPayment]: """Получает просроченные платежи в статусе pending.""" now = datetime.utcnow() result = await db.execute( select(FreekassaPayment).where( - FreekassaPayment.status == "pending", + FreekassaPayment.status == 'pending', FreekassaPayment.is_paid == False, FreekassaPayment.expires_at < now, ) diff --git a/app/database/crud/heleket.py b/app/database/crud/heleket.py index d02d5e83..b0ceae99 100644 --- a/app/database/crud/heleket.py +++ b/app/database/crud/heleket.py @@ -1,6 +1,6 @@ import logging from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload from app.database.models import HeleketPayment + logger = logging.getLogger(__name__) @@ -20,13 +21,13 @@ async def create_heleket_payment( amount: str, currency: str, status: str, - payer_amount: Optional[str] = None, - payer_currency: Optional[str] = None, - exchange_rate: Optional[float] = None, - discount_percent: Optional[int] = None, - payment_url: Optional[str] = None, - expires_at: Optional[datetime] = None, - metadata: Optional[Dict[str, Any]] = None, + payer_amount: str | None = None, + payer_currency: str | None = None, + exchange_rate: float | None = None, + discount_percent: int | None = None, + payment_url: str | None = None, + expires_at: datetime | None = None, + metadata: dict[str, Any] | None = None, ) -> HeleketPayment: payment = HeleketPayment( user_id=user_id, @@ -49,7 +50,7 @@ async def create_heleket_payment( await db.refresh(payment) logger.info( - "Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s", + 'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s', uuid, order_id, amount, @@ -63,11 +64,9 @@ async def create_heleket_payment( async def get_heleket_payment_by_uuid( db: AsyncSession, uuid: str, -) -> Optional[HeleketPayment]: +) -> HeleketPayment | None: result = await db.execute( - select(HeleketPayment) - .options(selectinload(HeleketPayment.user)) - .where(HeleketPayment.uuid == uuid) + select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid) ) return result.scalar_one_or_none() @@ -75,11 +74,9 @@ async def get_heleket_payment_by_uuid( async def get_heleket_payment_by_order_id( db: AsyncSession, order_id: str, -) -> Optional[HeleketPayment]: +) -> HeleketPayment | None: result = await db.execute( - select(HeleketPayment) - .options(selectinload(HeleketPayment.user)) - .where(HeleketPayment.order_id == order_id) + select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id) ) return result.scalar_one_or_none() @@ -87,11 +84,9 @@ async def get_heleket_payment_by_order_id( async def get_heleket_payment_by_id( db: AsyncSession, payment_id: int, -) -> Optional[HeleketPayment]: +) -> HeleketPayment | None: result = await db.execute( - select(HeleketPayment) - .options(selectinload(HeleketPayment.user)) - .where(HeleketPayment.id == payment_id) + select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id) ) return result.scalar_one_or_none() @@ -100,19 +95,19 @@ async def update_heleket_payment( db: AsyncSession, uuid: str, *, - status: Optional[str] = None, - payer_amount: Optional[str] = None, - payer_currency: Optional[str] = None, - exchange_rate: Optional[float] = None, - discount_percent: Optional[int] = None, - paid_at: Optional[datetime] = None, - payment_url: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, -) -> Optional[HeleketPayment]: + status: str | None = None, + payer_amount: str | None = None, + payer_currency: str | None = None, + exchange_rate: float | None = None, + discount_percent: int | None = None, + paid_at: datetime | None = None, + payment_url: str | None = None, + metadata: dict[str, Any] | None = None, +) -> HeleketPayment | None: payment = await get_heleket_payment_by_uuid(db, uuid) if not payment: - logger.error("Heleket платеж с uuid=%s не найден", uuid) + logger.error('Heleket платеж с uuid=%s не найден', uuid) return None if status is not None: @@ -140,7 +135,7 @@ async def update_heleket_payment( await db.refresh(payment) logger.info( - "Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s", + 'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s', uuid, payment.status, payment.payer_amount, @@ -154,11 +149,11 @@ async def link_heleket_payment_to_transaction( db: AsyncSession, uuid: str, transaction_id: int, -) -> Optional[HeleketPayment]: +) -> HeleketPayment | None: payment = await get_heleket_payment_by_uuid(db, uuid) if not payment: - logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid) + logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid) return None payment.transaction_id = transaction_id @@ -168,7 +163,7 @@ async def link_heleket_payment_to_transaction( await db.refresh(payment) logger.info( - "Heleket платеж %s связан с транзакцией %s", + 'Heleket платеж %s связан с транзакцией %s', uuid, transaction_id, ) diff --git a/app/database/crud/kassa_ai.py b/app/database/crud/kassa_ai.py index 610c39a0..34aefb71 100644 --- a/app/database/crud/kassa_ai.py +++ b/app/database/crud/kassa_ai.py @@ -3,13 +3,13 @@ import json import logging from datetime import datetime -from typing import Optional, List from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import KassaAiPayment + logger = logging.getLogger(__name__) @@ -19,12 +19,12 @@ async def create_kassa_ai_payment( user_id: int, order_id: str, amount_kopeks: int, - currency: str = "RUB", - description: Optional[str] = None, - payment_url: Optional[str] = None, - payment_system_id: Optional[int] = None, - expires_at: Optional[datetime] = None, - metadata_json: Optional[str] = None, + currency: str = 'RUB', + description: str | None = None, + payment_url: str | None = None, + payment_system_id: int | None = None, + expires_at: datetime | None = None, + metadata_json: str | None = None, ) -> KassaAiPayment: """Создает запись о платеже KassaAI.""" payment = KassaAiPayment( @@ -37,45 +37,31 @@ async def create_kassa_ai_payment( payment_system_id=payment_system_id, expires_at=expires_at, metadata_json=json.loads(metadata_json) if metadata_json else None, - status="pending", + status='pending', is_paid=False, ) db.add(payment) await db.commit() await db.refresh(payment) - logger.info(f"Создан платеж KassaAI: order_id={order_id}, user_id={user_id}") + logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}') return payment -async def get_kassa_ai_payment_by_order_id( - db: AsyncSession, order_id: str -) -> Optional[KassaAiPayment]: +async def get_kassa_ai_payment_by_order_id(db: AsyncSession, order_id: str) -> KassaAiPayment | None: """Получает платеж по order_id.""" - result = await db.execute( - select(KassaAiPayment).where(KassaAiPayment.order_id == order_id) - ) + result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.order_id == order_id)) return result.scalar_one_or_none() -async def get_kassa_ai_payment_by_external_order_id( - db: AsyncSession, kassa_ai_order_id: str -) -> Optional[KassaAiPayment]: +async def get_kassa_ai_payment_by_external_order_id(db: AsyncSession, kassa_ai_order_id: str) -> KassaAiPayment | None: """Получает платеж по ID от KassaAI (orderId).""" - result = await db.execute( - select(KassaAiPayment).where( - KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id - ) - ) + result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id)) return result.scalar_one_or_none() -async def get_kassa_ai_payment_by_id( - db: AsyncSession, payment_id: int -) -> Optional[KassaAiPayment]: +async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> KassaAiPayment | None: """Получает платеж по ID.""" - result = await db.execute( - select(KassaAiPayment).where(KassaAiPayment.id == payment_id) - ) + result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id)) return result.scalar_one_or_none() @@ -85,10 +71,10 @@ async def update_kassa_ai_payment_status( *, status: str, is_paid: bool = False, - kassa_ai_order_id: Optional[str] = None, - payment_system_id: Optional[int] = None, - callback_payload: Optional[dict] = None, - transaction_id: Optional[int] = None, + kassa_ai_order_id: str | None = None, + payment_system_id: int | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, ) -> KassaAiPayment: """Обновляет статус платежа.""" payment.status = status @@ -108,21 +94,16 @@ async def update_kassa_ai_payment_status( await db.commit() await db.refresh(payment) - logger.info( - f"Обновлен статус платежа KassaAI: order_id={payment.order_id}, " - f"status={status}, is_paid={is_paid}" - ) + logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}') return payment -async def get_pending_kassa_ai_payments( - db: AsyncSession, user_id: int -) -> List[KassaAiPayment]: +async def get_pending_kassa_ai_payments(db: AsyncSession, user_id: int) -> list[KassaAiPayment]: """Получает незавершенные платежи пользователя.""" result = await db.execute( select(KassaAiPayment).where( KassaAiPayment.user_id == user_id, - KassaAiPayment.status == "pending", + KassaAiPayment.status == 'pending', KassaAiPayment.is_paid == False, ) ) @@ -134,7 +115,7 @@ async def get_user_kassa_ai_payments( user_id: int, limit: int = 10, offset: int = 0, -) -> List[KassaAiPayment]: +) -> list[KassaAiPayment]: """Получает платежи пользователя с пагинацией.""" result = await db.execute( select(KassaAiPayment) @@ -148,12 +129,12 @@ async def get_user_kassa_ai_payments( async def get_expired_pending_kassa_ai_payments( db: AsyncSession, -) -> List[KassaAiPayment]: +) -> list[KassaAiPayment]: """Получает просроченные платежи в статусе pending.""" now = datetime.utcnow() result = await db.execute( select(KassaAiPayment).where( - KassaAiPayment.status == "pending", + KassaAiPayment.status == 'pending', KassaAiPayment.is_paid == False, KassaAiPayment.expires_at < now, ) diff --git a/app/database/crud/main_menu_button.py b/app/database/crud/main_menu_button.py index 97f067b6..a57ae7b4 100644 --- a/app/database/crud/main_menu_button.py +++ b/app/database/crud/main_menu_button.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional, Sequence +from collections.abc import Sequence from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,8 +20,8 @@ async def count_main_menu_buttons(db: AsyncSession) -> int: async def get_main_menu_buttons( db: AsyncSession, *, - limit: Optional[int] = None, - offset: Optional[int] = None, + limit: int | None = None, + offset: int | None = None, ) -> list[MainMenuButton]: stmt = select(MainMenuButton).order_by( MainMenuButton.display_order.asc(), @@ -37,12 +37,8 @@ async def get_main_menu_buttons( return list(result.scalars().all()) -async def get_main_menu_button_by_id( - db: AsyncSession, button_id: int -) -> MainMenuButton | None: - result = await db.execute( - select(MainMenuButton).where(MainMenuButton.id == button_id) - ) +async def get_main_menu_button_by_id(db: AsyncSession, button_id: int) -> MainMenuButton | None: + result = await db.execute(select(MainMenuButton).where(MainMenuButton.id == button_id)) return result.scalar_one_or_none() @@ -68,7 +64,7 @@ async def create_main_menu_button( action_value: str, visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL, is_active: bool = True, - display_order: Optional[int] = None, + display_order: int | None = None, ) -> MainMenuButton: if display_order is None: display_order = await get_next_display_order(db) @@ -77,8 +73,7 @@ async def create_main_menu_button( text=text, action_type=_enum_value(action_type, MainMenuButtonActionType), action_value=action_value, - visibility=_enum_value(visibility, MainMenuButtonVisibility) - or MainMenuButtonVisibility.ALL.value, + visibility=_enum_value(visibility, MainMenuButtonVisibility) or MainMenuButtonVisibility.ALL.value, is_active=bool(is_active), display_order=int(display_order), ) @@ -93,12 +88,12 @@ async def update_main_menu_button( db: AsyncSession, button: MainMenuButton, *, - text: Optional[str] = None, + text: str | None = None, action_type: MainMenuButtonActionType | str | None = None, - action_value: Optional[str] = None, + action_value: str | None = None, visibility: MainMenuButtonVisibility | str | None = None, - is_active: Optional[bool] = None, - display_order: Optional[int] = None, + is_active: bool | None = None, + display_order: int | None = None, ) -> MainMenuButton: if text is not None: button.text = text @@ -132,9 +127,7 @@ async def reorder_main_menu_buttons( order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)} - result = await db.execute( - select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys())) - ) + result = await db.execute(select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))) buttons = result.scalars().all() for button in buttons: diff --git a/app/database/crud/mulenpay.py b/app/database/crud/mulenpay.py index 062dcaab..b6cb1ecb 100644 --- a/app/database/crud/mulenpay.py +++ b/app/database/crud/mulenpay.py @@ -1,14 +1,13 @@ import logging from datetime import datetime -from typing import Optional from sqlalchemy import select -from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import MulenPayPayment + logger = logging.getLogger(__name__) @@ -19,11 +18,11 @@ async def create_mulenpay_payment( amount_kopeks: int, uuid: str, description: str, - payment_url: Optional[str], - mulen_payment_id: Optional[int], + payment_url: str | None, + mulen_payment_id: int | None, currency: str, status: str, - metadata: Optional[dict] = None, + metadata: dict | None = None, ) -> MulenPayPayment: payment = MulenPayPayment( user_id=user_id, @@ -42,7 +41,7 @@ async def create_mulenpay_payment( await db.refresh(payment) logger.info( - "Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s", + 'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s', settings.get_mulenpay_display_name(), payment.mulen_payment_id, uuid, @@ -53,32 +52,18 @@ async def create_mulenpay_payment( return payment -async def get_mulenpay_payment_by_local_id( - db: AsyncSession, payment_id: int -) -> Optional[MulenPayPayment]: - result = await db.execute( - select(MulenPayPayment).where(MulenPayPayment.id == payment_id) - ) +async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) -> MulenPayPayment | None: + result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id)) return result.scalar_one_or_none() -async def get_mulenpay_payment_by_uuid( - db: AsyncSession, uuid: str -) -> Optional[MulenPayPayment]: - result = await db.execute( - select(MulenPayPayment).where(MulenPayPayment.uuid == uuid) - ) +async def get_mulenpay_payment_by_uuid(db: AsyncSession, uuid: str) -> MulenPayPayment | None: + result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)) return result.scalar_one_or_none() -async def get_mulenpay_payment_by_mulen_id( - db: AsyncSession, mulen_payment_id: int -) -> Optional[MulenPayPayment]: - result = await db.execute( - select(MulenPayPayment).where( - MulenPayPayment.mulen_payment_id == mulen_payment_id - ) - ) +async def get_mulenpay_payment_by_mulen_id(db: AsyncSession, mulen_payment_id: int) -> MulenPayPayment | None: + result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.mulen_payment_id == mulen_payment_id)) return result.scalar_one_or_none() @@ -87,11 +72,11 @@ async def update_mulenpay_payment_status( *, payment: MulenPayPayment, status: str, - is_paid: Optional[bool] = None, - paid_at: Optional[datetime] = None, - callback_payload: Optional[dict] = None, - mulen_payment_id: Optional[int] = None, - metadata: Optional[dict] = None, + is_paid: bool | None = None, + paid_at: datetime | None = None, + callback_payload: dict | None = None, + mulen_payment_id: int | None = None, + metadata: dict | None = None, ) -> MulenPayPayment: payment.status = status if is_paid is not None: diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py index ccbd8be7..ef680598 100644 --- a/app/database/crud/notification.py +++ b/app/database/crud/notification.py @@ -1,10 +1,11 @@ import logging -from typing import Optional + +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete from app.database.models import SentNotification + logger = logging.getLogger(__name__) @@ -13,7 +14,7 @@ async def notification_sent( user_id: int, subscription_id: int, notification_type: str, - days_before: Optional[int] = None, + days_before: int | None = None, ) -> bool: result = await db.execute( select(SentNotification).where( @@ -31,7 +32,7 @@ async def record_notification( user_id: int, subscription_id: int, notification_type: str, - days_before: Optional[int] = None, + days_before: int | None = None, ) -> None: notification = SentNotification( user_id=user_id, @@ -44,11 +45,7 @@ async def record_notification( async def clear_notifications(db: AsyncSession, subscription_id: int) -> None: - await db.execute( - delete(SentNotification).where( - SentNotification.subscription_id == subscription_id - ) - ) + await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id)) await db.commit() diff --git a/app/database/crud/pal24.py b/app/database/crud/pal24.py index 35cffa61..0eb0087a 100644 --- a/app/database/crud/pal24.py +++ b/app/database/crud/pal24.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Pal24Payment + logger = logging.getLogger(__name__) @@ -20,15 +21,15 @@ async def create_pal24_payment( user_id: int, bill_id: str, amount_kopeks: int, - description: Optional[str], + description: str | None, status: str, type_: str, currency: str, - link_url: Optional[str], - link_page_url: Optional[str], - order_id: Optional[str] = None, - ttl: Optional[int] = None, - metadata: Optional[Dict[str, Any]] = None, + link_url: str | None, + link_page_url: str | None, + order_id: str | None = None, + ttl: int | None = None, + metadata: dict[str, Any] | None = None, ) -> Pal24Payment: payment = Pal24Payment( user_id=user_id, @@ -50,7 +51,7 @@ async def create_pal24_payment( await db.refresh(payment) logger.info( - "Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)", + 'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)', payment.id, user_id, amount_kopeks, @@ -60,24 +61,18 @@ async def create_pal24_payment( return payment -async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]: - result = await db.execute( - select(Pal24Payment).where(Pal24Payment.id == payment_id) - ) +async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Payment | None: + result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id)) return result.scalar_one_or_none() -async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]: - result = await db.execute( - select(Pal24Payment).where(Pal24Payment.bill_id == bill_id) - ) +async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Pal24Payment | None: + result = await db.execute(select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)) return result.scalar_one_or_none() -async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]: - result = await db.execute( - select(Pal24Payment).where(Pal24Payment.order_id == order_id) - ) +async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Pal24Payment | None: + result = await db.execute(select(Pal24Payment).where(Pal24Payment.order_id == order_id)) return result.scalar_one_or_none() @@ -86,58 +81,54 @@ async def update_pal24_payment_status( payment: Pal24Payment, *, status: str, - is_active: Optional[bool] = None, - is_paid: Optional[bool] = None, - paid_at: Optional[datetime] = None, - payment_id: Optional[str] = None, - payment_status: Optional[str] = None, - payment_method: Optional[str] = None, - balance_amount: Optional[str] = None, - balance_currency: Optional[str] = None, - payer_account: Optional[str] = None, - callback_payload: Optional[Dict[str, Any]] = None, - metadata: Optional[Dict[str, Any]] = None, + is_active: bool | None = None, + is_paid: bool | None = None, + paid_at: datetime | None = None, + payment_id: str | None = None, + payment_status: str | None = None, + payment_method: str | None = None, + balance_amount: str | None = None, + balance_currency: str | None = None, + payer_account: str | None = None, + callback_payload: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, ) -> Pal24Payment: - update_values: Dict[str, Any] = { - "status": status, + update_values: dict[str, Any] = { + 'status': status, } if is_active is not None: - update_values["is_active"] = is_active + update_values['is_active'] = is_active if is_paid is not None: - update_values["is_paid"] = is_paid + update_values['is_paid'] = is_paid if paid_at is not None: - update_values["paid_at"] = paid_at + update_values['paid_at'] = paid_at if payment_id is not None: - update_values["payment_id"] = payment_id + update_values['payment_id'] = payment_id if payment_status is not None: - update_values["payment_status"] = payment_status + update_values['payment_status'] = payment_status if payment_method is not None: - update_values["payment_method"] = payment_method + update_values['payment_method'] = payment_method if balance_amount is not None: - update_values["balance_amount"] = balance_amount + update_values['balance_amount'] = balance_amount if balance_currency is not None: - update_values["balance_currency"] = balance_currency + update_values['balance_currency'] = balance_currency if payer_account is not None: - update_values["payer_account"] = payer_account + update_values['payer_account'] = payer_account if callback_payload is not None: - update_values["callback_payload"] = callback_payload + update_values['callback_payload'] = callback_payload if metadata is not None: - update_values["metadata_json"] = metadata + update_values['metadata_json'] = metadata - update_values["last_status"] = status + update_values['last_status'] = status - await db.execute( - update(Pal24Payment) - .where(Pal24Payment.id == payment.id) - .values(**update_values) - ) + await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(**update_values)) await db.commit() await db.refresh(payment) logger.info( - "Обновлен Pal24 платеж %s: статус=%s, is_paid=%s", + 'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s', payment.bill_id, payment.status, payment.is_paid, @@ -151,17 +142,12 @@ async def link_pal24_payment_to_transaction( payment: Pal24Payment, transaction_id: int, ) -> Pal24Payment: - await db.execute( - update(Pal24Payment) - .where(Pal24Payment.id == payment.id) - .values(transaction_id=transaction_id) - ) + await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id)) await db.commit() await db.refresh(payment) logger.info( - "Pal24 платеж %s привязан к транзакции %s", + 'Pal24 платеж %s привязан к транзакции %s', payment.bill_id, transaction_id, ) return payment - diff --git a/app/database/crud/platega.py b/app/database/crud/platega.py index 27a06b25..830b2536 100644 --- a/app/database/crud/platega.py +++ b/app/database/crud/platega.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging from datetime import datetime -from typing import Any, Optional +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PlategaPayment + logger = logging.getLogger(__name__) @@ -20,17 +21,17 @@ async def create_platega_payment( user_id: int, amount_kopeks: int, currency: str, - description: Optional[str], + description: str | None, status: str, payment_method_code: int, correlation_id: str, - platega_transaction_id: Optional[str], - redirect_url: Optional[str], - return_url: Optional[str], - failed_url: Optional[str], - payload: Optional[str], - metadata: Optional[dict[str, Any]] = None, - expires_at: Optional[datetime] = None, + platega_transaction_id: str | None, + redirect_url: str | None, + return_url: str | None, + failed_url: str | None, + payload: str | None, + metadata: dict[str, Any] | None = None, + expires_at: datetime | None = None, ) -> PlategaPayment: payment = PlategaPayment( user_id=user_id, @@ -54,7 +55,7 @@ async def create_platega_payment( await db.refresh(payment) logger.info( - "Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s", + 'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s', payment.id, platega_transaction_id, amount_kopeks, @@ -64,45 +65,23 @@ async def create_platega_payment( return payment -async def get_platega_payment_by_id( - db: AsyncSession, payment_id: int -) -> Optional[PlategaPayment]: - result = await db.execute( - select(PlategaPayment).where(PlategaPayment.id == payment_id) - ) +async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> PlategaPayment | None: + result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id)) return result.scalar_one_or_none() -async def get_platega_payment_by_id_for_update( - db: AsyncSession, payment_id: int -) -> Optional[PlategaPayment]: - result = await db.execute( - select(PlategaPayment) - .where(PlategaPayment.id == payment_id) - .with_for_update() - ) +async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None: + result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update()) return result.scalar_one_or_none() -async def get_platega_payment_by_transaction_id( - db: AsyncSession, transaction_id: str -) -> Optional[PlategaPayment]: - result = await db.execute( - select(PlategaPayment).where( - PlategaPayment.platega_transaction_id == transaction_id - ) - ) +async def get_platega_payment_by_transaction_id(db: AsyncSession, transaction_id: str) -> PlategaPayment | None: + result = await db.execute(select(PlategaPayment).where(PlategaPayment.platega_transaction_id == transaction_id)) return result.scalar_one_or_none() -async def get_platega_payment_by_correlation_id( - db: AsyncSession, correlation_id: str -) -> Optional[PlategaPayment]: - result = await db.execute( - select(PlategaPayment).where( - PlategaPayment.correlation_id == correlation_id - ) - ) +async def get_platega_payment_by_correlation_id(db: AsyncSession, correlation_id: str) -> PlategaPayment | None: + result = await db.execute(select(PlategaPayment).where(PlategaPayment.correlation_id == correlation_id)) return result.scalar_one_or_none() @@ -110,14 +89,14 @@ async def update_platega_payment( db: AsyncSession, *, payment: PlategaPayment, - status: Optional[str] = None, - is_paid: Optional[bool] = None, - paid_at: Optional[datetime] = None, - platega_transaction_id: Optional[str] = None, - redirect_url: Optional[str] = None, - callback_payload: Optional[dict[str, Any]] = None, - metadata: Optional[dict[str, Any]] = None, - expires_at: Optional[datetime] = None, + status: str | None = None, + is_paid: bool | None = None, + paid_at: datetime | None = None, + platega_transaction_id: str | None = None, + redirect_url: str | None = None, + callback_payload: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + expires_at: datetime | None = None, ) -> PlategaPayment: if status is not None: payment.status = status diff --git a/app/database/crud/poll.py b/app/database/crud/poll.py index db1a9b7e..a286f6ce 100644 --- a/app/database/crud/poll.py +++ b/app/database/crud/poll.py @@ -1,5 +1,5 @@ import logging -from typing import Iterable, Sequence +from collections.abc import Iterable, Sequence from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -13,6 +13,7 @@ from app.database.models import ( PollResponse, ) + logger = logging.getLogger(__name__) @@ -37,7 +38,7 @@ async def create_poll( await db.flush() for order, question_data in enumerate(questions, start=1): - question_text = question_data.get("text", "").strip() + question_text = question_data.get('text', '').strip() if not question_text: continue @@ -49,7 +50,7 @@ async def create_poll( db.add(question) await db.flush() - for option_order, option_text in enumerate(question_data.get("options", []), start=1): + for option_order, option_text in enumerate(question_data.get('options', []), start=1): option_text = option_text.strip() if not option_text: continue @@ -63,7 +64,7 @@ async def create_poll( await db.commit() await db.refresh( poll, - attribute_names=["questions"], + attribute_names=['questions'], ) return poll @@ -71,9 +72,7 @@ async def create_poll( async def list_polls(db: AsyncSession) -> list[Poll]: result = await db.execute( select(Poll) - .options( - selectinload(Poll.questions).options(selectinload(PollQuestion.options)) - ) + .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))) .order_by(Poll.created_at.desc()) ) return result.scalars().all() @@ -98,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool: await db.delete(poll) await db.commit() - logger.info("🗑️ Удалён опрос %s", poll_id) + logger.info('🗑️ Удалён опрос %s', poll_id) return True @@ -108,8 +107,7 @@ async def create_poll_response( user_id: int, ) -> PollResponse: result = await db.execute( - select(PollResponse) - .where( + select(PollResponse).where( and_( PollResponse.poll_id == poll_id, PollResponse.user_id == user_id, @@ -137,8 +135,9 @@ async def get_poll_response_by_id( result = await db.execute( select(PollResponse) .options( - selectinload(PollResponse.poll) - .options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))), + selectinload(PollResponse.poll).options( + selectinload(Poll.questions).options(selectinload(PollQuestion.options)) + ), selectinload(PollResponse.answers), selectinload(PollResponse.user), ) @@ -155,8 +154,7 @@ async def record_poll_answer( option_id: int, ) -> PollAnswer: result = await db.execute( - select(PollAnswer) - .where( + select(PollAnswer).where( and_( PollAnswer.response_id == response_id, PollAnswer.question_id == question_id, @@ -182,9 +180,7 @@ async def record_poll_answer( async def reset_poll_answers(db: AsyncSession, response_id: int) -> None: - await db.execute( - delete(PollAnswer).where(PollAnswer.response_id == response_id) - ) + await db.execute(delete(PollAnswer).where(PollAnswer.response_id == response_id)) await db.commit() @@ -241,27 +237,27 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict: question_entry = questions_map.setdefault( question_id, { - "id": question_id, - "text": question_text, - "order": question_order, - "options": [], + 'id': question_id, + 'text': question_text, + 'order': question_order, + 'options': [], }, ) - question_entry["options"].append( + question_entry['options'].append( { - "id": option_id, - "text": option_text, - "count": answer_count, + 'id': option_id, + 'text': option_text, + 'count': answer_count, } ) - questions = sorted(questions_map.values(), key=lambda item: item["order"]) + questions = sorted(questions_map.values(), key=lambda item: item['order']) return { - "total_responses": total_responses, - "completed_responses": completed_responses, - "reward_sum_kopeks": reward_sum, - "questions": questions, + 'total_responses': total_responses, + 'completed_responses': completed_responses, + 'reward_sum_kopeks': reward_sum, + 'questions': questions, } diff --git a/app/database/crud/privacy_policy.py b/app/database/crud/privacy_policy.py index 627a0e5e..fa7f883a 100644 --- a/app/database/crud/privacy_policy.py +++ b/app/database/crud/privacy_policy.py @@ -1,19 +1,17 @@ import logging from datetime import datetime -from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PrivacyPolicy + logger = logging.getLogger(__name__) -async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]: - result = await db.execute( - select(PrivacyPolicy).where(PrivacyPolicy.language == language) - ) +async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None: + result = await db.execute(select(PrivacyPolicy).where(PrivacyPolicy.language == language)) return result.scalar_one_or_none() @@ -27,13 +25,13 @@ async def upsert_privacy_policy( policy = await get_privacy_policy(db, language) if policy: - policy.content = content or "" + policy.content = content or '' policy.updated_at = datetime.utcnow() else: policy = PrivacyPolicy( language=language, - content=content or "", - is_enabled=True if enable_if_new else False, + content=content or '', + is_enabled=bool(enable_if_new), ) db.add(policy) @@ -41,7 +39,7 @@ async def upsert_privacy_policy( await db.refresh(policy) logger.info( - "✅ Политика конфиденциальности для языка %s обновлена (ID: %s)", + '✅ Политика конфиденциальности для языка %s обновлена (ID: %s)', language, policy.id, ) @@ -62,7 +60,7 @@ async def set_privacy_policy_enabled( else: policy = PrivacyPolicy( language=language, - content="", + content='', is_enabled=bool(enabled), ) db.add(policy) @@ -71,9 +69,9 @@ async def set_privacy_policy_enabled( await db.refresh(policy) logger.info( - "✅ Статус политики конфиденциальности для языка %s обновлен: %s", + '✅ Статус политики конфиденциальности для языка %s обновлен: %s', language, - "enabled" if policy.is_enabled else "disabled", + 'enabled' if policy.is_enabled else 'disabled', ) return policy diff --git a/app/database/crud/promo_group.py b/app/database/crud/promo_group.py index 5a986d10..2b4c96ee 100644 --- a/app/database/crud/promo_group.py +++ b/app/database/crud/promo_group.py @@ -1,5 +1,4 @@ import logging -from typing import Dict, List, Optional, Set, Tuple from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -8,11 +7,11 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoGroup, User, UserPromoGroup -def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]: +def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict[int, int]: if not period_discounts: return {} - normalized: Dict[int, int] = {} + normalized: dict[int, int] = {} for key, value in period_discounts.items(): try: @@ -25,6 +24,7 @@ def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> D return normalized + logger = logging.getLogger(__name__) @@ -32,8 +32,8 @@ async def get_promo_groups_with_counts( db: AsyncSession, *, offset: int = 0, - limit: Optional[int] = None, -) -> List[Tuple[PromoGroup, int]]: + limit: int | None = None, +) -> list[tuple[PromoGroup, int]]: query = ( select(PromoGroup, func.count(User.id)) .outerjoin(User, User.promo_group_id == PromoGroup.id) @@ -50,7 +50,7 @@ async def get_promo_groups_with_counts( return result.all() -async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]: +async def get_auto_assign_promo_groups(db: AsyncSession) -> list[PromoGroup]: result = await db.execute( select(PromoGroup) .where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None)) @@ -69,7 +69,7 @@ async def has_auto_assign_promo_groups(db: AsyncSession) -> bool: return bool(result.scalar_one()) -async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]: +async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> PromoGroup | None: return await db.get(PromoGroup, group_id) @@ -78,10 +78,8 @@ async def count_promo_groups(db: AsyncSession) -> int: 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)) - ) +async def get_default_promo_group(db: AsyncSession) -> PromoGroup | None: + result = await db.execute(select(PromoGroup).where(PromoGroup.is_default.is_(True))) return result.scalars().first() @@ -93,17 +91,15 @@ async def create_promo_group( server_discount_percent: int, traffic_discount_percent: int, device_discount_percent: int, - period_discounts: Optional[Dict[int, int]] = None, - auto_assign_total_spent_kopeks: Optional[int] = None, + period_discounts: dict[int, int] | None = None, + auto_assign_total_spent_kopeks: int | None = None, apply_discounts_to_addons: bool = True, is_default: bool = False, ) -> PromoGroup: normalized_period_discounts = _normalize_period_discounts(period_discounts) auto_assign_total_spent_kopeks = ( - max(0, auto_assign_total_spent_kopeks) - if auto_assign_total_spent_kopeks is not None - else None + max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None ) existing_default = await get_default_promo_group(db) @@ -125,11 +121,7 @@ async def create_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.execute(update(PromoGroup).where(PromoGroup.id != promo_group.id).values(is_default=False)) await db.commit() await db.refresh(promo_group) @@ -143,7 +135,7 @@ async def create_promo_group( 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", + 'on' if promo_group.apply_discounts_to_addons else 'off', ) return promo_group @@ -153,15 +145,15 @@ async def update_promo_group( db: AsyncSession, group: PromoGroup, *, - name: Optional[str] = None, - priority: Optional[int] = None, - server_discount_percent: Optional[int] = None, - traffic_discount_percent: Optional[int] = None, - 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, + name: str | None = None, + priority: int | None = None, + server_discount_percent: int | None = None, + traffic_discount_percent: int | None = None, + device_discount_percent: int | None = None, + period_discounts: dict[int, int] | None = None, + auto_assign_total_spent_kopeks: int | None = None, + apply_discounts_to_addons: bool | None = None, + is_default: bool | None = None, ) -> PromoGroup: if name is not None: group.name = name.strip() @@ -185,31 +177,19 @@ async def update_promo_group( if is_default: group.is_default = True await db.flush() - await db.execute( - update(PromoGroup) - .where(PromoGroup.id != group.id) - .values(is_default=False) + await db.execute(update(PromoGroup).where(PromoGroup.id != group.id).values(is_default=False)) + elif 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) ) - 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 + 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) @@ -224,21 +204,18 @@ async def update_promo_group( async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: if group.is_default: - logger.warning("Попытка удалить базовую промогруппу запрещена") + logger.warning('Попытка удалить базовую промогруппу запрещена') return False default_group = await get_default_promo_group(db) if not default_group: - logger.error("Не найдена базовая промогруппа для reassignment") + logger.error('Не найдена базовая промогруппа для reassignment') return False - # Получаем список пользователей, связанных с удаляемой промогруппой - affected_user_ids: Set[int] = set() + affected_user_ids: set[int] = set() - user_ids_result = await db.execute( - select(User.id).where(User.promo_group_id == group.id) - ) + user_ids_result = await db.execute(select(User.id).where(User.promo_group_id == group.id)) affected_user_ids.update(user_ids_result.scalars().all()) promo_group_links_result = await db.execute( @@ -246,11 +223,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: ) affected_user_ids.update(promo_group_links_result.scalars().all()) - await db.execute( - update(User) - .where(User.promo_group_id == group.id) - .values(promo_group_id=default_group.id) - ) + await db.execute(update(User).where(User.promo_group_id == group.id).values(promo_group_id=default_group.id)) if affected_user_ids: existing_defaults_result = await db.execute( @@ -265,7 +238,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool: UserPromoGroup( user_id=user_id, promo_group_id=default_group.id, - assigned_by="system", + assigned_by='system', ) ) @@ -287,7 +260,7 @@ async def get_promo_group_members( *, offset: int = 0, limit: int = 20, -) -> List[User]: +) -> list[User]: result = await db.execute( select(User) .options(selectinload(User.subscription)) @@ -300,7 +273,5 @@ async def get_promo_group_members( async def count_promo_group_members(db: AsyncSession, group_id: int) -> int: - result = await db.execute( - select(func.count(User.id)).where(User.promo_group_id == group_id) - ) + result = await db.execute(select(func.count(User.id)).where(User.promo_group_id == group_id)) return result.scalar_one() diff --git a/app/database/crud/promo_offer_log.py b/app/database/crud/promo_offer_log.py index ccb8b633..d96588d5 100644 --- a/app/database/crud/promo_offer_log.py +++ b/app/database/crud/promo_offer_log.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from typing import Dict, List, Optional, Tuple from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -9,19 +8,20 @@ from sqlalchemy.orm import selectinload from app.database.models import PromoOfferLog + logger = logging.getLogger(__name__) async def log_promo_offer_action( db: AsyncSession, *, - user_id: Optional[int], - offer_id: Optional[int], + user_id: int | None, + offer_id: int | None, action: str, - source: Optional[str] = None, - percent: Optional[int] = None, - effect_type: Optional[str] = None, - details: Optional[Dict[str, object]] = None, + source: str | None = None, + percent: int | None = None, + effect_type: str | None = None, + details: dict[str, object] | None = None, commit: bool = True, ) -> PromoOfferLog: """Persist a promo offer log entry.""" @@ -42,7 +42,7 @@ async def log_promo_offer_action( await db.commit() await db.refresh(entry) except Exception: - logger.exception("Failed to commit promo offer log entry") + logger.exception('Failed to commit promo offer log entry') raise return entry @@ -53,11 +53,11 @@ async def list_promo_offer_logs( offset: int = 0, limit: int = 20, *, - user_id: Optional[int] = None, - offer_id: Optional[int] = None, - action: Optional[str] = None, - source: Optional[str] = None, -) -> Tuple[List[PromoOfferLog], int]: + user_id: int | None = None, + offer_id: int | None = None, + action: str | None = None, + source: str | None = None, +) -> tuple[list[PromoOfferLog], int]: stmt = ( select(PromoOfferLog) .options( diff --git a/app/database/crud/promo_offer_template.py b/app/database/crud/promo_offer_template.py index e4abfcde..0f1deab7 100644 --- a/app/database/crud/promo_offer_template.py +++ b/app/database/crud/promo_offer_template.py @@ -1,121 +1,118 @@ from __future__ import annotations +from collections.abc import Iterable from datetime import datetime -from typing import Iterable, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.models import PromoOfferTemplate UPDATED_TEMPLATE_MESSAGES = { - "extend_discount": ( - "💎 Экономия {discount_percent}% при продлении\n\n" - "Скидка суммируется с промогруппой и действует один раз.\n" - "Срок действия предложения — {valid_hours} ч.\n" - "После активации скидка действует {active_discount_hours} ч." + 'extend_discount': ( + '💎 Экономия {discount_percent}% при продлении\n\n' + 'Скидка суммируется с промогруппой и действует один раз.\n' + 'Срок действия предложения — {valid_hours} ч.\n' + 'После активации скидка действует {active_discount_hours} ч.' ), - "purchase_discount": ( - "🎯 Вернитесь со скидкой {discount_percent}%\n\n" - "Скидка суммируется с промогруппой и действует один раз.\n" - "Предложение действует {valid_hours} ч.\n" - "После активации скидка действует {active_discount_hours} ч." + 'purchase_discount': ( + '🎯 Вернитесь со скидкой {discount_percent}%\n\n' + 'Скидка суммируется с промогруппой и действует один раз.\n' + 'Предложение действует {valid_hours} ч.\n' + 'После активации скидка действует {active_discount_hours} ч.' ), } LEGACY_TEMPLATE_MESSAGES = { - "extend_discount": ( - "💎 Экономия {discount_percent}% при продлении\n\n" - "Активируйте предложение и получите дополнительную скидку на оплату продления. " - "Она суммируется с вашими промогрупповыми скидками и действует один раз.\n" - "Срок действия предложения — {valid_hours} ч.\n" - "После активации скидка действует {active_discount_hours} ч." + 'extend_discount': ( + '💎 Экономия {discount_percent}% при продлении\n\n' + 'Активируйте предложение и получите дополнительную скидку на оплату продления. ' + 'Она суммируется с вашими промогрупповыми скидками и действует один раз.\n' + 'Срок действия предложения — {valid_hours} ч.\n' + 'После активации скидка действует {active_discount_hours} ч.' ), - "purchase_discount": ( - "🎯 Вернитесь со скидкой {discount_percent}%\n\n" - "После активации мы применим дополнительную скидку к вашей следующей оплате подписки. " - "Скидка суммируется с промогруппой и действует один раз.\n" - "Предложение действует {valid_hours} ч.\n" - "После активации скидка действует {active_discount_hours} ч." + 'purchase_discount': ( + '🎯 Вернитесь со скидкой {discount_percent}%\n\n' + 'После активации мы применим дополнительную скидку к вашей следующей оплате подписки. ' + 'Скидка суммируется с промогруппой и действует один раз.\n' + 'Предложение действует {valid_hours} ч.\n' + 'После активации скидка действует {active_discount_hours} ч.' ), } DEFAULT_TEMPLATES: tuple[dict, ...] = ( { - "offer_type": "test_access", - "name": "Тестовые сервера", - "message_text": ( - "🔥 Испытайте новые сервера\n\n" - "Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n" - "Предложение действительно {valid_hours} ч." + 'offer_type': 'test_access', + 'name': 'Тестовые сервера', + 'message_text': ( + '🔥 Испытайте новые сервера\n\n' + 'Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n' + 'Предложение действительно {valid_hours} ч.' ), - "button_text": "🚀 Попробовать серверы", - "valid_hours": 24, - "discount_percent": 0, - "bonus_amount_kopeks": 0, - "active_discount_hours": None, - "test_duration_hours": 24, - "test_squad_uuids": [], + 'button_text': '🚀 Попробовать серверы', + 'valid_hours': 24, + 'discount_percent': 0, + 'bonus_amount_kopeks': 0, + 'active_discount_hours': None, + 'test_duration_hours': 24, + 'test_squad_uuids': [], }, { - "offer_type": "extend_discount", - "name": "Скидка на продление", - "message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"], - "button_text": "🎁 Получить скидку", - "valid_hours": 24, - "discount_percent": 20, - "bonus_amount_kopeks": 0, - "active_discount_hours": 24, - "test_duration_hours": None, - "test_squad_uuids": [], + 'offer_type': 'extend_discount', + 'name': 'Скидка на продление', + 'message_text': UPDATED_TEMPLATE_MESSAGES['extend_discount'], + 'button_text': '🎁 Получить скидку', + 'valid_hours': 24, + 'discount_percent': 20, + 'bonus_amount_kopeks': 0, + 'active_discount_hours': 24, + 'test_duration_hours': None, + 'test_squad_uuids': [], }, { - "offer_type": "purchase_discount", - "name": "Скидка на покупку", - "message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"], - "button_text": "🎁 Забрать скидку", - "valid_hours": 48, - "discount_percent": 25, - "bonus_amount_kopeks": 0, - "active_discount_hours": 48, - "test_duration_hours": None, - "test_squad_uuids": [], + 'offer_type': 'purchase_discount', + 'name': 'Скидка на покупку', + 'message_text': UPDATED_TEMPLATE_MESSAGES['purchase_discount'], + 'button_text': '🎁 Забрать скидку', + 'valid_hours': 48, + 'discount_percent': 25, + 'bonus_amount_kopeks': 0, + 'active_discount_hours': 48, + 'test_duration_hours': None, + 'test_squad_uuids': [], }, ) def _format_template_fields(payload: dict) -> dict: data = dict(payload) - data.setdefault("valid_hours", 24) - data.setdefault("discount_percent", 0) - data.setdefault("bonus_amount_kopeks", 0) - data.setdefault("active_discount_hours", None) - data.setdefault("test_duration_hours", None) - data.setdefault("test_squad_uuids", []) + data.setdefault('valid_hours', 24) + data.setdefault('discount_percent', 0) + data.setdefault('bonus_amount_kopeks', 0) + data.setdefault('active_discount_hours', None) + data.setdefault('test_duration_hours', None) + data.setdefault('test_squad_uuids', []) return data -async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]: - templates: List[PromoOfferTemplate] = [] +async def ensure_default_templates(db: AsyncSession, *, created_by: int | None = None) -> list[PromoOfferTemplate]: + templates: list[PromoOfferTemplate] = [] for template_data in DEFAULT_TEMPLATES: result = await db.execute( - select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"]) + select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data['offer_type']) ) existing = result.scalars().first() if existing: - new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"]) - legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"]) + new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data['offer_type']) + legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data['offer_type']) should_update = False - if new_message and legacy_message and existing.message_text == legacy_message: - should_update = True - elif new_message and ( - "{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text + if (new_message and legacy_message and existing.message_text == legacy_message) or ( + new_message and ('{bonus_amount' in existing.message_text or 'Мы начислим' in existing.message_text) ): should_update = True @@ -124,12 +121,8 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int existing.updated_at = datetime.utcnow() await db.flush() - target_active_hours = template_data.get("active_discount_hours") - if ( - target_active_hours is not None - and target_active_hours > 0 - and not existing.active_discount_hours - ): + target_active_hours = template_data.get('active_discount_hours') + if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours: existing.active_discount_hours = target_active_hours existing.updated_at = datetime.utcnow() await db.flush() @@ -138,16 +131,16 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int payload = _format_template_fields(template_data) template = PromoOfferTemplate( - name=payload["name"], - offer_type=payload["offer_type"], - message_text=payload["message_text"], - button_text=payload["button_text"], - valid_hours=payload["valid_hours"], - discount_percent=payload["discount_percent"], - bonus_amount_kopeks=payload["bonus_amount_kopeks"], - active_discount_hours=payload["active_discount_hours"], - test_duration_hours=payload["test_duration_hours"], - test_squad_uuids=payload["test_squad_uuids"], + name=payload['name'], + offer_type=payload['offer_type'], + message_text=payload['message_text'], + button_text=payload['button_text'], + valid_hours=payload['valid_hours'], + discount_percent=payload['discount_percent'], + bonus_amount_kopeks=payload['bonus_amount_kopeks'], + active_discount_hours=payload['active_discount_hours'], + test_duration_hours=payload['test_duration_hours'], + test_squad_uuids=payload['test_squad_uuids'], is_active=True, created_by=created_by, ) @@ -160,24 +153,18 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int return templates -async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]: - result = await db.execute( - select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id) - ) +async def list_promo_offer_templates(db: AsyncSession) -> list[PromoOfferTemplate]: + result = await db.execute(select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)) return result.scalars().all() -async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]: - result = await db.execute( - select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id) - ) +async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> PromoOfferTemplate | None: + result = await db.execute(select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)) return result.scalar_one_or_none() -async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]: - result = await db.execute( - select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type) - ) +async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> PromoOfferTemplate | None: + result = await db.execute(select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)) return result.scalar_one_or_none() @@ -185,16 +172,16 @@ async def update_promo_offer_template( db: AsyncSession, template: PromoOfferTemplate, *, - name: Optional[str] = None, - message_text: Optional[str] = None, - button_text: Optional[str] = None, - valid_hours: Optional[int] = None, - discount_percent: Optional[int] = None, - bonus_amount_kopeks: Optional[int] = None, - active_discount_hours: Optional[int] = None, - test_duration_hours: Optional[int] = None, - test_squad_uuids: Optional[Iterable[str]] = None, - is_active: Optional[bool] = None, + name: str | None = None, + message_text: str | None = None, + button_text: str | None = None, + valid_hours: int | None = None, + discount_percent: int | None = None, + bonus_amount_kopeks: int | None = None, + active_discount_hours: int | None = None, + test_duration_hours: int | None = None, + test_squad_uuids: Iterable[str] | None = None, + is_active: bool | None = None, ) -> PromoOfferTemplate: if name is not None: template.name = name diff --git a/app/database/crud/promocode.py b/app/database/crud/promocode.py index d765d655..d7236015 100644 --- a/app/database/crud/promocode.py +++ b/app/database/crud/promocode.py @@ -1,38 +1,33 @@ import logging from datetime import datetime -from typing import Optional, List -from sqlalchemy import select, and_, func + +from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User +from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User + logger = logging.getLogger(__name__) -async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]: +async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None: result = await db.execute( select(PromoCode) - .options( - selectinload(PromoCode.uses), - selectinload(PromoCode.promo_group) - ) + .options(selectinload(PromoCode.uses), selectinload(PromoCode.promo_group)) .where(PromoCode.code == code.upper()) ) return result.scalar_one_or_none() -async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]: +async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> PromoCode | None: """ Получает промокод по ID с eager loading всех связанных данных. Используется для избежания lazy loading в async контексте. """ result = await db.execute( select(PromoCode) - .options( - selectinload(PromoCode.uses), - selectinload(PromoCode.promo_group) - ) + .options(selectinload(PromoCode.uses), selectinload(PromoCode.promo_group)) .where(PromoCode.id == promo_id) ) return result.scalar_one_or_none() @@ -46,15 +41,14 @@ async def check_promocode_validity(db: AsyncSession, code: str) -> dict: promocode = await get_promocode_by_code(db, code) if not promocode: - return {"valid": False, "error": "not_found", "promocode": None} + return {'valid': False, 'error': 'not_found', 'promocode': None} if not promocode.is_valid: if promocode.current_uses >= promocode.max_uses: - return {"valid": False, "error": "used", "promocode": None} - else: - return {"valid": False, "error": "expired", "promocode": None} + return {'valid': False, 'error': 'used', 'promocode': None} + return {'valid': False, 'error': 'expired', 'promocode': None} - return {"valid": True, "error": None, "promocode": promocode} + return {'valid': True, 'error': None, 'promocode': promocode} async def create_promocode( @@ -64,11 +58,10 @@ async def create_promocode( balance_bonus_kopeks: int = 0, subscription_days: int = 0, max_uses: int = 1, - valid_until: Optional[datetime] = None, - created_by: Optional[int] = None, - promo_group_id: Optional[int] = None + valid_until: datetime | None = None, + created_by: int | None = None, + promo_group_id: int | None = None, ) -> PromoCode: - promocode = PromoCode( code=code.upper(), type=type.value, @@ -77,120 +70,78 @@ async def create_promocode( max_uses=max_uses, valid_until=valid_until, created_by=created_by, - promo_group_id=promo_group_id + promo_group_id=promo_group_id, ) - + db.add(promocode) await db.commit() await db.refresh(promocode) if promo_group_id: - logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}") + logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}') else: - logger.info(f"✅ Создан промокод: {code}") + logger.info(f'✅ Создан промокод: {code}') return promocode -async def use_promocode( - db: AsyncSession, - promocode_id: int, - user_id: int -) -> bool: - +async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bool: try: promocode = await get_promocode_by_id(db, promocode_id) if not promocode: return False - - usage = PromoCodeUse( - promocode_id=promocode_id, - user_id=user_id - ) + + usage = PromoCodeUse(promocode_id=promocode_id, user_id=user_id) db.add(usage) - + promocode.current_uses += 1 - + await db.commit() - - logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}") + + logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка использования промокода: {e}") + logger.error(f'Ошибка использования промокода: {e}') await db.rollback() return False -async def check_user_promocode_usage( - db: AsyncSession, - user_id: int, - promocode_id: int -) -> bool: - +async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_id: int) -> bool: result = await db.execute( - select(PromoCodeUse).where( - and_( - PromoCodeUse.user_id == user_id, - PromoCodeUse.promocode_id == promocode_id - ) - ) + select(PromoCodeUse).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.promocode_id == promocode_id)) ) return result.scalar_one_or_none() is not None - async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse: - promocode_use = PromoCodeUse( - promocode_id=promocode_id, - user_id=user_id, - used_at=datetime.utcnow() - ) - + promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.utcnow()) + db.add(promocode_use) await db.commit() await db.refresh(promocode_use) - - logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}") + + logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}') return promocode_use -async def get_promocode_use_by_user_and_code( - db: AsyncSession, - user_id: int, - promocode_id: int -) -> Optional[PromoCodeUse]: +async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, promocode_id: int) -> PromoCodeUse | None: result = await db.execute( - select(PromoCodeUse).where( - and_( - PromoCodeUse.user_id == user_id, - PromoCodeUse.promocode_id == promocode_id - ) - ) + select(PromoCodeUse).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.promocode_id == promocode_id)) ) return result.scalar_one_or_none() -async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]: +async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]: result = await db.execute( - select(PromoCodeUse) - .where(PromoCodeUse.user_id == user_id) - .order_by(PromoCodeUse.used_at.desc()) + select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc()) ) return result.scalars().all() - async def get_promocodes_list( - db: AsyncSession, - offset: int = 0, - limit: int = 50, - is_active: Optional[bool] = None -) -> List[PromoCode]: - - query = select(PromoCode).options( - selectinload(PromoCode.uses), - selectinload(PromoCode.promo_group) - ) + db: AsyncSession, offset: int = 0, limit: int = 50, is_active: bool | None = None +) -> list[PromoCode]: + query = select(PromoCode).options(selectinload(PromoCode.uses), selectinload(PromoCode.promo_group)) if is_active is not None: query = query.where(PromoCode.is_active == is_active) @@ -201,77 +152,60 @@ async def get_promocodes_list( return result.scalars().all() -async def get_promocodes_count( - db: AsyncSession, - is_active: Optional[bool] = None -) -> int: - +async def get_promocodes_count(db: AsyncSession, is_active: bool | None = None) -> int: query = select(func.count(PromoCode.id)) - + if is_active is not None: query = query.where(PromoCode.is_active == is_active) - + result = await db.execute(query) return result.scalar() -async def update_promocode( - db: AsyncSession, - promocode: PromoCode, - **kwargs -) -> PromoCode: - +async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) -> PromoCode: for field, value in kwargs.items(): if hasattr(promocode, field): setattr(promocode, field, value) - + promocode.updated_at = datetime.utcnow() await db.commit() await db.refresh(promocode) - + return promocode async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool: try: from sqlalchemy import delete as sql_delete - - await db.execute( - sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id) - ) - + + await db.execute(sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)) + await db.delete(promocode) await db.commit() - - logger.info(f"🗑️ Удален промокод: {promocode.code}") + + logger.info(f'🗑️ Удален промокод: {promocode.code}') return True - + except Exception as e: - logger.error(f"Ошибка удаления промокода: {e}") + logger.error(f'Ошибка удаления промокода: {e}') await db.rollback() return False async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict: - total_uses_result = await db.execute( - select(func.count(PromoCodeUse.id)) - .where(PromoCodeUse.promocode_id == promocode_id) + select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id) ) total_uses = total_uses_result.scalar() - + today = datetime.utcnow().date() today_uses_result = await db.execute( - select(func.count(PromoCodeUse.id)) - .where( - and_( - PromoCodeUse.promocode_id == promocode_id, - PromoCodeUse.used_at >= today - ) + select(func.count(PromoCodeUse.id)).where( + and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today) ) ) today_uses = today_uses_result.scalar() - + recent_uses_result = await db.execute( select(PromoCodeUse, User) .join(User, PromoCodeUse.user_id == User.id) @@ -280,17 +214,12 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict: .limit(10) ) recent_uses_data = recent_uses_result.all() - + recent_uses = [] for use, user in recent_uses_data: use.user_username = user.username use.user_full_name = user.full_name use.user_telegram_id = user.telegram_id recent_uses.append(use) - - return { - "total_uses": total_uses, - "today_uses": today_uses, - "recent_uses": recent_uses - } + return {'total_uses': total_uses, 'today_uses': today_uses, 'recent_uses': recent_uses} diff --git a/app/database/crud/public_offer.py b/app/database/crud/public_offer.py index 78d5716e..83a85ab4 100644 --- a/app/database/crud/public_offer.py +++ b/app/database/crud/public_offer.py @@ -1,19 +1,17 @@ import logging from datetime import datetime -from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import PublicOffer + logger = logging.getLogger(__name__) -async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]: - result = await db.execute( - select(PublicOffer).where(PublicOffer.language == language) - ) +async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None: + result = await db.execute(select(PublicOffer).where(PublicOffer.language == language)) return result.scalar_one_or_none() @@ -27,13 +25,13 @@ async def upsert_public_offer( offer = await get_public_offer(db, language) if offer: - offer.content = content or "" + offer.content = content or '' offer.updated_at = datetime.utcnow() else: offer = PublicOffer( language=language, - content=content or "", - is_enabled=True if enable_if_new else False, + content=content or '', + is_enabled=bool(enable_if_new), ) db.add(offer) @@ -41,7 +39,7 @@ async def upsert_public_offer( await db.refresh(offer) logger.info( - "✅ Публичная оферта для языка %s обновлена (ID: %s)", + '✅ Публичная оферта для языка %s обновлена (ID: %s)', language, offer.id, ) @@ -62,7 +60,7 @@ async def set_public_offer_enabled( else: offer = PublicOffer( language=language, - content="", + content='', is_enabled=bool(enabled), ) db.add(offer) @@ -71,9 +69,9 @@ async def set_public_offer_enabled( await db.refresh(offer) logger.info( - "✅ Статус публичной оферты для языка %s обновлен: %s", + '✅ Статус публичной оферты для языка %s обновлен: %s', language, - "enabled" if offer.is_enabled else "disabled", + 'enabled' if offer.is_enabled else 'disabled', ) return offer diff --git a/app/database/crud/referral.py b/app/database/crud/referral.py index bf5a4990..12cca9ae 100644 --- a/app/database/crud/referral.py +++ b/app/database/crud/referral.py @@ -1,12 +1,13 @@ import logging from datetime import datetime, timedelta -from typing import List, Optional -from sqlalchemy import select, and_, func + +from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database.models import ReferralEarning, User + logger = logging.getLogger(__name__) @@ -16,38 +17,30 @@ async def create_referral_earning( referral_id: int, amount_kopeks: int, reason: str, - referral_transaction_id: Optional[int] = None + referral_transaction_id: int | None = None, ) -> ReferralEarning: - earning = ReferralEarning( user_id=user_id, referral_id=referral_id, amount_kopeks=amount_kopeks, reason=reason, - referral_transaction_id=referral_transaction_id + referral_transaction_id=referral_transaction_id, ) - + db.add(earning) await db.commit() await db.refresh(earning) - - logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}") + + logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}') return earning async def get_referral_earnings_by_user( - db: AsyncSession, - user_id: int, - limit: int = 50, - offset: int = 0 -) -> List[ReferralEarning]: - + db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0 +) -> list[ReferralEarning]: result = await db.execute( select(ReferralEarning) - .options( - selectinload(ReferralEarning.referral), - selectinload(ReferralEarning.referral_transaction) - ) + .options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction)) .where(ReferralEarning.user_id == user_id) .order_by(ReferralEarning.created_at.desc()) .offset(offset) @@ -56,11 +49,7 @@ async def get_referral_earnings_by_user( return result.scalars().all() -async def get_referral_earnings_by_referral( - db: AsyncSession, - referral_id: int -) -> List[ReferralEarning]: - +async def get_referral_earnings_by_referral(db: AsyncSession, referral_id: int) -> list[ReferralEarning]: result = await db.execute( select(ReferralEarning) .where(ReferralEarning.referral_id == referral_id) @@ -70,212 +59,177 @@ async def get_referral_earnings_by_referral( async def get_referral_earnings_sum( - db: AsyncSession, - user_id: int, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + db: AsyncSession, user_id: int, start_date: datetime | None = None, end_date: datetime | None = None ) -> int: - - query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( - ReferralEarning.user_id == user_id - ) - + query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.user_id == user_id) + if start_date: query = query.where(ReferralEarning.created_at >= start_date) - + if end_date: query = query.where(ReferralEarning.created_at <= end_date) - + result = await db.execute(query) return result.scalar() async def get_referral_statistics(db: AsyncSession) -> dict: - users_with_referrals_result = await db.execute( - select(func.count(func.distinct(User.id))) - .where(User.referred_by_id.isnot(None)) + select(func.count(func.distinct(User.id))).where(User.referred_by_id.isnot(None)) ) users_with_referrals = users_with_referrals_result.scalar() - + active_referrers_result = await db.execute( - select(func.count(func.distinct(User.referred_by_id))) - .where(User.referred_by_id.isnot(None)) + select(func.count(func.distinct(User.referred_by_id))).where(User.referred_by_id.isnot(None)) ) active_referrers = active_referrers_result.scalar() - - referral_paid_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - ) + + referral_paid_result = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))) referral_paid = referral_paid_result.scalar() - + from app.database.models import Transaction, TransactionType + transaction_paid_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.type == TransactionType.REFERRAL_REWARD.value + ) ) transaction_paid = transaction_paid_result.scalar() - + total_paid = referral_paid + transaction_paid - + referrals_stats_result = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('referrals_count') - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('referrals_count')) .where(User.referred_by_id.isnot(None)) .group_by(User.referred_by_id) ) referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()} - + referral_earnings_result = await db.execute( select( ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('referral_earnings') - ) - .group_by(ReferralEarning.user_id) + func.sum(ReferralEarning.amount_kopeks).label('referral_earnings'), + ).group_by(ReferralEarning.user_id) ) referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()} - + transaction_earnings_result = await db.execute( select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('transaction_earnings') + Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('transaction_earnings') ) .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) .group_by(Transaction.user_id) ) transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()} - + top_referrers_data = {} - + for referrer_id, count in referrals_stats.items(): if referrer_id not in top_referrers_data: - top_referrers_data[referrer_id] = { - 'referrals_count': 0, - 'total_earned': 0 - } + top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0} top_referrers_data[referrer_id]['referrals_count'] = count - + for referrer_id, earnings in referral_earnings.items(): if referrer_id not in top_referrers_data: - top_referrers_data[referrer_id] = { - 'referrals_count': 0, - 'total_earned': 0 - } + top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0} top_referrers_data[referrer_id]['total_earned'] += earnings or 0 - + for referrer_id, earnings in transaction_earnings.items(): if referrer_id not in top_referrers_data: - top_referrers_data[referrer_id] = { - 'referrals_count': 0, - 'total_earned': 0 - } + top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0} top_referrers_data[referrer_id]['total_earned'] += earnings or 0 - + sorted_referrers = sorted( - top_referrers_data.items(), - key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']), - reverse=True + top_referrers_data.items(), key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']), reverse=True ) - + top_referrers = [] for referrer_id, stats in sorted_referrers[:5]: user_result = await db.execute( - select(User.id, User.username, User.first_name, User.last_name, User.telegram_id) - .where(User.id == referrer_id) + select(User.id, User.username, User.first_name, User.last_name, User.telegram_id).where( + User.id == referrer_id + ) ) user = user_result.first() - + if user: - display_name = "" + display_name = '' if user.first_name: display_name = user.first_name if user.last_name: - display_name += f" {user.last_name}" + display_name += f' {user.last_name}' elif user.username: - display_name = f"@{user.username}" + display_name = f'@{user.username}' elif user.telegram_id: - display_name = f"ID{user.telegram_id}" + display_name = f'ID{user.telegram_id}' else: - display_name = user.email or f"#{user.id}" + display_name = user.email or f'#{user.id}' + + top_referrers.append( + { + 'user_id': user.id, # Use internal ID, not telegram_id + 'display_name': display_name, + 'username': user.username, + 'telegram_id': user.telegram_id, # Can be None for email users + 'total_earned_kopeks': stats['total_earned'], + 'referrals_count': stats['referrals_count'], + } + ) - top_referrers.append({ - "user_id": user.id, # Use internal ID, not telegram_id - "display_name": display_name, - "username": user.username, - "telegram_id": user.telegram_id, # Can be None for email users - "total_earned_kopeks": stats['total_earned'], - "referrals_count": stats['referrals_count'] - }) - today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - + today_referral_earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.created_at >= today) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today) ) today_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= today - ) + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today) ) ) today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar() - + week_ago = datetime.utcnow() - timedelta(days=7) week_referral_earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.created_at >= week_ago) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago) ) week_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= week_ago - ) + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago) ) ) week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar() - + month_ago = datetime.utcnow() - timedelta(days=30) month_referral_earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.created_at >= month_ago) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago) ) month_transaction_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= month_ago - ) + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago) ) ) month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar() - - logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек") - + + logger.info( + f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек' + ) + return { - "users_with_referrals": users_with_referrals, - "active_referrers": active_referrers, - "total_paid_kopeks": total_paid, - "today_earnings_kopeks": today_earnings, - "week_earnings_kopeks": week_earnings, - "month_earnings_kopeks": month_earnings, - "top_referrers": top_referrers + 'users_with_referrals': users_with_referrals, + 'active_referrers': active_referrers, + 'total_paid_kopeks': total_paid, + 'today_earnings_kopeks': today_earnings, + 'week_earnings_kopeks': week_earnings, + 'month_earnings_kopeks': month_earnings, + 'top_referrers': top_referrers, } async def get_top_referrers_by_period( db: AsyncSession, - period: str = "week", # "week" или "month" - sort_by: str = "earnings", # "earnings" или "invited" - limit: int = 20 + period: str = 'week', # "week" или "month" + sort_by: str = 'earnings', # "earnings" или "invited" + limit: int = 20, ) -> list: """ Получает топ рефереров за период. @@ -291,24 +245,16 @@ async def get_top_referrers_by_period( from app.database.models import Transaction, TransactionType now = datetime.utcnow() - if period == "week": + if period == 'week': start_date = now - timedelta(days=7) else: # month start_date = now - timedelta(days=30) - if sort_by == "invited": + if sort_by == 'invited': # Топ по количеству приглашённых за период referrals_result = await db.execute( - select( - User.referred_by_id.label('referrer_id'), - func.count(User.id).label('invited_count') - ) - .where( - and_( - User.referred_by_id.isnot(None), - User.created_at >= start_date - ) - ) + select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('invited_count')) + .where(and_(User.referred_by_id.isnot(None), User.created_at >= start_date)) .group_by(User.referred_by_id) .order_by(func.count(User.id).desc()) .limit(limit) @@ -318,41 +264,34 @@ async def get_top_referrers_by_period( for row in referrals_result: # Получаем заработок за период для этого реферера earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where( - and_( - ReferralEarning.user_id == row.referrer_id, - ReferralEarning.created_at >= start_date - ) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + and_(ReferralEarning.user_id == row.referrer_id, ReferralEarning.created_at >= start_date) ) ) earnings = earnings_result.scalar() or 0 # Добавляем транзакции REFERRAL_REWARD trans_earnings_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.user_id == row.referrer_id, Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= start_date + Transaction.created_at >= start_date, ) ) ) earnings += trans_earnings_result.scalar() or 0 - top_data.append({ - 'referrer_id': row.referrer_id, - 'invited_count': row.invited_count, - 'earnings_kopeks': earnings - }) + top_data.append( + {'referrer_id': row.referrer_id, 'invited_count': row.invited_count, 'earnings_kopeks': earnings} + ) else: # Топ по заработку за период # Собираем заработки из ReferralEarning referral_earnings_result = await db.execute( select( ReferralEarning.user_id.label('referrer_id'), - func.sum(ReferralEarning.amount_kopeks).label('ref_earnings') + func.sum(ReferralEarning.amount_kopeks).label('ref_earnings'), ) .where(ReferralEarning.created_at >= start_date) .group_by(ReferralEarning.user_id) @@ -362,14 +301,10 @@ async def get_top_referrers_by_period( # Добавляем транзакции REFERRAL_REWARD transaction_earnings_result = await db.execute( select( - Transaction.user_id.label('referrer_id'), - func.sum(Transaction.amount_kopeks).label('trans_earnings') + Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('trans_earnings') ) .where( - and_( - Transaction.type == TransactionType.REFERRAL_REWARD.value, - Transaction.created_at >= start_date - ) + and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= start_date) ) .group_by(Transaction.user_id) ) @@ -383,81 +318,70 @@ async def get_top_referrers_by_period( combined_earnings[row.referrer_id] = row.trans_earnings or 0 # Сортируем и берём топ - sorted_referrers = sorted( - combined_earnings.items(), - key=lambda x: x[1], - reverse=True - )[:limit] + sorted_referrers = sorted(combined_earnings.items(), key=lambda x: x[1], reverse=True)[:limit] top_data = [] for referrer_id, earnings in sorted_referrers: # Получаем количество приглашённых за период invited_result = await db.execute( - select(func.count(User.id)) - .where( - and_( - User.referred_by_id == referrer_id, - User.created_at >= start_date - ) + select(func.count(User.id)).where( + and_(User.referred_by_id == referrer_id, User.created_at >= start_date) ) ) invited_count = invited_result.scalar() or 0 - top_data.append({ - 'referrer_id': referrer_id, - 'invited_count': invited_count, - 'earnings_kopeks': earnings - }) + top_data.append({'referrer_id': referrer_id, 'invited_count': invited_count, 'earnings_kopeks': earnings}) # Добавляем информацию о пользователях result = [] for data in top_data: user_result = await db.execute( - select(User.id, User.username, User.first_name, User.last_name, User.telegram_id) - .where(User.id == data['referrer_id']) + select(User.id, User.username, User.first_name, User.last_name, User.telegram_id).where( + User.id == data['referrer_id'] + ) ) user = user_result.first() if user: - display_name = "" + display_name = '' if user.first_name: display_name = user.first_name if user.last_name: - display_name += f" {user.last_name}" + display_name += f' {user.last_name}' elif user.username: - display_name = f"@{user.username}" + display_name = f'@{user.username}' elif user.telegram_id: - display_name = f"ID{user.telegram_id}" + display_name = f'ID{user.telegram_id}' else: - display_name = user.email or f"#{user.id}" + display_name = user.email or f'#{user.id}' - result.append({ - 'user_id': user.id, - 'telegram_id': user.telegram_id, # Can be None for email users - 'username': user.username, - 'display_name': display_name, - 'invited_count': data['invited_count'], - 'earnings_kopeks': data['earnings_kopeks'] - }) + result.append( + { + 'user_id': user.id, + 'telegram_id': user.telegram_id, # Can be None for email users + 'username': user.username, + 'display_name': display_name, + 'invited_count': data['invited_count'], + 'earnings_kopeks': data['earnings_kopeks'], + } + ) return result async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict: - - invited_count_result = await db.execute( - select(func.count(User.id)).where(User.referred_by_id == user_id) - ) + invited_count_result = await db.execute(select(func.count(User.id)).where(User.referred_by_id == user_id)) invited_count = invited_count_result.scalar() - + total_earned = await get_referral_earnings_sum(db, user_id) - + month_ago = datetime.utcnow() - timedelta(days=30) month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) - + from app.database.models import Subscription, SubscriptionStatus + current_time = datetime.utcnow() - + active_referrals_result = await db.execute( select(func.count(User.id)) .join(Subscription, User.id == Subscription.user_id) @@ -465,15 +389,15 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict: and_( User.referred_by_id == user_id, Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.end_date > current_time + Subscription.end_date > current_time, ) ) ) active_referrals = active_referrals_result.scalar() - + return { - "invited_count": invited_count, - "active_referrals": active_referrals, - "total_earned_kopeks": total_earned, - "month_earned_kopeks": month_earned + 'invited_count': invited_count, + 'active_referrals': active_referrals, + 'total_earned_kopeks': total_earned, + 'month_earned_kopeks': month_earned, } diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py index 4790a979..d8742bad 100644 --- a/app/database/crud/referral_contest.py +++ b/app/database/crud/referral_contest.py @@ -1,19 +1,20 @@ import logging -from datetime import datetime, date, time, timezone -from typing import List, Optional, Sequence, Tuple +from collections.abc import Sequence +from datetime import date, datetime, time from sqlalchemy import and_, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload, aliased +from sqlalchemy.orm import selectinload from app.database.models import ( ReferralContest, ReferralContestEvent, - User, Transaction, TransactionType, + User, ) + logger = logging.getLogger(__name__) @@ -21,15 +22,15 @@ async def create_referral_contest( db: AsyncSession, *, title: str, - description: Optional[str], - prize_text: Optional[str], + description: str | None, + prize_text: str | None, contest_type: str, start_at: datetime, end_at: datetime, daily_summary_time: time, - daily_summary_times: Optional[str] = None, + daily_summary_times: str | None = None, timezone_name: str, - created_by: Optional[int] = None, + created_by: int | None = None, ) -> ReferralContest: contest = ReferralContest( title=title, @@ -40,7 +41,7 @@ async def create_referral_contest( end_at=end_at, daily_summary_time=daily_summary_time, daily_summary_times=daily_summary_times, - timezone=timezone_name or "UTC", + timezone=timezone_name or 'UTC', created_by=created_by, ) db.add(contest) @@ -54,8 +55,8 @@ async def list_referral_contests( *, limit: int = 10, offset: int = 0, - contest_type: Optional[str] = None, -) -> List[ReferralContest]: + contest_type: str | None = None, +) -> list[ReferralContest]: query = ( select(ReferralContest) .options(selectinload(ReferralContest.creator)) @@ -70,7 +71,7 @@ async def list_referral_contests( return list(result.scalars().all()) -async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int: +async def get_referral_contests_count(db: AsyncSession, contest_type: str | None = None) -> int: query = select(func.count(ReferralContest.id)) if contest_type: query = query.where(ReferralContest.contest_type == contest_type) @@ -78,7 +79,7 @@ async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[s return int(result.scalar_one()) -async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]: +async def get_referral_contest(db: AsyncSession, contest_id: int) -> ReferralContest | None: result = await db.execute( select(ReferralContest) .options( @@ -116,8 +117,8 @@ async def get_contests_for_events( db: AsyncSession, now_utc: datetime, *, - contest_types: Optional[List[str]] = None, -) -> List[ReferralContest]: + contest_types: list[str] | None = None, +) -> list[ReferralContest]: query = select(ReferralContest).where( and_( ReferralContest.is_active.is_(True), @@ -132,10 +133,8 @@ async def get_contests_for_events( return list(result.scalars().all()) -async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]: - result = await db.execute( - select(ReferralContest).where(ReferralContest.is_active.is_(True)) - ) +async def get_contests_for_summaries(db: AsyncSession) -> list[ReferralContest]: + result = await db.execute(select(ReferralContest).where(ReferralContest.is_active.is_(True))) return list(result.scalars().all()) @@ -146,8 +145,8 @@ async def add_contest_event( referrer_id: int, referral_id: int, amount_kopeks: int = 0, - event_type: str = "subscription_purchase", -) -> Optional[ReferralContestEvent]: + event_type: str = 'subscription_purchase', +) -> ReferralContestEvent | None: existing = await db.execute( select(ReferralContestEvent).where( and_( @@ -177,8 +176,8 @@ async def get_contest_leaderboard( db: AsyncSession, contest_id: int, *, - limit: Optional[int] = None, -) -> Sequence[Tuple[User, int, int]]: + limit: int | None = None, +) -> Sequence[tuple[User, int, int]]: """Получить лидерборд конкурса. Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. @@ -196,8 +195,8 @@ async def get_contest_leaderboard( query = ( select( User, - func.count(ReferralContestEvent.id).label("referral_count"), - func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"), + func.count(ReferralContestEvent.id).label('referral_count'), + func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label('total_amount'), ) .join(User, User.id == ReferralContestEvent.referrer_id) .where( @@ -208,7 +207,7 @@ async def get_contest_leaderboard( ) ) .group_by(User.id) - .order_by(desc("referral_count"), desc("total_amount"), User.id) + .order_by(desc('referral_count'), desc('total_amount'), User.id) ) if limit: query = query.limit(limit) @@ -221,7 +220,7 @@ async def get_contest_leaderboard( async def get_contest_participants( db: AsyncSession, contest_id: int, -) -> Sequence[Tuple[User, int]]: +) -> Sequence[tuple[User, int]]: """Получить участников конкурса. Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса. @@ -236,7 +235,7 @@ async def get_contest_participants( contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) result = await db.execute( - select(User, func.count(ReferralContestEvent.id).label("referral_count")) + select(User, func.count(ReferralContestEvent.id).label('referral_count')) .join(User, User.id == ReferralContestEvent.referrer_id) .where( and_( @@ -255,8 +254,8 @@ async def get_referrer_score( contest_id: int, referrer_id: int, *, - start: Optional[datetime] = None, - end: Optional[datetime] = None, + start: datetime | None = None, + end: datetime | None = None, ) -> int: query = select(func.count(ReferralContestEvent.id)).where( and_( @@ -277,12 +276,10 @@ async def get_contest_events_count( db: AsyncSession, contest_id: int, *, - start: Optional[datetime] = None, - end: Optional[datetime] = None, + start: datetime | None = None, + end: datetime | None = None, ) -> int: - query = select(func.count(ReferralContestEvent.id)).where( - ReferralContestEvent.contest_id == contest_id - ) + query = select(func.count(ReferralContestEvent.id)).where(ReferralContestEvent.contest_id == contest_id) if start: query = query.where(ReferralContestEvent.occurred_at >= start) if end: @@ -294,10 +291,8 @@ async def get_contest_events_count( async def get_contest_events( db: AsyncSession, contest_id: int, -) -> List[ReferralContestEvent]: - result = await db.execute( - select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id) - ) +) -> list[ReferralContestEvent]: + result = await db.execute(select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)) return list(result.scalars().all()) @@ -305,7 +300,7 @@ async def mark_daily_summary_sent( db: AsyncSession, contest: ReferralContest, summary_date: date, - summary_dt_utc: Optional[datetime] = None, + summary_dt_utc: datetime | None = None, ) -> ReferralContest: contest.last_daily_summary_date = summary_date if summary_dt_utc: @@ -352,7 +347,7 @@ async def get_contest_payment_stats( # Получаем даты конкурса для фильтрации contest = await get_referral_contest(db, contest_id) if not contest: - return {"paid_count": 0, "unpaid_count": 0, "total_amount": 0} + return {'paid_count': 0, 'unpaid_count': 0, 'total_amount': 0} contest_start = contest.start_at contest_end = contest.end_at @@ -361,8 +356,7 @@ async def get_contest_payment_stats( # Считаем рефералов с платежами (только зарегистрированных в период конкурса) paid_result = await db.execute( - select(func.count(ReferralContestEvent.id)) - .where( + select(func.count(ReferralContestEvent.id)).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.amount_kopeks > 0, @@ -375,8 +369,7 @@ async def get_contest_payment_stats( # Считаем рефералов без платежей (только зарегистрированных в период конкурса) unpaid_result = await db.execute( - select(func.count(ReferralContestEvent.id)) - .where( + select(func.count(ReferralContestEvent.id)).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.amount_kopeks == 0, @@ -389,8 +382,7 @@ async def get_contest_payment_stats( # Общая сумма (только за рефералов зарегистрированных в период конкурса) total_result = await db.execute( - select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0)).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.occurred_at >= contest_start, @@ -401,9 +393,9 @@ async def get_contest_payment_stats( total_amount = int(total_result.scalar_one() or 0) return { - "paid_count": paid_count, - "unpaid_count": unpaid_count, - "total_amount": total_amount, + 'paid_count': paid_count, + 'unpaid_count': unpaid_count, + 'total_amount': total_amount, } @@ -423,7 +415,7 @@ async def get_contest_transaction_breakdown( """ contest = await get_referral_contest(db, contest_id) if not contest: - return {"subscription_total": 0, "deposit_total": 0} + return {'subscription_total': 0, 'deposit_total': 0} contest_start = contest.start_at contest_end = contest.end_at @@ -432,8 +424,7 @@ async def get_contest_transaction_breakdown( # Получаем referral_id только из событий в период конкурса events_result = await db.execute( - select(ReferralContestEvent.referral_id) - .where( + select(ReferralContestEvent.referral_id).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.occurred_at >= contest_start, @@ -444,12 +435,11 @@ async def get_contest_transaction_breakdown( referral_ids = [r[0] for r in events_result.fetchall()] if not referral_ids: - return {"subscription_total": 0, "deposit_total": 0} + return {'subscription_total': 0, 'deposit_total': 0} # Сумма покупок подписок subscription_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), @@ -463,8 +453,7 @@ async def get_contest_transaction_breakdown( # Сумма пополнений баланса deposit_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), @@ -477,8 +466,8 @@ async def get_contest_transaction_breakdown( deposit_total = int(deposit_result.scalar_one() or 0) return { - "subscription_total": subscription_total, - "deposit_total": deposit_total, + 'subscription_total': subscription_total, + 'deposit_total': deposit_total, } @@ -489,8 +478,8 @@ async def upsert_contest_event( referrer_id: int, referral_id: int, amount_kopeks: int = 0, - event_type: str = "subscription_purchase", -) -> Tuple[ReferralContestEvent, bool]: + event_type: str = 'subscription_purchase', +) -> tuple[ReferralContestEvent, bool]: """Создать или обновить событие конкурса. Returns: @@ -540,7 +529,7 @@ async def debug_contest_transactions( """ contest = await get_referral_contest(db, contest_id) if not contest: - return {"error": "Contest not found"} + return {'error': 'Contest not found'} # Нормализуем границы дат contest_start = contest.start_at @@ -551,8 +540,7 @@ async def debug_contest_transactions( # Получаем referral_id ТОЛЬКО из событий которые произошли в период конкурса events_result = await db.execute( - select(ReferralContestEvent.referral_id) - .where( + select(ReferralContestEvent.referral_id).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.occurred_at >= contest_start, @@ -564,18 +552,17 @@ async def debug_contest_transactions( # Также считаем сколько всего событий для сравнения all_events_result = await db.execute( - select(func.count(ReferralContestEvent.id)) - .where(ReferralContestEvent.contest_id == contest_id) + select(func.count(ReferralContestEvent.id)).where(ReferralContestEvent.contest_id == contest_id) ) total_all_events = int(all_events_result.scalar_one() or 0) if not referral_ids: return { - "contest_start": contest_start.isoformat(), - "contest_end": contest_end.isoformat(), - "referral_count": 0, - "total_all_events": total_all_events, - "transactions": [], + 'contest_start': contest_start.isoformat(), + 'contest_end': contest_end.isoformat(), + 'referral_count': 0, + 'total_all_events': total_all_events, + 'transactions': [], } # Получаем транзакции этих рефералов ЗА период конкурса @@ -585,10 +572,12 @@ async def debug_contest_transactions( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), - Transaction.type.in_([ - TransactionType.DEPOSIT.value, - TransactionType.SUBSCRIPTION_PAYMENT.value, - ]), + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), Transaction.created_at >= contest_start, Transaction.created_at <= contest_end, ) @@ -605,10 +594,12 @@ async def debug_contest_transactions( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), - Transaction.type.in_([ - TransactionType.DEPOSIT.value, - TransactionType.SUBSCRIPTION_PAYMENT.value, - ]), + Transaction.type.in_( + [ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ] + ), func.not_( and_( Transaction.created_at >= contest_start, @@ -624,14 +615,15 @@ async def debug_contest_transactions( # Подсчёт общих сумм ПО ТИПАМ deposit_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value) - subscription_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value) + subscription_in_period = sum( + tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value + ) total_in_period = deposit_in_period + subscription_in_period total_outside = sum(tx.amount_kopeks for tx in txs_out) # Подсчёт ПОЛНЫХ сумм (не только sample) full_deposit_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), @@ -644,8 +636,7 @@ async def debug_contest_transactions( full_deposit_total = int(full_deposit_result.scalar_one() or 0) full_subscription_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.user_id.in_(referral_ids), Transaction.is_completed.is_(True), @@ -658,38 +649,38 @@ async def debug_contest_transactions( full_subscription_total = int(full_subscription_result.scalar_one() or 0) return { - "contest_start": contest_start.isoformat(), - "contest_end": contest_end.isoformat(), - "referral_count": len(referral_ids), - "total_all_events": total_all_events, - "filtered_out": total_all_events - len(referral_ids), - "transactions_in_period": [ + 'contest_start': contest_start.isoformat(), + 'contest_end': contest_end.isoformat(), + 'referral_count': len(referral_ids), + 'total_all_events': total_all_events, + 'filtered_out': total_all_events - len(referral_ids), + 'transactions_in_period': [ { - "id": tx.id, - "user_id": tx.user_id, - "type": tx.type, - "amount_kopeks": tx.amount_kopeks, - "created_at": tx.created_at.isoformat() if tx.created_at else None, - "payment_method": tx.payment_method, + 'id': tx.id, + 'user_id': tx.user_id, + 'type': tx.type, + 'amount_kopeks': tx.amount_kopeks, + 'created_at': tx.created_at.isoformat() if tx.created_at else None, + 'payment_method': tx.payment_method, } for tx in txs_in ], - "transactions_outside_period": [ + 'transactions_outside_period': [ { - "id": tx.id, - "user_id": tx.user_id, - "type": tx.type, - "amount_kopeks": tx.amount_kopeks, - "created_at": tx.created_at.isoformat() if tx.created_at else None, - "payment_method": tx.payment_method, + 'id': tx.id, + 'user_id': tx.user_id, + 'type': tx.type, + 'amount_kopeks': tx.amount_kopeks, + 'created_at': tx.created_at.isoformat() if tx.created_at else None, + 'payment_method': tx.payment_method, } for tx in txs_out ], - "total_in_period_kopeks": total_in_period, - "total_outside_period_kopeks": total_outside, - "deposit_total_kopeks": full_deposit_total, - "subscription_total_kopeks": full_subscription_total, - "sample_size": limit, + 'total_in_period_kopeks': total_in_period, + 'total_outside_period_kopeks': total_outside, + 'deposit_total_kopeks': full_deposit_total, + 'subscription_total_kopeks': full_subscription_total, + 'sample_size': limit, } @@ -714,7 +705,7 @@ async def sync_contest_events( """ contest = await get_referral_contest(db, contest_id) if not contest: - return {"error": "Contest not found"} + return {'error': 'Contest not found'} # Нормализуем границы дат для СТРОГОЙ фильтрации # start_at должен быть началом дня (00:00:00) @@ -728,27 +719,23 @@ async def sync_contest_events( # Конец дня: 23:59:59.999999 contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999) - logger.info( - "Синхронизация конкурса %s: период с %s по %s", - contest_id, contest_start, contest_end - ) + logger.info('Синхронизация конкурса %s: период с %s по %s', contest_id, contest_start, contest_end) stats = { - "updated": 0, - "skipped": 0, - "total_events": 0, - "total_amount": 0, - "paid_count": 0, - "unpaid_count": 0, - "contest_start": contest_start.isoformat(), - "contest_end": contest_end.isoformat(), + 'updated': 0, + 'skipped': 0, + 'total_events': 0, + 'total_amount': 0, + 'paid_count': 0, + 'unpaid_count': 0, + 'contest_start': contest_start.isoformat(), + 'contest_end': contest_end.isoformat(), } # Получаем события конкурса ТОЛЬКО те, что произошли в период конкурса # (реферал зарегистрировался в период проведения конкурса) events_result = await db.execute( - select(ReferralContestEvent) - .where( + select(ReferralContestEvent).where( and_( ReferralContestEvent.contest_id == contest_id, ReferralContestEvent.occurred_at >= contest_start, @@ -760,84 +747,79 @@ async def sync_contest_events( # Также считаем сколько всего событий (для отладки) all_events_result = await db.execute( - select(func.count(ReferralContestEvent.id)) - .where(ReferralContestEvent.contest_id == contest_id) + select(func.count(ReferralContestEvent.id)).where(ReferralContestEvent.contest_id == contest_id) ) total_all_events = int(all_events_result.scalar_one() or 0) - stats["total_events"] = len(events) - stats["total_all_events"] = total_all_events - stats["filtered_out_events"] = total_all_events - len(events) + stats['total_events'] = len(events) + stats['total_all_events'] = total_all_events + stats['filtered_out_events'] = total_all_events - len(events) - stats["deposit_total"] = 0 - stats["subscription_total"] = 0 + stats['deposit_total'] = 0 + stats['subscription_total'] = 0 for event in events: # Считаем ТОЛЬКО покупки подписок (реальные траты на подписки) - subscription_query = ( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.user_id == event.referral_id, - Transaction.is_completed.is_(True), - Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, - Transaction.created_at >= contest_start, - Transaction.created_at <= contest_end, - ) + subscription_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.user_id == event.referral_id, + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, ) ) sub_result = await db.execute(subscription_query) subscription_paid = int(sub_result.scalar_one() or 0) # Также считаем пополнения баланса (для информации) - deposit_query = ( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.user_id == event.referral_id, - Transaction.is_completed.is_(True), - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.created_at >= contest_start, - Transaction.created_at <= contest_end, - ) + deposit_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.user_id == event.referral_id, + Transaction.is_completed.is_(True), + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.created_at >= contest_start, + Transaction.created_at <= contest_end, ) ) dep_result = await db.execute(deposit_query) deposit_paid = int(dep_result.scalar_one() or 0) - stats["subscription_total"] += subscription_paid - stats["deposit_total"] += deposit_paid + stats['subscription_total'] += subscription_paid + stats['deposit_total'] += deposit_paid # Основная метрика — покупки подписок total_paid = subscription_paid # Считаем статистику if total_paid > 0: - stats["total_amount"] += total_paid - stats["paid_count"] += 1 + stats['total_amount'] += total_paid + stats['paid_count'] += 1 else: - stats["unpaid_count"] += 1 + stats['unpaid_count'] += 1 # Обновляем сумму если изменилась if event.amount_kopeks != total_paid: old_amount = event.amount_kopeks event.amount_kopeks = total_paid - stats["updated"] += 1 + stats['updated'] += 1 # Логируем значительные изменения if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы logger.debug( - "Событие %s (реферал %s): %s -> %s коп.", - event.id, event.referral_id, old_amount, total_paid + 'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid ) else: - stats["skipped"] += 1 + stats['skipped'] += 1 # Сохраняем изменения await db.commit() logger.info( - "Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.", - contest_id, stats["updated"], stats["skipped"], stats["total_amount"] + 'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.', + contest_id, + stats['updated'], + stats['skipped'], + stats['total_amount'], ) return stats diff --git a/app/database/crud/rules.py b/app/database/crud/rules.py index 43de490d..a98a1b13 100644 --- a/app/database/crud/rules.py +++ b/app/database/crud/rules.py @@ -1,21 +1,19 @@ import logging -from typing import Optional -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + from app.database.models import ServiceRule + logger = logging.getLogger(__name__) -async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]: +async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None: result = await db.execute( select(ServiceRule) - .where( - ServiceRule.language == language, - ServiceRule.is_active == True - ) + .where(ServiceRule.language == language, ServiceRule.is_active == True) .order_by(ServiceRule.order, ServiceRule.created_at.desc()) .limit(1) ) @@ -23,74 +21,54 @@ async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optio async def create_or_update_rules( - db: AsyncSession, - content: str, - language: str = "ru", - title: str = "Правила сервиса" + db: AsyncSession, content: str, language: str = 'ru', title: str = 'Правила сервиса' ) -> ServiceRule: - existing_rules_result = await db.execute( - select(ServiceRule).where( - ServiceRule.language == language, - ServiceRule.is_active == True - ) + select(ServiceRule).where(ServiceRule.language == language, ServiceRule.is_active == True) ) existing_rules = existing_rules_result.scalars().all() - + for rule in existing_rules: rule.is_active = False rule.updated_at = datetime.utcnow() - - new_rules = ServiceRule( - title=title, - content=content, - language=language, - is_active=True, - order=0 - ) - + + new_rules = ServiceRule(title=title, content=content, language=language, is_active=True, order=0) + db.add(new_rules) await db.commit() await db.refresh(new_rules) - - logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})") + + logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})') return new_rules -async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool: +async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool: try: result = await db.execute( update(ServiceRule) - .where( - ServiceRule.language == language, - ServiceRule.is_active == True - ) - .values( - is_active=False, - updated_at=datetime.utcnow() - ) + .where(ServiceRule.language == language, ServiceRule.is_active == True) + .values(is_active=False, updated_at=datetime.utcnow()) ) - + await db.commit() - + rows_affected = result.rowcount - logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}") - + logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}') + return rows_affected > 0 - + except Exception as e: - logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}") + logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}') await db.rollback() raise -async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str: +async def get_current_rules_content(db: AsyncSession, language: str = 'ru') -> str: rules = await get_rules_by_language(db, language) - + if rules: return rules.content - else: - return """ + return """ 🔒 Правила использования сервиса 1. Сервис предоставляется "как есть" без каких-либо гарантий. @@ -109,110 +87,74 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s """ -async def get_all_rules_versions( - db: AsyncSession, - language: str = "ru", - limit: int = 10 -) -> list[ServiceRule]: +async def get_all_rules_versions(db: AsyncSession, language: str = 'ru', limit: int = 10) -> list[ServiceRule]: result = await db.execute( - select(ServiceRule) - .where(ServiceRule.language == language) - .order_by(ServiceRule.created_at.desc()) - .limit(limit) + select(ServiceRule).where(ServiceRule.language == language).order_by(ServiceRule.created_at.desc()).limit(limit) ) return result.scalars().all() -async def restore_rules_version( - db: AsyncSession, - rule_id: int, - language: str = "ru" -) -> Optional[ServiceRule]: +async def restore_rules_version(db: AsyncSession, rule_id: int, language: str = 'ru') -> ServiceRule | None: try: result = await db.execute( - select(ServiceRule).where( - ServiceRule.id == rule_id, - ServiceRule.language == language - ) + select(ServiceRule).where(ServiceRule.id == rule_id, ServiceRule.language == language) ) rule_to_restore = result.scalar_one_or_none() - + if not rule_to_restore: - logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}") + logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}') return None - + await db.execute( update(ServiceRule) - .where( - ServiceRule.language == language, - ServiceRule.is_active == True - ) - .values( - is_active=False, - updated_at=datetime.utcnow() - ) + .where(ServiceRule.language == language, ServiceRule.is_active == True) + .values(is_active=False, updated_at=datetime.utcnow()) ) - + restored_rule = ServiceRule( - title=rule_to_restore.title, - content=rule_to_restore.content, - language=language, - is_active=True, - order=0 + title=rule_to_restore.title, content=rule_to_restore.content, language=language, is_active=True, order=0 ) - + db.add(restored_rule) await db.commit() await db.refresh(restored_rule) - - logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}") + + logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}') return restored_rule - + except Exception as e: - logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}") + logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}') await db.rollback() raise async def get_rules_statistics(db: AsyncSession) -> dict: try: - active_result = await db.execute( - select(ServiceRule).where(ServiceRule.is_active == True) - ) + active_result = await db.execute(select(ServiceRule).where(ServiceRule.is_active == True)) active_rules = active_result.scalars().all() - + all_result = await db.execute(select(ServiceRule)) all_rules = all_result.scalars().all() - + languages_stats = {} for rule in active_rules: lang = rule.language if lang not in languages_stats: - languages_stats[lang] = { - 'active_count': 0, - 'last_updated': None, - 'content_length': 0 - } - + languages_stats[lang] = {'active_count': 0, 'last_updated': None, 'content_length': 0} + languages_stats[lang]['active_count'] += 1 languages_stats[lang]['content_length'] = len(rule.content) - + if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']: languages_stats[lang]['last_updated'] = rule.updated_at - + return { 'total_active': len(active_rules), 'total_all_time': len(all_rules), 'languages': languages_stats, - 'total_languages': len(languages_stats) + 'total_languages': len(languages_stats), } - + except Exception as e: - logger.error(f"❌ Ошибка при получении статистики правил: {e}") - return { - 'total_active': 0, - 'total_all_time': 0, - 'languages': {}, - 'total_languages': 0, - 'error': str(e) - } + logger.error(f'❌ Ошибка при получении статистики правил: {e}') + return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)} diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index 538b8d48..caedf03f 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -1,18 +1,18 @@ import logging import random +from collections.abc import Iterable, Sequence from datetime import datetime -from typing import Iterable, List, Optional, Sequence, Tuple from sqlalchemy import ( - select, - and_, - func, - update, - delete, - text, - or_, - cast, String, + and_, + cast, + delete, + func, + or_, + select, + text, + update, ) from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -20,19 +20,18 @@ from sqlalchemy.orm import selectinload from app.database.models import ( PromoGroup, ServerSquad, - SubscriptionServer, Subscription, + SubscriptionServer, SubscriptionStatus, User, ) + logger = logging.getLogger(__name__) -async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]: - result = await db.execute( - select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1) - ) +async def _get_default_promo_group_id(db: AsyncSession) -> int | None: + result = await db.execute(select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)) return result.scalar_one_or_none() @@ -48,9 +47,8 @@ async def create_server_squad( is_available: bool = True, is_trial_eligible: bool = False, sort_order: int = 0, - promo_group_ids: Optional[Iterable[int]] = None, + promo_group_ids: Iterable[int] | None = None, ) -> ServerSquad: - normalized_group_ids: Sequence[int] if promo_group_ids is None: default_id = await _get_default_promo_group_id(db) @@ -59,17 +57,13 @@ async def create_server_squad( normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)] if not normalized_group_ids: - raise ValueError("Server squad must be linked to at least one promo group") + raise ValueError('Server squad must be linked to at least one promo group') - promo_groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids)) - ) + promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))) promo_groups = promo_groups_result.scalars().all() if len(promo_groups) != len(normalized_group_ids): - logger.warning( - "Не все промогруппы найдены при создании сервера %s", display_name - ) + logger.warning('Не все промогруппы найдены при создании сервера %s', display_name) server_squad = ServerSquad( squad_uuid=squad_uuid, @@ -88,16 +82,12 @@ async def create_server_squad( db.add(server_squad) await db.commit() await db.refresh(server_squad) - - logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})") + + logger.info(f'✅ Создан сервер {display_name} (UUID: {squad_uuid})') return server_squad -async def get_server_squad_by_uuid( - db: AsyncSession, - squad_uuid: str -) -> Optional[ServerSquad]: - +async def get_server_squad_by_uuid(db: AsyncSession, squad_uuid: str) -> ServerSquad | None: result = await db.execute( select(ServerSquad) .options(selectinload(ServerSquad.allowed_promo_groups)) @@ -106,54 +96,43 @@ async def get_server_squad_by_uuid( return result.scalars().unique().one_or_none() -async def get_server_squad_by_id( - db: AsyncSession, - server_id: int -) -> Optional[ServerSquad]: - +async def get_server_squad_by_id(db: AsyncSession, server_id: int) -> ServerSquad | None: result = await db.execute( - select(ServerSquad) - .options(selectinload(ServerSquad.allowed_promo_groups)) - .where(ServerSquad.id == server_id) + select(ServerSquad).options(selectinload(ServerSquad.allowed_promo_groups)).where(ServerSquad.id == server_id) ) return result.scalars().unique().one_or_none() async def get_all_server_squads( - db: AsyncSession, - available_only: bool = False, - page: int = 1, - limit: int = 50 -) -> Tuple[List[ServerSquad], int]: - + db: AsyncSession, available_only: bool = False, page: int = 1, limit: int = 50 +) -> tuple[list[ServerSquad], int]: query = select(ServerSquad) - + if available_only: query = query.where(ServerSquad.is_available == True) - + count_query = select(func.count(ServerSquad.id)) if available_only: count_query = count_query.where(ServerSquad.is_available == True) - + count_result = await db.execute(count_query) total_count = count_result.scalar() - + offset = (page - 1) * limit query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name) query = query.offset(offset).limit(limit) - + result = await db.execute(query) servers = result.scalars().all() - + return servers, total_count async def get_available_server_squads( db: AsyncSession, - promo_group_id: Optional[int] = None, + promo_group_id: int | None = None, exclude_trial_only: bool = False, -) -> List[ServerSquad]: - +) -> list[ServerSquad]: query = ( select(ServerSquad) .options(selectinload(ServerSquad.allowed_promo_groups)) @@ -165,15 +144,13 @@ async def get_available_server_squads( query = query.where(ServerSquad.is_trial_eligible.is_(False)) if promo_group_id is not None: - query = query.join(ServerSquad.allowed_promo_groups).where( - PromoGroup.id == promo_group_id - ) + query = query.join(ServerSquad.allowed_promo_groups).where(PromoGroup.id == promo_group_id) result = await db.execute(query) return result.scalars().unique().all() -async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]: +async def get_active_server_squads(db: AsyncSession) -> list[ServerSquad]: """Возвращает список активных серверов, доступных для подключения.""" squads = await get_available_server_squads(db) @@ -181,7 +158,7 @@ async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]: if not squads: return [] - eligible: List[ServerSquad] = [] + eligible: list[ServerSquad] = [] for squad in squads: max_users = squad.max_users @@ -200,7 +177,7 @@ async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]: async def choose_random_active_server_squad( db: AsyncSession, -) -> Optional[ServerSquad]: +) -> ServerSquad | None: """Возвращает случайный активный сервер.""" squads = await get_active_server_squads(db) @@ -213,8 +190,8 @@ async def choose_random_active_server_squad( async def get_random_active_squad_uuid( db: AsyncSession, - fallback_uuid: Optional[str] = None, -) -> Optional[str]: + fallback_uuid: str | None = None, +) -> str | None: """Возвращает UUID случайного активного сервера или запасной UUID.""" squad = await choose_random_active_server_squad(db) @@ -227,113 +204,94 @@ async def get_random_active_squad_uuid( async def update_server_squad_promo_groups( db: AsyncSession, server_id: int, promo_group_ids: Iterable[int] -) -> Optional[ServerSquad]: +) -> ServerSquad | None: unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)] if not unique_ids: - raise ValueError("Нужно выбрать хотя бы одну промогруппу") + raise ValueError('Нужно выбрать хотя бы одну промогруппу') server = await get_server_squad_by_id(db, server_id) if not server: return None - result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(unique_ids)) - ) + result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(unique_ids))) promo_groups = result.scalars().all() if not promo_groups: - raise ValueError("Не найдены промогруппы для обновления сервера") + raise ValueError('Не найдены промогруппы для обновления сервера') server.allowed_promo_groups = promo_groups await db.commit() await db.refresh(server) logger.info( - "Обновлены промогруппы сервера %s (ID: %s): %s", + 'Обновлены промогруппы сервера %s (ID: %s): %s', server.display_name, server.id, - ", ".join(pg.name for pg in promo_groups), + ', '.join(pg.name for pg in promo_groups), ) return server -async def update_server_squad( - db: AsyncSession, - server_id: int, - **updates -) -> Optional[ServerSquad]: - +async def update_server_squad(db: AsyncSession, server_id: int, **updates) -> ServerSquad | None: valid_fields = { - "display_name", - "original_name", - "country_code", - "price_kopeks", - "description", - "max_users", - "is_available", - "sort_order", - "is_trial_eligible", + 'display_name', + 'original_name', + 'country_code', + 'price_kopeks', + 'description', + 'max_users', + 'is_available', + 'sort_order', + 'is_trial_eligible', } - + filtered_updates = {k: v for k, v in updates.items() if k in valid_fields} - + if not filtered_updates: return None - - await db.execute( - update(ServerSquad) - .where(ServerSquad.id == server_id) - .values(**filtered_updates) - ) - + + await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(**filtered_updates)) + await db.commit() - + return await get_server_squad_by_id(db, server_id) async def delete_server_squad(db: AsyncSession, server_id: int) -> bool: - connections_result = await db.execute( - select(func.count(SubscriptionServer.id)) - .where(SubscriptionServer.server_squad_id == server_id) + select(func.count(SubscriptionServer.id)).where(SubscriptionServer.server_squad_id == server_id) ) connections_count = connections_result.scalar() - + if connections_count > 0: - logger.warning(f"⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})") + logger.warning(f'⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})') return False - - await db.execute( - delete(ServerSquad).where(ServerSquad.id == server_id) - ) + + await db.execute(delete(ServerSquad).where(ServerSquad.id == server_id)) await db.commit() - - logger.info(f"🗑️ Удален сервер (ID: {server_id})") + + logger.info(f'🗑️ Удален сервер (ID: {server_id})') return True -async def sync_with_remnawave( - db: AsyncSession, - remnawave_squads: List[dict] -) -> Tuple[int, int, int]: - +async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) -> tuple[int, int, int]: created = 0 updated = 0 removed = 0 - + existing_servers = {} result = await db.execute(select(ServerSquad)) for server in result.scalars().all(): existing_servers[server.squad_uuid] = server - + remnawave_uuids = {squad['uuid'] for squad in remnawave_squads} - + for squad in remnawave_squads: squad_uuid = squad['uuid'] original_name = squad.get('name', f'Squad {squad_uuid[:8]}') - + if squad_uuid in existing_servers: server = existing_servers[squad_uuid] if server.original_name != original_name: @@ -346,43 +304,35 @@ async def sync_with_remnawave( display_name=_generate_display_name(original_name), original_name=original_name, country_code=_extract_country_code(original_name), - price_kopeks=1000, - is_available=False + price_kopeks=1000, + is_available=False, ) created += 1 - - removed_servers = [ - server for uuid, server in existing_servers.items() - if uuid not in remnawave_uuids - ] + + 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)) + 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)", + '🗑️ Удаляется сервер %s (UUID: %s)', server.display_name, server.squad_uuid, ) - await db.execute( - delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids)) - ) + 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)) - ) + 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 @@ -391,10 +341,8 @@ async def sync_with_remnawave( continue extra_result = await db.execute( - select(Subscription).where( - text("connected_squads::text LIKE :uuid_pattern") - ), - {"uuid_pattern": f'%"{squad_uuid}"%'} + select(Subscription).where(text('connected_squads::text LIKE :uuid_pattern')), + {'uuid_pattern': f'%"{squad_uuid}"%'}, ) for subscription in extra_result.scalars().unique().all(): @@ -407,9 +355,7 @@ async def sync_with_remnawave( if not current_squads: continue - filtered_squads = [ - squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids - ] + 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 @@ -421,34 +367,24 @@ async def sync_with_remnawave( if cleaned_subscriptions: logger.info( - "🧹 Обновлены подписки после удаления серверов: %s", + '🧹 Обновлены подписки после удаления серверов: %s', cleaned_subscriptions, ) await db.commit() - logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}") + 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) - ) +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}"%' - ) - ) + connection_filters.append(cast(Subscription.connected_squads, String).like(f'%"{server_uuid}"%')) result = await db.execute( select(User) @@ -471,8 +407,7 @@ async def get_server_connected_users( async def get_trial_eligible_server_squads( db: AsyncSession, include_unavailable: bool = False, -) -> List[ServerSquad]: - +) -> list[ServerSquad]: query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True)) result = await db.execute(query) @@ -481,8 +416,8 @@ async def get_trial_eligible_server_squads( if include_unavailable: return squads - preferred_squads: List[ServerSquad] = [] - fallback_squads: List[ServerSquad] = [] + preferred_squads: list[ServerSquad] = [] + fallback_squads: list[ServerSquad] = [] for squad in squads: current_users = squad.current_users or 0 @@ -507,8 +442,7 @@ async def get_trial_eligible_server_squads( async def choose_random_trial_server_squad( db: AsyncSession, -) -> Optional[ServerSquad]: - +) -> ServerSquad | None: squads = await get_trial_eligible_server_squads(db) if not squads: @@ -519,8 +453,7 @@ async def choose_random_trial_server_squad( async def get_random_trial_squad_uuid( db: AsyncSession, -) -> Optional[str]: - +) -> str | None: squad = await choose_random_trial_server_squad(db) if squad: @@ -567,7 +500,6 @@ def _generate_display_name(original_name: str) -> str: 'MD': '🇲🇩 Молдова', 'BY': '🇧🇾 Беларусь', 'LU': '🇱🇺 Люксембург', - # СНГ и Азия 'RU': '🇷🇺 Россия', 'KZ': '🇰🇿 Казахстан', @@ -575,7 +507,6 @@ def _generate_display_name(original_name: str) -> str: 'GE': '🇬🇪 Грузия', 'AM': '🇦🇲 Армения', 'AZ': '🇦🇿 Азербайджан', - # Америка 'US': '🇺🇸 США', 'CA': '🇨🇦 Канада', @@ -584,7 +515,6 @@ def _generate_display_name(original_name: str) -> str: 'AR': '🇦🇷 Аргентина', 'CL': '🇨🇱 Чили', 'CO': '🇨🇴 Колумбия', - # Азия 'JP': '🇯🇵 Япония', 'KR': '🇰🇷 Южная Корея', @@ -599,7 +529,6 @@ def _generate_display_name(original_name: str) -> str: 'PH': '🇵🇭 Филиппины', 'IN': '🇮🇳 Индия', 'PK': '🇵🇰 Пакистан', - # Ближний Восток 'IL': '🇮🇱 Израиль', 'TR': '🇹🇷 Турция', @@ -608,11 +537,9 @@ def _generate_display_name(original_name: str) -> str: 'QA': '🇶🇦 Катар', 'BH': '🇧🇭 Бахрейн', 'KW': '🇰🇼 Кувейт', - # Океания 'AU': '🇦🇺 Австралия', 'NZ': '🇳🇿 Новая Зеландия', - # Африка 'ZA': '🇿🇦 ЮАР', 'EG': '🇪🇬 Египет', @@ -638,30 +565,93 @@ def _generate_display_name(original_name: str) -> str: if code in name_upper: return display_name - return f"🌍 {original_name}" + return f'🌍 {original_name}' -def _extract_country_code(original_name: str) -> Optional[str]: +def _extract_country_code(original_name: str) -> str | None: """Извлекает код страны из оригинального названия.""" # Полный список кодов стран codes = [ # Европа - 'NL', 'DE', 'FR', 'GB', 'UK', 'IT', 'ES', 'PT', 'PL', 'CZ', 'AT', 'CH', - 'SE', 'NO', 'FI', 'DK', 'BE', 'IE', 'RO', 'BG', 'HU', 'GR', 'LV', 'LT', - 'EE', 'SK', 'SI', 'HR', 'RS', 'UA', 'MD', 'BY', 'LU', + 'NL', + 'DE', + 'FR', + 'GB', + 'UK', + 'IT', + 'ES', + 'PT', + 'PL', + 'CZ', + 'AT', + 'CH', + 'SE', + 'NO', + 'FI', + 'DK', + 'BE', + 'IE', + 'RO', + 'BG', + 'HU', + 'GR', + 'LV', + 'LT', + 'EE', + 'SK', + 'SI', + 'HR', + 'RS', + 'UA', + 'MD', + 'BY', + 'LU', # СНГ - 'RU', 'KZ', 'UZ', 'GE', 'AM', 'AZ', + 'RU', + 'KZ', + 'UZ', + 'GE', + 'AM', + 'AZ', # Америка - 'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', + 'US', + 'CA', + 'MX', + 'BR', + 'AR', + 'CL', + 'CO', # Азия - 'JP', 'KR', 'CN', 'HK', 'TW', 'SG', 'TH', 'VN', 'MY', 'ID', 'PH', 'IN', 'PK', + 'JP', + 'KR', + 'CN', + 'HK', + 'TW', + 'SG', + 'TH', + 'VN', + 'MY', + 'ID', + 'PH', + 'IN', + 'PK', # Ближний Восток - 'IL', 'TR', 'AE', 'SA', 'QA', 'BH', 'KW', + 'IL', + 'TR', + 'AE', + 'SA', + 'QA', + 'BH', + 'KW', # Океания - 'AU', 'NZ', + 'AU', + 'NZ', # Африка - 'ZA', 'EG', 'NG', 'KE', + 'ZA', + 'EG', + 'NG', + 'KE', ] name_upper = original_name.upper() @@ -686,46 +676,40 @@ def _extract_country_code(original_name: str) -> Optional[str]: async def get_server_statistics(db: AsyncSession) -> dict: - total_result = await db.execute(select(func.count(ServerSquad.id))) total_servers = total_result.scalar() - available_result = await db.execute( - select(func.count(ServerSquad.id)) - .where(ServerSquad.is_available == True) - ) + available_result = await db.execute(select(func.count(ServerSquad.id)).where(ServerSquad.is_available == True)) available_servers = available_result.scalar() - + servers_with_connections = 0 all_servers_result = await db.execute(select(ServerSquad.squad_uuid)) all_server_uuids = [row[0] for row in all_servers_result.fetchall()] - + for squad_uuid in all_server_uuids: count_result = await db.execute( text(""" - SELECT COUNT(s.id) - FROM subscriptions s - WHERE s.status IN ('active', 'trial') + SELECT COUNT(s.id) + FROM subscriptions s + WHERE s.status IN ('active', 'trial') AND s.connected_squads::text LIKE :uuid_pattern """), - {"uuid_pattern": f'%"{squad_uuid}"%'} + {'uuid_pattern': f'%"{squad_uuid}"%'}, ) user_count = count_result.scalar() or 0 if user_count > 0: servers_with_connections += 1 - - revenue_result = await db.execute( - select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0)) - ) + + revenue_result = await db.execute(select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))) total_revenue_kopeks = revenue_result.scalar() - + return { 'total_servers': total_servers, 'available_servers': available_servers, 'unavailable_servers': total_servers - available_servers, 'servers_with_connections': servers_with_connections, 'total_revenue_kopeks': total_revenue_kopeks, - 'total_revenue_rubles': total_revenue_kopeks / 100 + 'total_revenue_rubles': total_revenue_kopeks / 100, } @@ -747,11 +731,7 @@ async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int return result.scalar() or 0 -async def add_user_to_servers( - db: AsyncSession, - server_squad_ids: List[int] -) -> bool: - +async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool: try: for server_id in server_squad_ids: await db.execute( @@ -759,22 +739,18 @@ async def add_user_to_servers( .where(ServerSquad.id == server_id) .values(current_users=ServerSquad.current_users + 1) ) - + await db.commit() - logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}") + logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}') return True - + except Exception as e: - logger.error(f"Ошибка увеличения счетчика пользователей: {e}") + logger.error(f'Ошибка увеличения счетчика пользователей: {e}') await db.rollback() return False -async def remove_user_from_servers( - db: AsyncSession, - server_squad_ids: List[int] -) -> bool: - +async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool: try: for server_id in server_squad_ids: await db.execute( @@ -782,33 +758,23 @@ async def remove_user_from_servers( .where(ServerSquad.id == server_id) .values(current_users=func.greatest(ServerSquad.current_users - 1, 0)) ) - + await db.commit() - logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}") + logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}') return True - + except Exception as e: - logger.error(f"Ошибка уменьшения счетчика пользователей: {e}") + logger.error(f'Ошибка уменьшения счетчика пользователей: {e}') await db.rollback() return False -async def get_server_ids_by_uuids( - db: AsyncSession, - squad_uuids: List[str] -) -> List[int]: - - result = await db.execute( - select(ServerSquad.id) - .where(ServerSquad.squad_uuid.in_(squad_uuids)) - ) +async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]: + result = await db.execute(select(ServerSquad.id).where(ServerSquad.squad_uuid.in_(squad_uuids))) return [row[0] for row in result.fetchall()] -async def get_server_squads_by_uuids( - db: AsyncSession, - squad_uuids: List[str] -) -> List[ServerSquad]: +async def get_server_squads_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[ServerSquad]: """Получает список ServerSquad объектов по их UUID с загрузкой allowed_promo_groups.""" if not squad_uuids: return [] @@ -833,72 +799,67 @@ async def ensure_servers_synced(db: AsyncSession) -> None: server_count = result.scalar() or 0 if server_count > 0: - logger.info(f"✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию") + logger.info(f'✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию') return - logger.info("🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...") + logger.info('🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...') # Импортируем сервис здесь чтобы избежать циклических импортов from app.services.subscription_service import SubscriptionService subscription_service = SubscriptionService() if not subscription_service.is_configured: - logger.warning("⚠️ RemnaWave не настроен, серверы не синхронизированы") + logger.warning('⚠️ RemnaWave не настроен, серверы не синхронизированы') return # Получаем скводы из RemnaWave squads = await subscription_service.get_remnawave_squads() if squads is None: - logger.error("❌ Не удалось получить список серверов из RemnaWave") + logger.error('❌ Не удалось получить список серверов из RemnaWave') return if not squads: - logger.warning("⚠️ RemnaWave вернул пустой список серверов") + logger.warning('⚠️ RemnaWave вернул пустой список серверов') return # Синхронизируем created, updated, removed = await sync_with_remnawave(db, squads) - logger.info(f"✅ Серверы синхронизированы: +{created} ~{updated} -{removed}") + logger.info(f'✅ Серверы синхронизированы: +{created} ~{updated} -{removed}') except Exception as e: - logger.error(f"❌ Ошибка синхронизации серверов: {e}") + logger.error(f'❌ Ошибка синхронизации серверов: {e}') async def sync_server_user_counts(db: AsyncSession) -> int: - try: all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid)) all_servers = all_servers_result.fetchall() - - logger.info(f"🔍 Найдено серверов для синхронизации: {len(all_servers)}") - + + logger.info(f'🔍 Найдено серверов для синхронизации: {len(all_servers)}') + updated_count = 0 for server_id, squad_uuid in all_servers: count_result = await db.execute( text(""" - SELECT COUNT(s.id) - FROM subscriptions s - WHERE s.status IN ('active', 'trial') + SELECT COUNT(s.id) + FROM subscriptions s + WHERE s.status IN ('active', 'trial') AND s.connected_squads::text LIKE :uuid_pattern """), - {"uuid_pattern": f'%"{squad_uuid}"%'} + {'uuid_pattern': f'%"{squad_uuid}"%'}, ) actual_users = count_result.scalar() or 0 - - logger.info(f"📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей") - - await db.execute( - update(ServerSquad) - .where(ServerSquad.id == server_id) - .values(current_users=actual_users) - ) + + logger.info(f'📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей') + + await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(current_users=actual_users)) updated_count += 1 - + await db.commit() - logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов") + logger.info(f'✅ Синхронизированы счетчики для {updated_count} серверов') return updated_count - + except Exception as e: - logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}") + logger.error(f'Ошибка синхронизации счетчиков пользователей: {e}') await db.rollback() return 0 diff --git a/app/database/crud/squad.py b/app/database/crud/squad.py index 9ab0e93f..4ac97e75 100644 --- a/app/database/crud/squad.py +++ b/app/database/crud/squad.py @@ -1,61 +1,43 @@ import logging -from typing import Optional, List -from sqlalchemy import select, and_ + +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Squad + logger = logging.getLogger(__name__) -async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]: - result = await db.execute( - select(Squad).where(Squad.uuid == uuid) - ) +async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Squad | None: + result = await db.execute(select(Squad).where(Squad.uuid == uuid)) return result.scalar_one_or_none() -async def get_available_squads(db: AsyncSession) -> List[Squad]: - result = await db.execute( - select(Squad).where(Squad.is_available == True) - ) +async def get_available_squads(db: AsyncSession) -> list[Squad]: + result = await db.execute(select(Squad).where(Squad.is_available == True)) return result.scalars().all() async def create_squad( - db: AsyncSession, - uuid: str, - name: str, - country_code: str = None, - price_kopeks: int = 0, - description: str = None + db: AsyncSession, uuid: str, name: str, country_code: str = None, price_kopeks: int = 0, description: str = None ) -> Squad: - squad = Squad( - uuid=uuid, - name=name, - country_code=country_code, - price_kopeks=price_kopeks, - description=description - ) - + squad = Squad(uuid=uuid, name=name, country_code=country_code, price_kopeks=price_kopeks, description=description) + db.add(squad) await db.commit() await db.refresh(squad) - - logger.info(f"✅ Создан сквад: {name}") + + logger.info(f'✅ Создан сквад: {name}') return squad -async def update_squad( - db: AsyncSession, - squad: Squad, - **kwargs -) -> Squad: +async def update_squad(db: AsyncSession, squad: Squad, **kwargs) -> Squad: for field, value in kwargs.items(): if hasattr(squad, field): setattr(squad, field, value) - + await db.commit() await db.refresh(squad) - - return squad \ No newline at end of file + + return squad diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index f0388234..f920f82d 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -1,27 +1,30 @@ import logging +from collections.abc import Iterable from datetime import datetime, timedelta -from typing import Iterable, Optional, List, Tuple -from sqlalchemy import select, and_, func, delete +from typing import Optional + +from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.config import settings +from app.database.crud.notification import clear_notifications from app.database.models import ( + PromoGroup, Subscription, + SubscriptionServer, SubscriptionStatus, User, - SubscriptionServer, - PromoGroup, UserPromoGroup, ) -from app.database.crud.notification import clear_notifications from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months -from app.config import settings from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) -async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]: +async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None: result = await db.execute( select(Subscription) .options( @@ -33,11 +36,13 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona .limit(1) ) subscription = result.scalar_one_or_none() - + if subscription: - logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}") + logger.info( + f'🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}' + ) subscription = await check_and_update_subscription_status(db, subscription) - + return subscription @@ -46,10 +51,10 @@ async def create_trial_subscription( user_id: int, duration_days: int = None, traffic_limit_gb: int = None, - device_limit: Optional[int] = None, + device_limit: int | None = None, squad_uuid: str = None, - connected_squads: List[str] = None, - tariff_id: Optional[int] = None, + connected_squads: list[str] = None, + tariff_id: int | None = None, ) -> Subscription: """Создает триальную подписку. @@ -77,13 +82,13 @@ async def create_trial_subscription( if random_squad: final_squads = [random_squad] logger.debug( - "Выбран сквад %s для триальной подписки пользователя %s", + 'Выбран сквад %s для триальной подписки пользователя %s', random_squad, user_id, ) except Exception as error: logger.error( - "Не удалось получить сквад для триальной подписки пользователя %s: %s", + 'Не удалось получить сквад для триальной подписки пользователя %s: %s', user_id, error, ) @@ -103,36 +108,37 @@ async def create_trial_subscription( autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, ) - + db.add(subscription) await db.commit() await db.refresh(subscription) - logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}" + - (f" с тарифом {tariff_id}" if tariff_id else "")) + logger.info( + f'🎁 Создана триальная подписка для пользователя {user_id}' + (f' с тарифом {tariff_id}' if tariff_id else '') + ) if final_squads: try: from app.database.crud.server_squad import ( - get_server_ids_by_uuids, add_user_to_servers, + get_server_ids_by_uuids, ) server_ids = await get_server_ids_by_uuids(db, final_squads) if server_ids: await add_user_to_servers(db, server_ids) logger.info( - "📈 Обновлен счетчик пользователей для триальных сквадов %s", + '📈 Обновлен счетчик пользователей для триальных сквадов %s', final_squads, ) else: logger.warning( - "⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)", + '⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)', final_squads, ) except Exception as error: logger.error( - "⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s", + '⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s', final_squads, error, ) @@ -145,13 +151,12 @@ async def create_paid_subscription( user_id: int, duration_days: int, traffic_limit_gb: int = 0, - device_limit: Optional[int] = None, - connected_squads: List[str] = None, + device_limit: int | None = None, + connected_squads: list[str] = None, update_server_counters: bool = False, is_trial: bool = False, - tariff_id: Optional[int] = None, + tariff_id: int | None = None, ) -> Subscription: - end_date = datetime.utcnow() + timedelta(days=duration_days) if device_limit is None: @@ -170,38 +175,40 @@ async def create_paid_subscription( autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, ) - + db.add(subscription) await db.commit() await db.refresh(subscription) - - logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}") + + logger.info( + f'💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}' + ) squad_uuids = list(connected_squads or []) if update_server_counters and squad_uuids: try: from app.database.crud.server_squad import ( - get_server_ids_by_uuids, add_user_to_servers, + get_server_ids_by_uuids, ) server_ids = await get_server_ids_by_uuids(db, squad_uuids) if server_ids: await add_user_to_servers(db, server_ids) logger.info( - "📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)", + '📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)', user_id, squad_uuids, ) else: logger.warning( - "⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)", + '⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)', user_id, squad_uuids, ) except Exception as error: logger.error( - "⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s", + '⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s', user_id, error, ) @@ -216,10 +223,10 @@ async def replace_subscription( duration_days: int, traffic_limit_gb: int, device_limit: int, - connected_squads: List[str], + connected_squads: list[str], is_trial: bool, - autopay_enabled: Optional[bool] = None, - autopay_days_before: Optional[int] = None, + autopay_enabled: bool | None = None, + autopay_days_before: int | None = None, update_server_counters: bool = False, ) -> Subscription: """Перезаписывает параметры существующей подписки пользователя.""" @@ -228,16 +235,8 @@ async def replace_subscription( old_squads = set(subscription.connected_squads or []) new_squads = set(connected_squads or []) - new_autopay_enabled = ( - subscription.autopay_enabled - if autopay_enabled is None - else autopay_enabled - ) - new_autopay_days_before = ( - subscription.autopay_days_before - if autopay_days_before is None - else autopay_days_before - ) + new_autopay_enabled = subscription.autopay_enabled if autopay_enabled is None else autopay_enabled + new_autopay_days_before = subscription.autopay_days_before if autopay_days_before is None else autopay_days_before subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = is_trial @@ -281,14 +280,14 @@ async def replace_subscription( await add_user_to_servers(db, sorted(server_ids)) logger.info( - "♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s", + '♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s', subscription.id, len(squads_to_remove), len(squads_to_add), ) except Exception as error: logger.error( - "⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s", + '⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s', subscription.id, error, ) @@ -301,10 +300,10 @@ async def extend_subscription( subscription: Subscription, days: int, *, - tariff_id: Optional[int] = None, - traffic_limit_gb: Optional[int] = None, - device_limit: Optional[int] = None, - connected_squads: Optional[List[str]] = None, + tariff_id: int | None = None, + traffic_limit_gb: int | None = None, + device_limit: int | None = None, + connected_squads: list[str] | None = None, ) -> Subscription: """Продлевает подписку на указанное количество дней. @@ -319,18 +318,18 @@ async def extend_subscription( """ current_time = datetime.utcnow() - logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней") - logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}") + logger.info(f'🔄 Продление подписки {subscription.id} на {days} дней') + logger.info( + f'📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}' + ) # Определяем, происходит ли СМЕНА тарифа (а не продление того же) is_tariff_change = ( - tariff_id is not None - and subscription.tariff_id is not None - and tariff_id != subscription.tariff_id + tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id ) if is_tariff_change: - logger.info(f"🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}") + logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}') # Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла # и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа. @@ -340,7 +339,7 @@ async def extend_subscription( if days < 0: subscription.end_date = subscription.end_date + timedelta(days=days) logger.info( - "📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s", + '📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s', abs(days), subscription.end_date, ) @@ -352,19 +351,19 @@ async def extend_subscription( if remaining.total_seconds() > 0: bonus_days = max(0, remaining.days) logger.info( - "🎁 Обнаружен остаток триала: %s дней для подписки %s", + '🎁 Обнаружен остаток триала: %s дней для подписки %s', bonus_days, subscription.id, ) total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) subscription.start_date = current_time - logger.info(f"📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней") + logger.info(f'📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней') elif subscription.end_date > current_time: # Подписка активна - просто добавляем дни к текущей дате окончания # БЕЗ бонусных дней (они уже учтены в end_date) subscription.end_date = subscription.end_date + timedelta(days=days) - logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания") + logger.info(f'📅 Подписка активна, добавляем {days} дней к текущей дате окончания') else: # Подписка истекла - начинаем с текущей даты + бонус от триала if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: @@ -372,14 +371,14 @@ async def extend_subscription( pass total_days = days + bonus_days subscription.end_date = current_time + timedelta(days=total_days) - logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней") + logger.info(f'📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней') # УДАЛЕНО: Автоматическая конвертация триала по длительности # Теперь триал конвертируется ТОЛЬКО после успешного коммита продления # и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription) # Логируем статус подписки перед проверкой - logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}") + logger.info(f'🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}') if days > 0 and subscription.status in ( SubscriptionStatus.EXPIRED.value, @@ -388,77 +387,79 @@ async def extend_subscription( previous_status = subscription.status subscription.status = SubscriptionStatus.ACTIVE.value logger.info( - "🔄 Статус подписки %s изменён с %s на ACTIVE", + '🔄 Статус подписки %s изменён с %s на ACTIVE', subscription.id, previous_status, ) elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value: - logger.warning( - "⚠️ Попытка продлить PENDING подписку %s, дни: %s", - subscription.id, - days - ) + logger.warning('⚠️ Попытка продлить PENDING подписку %s, дни: %s', subscription.id, days) # Обновляем параметры тарифа, если переданы if tariff_id is not None: old_tariff_id = subscription.tariff_id subscription.tariff_id = tariff_id - logger.info(f"📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}") + logger.info(f'📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}') # При покупке тарифа сбрасываем триальный статус if subscription.is_trial: subscription.is_trial = False - logger.info(f"🎓 Подписка {subscription.id} конвертирована из триала в платную") + logger.info(f'🎓 Подписка {subscription.id} конвертирована из триала в платную') if traffic_limit_gb is not None: old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_limit_gb subscription.traffic_used_gb = 0.0 # Сбрасываем все докупки трафика при смене тарифа - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f"📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)") + logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)') elif settings.RESET_TRAFFIC_ON_PAYMENT: subscription.traffic_used_gb = 0.0 # В режиме тарифов сохраняем докупленный трафик при продлении if subscription.tariff_id is None: subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT") + logger.info('🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT') else: # При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at - logger.info("🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)") + logger.info('🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)') if device_limit is not None: old_devices = subscription.device_limit subscription.device_limit = device_limit - logger.info(f"📱 Обновлен лимит устройств: {old_devices} → {device_limit}") + logger.info(f'📱 Обновлен лимит устройств: {old_devices} → {device_limit}') if connected_squads is not None: old_squads = subscription.connected_squads subscription.connected_squads = connected_squads - logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") + logger.info(f'🌍 Обновлены сквады: {old_squads} → {connected_squads}') # Обработка daily полей при смене тарифа if is_tariff_change and tariff_id is not None: # Получаем информацию о новом тарифе для проверки is_daily from app.database.crud.tariff import get_tariff_by_id + new_tariff = await get_tariff_by_id(db, tariff_id) - old_was_daily = getattr(subscription, 'is_daily_paused', False) or getattr(subscription, 'last_daily_charge_at', None) is not None + old_was_daily = ( + getattr(subscription, 'is_daily_paused', False) + or getattr(subscription, 'last_daily_charge_at', None) is not None + ) if new_tariff and getattr(new_tariff, 'is_daily', False): # Переход на суточный тариф - сбрасываем флаги subscription.is_daily_paused = False subscription.last_daily_charge_at = None # Будет установлено при первом списании - logger.info(f"🔄 Переход на суточный тариф: сброшены daily флаги") + logger.info('🔄 Переход на суточный тариф: сброшены daily флаги') elif old_was_daily: # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False subscription.last_daily_charge_at = None - logger.info(f"🔄 Переход с суточного тарифа: очищены daily флаги") + logger.info('🔄 Переход с суточного тарифа: очищены daily флаги') # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) @@ -470,7 +471,7 @@ async def extend_subscription( subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ") + logger.info(f'🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ') subscription.updated_at = current_time @@ -478,32 +479,25 @@ async def extend_subscription( await db.refresh(subscription) await clear_notifications(db, subscription.id) - logger.info(f"✅ Подписка продлена до: {subscription.end_date}") - logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}") + logger.info(f'✅ Подписка продлена до: {subscription.end_date}') + logger.info(f'📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}') return subscription -async def add_subscription_traffic( - db: AsyncSession, - subscription: Subscription, - gb: int -) -> Subscription: - +async def add_subscription_traffic(db: AsyncSession, subscription: Subscription, gb: int) -> Subscription: subscription.add_traffic(gb) subscription.updated_at = datetime.utcnow() # Создаём новую запись докупки с индивидуальной датой истечения (30 дней) - from app.database.models import TrafficPurchase - from sqlalchemy import select as sql_select from datetime import timedelta + from sqlalchemy import select as sql_select + + from app.database.models import TrafficPurchase + new_expires_at = datetime.utcnow() + timedelta(days=30) - new_purchase = TrafficPurchase( - subscription_id=subscription.id, - traffic_gb=gb, - expires_at=new_expires_at - ) + new_purchase = TrafficPurchase(subscription_id=subscription.id, traffic_gb=gb, expires_at=new_expires_at) db.add(new_purchase) # Обновляем общий счетчик докупленного трафика @@ -532,69 +526,56 @@ async def add_subscription_traffic( await db.commit() await db.refresh(subscription) - logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime('%d.%m.%Y')})") + logger.info( + f'📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime("%d.%m.%Y")})' + ) return subscription -async def add_subscription_devices( - db: AsyncSession, - subscription: Subscription, - devices: int -) -> Subscription: - +async def add_subscription_devices(db: AsyncSession, subscription: Subscription, devices: int) -> Subscription: subscription.device_limit += devices subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств") + + logger.info(f'📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств') return subscription -async def add_subscription_squad( - db: AsyncSession, - subscription: Subscription, - squad_uuid: str -) -> Subscription: - +async def add_subscription_squad(db: AsyncSession, subscription: Subscription, squad_uuid: str) -> Subscription: if squad_uuid not in subscription.connected_squads: subscription.connected_squads = subscription.connected_squads + [squad_uuid] subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}") - + + logger.info(f'🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}') + return subscription -async def remove_subscription_squad( - db: AsyncSession, - subscription: Subscription, - squad_uuid: str -) -> Subscription: - +async def remove_subscription_squad(db: AsyncSession, subscription: Subscription, squad_uuid: str) -> Subscription: if squad_uuid in subscription.connected_squads: squads = subscription.connected_squads.copy() squads.remove(squad_uuid) subscription.connected_squads = squads subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}") - + + logger.info(f'🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}') + return subscription async def decrement_subscription_server_counts( db: AsyncSession, - subscription: Optional[Subscription], + subscription: Subscription | None, *, - subscription_servers: Optional[Iterable[SubscriptionServer]] = None, + subscription_servers: Iterable[SubscriptionServer] | None = None, ) -> None: """Decrease server counters linked to the provided subscription.""" @@ -613,7 +594,7 @@ async def decrement_subscription_server_counts( server_ids.update(ids_from_links) except Exception as error: logger.error( - "⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s", + '⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s', subscription.id, error, ) @@ -627,7 +608,7 @@ async def decrement_subscription_server_counts( server_ids.update(squad_server_ids) except Exception as error: logger.error( - "⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s", + '⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s', subscription.id, error, ) @@ -641,7 +622,7 @@ async def decrement_subscription_server_counts( await remove_user_from_servers(db, sorted(server_ids)) except Exception as error: logger.error( - "⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s", + '⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s', list(server_ids), subscription.id, error, @@ -649,43 +630,32 @@ async def decrement_subscription_server_counts( async def update_subscription_autopay( - db: AsyncSession, - subscription: Subscription, - enabled: bool, - days_before: int = 3 + db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int = 3 ) -> Subscription: - subscription.autopay_enabled = enabled subscription.autopay_days_before = days_before subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - status = "включен" if enabled else "выключен" - logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}") + + status = 'включен' if enabled else 'выключен' + logger.info(f'💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}') return subscription -async def deactivate_subscription( - db: AsyncSession, - subscription: Subscription -) -> Subscription: - +async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription: subscription.status = SubscriptionStatus.DISABLED.value subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована") + + logger.info(f'❌ Подписка пользователя {subscription.user_id} деактивирована') return subscription -async def reactivate_subscription( - db: AsyncSession, - subscription: Subscription -) -> Subscription: +async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription: """Реактивация подписки (например, после повторной подписки на канал). Активирует только если подписка была DISABLED и ещё не истекла. @@ -709,13 +679,9 @@ async def reactivate_subscription( return subscription -async def get_expiring_subscriptions( - db: AsyncSession, - days_before: int = 3 -) -> List[Subscription]: - +async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]: threshold_date = datetime.utcnow() + timedelta(days=days_before) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -723,31 +689,25 @@ async def get_expiring_subscriptions( and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= threshold_date, - Subscription.end_date > datetime.utcnow() + Subscription.end_date > datetime.utcnow(), ) ) ) return result.scalars().all() -async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]: - +async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]: result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) - .where( - and_( - Subscription.status == SubscriptionStatus.ACTIVE.value, - Subscription.end_date <= datetime.utcnow() - ) - ) + .where(and_(Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date <= datetime.utcnow())) ) return result.scalars().all() -async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: +async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]: current_time = datetime.utcnow() - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -755,141 +715,119 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]: and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, - Subscription.is_trial == False + Subscription.is_trial == False, ) ) ) all_autopay_subscriptions = result.scalars().all() - + ready_for_autopay = [] for subscription in all_autopay_subscriptions: days_until_expiry = (subscription.end_date - current_time).days - + if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time: ready_for_autopay.append(subscription) - + return ready_for_autopay async def get_subscriptions_statistics(db: AsyncSession) -> dict: - total_result = await db.execute(select(func.count(Subscription.id))) total_subscriptions = total_result.scalar() - + active_result = await db.execute( - select(func.count(Subscription.id)) - .where(Subscription.status == SubscriptionStatus.ACTIVE.value) + select(func.count(Subscription.id)).where(Subscription.status == SubscriptionStatus.ACTIVE.value) ) active_subscriptions = active_result.scalar() - + trial_result = await db.execute( - select(func.count(Subscription.id)) - .where( - and_( - Subscription.is_trial == True, - Subscription.status == SubscriptionStatus.ACTIVE.value - ) + select(func.count(Subscription.id)).where( + and_(Subscription.is_trial == True, Subscription.status == SubscriptionStatus.ACTIVE.value) ) ) trial_subscriptions = trial_result.scalar() - + paid_subscriptions = active_subscriptions - trial_subscriptions - + today = datetime.utcnow().date() today_result = await db.execute( - select(func.count(Subscription.id)) - .where( - and_( - Subscription.created_at >= today, - Subscription.is_trial == False - ) + select(func.count(Subscription.id)).where( + and_(Subscription.created_at >= today, Subscription.is_trial == False) ) ) purchased_today = today_result.scalar() - + week_ago = datetime.utcnow() - timedelta(days=7) week_result = await db.execute( - select(func.count(Subscription.id)) - .where( - and_( - Subscription.created_at >= week_ago, - Subscription.is_trial == False - ) + select(func.count(Subscription.id)).where( + and_(Subscription.created_at >= week_ago, Subscription.is_trial == False) ) ) purchased_week = week_result.scalar() - + month_ago = datetime.utcnow() - timedelta(days=30) month_result = await db.execute( - select(func.count(Subscription.id)) - .where( - and_( - Subscription.created_at >= month_ago, - Subscription.is_trial == False - ) + select(func.count(Subscription.id)).where( + and_(Subscription.created_at >= month_ago, Subscription.is_trial == False) ) ) purchased_month = month_result.scalar() - + try: from app.database.crud.subscription_conversion import get_conversion_statistics + conversion_stats = await get_conversion_statistics(db) - - trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0) - renewals_count = conversion_stats.get("month_conversions", 0) - - logger.info(f"📊 Статистика конверсии из таблицы conversions:") - logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}") - logger.info(f" Процент конверсии: {trial_to_paid_conversion}%") - logger.info(f" Конверсий за месяц: {renewals_count}") - + + trial_to_paid_conversion = conversion_stats.get('conversion_rate', 0) + renewals_count = conversion_stats.get('month_conversions', 0) + + logger.info('📊 Статистика конверсии из таблицы conversions:') + logger.info(f' Общее количество конверсий: {conversion_stats.get("total_conversions", 0)}') + logger.info(f' Процент конверсии: {trial_to_paid_conversion}%') + logger.info(f' Конверсий за месяц: {renewals_count}') + except ImportError: - logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику") - + logger.warning('⚠️ Таблица subscription_conversions не найдена, используем старую логику') + users_with_paid_result = await db.execute( - select(func.count(User.id)) - .where(User.has_had_paid_subscription == True) + select(func.count(User.id)).where(User.has_had_paid_subscription == True) ) users_with_paid = users_with_paid_result.scalar() - + total_users_result = await db.execute(select(func.count(User.id))) total_users = total_users_result.scalar() - + if total_users > 0: trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1) else: trial_to_paid_conversion = 0 - + renewals_count = 0 - + return { - "total_subscriptions": total_subscriptions, - "active_subscriptions": active_subscriptions, - "trial_subscriptions": trial_subscriptions, - "paid_subscriptions": paid_subscriptions, - "purchased_today": purchased_today, - "purchased_week": purchased_week, - "purchased_month": purchased_month, - "trial_to_paid_conversion": trial_to_paid_conversion, - "renewals_count": renewals_count + 'total_subscriptions': total_subscriptions, + 'active_subscriptions': active_subscriptions, + 'trial_subscriptions': trial_subscriptions, + 'paid_subscriptions': paid_subscriptions, + 'purchased_today': purchased_today, + 'purchased_week': purchased_week, + 'purchased_month': purchased_month, + 'trial_to_paid_conversion': trial_to_paid_conversion, + 'renewals_count': renewals_count, } async def get_trial_statistics(db: AsyncSession) -> dict: now = datetime.utcnow() - total_trials_result = await db.execute( - select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True)) - ) + total_trials_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))) total_trials = total_trials_result.scalar() or 0 active_trials_result = await db.execute( select(func.count(Subscription.id)).where( Subscription.is_trial.is_(True), Subscription.end_date > now, - Subscription.status.in_( - [SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value] - ), + Subscription.status.in_([SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]), ) ) active_trials = active_trials_result.scalar() or 0 @@ -906,9 +844,9 @@ async def get_trial_statistics(db: AsyncSession) -> dict: resettable_trials = resettable_trials_result.scalar() or 0 return { - "used_trials": total_trials, - "active_trials": active_trials, - "resettable_trials": resettable_trials, + 'used_trials': total_trials, + 'active_trials': active_trials, + 'resettable_trials': resettable_trials, } @@ -943,7 +881,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Не удалось обновить счётчики серверов при сбросе триала %s: %s", + 'Не удалось обновить счётчики серверов при сбросе триала %s: %s', subscription.id, error, ) @@ -952,14 +890,10 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> if subscription_ids: try: - await db.execute( - delete(SubscriptionServer).where( - SubscriptionServer.subscription_id.in_(subscription_ids) - ) - ) + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id.in_(subscription_ids))) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Ошибка удаления серверных связей триалов %s: %s", + 'Ошибка удаления серверных связей триалов %s: %s', subscription_ids, error, ) @@ -971,33 +905,25 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> await db.commit() except Exception as error: # pragma: no cover - defensive logging await db.rollback() - logger.error("Ошибка сохранения сброса триалов: %s", error) + logger.error('Ошибка сохранения сброса триалов: %s', error) raise - logger.info("♻️ Сброшено триальных подписок: %s", reset_count) + logger.info('♻️ Сброшено триальных подписок: %s', reset_count) return reset_count -async def update_subscription_usage( - db: AsyncSession, - subscription: Subscription, - used_gb: float -) -> Subscription: + +async def update_subscription_usage(db: AsyncSession, subscription: Subscription, used_gb: float) -> Subscription: subscription.traffic_used_gb = used_gb subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - + return subscription -async def get_all_subscriptions( - db: AsyncSession, - page: int = 1, - limit: int = 10 -) -> Tuple[List[Subscription], int]: - count_result = await db.execute( - select(func.count(Subscription.id)) - ) + +async def get_all_subscriptions(db: AsyncSession, page: int = 1, limit: int = 10) -> tuple[list[Subscription], int]: + count_result = await db.execute(select(func.count(Subscription.id))) total_count = count_result.scalar() offset = (page - 1) * limit @@ -1019,7 +945,7 @@ async def get_subscriptions_batch( db: AsyncSession, offset: int = 0, limit: int = 500, -) -> List[Subscription]: +) -> list[Subscription]: """Получает подписки пачками для синхронизации. Загружает связанных пользователей.""" result = await db.execute( select(Subscription) @@ -1030,65 +956,57 @@ async def get_subscriptions_batch( ) return list(result.scalars().all()) + async def add_subscription_servers( - db: AsyncSession, - subscription: Subscription, - server_squad_ids: List[int], - paid_prices: List[int] = None + db: AsyncSession, subscription: Subscription, server_squad_ids: list[int], paid_prices: list[int] = None ) -> Subscription: await db.refresh(subscription) - + if paid_prices is None: months_remaining = get_remaining_months(subscription.end_date) paid_prices = [] - + from app.database.models import ServerSquad + for server_id in server_squad_ids: - result = await db.execute( - select(ServerSquad.price_kopeks) - .where(ServerSquad.id == server_id) - ) + result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id)) server_price_per_month = result.scalar() or 0 total_price_for_period = server_price_per_month * months_remaining paid_prices.append(total_price_for_period) - + for i, server_id in enumerate(server_squad_ids): subscription_server = SubscriptionServer( - subscription_id=subscription.id, + subscription_id=subscription.id, server_squad_id=server_id, - paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0 + paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0, ) db.add(subscription_server) - + await db.commit() await db.refresh(subscription) - - logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}") + + logger.info(f'🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}') return subscription -async def get_server_monthly_price( - db: AsyncSession, - server_squad_id: int -) -> int: + +async def get_server_monthly_price(db: AsyncSession, server_squad_id: int) -> int: from app.database.models import ServerSquad - - result = await db.execute( - select(ServerSquad.price_kopeks) - .where(ServerSquad.id == server_squad_id) - ) + + result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_squad_id)) return result.scalar() or 0 async def get_servers_monthly_prices( db: AsyncSession, - server_squad_ids: List[int], + server_squad_ids: list[int], *, - user: Optional["User"] = None, -) -> List[int]: + user: Optional['User'] = None, +) -> list[int]: """Получает месячные цены серверов с проверкой доступности для промогруппы пользователя.""" - from app.database.models import ServerSquad from sqlalchemy.orm import selectinload + from app.database.models import ServerSquad + prices = [] # Загружаем промогруппы пользователя если нужно @@ -1097,14 +1015,14 @@ async def get_servers_monthly_prices( if user: try: # Пробуем загрузить промогруппы если ещё не загружены - await db.refresh(user, ["user_promo_groups", "promo_group"]) + await db.refresh(user, ['user_promo_groups', 'promo_group']) except Exception: pass try: user_promo_group = user.get_primary_promo_group() user_promo_group_id = user_promo_group.id if user_promo_group else None except Exception as e: - logger.warning(f"Не удалось получить промогруппу пользователя: {e}") + logger.warning(f'Не удалось получить промогруппу пользователя: {e}') for server_id in server_squad_ids: # Загружаем сервер с промогруппами @@ -1130,20 +1048,21 @@ async def get_servers_monthly_prices( else: # Сервер недоступен для промогруппы пользователя logger.warning( - f"⚠️ Сервер {server.display_name} (id={server_id}) недоступен для " - f"промогруппы пользователя (promo_group_id={user_promo_group_id}), " - f"allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}" + f'⚠️ Сервер {server.display_name} (id={server_id}) недоступен для ' + f'промогруппы пользователя (promo_group_id={user_promo_group_id}), ' + f'allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}' ) prices.append(server.price_kopeks) # Всё равно берём реальную цену return prices + def _get_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], + user: User | None, + promo_group: PromoGroup | None, category: str, *, - period_days: Optional[int] = None, + period_days: int | None = None, ) -> int: if user is not None: try: @@ -1161,33 +1080,33 @@ async def calculate_subscription_total_cost( db: AsyncSession, period_days: int, traffic_gb: int, - server_squad_ids: List[int], + server_squad_ids: list[int], devices: int, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, -) -> Tuple[int, dict]: + user: User | None = None, + promo_group: PromoGroup | None = None, +) -> tuple[int, dict]: from app.config import PERIOD_PRICES - + months_in_period = calculate_months_from_days(period_days) - + base_price_original = PERIOD_PRICES.get(period_days, 0) period_discount_percent = _get_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - + promo_group = promo_group or (user.promo_group if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _get_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1200,7 +1119,7 @@ async def calculate_subscription_total_cost( servers_discount_percent = _get_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 @@ -1213,7 +1132,7 @@ async def calculate_subscription_total_cost( devices_discount_percent = _get_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1242,106 +1161,88 @@ async def calculate_subscription_total_cost( 'total_devices_price': total_devices_price, 'months_in_period': months_in_period, 'servers_individual_prices': [ - (price - (price * servers_discount_percent // 100)) * months_in_period - for price in servers_prices - ] + (price - (price * servers_discount_percent // 100)) * months_in_period for price in servers_prices + ], } - logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):") - logger.debug(f" Базовый период: {base_price/100}₽") + logger.debug(f'📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):') + logger.debug(f' Базовый период: {base_price / 100}₽') if total_traffic_price > 0: - message = ( - f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽" - ) + message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽' if total_traffic_discount > 0: - message += ( - f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)' logger.debug(message) if total_servers_price > 0: message = ( - f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽" + f' Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽' ) if total_servers_discount > 0: - message += ( - f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" - ) + message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' logger.debug(message) if total_devices_price > 0: message = ( - f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽" + f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽' ) if total_devices_discount > 0: - message += ( - f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)' logger.debug(message) - logger.debug(f" ИТОГО: {total_cost/100}₽") - + logger.debug(f' ИТОГО: {total_cost / 100}₽') + return total_cost, details - -async def get_subscription_server_ids( - db: AsyncSession, - subscription_id: int -) -> List[int]: - + + +async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) -> list[int]: result = await db.execute( - select(SubscriptionServer.server_squad_id) - .where(SubscriptionServer.subscription_id == subscription_id) + select(SubscriptionServer.server_squad_id).where(SubscriptionServer.subscription_id == subscription_id) ) return [row[0] for row in result.fetchall()] -async def get_subscription_servers( - db: AsyncSession, - subscription_id: int -) -> List[dict]: - +async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]: from app.database.models import ServerSquad - + result = await db.execute( select(SubscriptionServer, ServerSquad) .join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id) .where(SubscriptionServer.subscription_id == subscription_id) ) - + servers_info = [] for sub_server, server_squad in result.fetchall(): - servers_info.append({ - 'server_id': server_squad.id, - 'squad_uuid': server_squad.squad_uuid, - 'display_name': server_squad.display_name, - 'country_code': server_squad.country_code, - 'paid_price_kopeks': sub_server.paid_price_kopeks, - 'connected_at': sub_server.connected_at, - 'is_available': server_squad.is_available - }) - + servers_info.append( + { + 'server_id': server_squad.id, + 'squad_uuid': server_squad.squad_uuid, + 'display_name': server_squad.display_name, + 'country_code': server_squad.country_code, + 'paid_price_kopeks': sub_server.paid_price_kopeks, + 'connected_at': sub_server.connected_at, + 'is_available': server_squad.is_available, + } + ) + return servers_info -async def remove_subscription_servers( - db: AsyncSession, - subscription_id: int, - server_squad_ids: List[int] -) -> bool: + +async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool: try: - from app.database.models import SubscriptionServer from sqlalchemy import delete - + + from app.database.models import SubscriptionServer + await db.execute( - delete(SubscriptionServer) - .where( + delete(SubscriptionServer).where( SubscriptionServer.subscription_id == subscription_id, - SubscriptionServer.server_squad_id.in_(server_squad_ids) + SubscriptionServer.server_squad_id.in_(server_squad_ids), ) ) - + await db.commit() - logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}") + logger.info(f'🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}') return True - + except Exception as e: - logger.error(f"Ошибка удаления серверов из подписки: {e}") + logger.error(f'Ошибка удаления серверов из подписки: {e}') await db.rollback() return False @@ -1351,8 +1252,8 @@ async def get_subscription_renewal_cost( subscription_id: int, period_days: int, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, + user: User | None = None, + promo_group: PromoGroup | None = None, ) -> int: try: from app.config import PERIOD_PRICES @@ -1364,7 +1265,9 @@ async def get_subscription_renewal_cost( result = await db.execute( select(Subscription) .options( - selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group), + selectinload(Subscription.user) + .selectinload(User.user_promo_groups) + .selectinload(UserPromoGroup.promo_group), ) .where(Subscription.id == subscription_id) ) @@ -1380,9 +1283,9 @@ async def get_subscription_renewal_cost( servers_price_per_month = 0 for server_info in servers_info: from app.database.models import ServerSquad + result = await db.execute( - select(ServerSquad.price_kopeks) - .where(ServerSquad.id == server_info['server_id']) + select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id']) ) current_server_price = result.scalar() or 0 servers_price_per_month += current_server_price @@ -1390,7 +1293,7 @@ async def get_subscription_renewal_cost( servers_discount_percent = _get_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 @@ -1407,7 +1310,7 @@ async def get_subscription_renewal_cost( traffic_discount_percent = _get_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1420,7 +1323,7 @@ async def get_subscription_renewal_cost( devices_discount_percent = _get_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1430,53 +1333,45 @@ async def get_subscription_renewal_cost( total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost - logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):") - logger.info(f" 📅 Период: {base_price/100}₽") + logger.info(f'💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):') + logger.info(f' 📅 Период: {base_price / 100}₽') if total_servers_cost > 0: - message = ( - f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽" - ) + message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽' if total_servers_discount > 0: - message += ( - f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)" - ) + message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' logger.info(message) if total_traffic_cost > 0: message = ( - f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽" + f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}₽' ) if total_traffic_discount > 0: - message += ( - f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)' logger.info(message) if total_devices_cost > 0: - message = ( - f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽" - ) + message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}₽' if total_devices_discount > 0: - message += ( - f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)' logger.info(message) - logger.info(f" 💎 ИТОГО: {total_cost/100}₽") - + logger.info(f' 💎 ИТОГО: {total_cost / 100}₽') + return total_cost - + except Exception as e: - logger.error(f"Ошибка расчета стоимости продления: {e}") + logger.error(f'Ошибка расчета стоимости продления: {e}') from app.config import PERIOD_PRICES + return PERIOD_PRICES.get(period_days, 0) + async def calculate_addon_cost_for_remaining_period( db: AsyncSession, subscription: Subscription, additional_traffic_gb: int = 0, additional_devices: int = 0, - additional_server_ids: List[int] = None, + additional_server_ids: list[int] = None, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, + user: User | None = None, + promo_group: PromoGroup | None = None, ) -> int: if additional_server_ids is None: additional_server_ids = [] @@ -1487,7 +1382,7 @@ async def calculate_addon_cost_for_remaining_period( total_cost = 0 if user is None: - user = getattr(subscription, "user", None) + user = getattr(subscription, 'user', None) promo_group = promo_group or (user.promo_group if user else None) if additional_traffic_gb > 0: @@ -1495,20 +1390,16 @@ async def calculate_addon_cost_for_remaining_period( traffic_discount_percent = _get_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_hint_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month traffic_total_cost = discounted_traffic_per_month * months_to_pay total_cost += traffic_total_cost - message = ( - f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽" - ) + message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {months_to_pay} = {traffic_total_cost / 100}₽' if traffic_discount_per_month > 0: - message += ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)' logger.info(message) if additional_devices > 0: @@ -1516,28 +1407,24 @@ async def calculate_addon_cost_for_remaining_period( devices_discount_percent = _get_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_hint_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 discounted_devices_per_month = devices_price_per_month - devices_discount_per_month devices_total_cost = discounted_devices_per_month * months_to_pay total_cost += devices_total_cost - message = ( - f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽" - ) + message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {months_to_pay} = {devices_total_cost / 100}₽' if devices_discount_per_month > 0: - message += ( - f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)' logger.info(message) if additional_server_ids: from app.database.models import ServerSquad + for server_id in additional_server_ids: result = await db.execute( - select(ServerSquad.price_kopeks, ServerSquad.display_name) - .where(ServerSquad.id == server_id) + select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id) ) server_data = result.first() if server_data: @@ -1545,49 +1432,40 @@ async def calculate_addon_cost_for_remaining_period( servers_discount_percent = _get_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_hint_days, ) server_discount_per_month = server_price_per_month * servers_discount_percent // 100 discounted_server_per_month = server_price_per_month - server_discount_per_month server_total_cost = discounted_server_per_month * months_to_pay total_cost += server_total_cost - message = ( - f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽" - ) + message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {months_to_pay} = {server_total_cost / 100}₽' if server_discount_per_month > 0: message += ( - f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)" + f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay / 100}₽)' ) logger.info(message) - - logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽") + + logger.info(f'💰 Итого доплата за {months_to_pay} мес: {total_cost / 100}₽') return total_cost -async def expire_subscription( - db: AsyncSession, - subscription: Subscription -) -> Subscription: - + +async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription: subscription.status = SubscriptionStatus.EXPIRED.value subscription.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(subscription) - - logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая") + + logger.info(f'⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая') return subscription -async def check_and_update_subscription_status( - db: AsyncSession, - subscription: Subscription -) -> Subscription: - +async def check_and_update_subscription_status(db: AsyncSession, subscription: Subscription) -> Subscription: current_time = datetime.utcnow() logger.info( - "🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s", + '🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s', subscription.id, subscription.status, format_local_datetime(subscription.end_date), @@ -1598,21 +1476,17 @@ async def check_and_update_subscription_status( # (время "заморожено" пока пользователь на паузе) is_daily_paused = getattr(subscription, 'is_daily_paused', False) if is_daily_paused: - logger.info( - f"⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения" - ) + logger.info(f'⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения') return subscription - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): - + if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: # Детальное логирование для отладки проблемы с деактивацией time_diff = current_time - subscription.end_date logger.warning( - f"⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) " - f"деактивируется в check_and_update_subscription_status. " - f"end_date={subscription.end_date}, current_time={current_time}, " - f"просрочена на {time_diff}" + f'⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) ' + f'деактивируется в check_and_update_subscription_status. ' + f'end_date={subscription.end_date}, current_time={current_time}, ' + f'просрочена на {time_diff}' ) subscription.status = SubscriptionStatus.EXPIRED.value @@ -1623,14 +1497,15 @@ async def check_and_update_subscription_status( logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'") elif subscription.status == SubscriptionStatus.PENDING.value: - logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений") + logger.info(f'ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений') return subscription + async def create_subscription_no_commit( db: AsyncSession, user_id: int, - status: str = "trial", + status: str = 'trial', is_trial: bool = True, end_date: datetime = None, traffic_limit_gb: int = 10, @@ -1638,21 +1513,21 @@ async def create_subscription_no_commit( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - subscription_crypto_link: str = "", - autopay_enabled: Optional[bool] = None, - autopay_days_before: Optional[int] = None, + subscription_url: str = '', + subscription_crypto_link: str = '', + autopay_enabled: bool | None = None, + autopay_days_before: int | None = None, ) -> Subscription: """ Создает подписку без немедленного коммита для пакетной обработки """ - + if end_date is None: end_date = datetime.utcnow() + timedelta(days=3) - + if connected_squads is None: connected_squads = [] - + subscription = Subscription( user_id=user_id, status=status, @@ -1665,32 +1540,26 @@ async def create_subscription_no_commit( remnawave_short_uuid=remnawave_short_uuid, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, - autopay_enabled=( - settings.is_autopay_enabled_by_default() - if autopay_enabled is None - else autopay_enabled - ), + autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled), autopay_days_before=( - settings.DEFAULT_AUTOPAY_DAYS_BEFORE - if autopay_days_before is None - else autopay_days_before + settings.DEFAULT_AUTOPAY_DAYS_BEFORE if autopay_days_before is None else autopay_days_before ), ) - + db.add(subscription) # Выполняем flush, чтобы получить присвоенный первичный ключ await db.flush() # Не коммитим сразу, оставляем для пакетной обработки - logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)") + logger.info(f'✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)') return subscription async def create_subscription( db: AsyncSession, user_id: int, - status: str = "trial", + status: str = 'trial', is_trial: bool = True, end_date: datetime = None, traffic_limit_gb: int = 10, @@ -1698,18 +1567,17 @@ async def create_subscription( device_limit: int = 1, connected_squads: list = None, remnawave_short_uuid: str = None, - subscription_url: str = "", - subscription_crypto_link: str = "", - autopay_enabled: Optional[bool] = None, - autopay_days_before: Optional[int] = None, + subscription_url: str = '', + subscription_crypto_link: str = '', + autopay_enabled: bool | None = None, + autopay_days_before: int | None = None, ) -> Subscription: - if end_date is None: end_date = datetime.utcnow() + timedelta(days=3) - + if connected_squads is None: connected_squads = [] - + subscription = Subscription( user_id=user_id, status=status, @@ -1722,23 +1590,17 @@ async def create_subscription( remnawave_short_uuid=remnawave_short_uuid, subscription_url=subscription_url, subscription_crypto_link=subscription_crypto_link, - autopay_enabled=( - settings.is_autopay_enabled_by_default() - if autopay_enabled is None - else autopay_enabled - ), + autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled), autopay_days_before=( - settings.DEFAULT_AUTOPAY_DAYS_BEFORE - if autopay_days_before is None - else autopay_days_before + settings.DEFAULT_AUTOPAY_DAYS_BEFORE if autopay_days_before is None else autopay_days_before ), ) - + db.add(subscription) await db.commit() await db.refresh(subscription) - - logger.info(f"✅ Создана подписка для пользователя {user_id}") + + logger.info(f'✅ Создана подписка для пользователя {user_id}') return subscription @@ -1748,8 +1610,8 @@ async def create_pending_subscription( duration_days: int, traffic_limit_gb: int = 0, device_limit: int = 1, - connected_squads: List[str] = None, - payment_method: str = "pending", + connected_squads: list[str] = None, + payment_method: str = 'pending', total_price_kopeks: int = 0, is_trial: bool = False, ) -> Subscription: @@ -1758,7 +1620,7 @@ async def create_pending_subscription( Args: is_trial: If True, marks the subscription as a trial subscription. """ - trial_label = "триальная " if is_trial else "" + trial_label = 'триальная ' if is_trial else '' current_time = datetime.utcnow() end_date = current_time + timedelta(days=duration_days) @@ -1770,7 +1632,7 @@ async def create_pending_subscription( and existing_subscription.end_date > current_time ): logger.warning( - "⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.", + '⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.', trial_label, user_id, ) @@ -1790,7 +1652,7 @@ async def create_pending_subscription( await db.refresh(existing_subscription) logger.info( - "♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s", + '♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s', trial_label, user_id, existing_subscription.id, @@ -1816,7 +1678,7 @@ async def create_pending_subscription( await db.refresh(subscription) logger.info( - "💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s", + '💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s', trial_label, user_id, subscription.id, @@ -1833,8 +1695,8 @@ async def create_pending_trial_subscription( duration_days: int, traffic_limit_gb: int = 0, device_limit: int = 1, - connected_squads: List[str] = None, - payment_method: str = "pending", + connected_squads: list[str] = None, + payment_method: str = 'pending', total_price_kopeks: int = 0, ) -> Subscription: """Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True.""" @@ -1851,51 +1713,44 @@ async def create_pending_trial_subscription( ) -async def activate_pending_subscription( - db: AsyncSession, - user_id: int, - period_days: int = None -) -> Optional[Subscription]: +async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None: """Активирует pending подписку пользователя, меняя её статус на ACTIVE.""" - logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней") - + logger.info(f'Активация pending подписки: пользователь {user_id}, период {period_days} дней') + # Находим pending подписку пользователя result = await db.execute( - select(Subscription) - .where( - and_( - Subscription.user_id == user_id, - Subscription.status == SubscriptionStatus.PENDING.value - ) + select(Subscription).where( + and_(Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value) ) ) pending_subscription = result.scalar_one_or_none() - + if not pending_subscription: - logger.warning(f"Не найдена pending подписка для пользователя {user_id}") + logger.warning(f'Не найдена pending подписка для пользователя {user_id}') return None - - logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}") - + + logger.info( + f'Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}' + ) + # Обновляем статус подписки на ACTIVE current_time = datetime.utcnow() pending_subscription.status = SubscriptionStatus.ACTIVE.value - + # Если указан период, обновляем дату окончания if period_days is not None: effective_start = pending_subscription.start_date or current_time - if effective_start < current_time: - effective_start = current_time + effective_start = max(effective_start, current_time) pending_subscription.end_date = effective_start + timedelta(days=period_days) - + # Обновляем дату начала, если она не установлена или в прошлом if not pending_subscription.start_date or pending_subscription.start_date < current_time: pending_subscription.start_date = current_time - + await db.commit() await db.refresh(pending_subscription) - logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}") + logger.info(f'Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}') return pending_subscription @@ -1904,29 +1759,28 @@ async def activate_pending_trial_subscription( db: AsyncSession, subscription_id: int, user_id: int, -) -> Optional[Subscription]: +) -> Subscription | None: """Активирует pending триальную подписку по её ID после оплаты.""" - logger.info(f"Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}") + logger.info(f'Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}') # Находим pending подписку по ID result = await db.execute( - select(Subscription) - .where( + select(Subscription).where( and_( Subscription.id == subscription_id, Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value, - Subscription.is_trial == True + Subscription.is_trial == True, ) ) ) pending_subscription = result.scalar_one_or_none() if not pending_subscription: - logger.warning(f"Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}") + logger.warning(f'Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}') return None - logger.info(f"Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}") + logger.info(f'Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}') # Обновляем статус подписки на ACTIVE current_time = datetime.utcnow() @@ -1943,12 +1797,13 @@ async def activate_pending_trial_subscription( elif pending_subscription.end_date and pending_subscription.end_date < current_time: # Если end_date в прошлом, пересчитываем from app.config import settings + pending_subscription.end_date = current_time + timedelta(days=settings.TRIAL_DURATION_DAYS) await db.commit() await db.refresh(pending_subscription) - logger.info(f"Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}") + logger.info(f'Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}') return pending_subscription @@ -1956,7 +1811,7 @@ async def activate_pending_trial_subscription( # ==================== СУТОЧНЫЕ ПОДПИСКИ ==================== -async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscription]: +async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscription]: """ Получает все суточные подписки, которые нужно обработать для списания. @@ -1985,10 +1840,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscript Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_daily_paused.is_(False), # Списания ещё не было ИЛИ прошло более 24 часов - ( - (Subscription.last_daily_charge_at.is_(None)) | - (Subscription.last_daily_charge_at < one_day_ago) - ), + ((Subscription.last_daily_charge_at.is_(None)) | (Subscription.last_daily_charge_at < one_day_ago)), ) ) ) @@ -1996,9 +1848,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> List[Subscript result = await db.execute(query) subscriptions = result.scalars().all() - logger.info( - f"🔍 Найдено {len(subscriptions)} суточных подписок для списания" - ) + logger.info(f'🔍 Найдено {len(subscriptions)} суточных подписок для списания') return list(subscriptions) @@ -2009,18 +1859,14 @@ async def pause_daily_subscription( ) -> Subscription: """Приостанавливает суточную подписку (списание не будет происходить).""" if not subscription.is_daily_tariff: - logger.warning( - f"Попытка приостановить не-суточную подписку {subscription.id}" - ) + logger.warning(f'Попытка приостановить не-суточную подписку {subscription.id}') return subscription subscription.is_daily_paused = True await db.commit() await db.refresh(subscription) - logger.info( - f"⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}" - ) + logger.info(f'⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}') return subscription @@ -2031,9 +1877,7 @@ async def resume_daily_subscription( ) -> Subscription: """Возобновляет суточную подписку (списание продолжится).""" if not subscription.is_daily_tariff: - logger.warning( - f"Попытка возобновить не-суточную подписку {subscription.id}" - ) + logger.warning(f'Попытка возобновить не-суточную подписку {subscription.id}') return subscription subscription.is_daily_paused = False @@ -2044,16 +1888,12 @@ async def resume_daily_subscription( # Обновляем время последнего списания для корректного расчёта следующего subscription.last_daily_charge_at = datetime.utcnow() subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info( - f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" - ) + logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE') await db.commit() await db.refresh(subscription) - logger.info( - f"▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}" - ) + logger.info(f'▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}') return subscription @@ -2071,7 +1911,7 @@ async def update_daily_charge_time( new_end_date = now + timedelta(days=1) if subscription.end_date is None or subscription.end_date < new_end_date: subscription.end_date = new_end_date - logger.info(f"📅 Продлена подписка {subscription.id} до {new_end_date}") + logger.info(f'📅 Продлена подписка {subscription.id} до {new_end_date}') await db.commit() await db.refresh(subscription) @@ -2092,7 +1932,7 @@ async def suspend_daily_subscription_insufficient_balance( await db.refresh(subscription) logger.info( - f"⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})" + f'⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})' ) return subscription @@ -2101,7 +1941,7 @@ async def suspend_daily_subscription_insufficient_balance( async def get_subscription_with_tariff( db: AsyncSession, user_id: int, -) -> Optional[Subscription]: +) -> Subscription | None: """Получает подписку пользователя с загруженным тарифом.""" result = await db.execute( select(Subscription) @@ -2128,5 +1968,4 @@ async def toggle_daily_subscription_pause( """Переключает состояние паузы суточной подписки.""" if subscription.is_daily_paused: return await resume_daily_subscription(db, subscription) - else: - return await pause_daily_subscription(db, subscription) + return await pause_daily_subscription(db, subscription) diff --git a/app/database/crud/subscription_conversion.py b/app/database/crud/subscription_conversion.py index f8764451..62d78743 100644 --- a/app/database/crud/subscription_conversion.py +++ b/app/database/crud/subscription_conversion.py @@ -1,11 +1,12 @@ import logging from datetime import datetime, timedelta -from typing import Optional, List -from sqlalchemy import select, func + +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import SubscriptionConversion, User + logger = logging.getLogger(__name__) @@ -15,39 +16,36 @@ async def create_subscription_conversion( trial_duration_days: int, payment_method: str, first_payment_amount_kopeks: int, - first_paid_period_days: int + first_paid_period_days: int, ) -> SubscriptionConversion: - conversion = SubscriptionConversion( user_id=user_id, converted_at=datetime.utcnow(), trial_duration_days=trial_duration_days, payment_method=payment_method, first_payment_amount_kopeks=first_payment_amount_kopeks, - first_paid_period_days=first_paid_period_days + first_paid_period_days=first_paid_period_days, ) - + db.add(conversion) await db.commit() await db.refresh(conversion) - - logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}₽") - + + logger.info( + f'✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks / 100}₽' + ) + return conversion -async def get_conversion_by_user_id( - db: AsyncSession, - user_id: int -) -> Optional[SubscriptionConversion]: - +async def get_conversion_by_user_id(db: AsyncSession, user_id: int) -> SubscriptionConversion | None: result = await db.execute( select(SubscriptionConversion) .where(SubscriptionConversion.user_id == user_id) .order_by(SubscriptionConversion.converted_at.desc()) .limit(1) ) - + return result.scalar_one_or_none() @@ -55,23 +53,16 @@ async def get_conversion_statistics(db: AsyncSession) -> dict: from app.database.models import Subscription # Получаем количество записей о конверсиях в таблице - total_conversions_result = await db.execute( - select(func.count(SubscriptionConversion.id)) - ) + total_conversions_result = await db.execute(select(func.count(SubscriptionConversion.id))) total_conversions = total_conversions_result.scalar() or 0 # Подсчитываем пользователей с платными подписками - users_with_paid_result = await db.execute( - select(func.count(User.id)) - .where(User.has_had_paid_subscription == True) - ) + users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True)) users_with_paid = users_with_paid_result.scalar() or 0 # Подсчитываем всех пользователей с подписками (использовавших триал) # Считаем что все новые пользователи начинают с триала - total_users_with_subscriptions_result = await db.execute( - select(func.count(func.distinct(Subscription.user_id))) - ) + total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id)))) total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0 # Расчёт конверсии: (оплатившие) / (всего с подписками) * 100 @@ -81,50 +72,39 @@ async def get_conversion_statistics(db: AsyncSession) -> dict: else: conversion_rate = 0.0 - avg_trial_duration_result = await db.execute( - select(func.avg(SubscriptionConversion.trial_duration_days)) - ) + avg_trial_duration_result = await db.execute(select(func.avg(SubscriptionConversion.trial_duration_days))) avg_trial_duration = avg_trial_duration_result.scalar() or 0 - avg_first_payment_result = await db.execute( - select(func.avg(SubscriptionConversion.first_payment_amount_kopeks)) - ) + avg_first_payment_result = await db.execute(select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))) avg_first_payment = avg_first_payment_result.scalar() or 0 month_ago = datetime.utcnow() - timedelta(days=30) month_conversions_result = await db.execute( - select(func.count(SubscriptionConversion.id)) - .where(SubscriptionConversion.converted_at >= month_ago) + select(func.count(SubscriptionConversion.id)).where(SubscriptionConversion.converted_at >= month_ago) ) month_conversions = month_conversions_result.scalar() or 0 - logger.info(f"📊 Статистика конверсий:") - logger.info(f" Всего пользователей с подписками: {total_users_with_subscriptions}") - logger.info(f" Оплативших подписку: {users_with_paid}") - logger.info(f" Рассчитанная конверсия: {conversion_rate}%") + logger.info('📊 Статистика конверсий:') + logger.info(f' Всего пользователей с подписками: {total_users_with_subscriptions}') + logger.info(f' Оплативших подписку: {users_with_paid}') + logger.info(f' Рассчитанная конверсия: {conversion_rate}%') return { - "total_conversions": total_conversions, - "conversion_rate": conversion_rate, - "users_with_trial": total_users_with_subscriptions, - "converted_users": users_with_paid, - "avg_trial_duration_days": round(avg_trial_duration, 1), - "avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2), - "month_conversions": month_conversions + 'total_conversions': total_conversions, + 'conversion_rate': conversion_rate, + 'users_with_trial': total_users_with_subscriptions, + 'converted_users': users_with_paid, + 'avg_trial_duration_days': round(avg_trial_duration, 1), + 'avg_first_payment_rubles': round((avg_first_payment or 0) / 100, 2), + 'month_conversions': month_conversions, } async def get_users_had_trial_count(db: AsyncSession) -> int: - - conversions_count_result = await db.execute( - select(func.count(func.distinct(SubscriptionConversion.user_id))) - ) + conversions_count_result = await db.execute(select(func.count(func.distinct(SubscriptionConversion.user_id)))) conversions_count = conversions_count_result.scalar() - - paid_users_result = await db.execute( - select(func.count(User.id)) - .where(User.has_had_paid_subscription == True) - ) + + paid_users_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True)) paid_users_count = paid_users_result.scalar() - + return max(conversions_count, paid_users_count) diff --git a/app/database/crud/subscription_event.py b/app/database/crud/subscription_event.py index c1c55a4f..63cb02cd 100644 --- a/app/database/crud/subscription_event.py +++ b/app/database/crud/subscription_event.py @@ -1,11 +1,12 @@ from __future__ import annotations +from collections.abc import Iterable from datetime import datetime -from typing import Any, Dict, Iterable, Optional, Tuple +from typing import Any from sqlalchemy import and_, func, select -from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database.models import SubscriptionEvent @@ -15,13 +16,13 @@ async def create_subscription_event( *, user_id: int, event_type: str, - subscription_id: Optional[int] = None, - transaction_id: Optional[int] = None, - amount_kopeks: Optional[int] = None, - currency: Optional[str] = None, - message: Optional[str] = None, - occurred_at: Optional[datetime] = None, - extra: Optional[Dict[str, Any]] = None, + subscription_id: int | None = None, + transaction_id: int | None = None, + amount_kopeks: int | None = None, + currency: str | None = None, + message: str | None = None, + occurred_at: datetime | None = None, + extra: dict[str, Any] | None = None, ) -> SubscriptionEvent: event = SubscriptionEvent( user_id=user_id, @@ -45,9 +46,9 @@ async def list_subscription_events( *, limit: int, offset: int, - event_types: Optional[Iterable[str]] = None, - user_id: Optional[int] = None, -) -> Tuple[list[SubscriptionEvent], int]: + event_types: Iterable[str] | None = None, + user_id: int | None = None, +) -> tuple[list[SubscriptionEvent], int]: base_query = select(SubscriptionEvent) filters = [] diff --git a/app/database/crud/system_setting.py b/app/database/crud/system_setting.py index 63aaf719..d07b1d13 100644 --- a/app/database/crud/system_setting.py +++ b/app/database/crud/system_setting.py @@ -1,5 +1,3 @@ -from typing import Optional - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -9,12 +7,10 @@ from app.database.models import SystemSetting async def upsert_system_setting( db: AsyncSession, key: str, - value: Optional[str], - description: Optional[str] = None, + value: str | None, + description: str | None = None, ) -> SystemSetting: - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == key) - ) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) setting = result.scalar_one_or_none() if setting is None: @@ -30,11 +26,8 @@ async def upsert_system_setting( async def delete_system_setting(db: AsyncSession, key: str) -> None: - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == key) - ) + 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() - diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 5ca6401b..20621085 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -1,22 +1,21 @@ import logging -from typing import Dict, List, Optional -from sqlalchemy import func, select, update, delete +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Tariff, Subscription, PromoGroup, tariff_promo_groups +from app.database.models import PromoGroup, Subscription, Tariff logger = logging.getLogger(__name__) -def _normalize_period_prices(period_prices: Optional[Dict[int, int]]) -> Dict[str, int]: +def _normalize_period_prices(period_prices: dict[int, int] | None) -> dict[str, int]: """Нормализует цены периодов в формат {str: int}.""" if not period_prices: return {} - normalized: Dict[str, int] = {} + normalized: dict[str, int] = {} for key, value in period_prices.items(): try: @@ -36,8 +35,8 @@ async def get_all_tariffs( *, include_inactive: bool = False, offset: int = 0, - limit: Optional[int] = None, -) -> List[Tariff]: + limit: int | None = None, +) -> list[Tariff]: """Получает все тарифы с опциональной фильтрацией по активности.""" query = select(Tariff).options(selectinload(Tariff.allowed_promo_groups)) @@ -60,7 +59,7 @@ async def get_tariff_by_id( tariff_id: int, *, with_promo_groups: bool = True, -) -> Optional[Tariff]: +) -> Tariff | None: """Получает тариф по ID.""" query = select(Tariff).where(Tariff.id == tariff_id) @@ -82,7 +81,7 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) -> return int(result.scalar_one()) -async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]: +async def get_trial_tariff(db: AsyncSession) -> Tariff | None: """Получает тариф, доступный для триала (is_trial_available=True). Сортируется по updated_at DESC, чтобы вернуть последний установленный @@ -100,12 +99,10 @@ async def get_trial_tariff(db: AsyncSession) -> Optional[Tariff]: return result.scalars().first() -async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff]: +async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Tariff | None: """Устанавливает тариф как триальный (снимает флаг с других тарифов).""" # Снимаем флаг с всех тарифов - await db.execute( - Tariff.__table__.update().values(is_trial_available=False) - ) + await db.execute(Tariff.__table__.update().values(is_trial_available=False)) # Устанавливаем флаг на выбранный тариф tariff = await get_tariff_by_id(db, tariff_id) @@ -119,16 +116,14 @@ async def set_trial_tariff(db: AsyncSession, tariff_id: int) -> Optional[Tariff] async def clear_trial_tariff(db: AsyncSession) -> None: """Снимает флаг триала со всех тарифов.""" - await db.execute( - Tariff.__table__.update().values(is_trial_available=False) - ) + await db.execute(Tariff.__table__.update().values(is_trial_available=False)) await db.commit() async def get_tariffs_for_user( db: AsyncSession, - promo_group_id: Optional[int] = None, -) -> List[Tariff]: + promo_group_id: int | None = None, +) -> list[Tariff]: """ Получает тарифы, доступные для пользователя с учетом его промогруппы. Если у тарифа нет ограничений по промогруппам - он доступен всем. @@ -162,22 +157,22 @@ async def create_tariff( db: AsyncSession, name: str, *, - description: Optional[str] = None, + description: str | None = None, display_order: int = 0, is_active: bool = True, traffic_limit_gb: int = 100, device_limit: int = 1, - device_price_kopeks: Optional[int] = None, - max_device_limit: Optional[int] = None, - allowed_squads: Optional[List[str]] = None, - server_traffic_limits: Optional[Dict[str, dict]] = None, - period_prices: Optional[Dict[int, int]] = None, + device_price_kopeks: int | None = None, + max_device_limit: int | None = None, + allowed_squads: list[str] | None = None, + server_traffic_limits: dict[str, dict] | None = None, + period_prices: dict[int, int] | None = None, tier_level: int = 1, is_trial_available: bool = False, allow_traffic_topup: bool = True, - promo_group_ids: Optional[List[int]] = None, + promo_group_ids: list[int] | None = None, traffic_topup_enabled: bool = False, - traffic_topup_packages: Optional[Dict[str, int]] = None, + traffic_topup_packages: dict[str, int] | None = None, max_topup_traffic_gb: int = 0, is_daily: bool = False, daily_price_kopeks: int = 0, @@ -192,7 +187,7 @@ async def create_tariff( min_traffic_gb: int = 1, max_traffic_gb: int = 1000, # Режим сброса трафика - traffic_reset_mode: Optional[str] = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка + traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка ) -> Tariff: """Создает новый тариф.""" normalized_prices = _normalize_period_prices(period_prices) @@ -236,9 +231,7 @@ async def create_tariff( # Добавляем промогруппы если указаны if promo_group_ids: - promo_groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) - ) + promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))) promo_groups = promo_groups_result.scalars().all() tariff.allowed_promo_groups = list(promo_groups) @@ -262,38 +255,38 @@ async def update_tariff( db: AsyncSession, tariff: Tariff, *, - name: Optional[str] = None, - description: Optional[str] = None, - display_order: Optional[int] = None, - is_active: Optional[bool] = None, - traffic_limit_gb: Optional[int] = None, - device_limit: Optional[int] = None, - device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить - max_device_limit: Optional[int] = ..., # ... = не передан, None = сбросить (без лимита) - allowed_squads: Optional[List[str]] = None, - server_traffic_limits: Optional[Dict[str, dict]] = None, - period_prices: Optional[Dict[int, int]] = None, - tier_level: Optional[int] = None, - is_trial_available: Optional[bool] = None, - allow_traffic_topup: Optional[bool] = None, - promo_group_ids: Optional[List[int]] = None, - traffic_topup_enabled: Optional[bool] = None, - traffic_topup_packages: Optional[Dict[str, int]] = None, - max_topup_traffic_gb: Optional[int] = None, - is_daily: Optional[bool] = None, - daily_price_kopeks: Optional[int] = None, + name: str | None = None, + description: str | None = None, + display_order: int | None = None, + is_active: bool | None = None, + traffic_limit_gb: int | None = None, + device_limit: int | None = None, + device_price_kopeks: int | None = ..., # ... = не передан, None = сбросить + max_device_limit: int | None = ..., # ... = не передан, None = сбросить (без лимита) + allowed_squads: list[str] | None = None, + server_traffic_limits: dict[str, dict] | None = None, + period_prices: dict[int, int] | None = None, + tier_level: int | None = None, + is_trial_available: bool | None = None, + allow_traffic_topup: bool | None = None, + promo_group_ids: list[int] | None = None, + traffic_topup_enabled: bool | None = None, + traffic_topup_packages: dict[str, int] | None = None, + max_topup_traffic_gb: int | None = None, + is_daily: bool | None = None, + daily_price_kopeks: int | None = None, # Произвольное количество дней - custom_days_enabled: Optional[bool] = None, - price_per_day_kopeks: Optional[int] = None, - min_days: Optional[int] = None, - max_days: Optional[int] = None, + custom_days_enabled: bool | None = None, + price_per_day_kopeks: int | None = None, + min_days: int | None = None, + max_days: int | None = None, # Произвольный трафик при покупке - custom_traffic_enabled: Optional[bool] = None, - traffic_price_per_gb_kopeks: Optional[int] = None, - min_traffic_gb: Optional[int] = None, - max_traffic_gb: Optional[int] = None, + custom_traffic_enabled: bool | None = None, + traffic_price_per_gb_kopeks: int | None = None, + min_traffic_gb: int | None = None, + max_traffic_gb: int | None = None, # Режим сброса трафика - traffic_reset_mode: Optional[str] = ..., # ... = не передан, None = сбросить к глобальной настройке + traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке ) -> Tariff: """Обновляет существующий тариф.""" if name is not None: @@ -361,9 +354,7 @@ async def update_tariff( # Обновляем промогруппы если указаны if promo_group_ids is not None: if promo_group_ids: - promo_groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) - ) + promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))) promo_groups = promo_groups_result.scalars().all() tariff.allowed_promo_groups = list(promo_groups) else: @@ -411,22 +402,18 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool: async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> int: """Подсчитывает количество подписок на тарифе.""" - result = await db.execute( - select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id) - ) + result = await db.execute(select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id)) return int(result.scalar_one()) async def set_tariff_promo_groups( db: AsyncSession, tariff: Tariff, - promo_group_ids: List[int], + promo_group_ids: list[int], ) -> Tariff: """Устанавливает промогруппы для тарифа.""" if promo_group_ids: - promo_groups_result = await db.execute( - select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)) - ) + promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids))) promo_groups = promo_groups_result.scalars().all() tariff.allowed_promo_groups = list(promo_groups) else: @@ -473,7 +460,7 @@ async def get_tariffs_with_subscriptions_count( db: AsyncSession, *, include_inactive: bool = False, -) -> List[tuple]: +) -> list[tuple]: """Получает тарифы с количеством подписок.""" query = ( select(Tariff, func.count(Subscription.id)) @@ -491,22 +478,18 @@ async def get_tariffs_with_subscriptions_count( async def reorder_tariffs( db: AsyncSession, - tariff_order: List[int], + tariff_order: list[int], ) -> None: """Изменяет порядок отображения тарифов.""" for order, tariff_id in enumerate(tariff_order): - await db.execute( - update(Tariff) - .where(Tariff.id == tariff_id) - .values(display_order=order) - ) + await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order)) await db.commit() - logger.info("Изменен порядок тарифов: %s", tariff_order) + logger.info('Изменен порядок тарифов: %s', tariff_order) -async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]: +async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None: """ Синхронизирует дефолтный тариф из конфига (.env) в БД. Создаёт тариф "Стандартный" если в БД нет тарифов. @@ -515,7 +498,7 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]: Returns: Tariff или None если не требуется синхронизация """ - from app.config import settings, PERIOD_PRICES + from app.config import PERIOD_PRICES, settings # Проверяем есть ли тарифы в БД result = await db.execute(select(func.count(Tariff.id))) @@ -528,13 +511,11 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]: period_prices[str(period)] = price if not period_prices: - logger.warning("Нет цен в конфиге для создания дефолтного тарифа") + logger.warning('Нет цен в конфиге для создания дефолтного тарифа') return None # Ищем тариф с именем "Стандартный" или первый тариф - result = await db.execute( - select(Tariff).where(Tariff.name == "Стандартный").limit(1) - ) + result = await db.execute(select(Tariff).where(Tariff.name == 'Стандартный').limit(1)) existing_tariff = result.scalar_one_or_none() if existing_tariff: @@ -550,8 +531,8 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]: if tariff_count == 0: # Создаём новый дефолтный тариф new_tariff = Tariff( - name="Стандартный", - description="Базовый тарифный план", + name='Стандартный', + description='Базовый тарифный план', is_active=True, is_trial_available=True, traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB, @@ -580,41 +561,34 @@ async def load_period_prices_from_db(db: AsyncSession) -> None: # В режиме classic НЕ загружаем цены из тарифов - используем .env if settings.is_classic_mode(): - logger.info("Режим classic: цены периодов берутся из .env, тарифы игнорируются") + logger.info('Режим classic: цены периодов берутся из .env, тарифы игнорируются') return try: # Ищем тариф "Стандартный" или первый активный тариф result = await db.execute( - select(Tariff) - .where(Tariff.is_active.is_(True)) - .order_by(Tariff.display_order, Tariff.id) - .limit(1) + select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id).limit(1) ) tariff = result.scalar_one_or_none() if tariff and tariff.period_prices: # Преобразуем строковые ключи в int - period_prices = { - int(days): int(price) - for days, price in tariff.period_prices.items() - if int(price) > 0 - } + period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0} if period_prices: set_period_prices_from_db(period_prices) logger.info( "Загружены периоды из тарифа '%s': %s", tariff.name, - {f"{d}д": f"{p//100}₽" for d, p in period_prices.items()} + {f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()}, ) else: logger.warning("Тариф '%s' не имеет активных периодов", tariff.name) else: - logger.info("Активные тарифы не найдены, используются цены из .env") + logger.info('Активные тарифы не найдены, используются цены из .env') except Exception as e: - logger.error("Ошибка загрузки периодов из БД: %s", e) + logger.error('Ошибка загрузки периодов из БД: %s', e) async def ensure_tariffs_synced(db: AsyncSession) -> None: @@ -627,4 +601,4 @@ async def ensure_tariffs_synced(db: AsyncSession) -> None: # Загружаем периоды из БД в PERIOD_PRICES await load_period_prices_from_db(db) except Exception as e: - logger.error("Ошибка синхронизации тарифов: %s", e) + logger.error('Ошибка синхронизации тарифов: %s', e) diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 85a0547e..34776407 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -1,40 +1,36 @@ -from typing import List, Optional import logging -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, and_, or_, update, func -from sqlalchemy.orm import selectinload from datetime import datetime -from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog +from sqlalchemy import and_, desc, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import SupportAuditLog, Ticket, TicketMessage, TicketStatus + logger = logging.getLogger(__name__) class TicketCRUD: """CRUD операции для работы с тикетами""" - + @staticmethod async def create_ticket( db: AsyncSession, user_id: int, title: str, message_text: str, - priority: str = "normal", + priority: str = 'normal', *, - media_type: Optional[str] = None, - media_file_id: Optional[str] = None, - media_caption: Optional[str] = None, + media_type: str | None = None, + media_file_id: str | None = None, + media_caption: str | None = None, ) -> Ticket: """Создать новый тикет с первым сообщением""" - ticket = Ticket( - user_id=user_id, - title=title, - status=TicketStatus.OPEN.value, - priority=priority - ) + ticket = Ticket(user_id=user_id, title=title, status=TicketStatus.OPEN.value, priority=priority) db.add(ticket) await db.flush() # Получаем ID тикета - + # Создаем первое сообщение message = TicketMessage( ticket_id=ticket.id, @@ -47,74 +43,64 @@ class TicketCRUD: media_caption=media_caption, ) db.add(message) - + await db.commit() await db.refresh(ticket) - + # Отправляем событие о создании тикета try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "ticket.created", + 'ticket.created', { - "ticket_id": ticket.id, - "user_id": user_id, - "title": title, - "status": ticket.status, - "priority": priority, - "has_media": bool(media_type and media_file_id), + 'ticket_id': ticket.id, + 'user_id': user_id, + 'title': title, + 'status': ticket.status, + 'priority': priority, + 'has_media': bool(media_type and media_file_id), }, db=db, ) except Exception as error: - logger.warning("Failed to emit ticket.created event: %s", error) - + logger.warning('Failed to emit ticket.created event: %s', error) + return ticket - + @staticmethod async def get_ticket_by_id( - db: AsyncSession, - ticket_id: int, - load_messages: bool = True, - load_user: bool = False - ) -> Optional[Ticket]: + db: AsyncSession, ticket_id: int, load_messages: bool = True, load_user: bool = False + ) -> Ticket | None: """Получить тикет по ID""" query = select(Ticket).where(Ticket.id == ticket_id) - + if load_user: query = query.options(selectinload(Ticket.user)) - + if load_messages: query = query.options(selectinload(Ticket.messages)) - + result = await db.execute(query) return result.scalar_one_or_none() - + @staticmethod async def get_user_tickets( - db: AsyncSession, - user_id: int, - status: Optional[str] = None, - limit: int = 20, - offset: int = 0 - ) -> List[Ticket]: + db: AsyncSession, user_id: int, status: str | None = None, limit: int = 20, offset: int = 0 + ) -> list[Ticket]: """Получить тикеты пользователя""" query = select(Ticket).where(Ticket.user_id == user_id) - + if status: query = query.where(Ticket.status == status) - + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit) - + result = await db.execute(query) return result.scalars().all() @staticmethod - async def count_user_tickets_by_statuses( - db: AsyncSession, - user_id: int, - statuses: List[str] - ) -> int: + async def count_user_tickets_by_statuses(db: AsyncSession, user_id: int, statuses: list[str]) -> int: """Подсчитать количество тикетов пользователя по списку статусов""" query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id) if statuses: @@ -124,12 +110,8 @@ class TicketCRUD: @staticmethod async def get_user_tickets_by_statuses( - db: AsyncSession, - user_id: int, - statuses: List[str], - limit: int = 20, - offset: int = 0 - ) -> List[Ticket]: + db: AsyncSession, user_id: int, statuses: list[str], limit: int = 20, offset: int = 0 + ) -> list[Ticket]: """Получить тикеты пользователя по списку статусов с пагинацией""" query = ( select(Ticket) @@ -144,39 +126,36 @@ class TicketCRUD: return result.scalars().all() @staticmethod - async def user_has_active_ticket( - db: AsyncSession, - user_id: int - ) -> bool: + async def user_has_active_ticket(db: AsyncSession, user_id: int) -> bool: """Проверить, есть ли у пользователя активный (не закрытый) тикет""" query = ( select(Ticket.id) - .where( - Ticket.user_id == user_id, - Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]) - ) + .where(Ticket.user_id == user_id, Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])) .limit(1) ) result = await db.execute(query) return result.scalar_one_or_none() is not None @staticmethod - async def is_user_globally_blocked( - db: AsyncSession, - user_id: int - ) -> Optional[datetime]: + async def is_user_globally_blocked(db: AsyncSession, user_id: int) -> datetime | None: """Проверить, заблокирован ли пользователь для создания/ответов по любому тикету. Возвращает дату окончания блокировки, если активна, или None. """ - query = select(Ticket).where( - Ticket.user_id == user_id, - or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None)) - ).order_by(desc(Ticket.updated_at)).limit(10) + query = ( + select(Ticket) + .where( + Ticket.user_id == user_id, + or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None)), + ) + .order_by(desc(Ticket.updated_at)) + .limit(10) + ) result = await db.execute(query) tickets = result.scalars().all() if not tickets: return None from datetime import datetime + # Если есть вечная блокировка в любом тикете — блок активен без срока for t in tickets: if t.user_reply_block_permanent: @@ -187,39 +166,32 @@ class TicketCRUD: return None max_until = max(future_until) return max_until if max_until > datetime.utcnow() else None - + @staticmethod async def get_all_tickets( - db: AsyncSession, - status: Optional[str] = None, - priority: Optional[str] = None, - limit: int = 50, - offset: int = 0 - ) -> List[Ticket]: + db: AsyncSession, status: str | None = None, priority: str | None = None, limit: int = 50, offset: int = 0 + ) -> list[Ticket]: """Получить все тикеты (для админов)""" query = select(Ticket).options(selectinload(Ticket.user)) - + conditions = [] if status: conditions.append(Ticket.status == status) if priority: conditions.append(Ticket.priority == priority) - + if conditions: query = query.where(and_(*conditions)) - + query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit) - + result = await db.execute(query) return result.scalars().all() @staticmethod async def get_tickets_by_statuses( - db: AsyncSession, - statuses: List[str], - limit: int = 50, - offset: int = 0 - ) -> List[Ticket]: + db: AsyncSession, statuses: list[str], limit: int = 50, offset: int = 0 + ) -> list[Ticket]: query = select(Ticket).options(selectinload(Ticket.user)) if statuses: query = query.where(Ticket.status.in_(statuses)) @@ -228,10 +200,7 @@ class TicketCRUD: return result.scalars().all() @staticmethod - async def count_tickets( - db: AsyncSession, - status: Optional[str] = None - ) -> int: + async def count_tickets(db: AsyncSession, status: str | None = None) -> int: query = select(func.count()).select_from(Ticket) if status: query = query.where(Ticket.status == status) @@ -239,62 +208,52 @@ class TicketCRUD: return int(result.scalar() or 0) @staticmethod - async def count_tickets_by_statuses( - db: AsyncSession, - statuses: List[str] - ) -> int: + async def count_tickets_by_statuses(db: AsyncSession, statuses: list[str]) -> int: query = select(func.count()).select_from(Ticket) if statuses: query = query.where(Ticket.status.in_(statuses)) result = await db.execute(query) return int(result.scalar() or 0) - + @staticmethod async def update_ticket_status( - db: AsyncSession, - ticket_id: int, - status: str, - closed_at: Optional[datetime] = None + db: AsyncSession, ticket_id: int, status: str, closed_at: datetime | None = None ) -> bool: """Обновить статус тикета""" ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if not ticket: return False - + ticket.status = status ticket.updated_at = datetime.utcnow() - + if status == TicketStatus.CLOSED.value and closed_at: ticket.closed_at = closed_at - + await db.commit() - + # Отправляем событие об изменении статуса тикета try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "ticket.status_changed", + 'ticket.status_changed', { - "ticket_id": ticket_id, - "user_id": ticket.user_id, - "old_status": ticket.status, # На самом деле это уже новый статус, но для простоты оставим так - "new_status": status, - "closed_at": closed_at.isoformat() if closed_at else None, + 'ticket_id': ticket_id, + 'user_id': ticket.user_id, + 'old_status': ticket.status, # На самом деле это уже новый статус, но для простоты оставим так + 'new_status': status, + 'closed_at': closed_at.isoformat() if closed_at else None, }, db=db, ) except Exception as error: - logger.warning("Failed to emit ticket.status_changed event: %s", error) - + logger.warning('Failed to emit ticket.status_changed event: %s', error) + return True @staticmethod - async def set_user_reply_block( - db: AsyncSession, - ticket_id: int, - permanent: bool, - until: Optional[datetime] - ) -> bool: + async def set_user_reply_block(db: AsyncSession, ticket_id: int, permanent: bool, until: datetime | None) -> bool: ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if not ticket: return False @@ -303,26 +262,19 @@ class TicketCRUD: ticket.updated_at = datetime.utcnow() await db.commit() return True - + @staticmethod - async def close_ticket( - db: AsyncSession, - ticket_id: int - ) -> bool: + async def close_ticket(db: AsyncSession, ticket_id: int) -> bool: """Закрыть тикет""" - return await TicketCRUD.update_ticket_status( - db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow() - ) + return await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()) @staticmethod async def close_all_open_tickets( db: AsyncSession, - ) -> List[int]: + ) -> list[int]: """Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов.""" open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] - result = await db.execute( - select(Ticket.id).where(Ticket.status.in_(open_statuses)) - ) + result = await db.execute(select(Ticket.id).where(Ticket.status.in_(open_statuses))) ticket_ids = result.scalars().all() if not ticket_ids: @@ -342,13 +294,13 @@ class TicketCRUD: async def add_support_audit( db: AsyncSession, *, - actor_user_id: Optional[int], + actor_user_id: int | None, actor_telegram_id: int, is_moderator: bool, action: str, - ticket_id: Optional[int] = None, - target_user_id: Optional[int] = None, - details: Optional[dict] = None, + ticket_id: int | None = None, + target_user_id: int | None = None, + details: dict | None = None, ) -> None: try: log = SupportAuditLog( @@ -365,7 +317,6 @@ class TicketCRUD: except Exception: await db.rollback() # не мешаем основной логике - pass @staticmethod async def list_support_audit( @@ -373,9 +324,9 @@ class TicketCRUD: *, limit: int = 50, offset: int = 0, - action: Optional[str] = None, - ) -> List[SupportAuditLog]: - from sqlalchemy import select, desc + action: str | None = None, + ) -> list[SupportAuditLog]: + from sqlalchemy import desc, select query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)) @@ -386,8 +337,8 @@ class TicketCRUD: return result.scalars().all() @staticmethod - async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int: - from sqlalchemy import select, func + async def count_support_audit(db: AsyncSession, action: str | None = None) -> int: + from sqlalchemy import func, select query = select(func.count()).select_from(SupportAuditLog) @@ -398,7 +349,7 @@ class TicketCRUD: return int(result.scalar() or 0) @staticmethod - async def list_support_audit_actions(db: AsyncSession) -> List[str]: + async def list_support_audit_actions(db: AsyncSession) -> list[str]: from sqlalchemy import select result = await db.execute( @@ -409,21 +360,18 @@ class TicketCRUD: ) return [row[0] for row in result.fetchall()] - + @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: """Получить количество открытых тикетов""" - query = select(Ticket).where(Ticket.status.in_([ - TicketStatus.OPEN.value, - TicketStatus.ANSWERED.value - ])) + query = select(Ticket).where(Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])) result = await db.execute(query) return len(result.scalars().all()) class TicketMessageCRUD: """CRUD операции для работы с сообщениями тикетов""" - + @staticmethod async def add_message( db: AsyncSession, @@ -431,9 +379,9 @@ class TicketMessageCRUD: user_id: int, message_text: str, is_from_admin: bool = False, - media_type: Optional[str] = None, - media_file_id: Optional[str] = None, - media_caption: Optional[str] = None + media_type: str | None = None, + media_file_id: str | None = None, + media_caption: str | None = None, ) -> TicketMessage: """Добавить сообщение в тикет""" message = TicketMessage( @@ -444,11 +392,11 @@ class TicketMessageCRUD: has_media=bool(media_type and media_file_id), media_type=media_type, media_file_id=media_file_id, - media_caption=media_caption + media_caption=media_caption, ) - + db.add(message) - + # Обновляем статус тикета ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if ticket: @@ -463,46 +411,43 @@ class TicketMessageCRUD: ticket.status = TicketStatus.OPEN.value # Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения try: - from sqlalchemy import inspect as sa_inspect # если колонка существует в модели if hasattr(ticket, 'last_sla_reminder_at'): ticket.last_sla_reminder_at = None except Exception: pass - + ticket.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(message) - + # Отправляем событие о новом сообщении в тикете try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "ticket.message_added", + 'ticket.message_added', { - "ticket_id": ticket_id, - "message_id": message.id, - "user_id": user_id, - "is_from_admin": is_from_admin, - "message_text": message_text[:200], # Ограничиваем длину для события - "has_media": bool(media_type and media_file_id), - "status": ticket.status if ticket else None, + 'ticket_id': ticket_id, + 'message_id': message.id, + 'user_id': user_id, + 'is_from_admin': is_from_admin, + 'message_text': message_text[:200], # Ограничиваем длину для события + 'has_media': bool(media_type and media_file_id), + 'status': ticket.status if ticket else None, }, db=db, ) except Exception as error: - logger.warning("Failed to emit ticket.message_added event: %s", error) - + logger.warning('Failed to emit ticket.message_added event: %s', error) + return message - + @staticmethod async def get_ticket_messages( - db: AsyncSession, - ticket_id: int, - limit: int = 50, - offset: int = 0 - ) -> List[TicketMessage]: + db: AsyncSession, ticket_id: int, limit: int = 50, offset: int = 0 + ) -> list[TicketMessage]: """Получить сообщения тикета""" query = ( select(TicketMessage) @@ -511,15 +456,12 @@ class TicketMessageCRUD: .offset(offset) .limit(limit) ) - + result = await db.execute(query) return result.scalars().all() - + @staticmethod - async def get_last_message( - db: AsyncSession, - ticket_id: int - ) -> Optional[TicketMessage]: + async def get_last_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None: """Получить последнее сообщение в тикете""" query = ( select(TicketMessage) @@ -527,6 +469,6 @@ class TicketMessageCRUD: .order_by(desc(TicketMessage.created_at)) .limit(1) ) - + result = await db.execute(query) return result.scalar_one_or_none() diff --git a/app/database/crud/ticket_notification.py b/app/database/crud/ticket_notification.py index 3623ae68..0f801e05 100644 --- a/app/database/crud/ticket_notification.py +++ b/app/database/crud/ticket_notification.py @@ -2,13 +2,12 @@ import logging from datetime import datetime -from typing import List, Optional -from sqlalchemy import select, func, desc, and_, update +from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import TicketNotification, Ticket, User +from app.database.models import Ticket, TicketNotification logger = logging.getLogger(__name__) @@ -18,7 +17,7 @@ class TicketNotificationCRUD: """CRUD operations for ticket notifications in cabinet.""" @staticmethod - async def get_by_id(db: AsyncSession, notification_id: int) -> Optional[TicketNotification]: + async def get_by_id(db: AsyncSession, notification_id: int) -> TicketNotification | None: """Get notification by ID.""" query = select(TicketNotification).where(TicketNotification.id == notification_id) result = await db.execute(query) @@ -30,7 +29,7 @@ class TicketNotificationCRUD: ticket_id: int, user_id: int, notification_type: str, - message: Optional[str] = None, + message: str | None = None, is_for_admin: bool = False, ) -> TicketNotification: """Create a new ticket notification.""" @@ -55,7 +54,7 @@ class TicketNotificationCRUD: unread_only: bool = False, limit: int = 50, offset: int = 0, - ) -> List[TicketNotification]: + ) -> list[TicketNotification]: """Get notifications for a user (not admin).""" query = ( select(TicketNotification) @@ -80,7 +79,7 @@ class TicketNotificationCRUD: unread_only: bool = False, limit: int = 50, offset: int = 0, - ) -> List[TicketNotification]: + ) -> list[TicketNotification]: """Get notifications for admins.""" query = ( select(TicketNotification) @@ -99,10 +98,14 @@ class TicketNotificationCRUD: @staticmethod async def count_unread_user(db: AsyncSession, user_id: int) -> int: """Count unread notifications for a user.""" - query = select(func.count()).select_from(TicketNotification).where( - TicketNotification.user_id == user_id, - TicketNotification.is_for_admin == False, - TicketNotification.is_read == False, + query = ( + select(func.count()) + .select_from(TicketNotification) + .where( + TicketNotification.user_id == user_id, + TicketNotification.is_for_admin == False, + TicketNotification.is_read == False, + ) ) result = await db.execute(query) return result.scalar() or 0 @@ -110,9 +113,13 @@ class TicketNotificationCRUD: @staticmethod async def count_unread_admin(db: AsyncSession) -> int: """Count unread notifications for admins.""" - query = select(func.count()).select_from(TicketNotification).where( - TicketNotification.is_for_admin == True, - TicketNotification.is_read == False, + query = ( + select(func.count()) + .select_from(TicketNotification) + .where( + TicketNotification.is_for_admin == True, + TicketNotification.is_read == False, + ) ) result = await db.execute(query) return result.scalar() or 0 @@ -187,23 +194,21 @@ class TicketNotificationCRUD: return result.rowcount @staticmethod - async def create_admin_notification_for_new_ticket( - db: AsyncSession, ticket: Ticket - ) -> Optional[TicketNotification]: + async def create_admin_notification_for_new_ticket(db: AsyncSession, ticket: Ticket) -> TicketNotification | None: """Create notification for admins about new ticket.""" from app.services.support_settings_service import SupportSettingsService if not SupportSettingsService.get_cabinet_admin_notifications_enabled(): return None - title = (ticket.title or "").strip()[:50] - message = f"Новый тикет #{ticket.id}: {title}" + title = (ticket.title or '').strip()[:50] + message = f'Новый тикет #{ticket.id}: {title}' return await TicketNotificationCRUD.create( db=db, ticket_id=ticket.id, user_id=ticket.user_id, - notification_type="new_ticket", + notification_type='new_ticket', message=message, is_for_admin=True, ) @@ -211,21 +216,21 @@ class TicketNotificationCRUD: @staticmethod async def create_user_notification_for_admin_reply( db: AsyncSession, ticket: Ticket, reply_preview: str - ) -> Optional[TicketNotification]: + ) -> TicketNotification | None: """Create notification for user about admin reply.""" from app.services.support_settings_service import SupportSettingsService if not SupportSettingsService.get_cabinet_user_notifications_enabled(): return None - preview = (reply_preview or "").strip()[:100] - message = f"Ответ на тикет #{ticket.id}: {preview}..." + preview = (reply_preview or '').strip()[:100] + message = f'Ответ на тикет #{ticket.id}: {preview}...' return await TicketNotificationCRUD.create( db=db, ticket_id=ticket.id, user_id=ticket.user_id, - notification_type="admin_reply", + notification_type='admin_reply', message=message, is_for_admin=False, ) @@ -233,21 +238,21 @@ class TicketNotificationCRUD: @staticmethod async def create_admin_notification_for_user_reply( db: AsyncSession, ticket: Ticket, reply_preview: str - ) -> Optional[TicketNotification]: + ) -> TicketNotification | None: """Create notification for admins about user reply.""" from app.services.support_settings_service import SupportSettingsService if not SupportSettingsService.get_cabinet_admin_notifications_enabled(): return None - preview = (reply_preview or "").strip()[:100] - message = f"Ответ в тикете #{ticket.id}: {preview}..." + preview = (reply_preview or '').strip()[:100] + message = f'Ответ в тикете #{ticket.id}: {preview}...' return await TicketNotificationCRUD.create( db=db, ticket_id=ticket.id, user_id=ticket.user_id, - notification_type="user_reply", + notification_type='user_reply', message=message, is_for_admin=True, ) diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index f554eb56..d589cc0c 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -1,11 +1,12 @@ import logging from datetime import datetime, timedelta -from typing import Optional, List -from sqlalchemy import select, and_, or_, func, desc + +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import Transaction, TransactionType, PaymentMethod, User +from app.database.models import PaymentMethod, Transaction, TransactionType, User + logger = logging.getLogger(__name__) @@ -33,11 +34,10 @@ async def create_transaction( type: TransactionType, amount_kopeks: int, description: str, - payment_method: Optional[PaymentMethod] = None, - external_id: Optional[str] = None, - is_completed: bool = True + payment_method: PaymentMethod | None = None, + external_id: str | None = None, + is_completed: bool = True, ) -> Transaction: - transaction = Transaction( user_id=user_id, type=type.value, @@ -46,35 +46,36 @@ async def create_transaction( payment_method=payment_method.value if payment_method else None, external_id=external_id, is_completed=is_completed, - completed_at=datetime.utcnow() if is_completed else None + completed_at=datetime.utcnow() if is_completed else None, ) - + db.add(transaction) await db.commit() await db.refresh(transaction) - - logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}") + + logger.info(f'💳 Создана транзакция: {type.value} на {amount_kopeks / 100}₽ для пользователя {user_id}') # Отправляем событие о транзакции try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "payment.completed" if type == TransactionType.DEPOSIT else "transaction.created", + 'payment.completed' if type == TransactionType.DEPOSIT else 'transaction.created', { - "transaction_id": transaction.id, - "user_id": user_id, - "type": type.value, - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "payment_method": payment_method.value if payment_method else None, - "external_id": external_id, - "is_completed": is_completed, - "description": description, + 'transaction_id': transaction.id, + 'user_id': user_id, + 'type': type.value, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'payment_method': payment_method.value if payment_method else None, + 'external_id': external_id, + 'is_completed': is_completed, + 'description': description, }, db=db, ) except Exception as error: - logger.warning("Failed to emit transaction event: %s", error) + logger.warning('Failed to emit transaction event: %s', error) try: from app.services.promo_group_assignment import ( @@ -84,7 +85,7 @@ async def create_transaction( await maybe_assign_promo_group_by_total_spent(db, user_id) except Exception as exc: logger.debug( - "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + 'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s', user_id, exc, ) @@ -99,7 +100,7 @@ async def create_transaction( ) except Exception as exc: logger.debug( - "Не удалось записать событие конкурса для пользователя %s: %s", + 'Не удалось записать событие конкурса для пользователя %s: %s', user_id, exc, ) @@ -107,39 +108,25 @@ async def create_transaction( return transaction -async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]: +async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Transaction | None: result = await db.execute( - select(Transaction) - .options(selectinload(Transaction.user)) - .where(Transaction.id == transaction_id) + select(Transaction).options(selectinload(Transaction.user)).where(Transaction.id == transaction_id) ) return result.scalar_one_or_none() async def get_transaction_by_external_id( - db: AsyncSession, - external_id: str, - payment_method: PaymentMethod -) -> Optional[Transaction]: + db: AsyncSession, external_id: str, payment_method: PaymentMethod +) -> Transaction | None: result = await db.execute( - select(Transaction) - .where( - and_( - Transaction.external_id == external_id, - Transaction.payment_method == payment_method.value - ) + select(Transaction).where( + and_(Transaction.external_id == external_id, Transaction.payment_method == payment_method.value) ) ) return result.scalar_one_or_none() -async def get_user_transactions( - db: AsyncSession, - user_id: int, - limit: int = 50, - offset: int = 0 -) -> List[Transaction]: - +async def get_user_transactions(db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0) -> list[Transaction]: result = await db.execute( select(Transaction) .where(Transaction.user_id == user_id) @@ -151,16 +138,13 @@ async def get_user_transactions( async def get_user_transactions_count( - db: AsyncSession, - user_id: int, - transaction_type: Optional[TransactionType] = None + db: AsyncSession, user_id: int, transaction_type: TransactionType | None = None ) -> int: - query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id) - + if transaction_type: query = query.where(Transaction.type == transaction_type.value) - + result = await db.execute(query) return result.scalar() @@ -179,14 +163,13 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int: async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction: - transaction.is_completed = True transaction.completed_at = datetime.utcnow() await db.commit() await db.refresh(transaction) - logger.info(f"✅ Транзакция {transaction.id} завершена") + logger.info(f'✅ Транзакция {transaction.id} завершена') try: from app.services.promo_group_assignment import ( @@ -196,7 +179,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr await maybe_assign_promo_group_by_total_spent(db, transaction.user_id) except Exception as exc: logger.debug( - "Не удалось проверить автовыдачу промогруппы для пользователя %s: %s", + 'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s', transaction.user_id, exc, ) @@ -204,8 +187,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr return transaction -async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: - +async def get_pending_transactions(db: AsyncSession) -> list[Transaction]: result = await db.execute( select(Transaction) .options(selectinload(Transaction.user)) @@ -216,243 +198,216 @@ async def get_pending_transactions(db: AsyncSession) -> List[Transaction]: async def get_transactions_statistics( - db: AsyncSession, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + db: AsyncSession, start_date: datetime | None = None, end_date: datetime | None = None ) -> dict: - if not start_date: start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0) if not end_date: end_date = datetime.utcnow() - + # Доход считаем только по реальным платежам (исключаем колесо, промокоды, админские пополнения) income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date, + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), + ) + ) + ) + total_income = income_result.scalar() + + expenses_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.type == TransactionType.WITHDRAWAL.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date, + ) + ) + ) + total_expenses = expenses_result.scalar() + + subscription_income_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + and_( + Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date, + ) + ) + ) + subscription_income = subscription_income_result.scalar() + + transactions_count_result = await db.execute( + select( + Transaction.type, + func.count(Transaction.id).label('count'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'), + ) + .where( + and_( + Transaction.is_completed == True, + Transaction.created_at >= start_date, + Transaction.created_at <= end_date, + ) + ) + .group_by(Transaction.type) + ) + transactions_by_type = { + row.type: {'count': row.count, 'amount': row.total_amount} for row in transactions_count_result + } + + payment_methods_result = await db.execute( + select( + Transaction.payment_method, + func.count(Transaction.id).label('count'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'), + ) .where( and_( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= start_date, Transaction.created_at <= end_date, - Transaction.payment_method.in_(REAL_PAYMENT_METHODS) - ) - ) - ) - total_income = income_result.scalar() - - expenses_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.type == TransactionType.WITHDRAWAL.value, - Transaction.is_completed == True, - Transaction.created_at >= start_date, - Transaction.created_at <= end_date - ) - ) - ) - total_expenses = expenses_result.scalar() - - subscription_income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - and_( - Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value, - Transaction.is_completed == True, - Transaction.created_at >= start_date, - Transaction.created_at <= end_date - ) - ) - ) - subscription_income = subscription_income_result.scalar() - - transactions_count_result = await db.execute( - select( - Transaction.type, - func.count(Transaction.id).label('count'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount') - ) - .where( - and_( - Transaction.is_completed == True, - Transaction.created_at >= start_date, - Transaction.created_at <= end_date - ) - ) - .group_by(Transaction.type) - ) - transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount} - for row in transactions_count_result} - - payment_methods_result = await db.execute( - select( - Transaction.payment_method, - func.count(Transaction.id).label('count'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount') - ) - .where( - and_( - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed == True, - Transaction.created_at >= start_date, - Transaction.created_at <= end_date ) ) .group_by(Transaction.payment_method) ) - payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount} - for row in payment_methods_result} - + payment_methods = { + row.payment_method: {'count': row.count, 'amount': row.total_amount} for row in payment_methods_result + } + today = datetime.utcnow().date() today_result = await db.execute( - select(func.count(Transaction.id)) - .where( - and_( - Transaction.is_completed == True, - Transaction.created_at >= today - ) + select(func.count(Transaction.id)).where( + and_(Transaction.is_completed == True, Transaction.created_at >= today) ) ) transactions_today = today_result.scalar() - + # Доход за сегодня - только реальные платежи today_income_result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( and_( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= today, - Transaction.payment_method.in_(REAL_PAYMENT_METHODS) + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) ) income_today = today_income_result.scalar() - + return { - "period": { - "start_date": start_date, - "end_date": end_date + 'period': {'start_date': start_date, 'end_date': end_date}, + 'totals': { + 'income_kopeks': total_income, + 'expenses_kopeks': total_expenses, + 'profit_kopeks': total_income - total_expenses, + 'subscription_income_kopeks': subscription_income, }, - "totals": { - "income_kopeks": total_income, - "expenses_kopeks": total_expenses, - "profit_kopeks": total_income - total_expenses, - "subscription_income_kopeks": subscription_income - }, - "today": { - "transactions_count": transactions_today, - "income_kopeks": income_today - }, - "by_type": transactions_by_type, - "by_payment_method": payment_methods + 'today': {'transactions_count': transactions_today, 'income_kopeks': income_today}, + 'by_type': transactions_by_type, + 'by_payment_method': payment_methods, } -async def get_revenue_by_period( - db: AsyncSession, - days: int = 30 -) -> List[dict]: +async def get_revenue_by_period(db: AsyncSession, days: int = 30) -> list[dict]: """Доход по дням - только реальные платежи.""" start_date = datetime.utcnow() - timedelta(days=days) result = await db.execute( select( func.date(Transaction.created_at).label('date'), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount') + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'), ) .where( and_( Transaction.type == TransactionType.DEPOSIT.value, Transaction.is_completed == True, Transaction.created_at >= start_date, - Transaction.payment_method.in_(REAL_PAYMENT_METHODS) + Transaction.payment_method.in_(REAL_PAYMENT_METHODS), ) ) .group_by(func.date(Transaction.created_at)) .order_by(func.date(Transaction.created_at)) ) - return [{"date": row.date, "amount_kopeks": row.amount} for row in result] + return [{'date': row.date, 'amount_kopeks': row.amount} for row in result] async def find_tribute_transactions_by_payment_id( - db: AsyncSession, - payment_id: str, - user_telegram_id: Optional[int] = None -) -> List[Transaction]: - + db: AsyncSession, payment_id: str, user_telegram_id: int | None = None +) -> list[Transaction]: query = select(Transaction).options(selectinload(Transaction.user)) - + conditions = [ - Transaction.external_id == f"donation_{payment_id}", + Transaction.external_id == f'donation_{payment_id}', Transaction.external_id == payment_id, - Transaction.external_id.like(f"%{payment_id}%") + Transaction.external_id.like(f'%{payment_id}%'), ] - - query = query.where( - and_( - Transaction.payment_method == PaymentMethod.TRIBUTE.value, - or_(*conditions) - ) - ) - + + query = query.where(and_(Transaction.payment_method == PaymentMethod.TRIBUTE.value, or_(*conditions))) + if user_telegram_id: from app.database.models import User + query = query.join(User).where(User.telegram_id == user_telegram_id) - + result = await db.execute(query.order_by(Transaction.created_at.desc())) return result.scalars().all() async def check_tribute_payment_duplicate( - db: AsyncSession, - payment_id: str, - amount_kopeks: int, - user_telegram_id: int -) -> Optional[Transaction]: + db: AsyncSession, payment_id: str, amount_kopeks: int, user_telegram_id: int +) -> Transaction | None: cutoff_time = datetime.utcnow() - timedelta(hours=24) - - exact_external_id = f"donation_{payment_id}" - - query = select(Transaction).options(selectinload(Transaction.user)).where( - and_( - Transaction.payment_method == PaymentMethod.TRIBUTE.value, - Transaction.external_id == exact_external_id, - Transaction.amount_kopeks == amount_kopeks, - Transaction.is_completed == True, - Transaction.created_at >= cutoff_time + + exact_external_id = f'donation_{payment_id}' + + query = ( + select(Transaction) + .options(selectinload(Transaction.user)) + .where( + and_( + Transaction.payment_method == PaymentMethod.TRIBUTE.value, + Transaction.external_id == exact_external_id, + Transaction.amount_kopeks == amount_kopeks, + Transaction.is_completed == True, + Transaction.created_at >= cutoff_time, + ) ) - ).join(User).where(User.telegram_id == user_telegram_id) - + .join(User) + .where(User.telegram_id == user_telegram_id) + ) + result = await db.execute(query) transaction = result.scalar_one_or_none() - + if transaction: - logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}") - + logger.info(f'🔍 Найден дубликат платежа в течение 24ч: {transaction.id}') + return transaction async def create_unique_tribute_transaction( - db: AsyncSession, - user_id: int, - payment_id: str, - amount_kopeks: int, - description: str + db: AsyncSession, user_id: int, payment_id: str, amount_kopeks: int, description: str ) -> Transaction: - - external_id = f"donation_{payment_id}" - + external_id = f'donation_{payment_id}' + existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE) - + if existing: timestamp = int(datetime.utcnow().timestamp()) - external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}" - - logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}") - + external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}' + + logger.info(f'Создан уникальный external_id для избежания дубликатов: {external_id}') + return await create_transaction( db=db, user_id=user_id, @@ -461,5 +416,5 @@ async def create_unique_tribute_transaction( description=description, payment_method=PaymentMethod.TRIBUTE, external_id=external_id, - is_completed=True + is_completed=True, ) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index dde42a30..a6ff15d6 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -2,29 +2,29 @@ import logging import secrets import string from datetime import datetime, timedelta -from typing import Optional, List, Dict -from sqlalchemy import select, and_, or_, func, case, nullslast, text -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload, joinedload -from sqlalchemy.exc import IntegrityError +from sqlalchemy import and_, case, func, nullslast, or_, select, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.crud.discount_offer import get_latest_claimed_offer_for_user +from app.database.crud.promo_group import get_default_promo_group +from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import ( - User, - UserStatus, + PaymentMethod, + PromoGroup, Subscription, SubscriptionStatus, Transaction, - PromoGroup, - UserPromoGroup, - PaymentMethod, TransactionType, + User, + UserPromoGroup, + UserStatus, ) -from app.config import settings -from app.database.crud.promo_group import get_default_promo_group -from app.database.crud.discount_offer import get_latest_claimed_offer_for_user -from app.database.crud.promo_offer_log import log_promo_offer_action from app.utils.validators import sanitize_telegram_name + logger = logging.getLogger(__name__) @@ -39,10 +39,9 @@ def _build_spending_stats_select(): Returns: Tuple колонок (user_id, total_spent, purchase_count) """ - from app.database.models import Transaction return ( - Transaction.user_id.label("user_id"), + Transaction.user_id.label('user_id'), func.coalesce( func.sum( case( @@ -54,7 +53,7 @@ def _build_spending_stats_select(): ) ), 0, - ).label("total_spent"), + ).label('total_spent'), func.coalesce( func.sum( case( @@ -66,17 +65,17 @@ def _build_spending_stats_select(): ) ), 0, - ).label("purchase_count"), + ).label('purchase_count'), ) def generate_referral_code() -> str: alphabet = string.ascii_letters + string.digits code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8)) - return f"ref{code_suffix}" + return f'ref{code_suffix}' -async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: +async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None: result = await db.execute( select(User) .options( @@ -88,15 +87,15 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]: .where(User.id == user_id) ) user = result.scalar_one_or_none() - + if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return user -async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]: +async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User | None: result = await db.execute( select(User) .options( @@ -116,7 +115,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona return user -async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]: +async def get_user_by_username(db: AsyncSession, username: str) -> User | None: if not username: return None @@ -142,7 +141,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User return user -async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]: +async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> User | None: result = await db.execute( select(User) .options( @@ -153,15 +152,15 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Opt .where(User.referral_code == referral_code) ) user = result.scalar_one_or_none() - + if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return user -async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> Optional[User]: +async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> User | None: result = await db.execute( select(User) .options( @@ -182,29 +181,22 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> O async def create_unique_referral_code(db: AsyncSession) -> str: max_attempts = 10 - + for _ in range(max_attempts): code = generate_referral_code() existing_user = await get_user_by_referral_code(db, code) if not existing_user: return code - + timestamp = str(int(datetime.utcnow().timestamp()))[-6:] - return f"ref{timestamp}" + return f'ref{timestamp}' async def _sync_users_sequence(db: AsyncSession) -> None: """Ensure the users.id sequence matches the current max ID.""" - await db.execute( - text( - "SELECT setval('users_id_seq', " - "COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)" - ) - ) + await db.execute(text("SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)")) await db.commit() - logger.warning( - "🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id" - ) + logger.warning('🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id') async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup: @@ -213,7 +205,7 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup: return default_group default_group = PromoGroup( - name="Базовый юзер", + name='Базовый юзер', server_discount_percent=0, traffic_discount_percent=0, device_discount_percent=0, @@ -230,17 +222,17 @@ async def create_user_no_commit( username: str = None, first_name: str = None, last_name: str = None, - language: str = "ru", + language: str = 'ru', referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, ) -> User: """ Создает пользователя без немедленного коммита для пакетной обработки """ - + if not referral_code: referral_code = await create_unique_referral_code(db) - + default_group = await _get_or_create_default_promo_group(db) promo_group_id = default_group.id @@ -269,9 +261,7 @@ async def create_user_no_commit( user.promo_group = default_group # Не коммитим сразу, оставляем для пакетной обработки - logger.info( - f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)" - ) + logger.info(f'✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)') return user @@ -281,14 +271,13 @@ async def create_user( username: str = None, first_name: str = None, last_name: str = None, - language: str = "ru", + language: str = 'ru', referred_by_id: int = None, - referral_code: str = None + referral_code: str = None, ) -> User: - if not referral_code: referral_code = await create_unique_referral_code(db) - + attempts = 3 for attempt in range(1, attempts + 1): @@ -318,42 +307,41 @@ async def create_user( await db.refresh(user) user.promo_group = default_group - logger.info( - f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}" - ) - + logger.info(f'✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}') + # Отправляем событие о создании пользователя try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "user.created", + 'user.created', { - "user_id": user.id, - "telegram_id": user.telegram_id, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "referral_code": user.referral_code, - "referred_by_id": user.referred_by_id, + 'user_id': user.id, + 'telegram_id': user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'referral_code': user.referral_code, + 'referred_by_id': user.referred_by_id, }, db=db, ) except Exception as error: - logger.warning("Failed to emit user.created event: %s", error) - + logger.warning('Failed to emit user.created event: %s', error) + return user except IntegrityError as exc: await db.rollback() if ( - isinstance(getattr(exc, "orig", None), Exception) - and "users_pkey" in str(exc.orig) + isinstance(getattr(exc, 'orig', None), Exception) + and 'users_pkey' in str(exc.orig) and attempt < attempts ): logger.warning( - "⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. " - "Выполняем повторную синхронизацию (попытка %s/%s)", + '⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. ' + 'Выполняем повторную синхронизацию (попытка %s/%s)', telegram_id, attempt, attempts, @@ -363,26 +351,22 @@ async def create_user( raise - raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности") + raise RuntimeError('Не удалось создать пользователя после синхронизации последовательности') -async def update_user( - db: AsyncSession, - user: User, - **kwargs -) -> User: - +async def update_user(db: AsyncSession, user: User, **kwargs) -> User: from app.utils.validators import sanitize_telegram_name + for field, value in kwargs.items(): - if field in ("first_name", "last_name"): + if field in ('first_name', 'last_name'): value = sanitize_telegram_name(value) if hasattr(user, field): setattr(user, field, value) - + user.updated_at = datetime.utcnow() await db.commit() await db.refresh(user) - + return user @@ -390,11 +374,11 @@ async def add_user_balance( db: AsyncSession, user: User, amount_kopeks: int, - description: str = "Пополнение баланса", + description: str = 'Пополнение баланса', create_transaction: bool = True, transaction_type: TransactionType = TransactionType.DEPOSIT, - bot = None, - payment_method: Optional[PaymentMethod] = None + bot=None, + payment_method: PaymentMethod | None = None, ) -> bool: try: old_balance = user.balance_kopeks @@ -410,14 +394,16 @@ async def add_user_balance( type=transaction_type, amount_kopeks=amount_kopeks, description=description, - payment_method=payment_method + payment_method=payment_method, ) - + await db.commit() await db.refresh(user) - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"💰 Баланс пользователя {user_id_display} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info( + f'💰 Баланс пользователя {user_id_display} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})' + ) # Автоматическое возобновление приостановленной суточной подписки try: @@ -434,23 +420,24 @@ async def add_user_balance( if daily_price > 0 and user.balance_kopeks >= daily_price: await resume_daily_subscription(db, subscription) logger.info( - f"✅ Автоматически возобновлена суточная подписка {subscription.id} " - f"после пополнения баланса (user_id={user.id})" + f'✅ Автоматически возобновлена суточная подписка {subscription.id} ' + f'после пополнения баланса (user_id={user.id})' ) # Синхронизируем с RemnaWave try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as sync_err: - logger.warning(f"Не удалось синхронизировать с RemnaWave: {sync_err}") + logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}') except Exception as resume_err: - logger.warning(f"Ошибка при попытке возобновить суточную подписку: {resume_err}") + logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}') return True except Exception as e: - logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}") + logger.error(f'Ошибка изменения баланса пользователя {user.id}: {e}') await db.rollback() return False @@ -459,14 +446,14 @@ async def add_user_balance_by_id( db: AsyncSession, telegram_id: int, amount_kopeks: int, - description: str = "Пополнение баланса", + description: str = 'Пополнение баланса', transaction_type: TransactionType = TransactionType.DEPOSIT, - payment_method: Optional[PaymentMethod] = None, + payment_method: PaymentMethod | None = None, ) -> bool: try: user = await get_user_by_telegram_id(db, telegram_id) if not user: - logger.error(f"Пользователь с telegram_id {telegram_id} не найден") + logger.error(f'Пользователь с telegram_id {telegram_id} не найден') return False return await add_user_balance( @@ -477,9 +464,9 @@ async def add_user_balance_by_id( transaction_type=transaction_type, payment_method=payment_method, ) - + except Exception as e: - logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}") + logger.error(f'Ошибка пополнения баланса пользователя {telegram_id}: {e}') return False @@ -489,62 +476,62 @@ async def subtract_user_balance( amount_kopeks: int, description: str, create_transaction: bool = False, - payment_method: Optional[PaymentMethod] = None, + payment_method: PaymentMethod | None = None, *, consume_promo_offer: bool = False, ) -> bool: - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"💸 ОТЛАДКА subtract_user_balance:") - logger.info(f" 👤 User ID: {user.id} (ID: {user_id_display})") - logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек") - logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек") - logger.info(f" 📝 Описание: {description}") - - log_context: Optional[Dict[str, object]] = None + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info('💸 ОТЛАДКА subtract_user_balance:') + logger.info(f' 👤 User ID: {user.id} (ID: {user_id_display})') + logger.info(f' 💰 Баланс до списания: {user.balance_kopeks} копеек') + logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек') + logger.info(f' 📝 Описание: {description}') + + log_context: dict[str, object] | None = None if consume_promo_offer: try: - current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + current_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): current_percent = 0 if current_percent > 0: - source = getattr(user, "promo_offer_discount_source", None) + source = getattr(user, 'promo_offer_discount_source', None) log_context = { - "offer_id": None, - "percent": current_percent, - "source": source, - "effect_type": None, - "details": { - "reason": "manual_charge", - "description": description, - "amount_kopeks": amount_kopeks, + 'offer_id': None, + 'percent': current_percent, + 'source': source, + 'effect_type': None, + 'details': { + 'reason': 'manual_charge', + 'description': description, + 'amount_kopeks': amount_kopeks, }, } try: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - "Failed to fetch latest claimed promo offer for user %s: %s", + 'Failed to fetch latest claimed promo offer for user %s: %s', user.id, lookup_error, ) offer = None if offer: - log_context["offer_id"] = offer.id - log_context["effect_type"] = offer.effect_type - if not log_context["percent"] and offer.discount_percent: - log_context["percent"] = offer.discount_percent + log_context['offer_id'] = offer.id + log_context['effect_type'] = offer.effect_type + if not log_context['percent'] and offer.discount_percent: + log_context['percent'] = offer.discount_percent if user.balance_kopeks < amount_kopeks: - logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!") + logger.error(' ❌ НЕДОСТАТОЧНО СРЕДСТВ!') return False try: old_balance = user.balance_kopeks user.balance_kopeks -= amount_kopeks - if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0): + if consume_promo_offer and getattr(user, 'promo_offer_discount_percent', 0): user.promo_offer_discount_percent = 0 user.promo_offer_discount_source = None user.promo_offer_discount_expires_at = None @@ -573,16 +560,16 @@ async def subtract_user_balance( await log_promo_offer_action( db, user_id=user.id, - offer_id=log_context.get("offer_id"), - action="consumed", - source=log_context.get("source"), - percent=log_context.get("percent"), - effect_type=log_context.get("effect_type"), - details=log_context.get("details"), + offer_id=log_context.get('offer_id'), + action='consumed', + source=log_context.get('source'), + percent=log_context.get('percent'), + effect_type=log_context.get('effect_type'), + details=log_context.get('details'), ) except Exception as log_error: # pragma: no cover - defensive logging logger.warning( - "Failed to record promo offer consumption log for user %s: %s", + 'Failed to record promo offer consumption log for user %s: %s', user.id, log_error, ) @@ -590,15 +577,15 @@ async def subtract_user_balance( await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer consumption log failure: %s", + 'Failed to rollback session after promo offer consumption log failure: %s', rollback_error, ) - logger.info(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}") + logger.info(f' ✅ Средства списаны: {old_balance} → {user.balance_kopeks}') return True - + except Exception as e: - logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}") + logger.error(f' ❌ ОШИБКА СПИСАНИЯ: {e}') await db.rollback() return False @@ -616,15 +603,15 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: if not users: return 0 - log_payloads: List[Dict[str, object]] = [] + log_payloads: list[dict[str, object]] = [] for user in users: try: - percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): percent = 0 - source = getattr(user, "promo_offer_discount_source", None) + source = getattr(user, 'promo_offer_discount_source', None) offer_id = None effect_type = None @@ -633,7 +620,7 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - "Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s", + 'Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s', user.id, lookup_error, ) @@ -647,11 +634,11 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: log_payloads.append( { - "user_id": user.id, - "offer_id": offer_id, - "source": source, - "percent": percent, - "effect_type": effect_type, + 'user_id': user.id, + 'offer_id': offer_id, + 'source': source, + 'percent': percent, + 'effect_type': effect_type, } ) @@ -663,23 +650,23 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: await db.commit() for payload in log_payloads: - user_id = payload.get("user_id") + user_id = payload.get('user_id') if not user_id: continue try: await log_promo_offer_action( db, user_id=user_id, - offer_id=payload.get("offer_id"), - action="disabled", - source=payload.get("source"), - percent=payload.get("percent"), - effect_type=payload.get("effect_type"), - details={"reason": "offer_expired"}, + offer_id=payload.get('offer_id'), + action='disabled', + source=payload.get('source'), + percent=payload.get('percent'), + effect_type=payload.get('effect_type'), + details={'reason': 'offer_expired'}, ) except Exception as log_error: # pragma: no cover - defensive logging logger.warning( - "Failed to log promo offer expiration for user %s: %s", + 'Failed to log promo offer expiration for user %s: %s', user_id, log_error, ) @@ -687,7 +674,7 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer expiration log failure: %s", + 'Failed to rollback session after promo offer expiration log failure: %s', rollback_error, ) @@ -698,32 +685,31 @@ async def get_users_list( db: AsyncSession, offset: int = 0, limit: int = 50, - search: Optional[str] = None, - status: Optional[UserStatus] = None, + search: str | None = None, + status: UserStatus | None = None, order_by_balance: bool = False, order_by_traffic: bool = False, order_by_last_activity: bool = False, order_by_total_spent: bool = False, - order_by_purchase_count: bool = False -) -> List[User]: - + order_by_purchase_count: bool = False, +) -> list[User]: query = select(User).options( selectinload(User.subscription), selectinload(User.promo_group), selectinload(User.referrer), ) - + if status: query = query.where(User.status == status.value) - + if search: - search_term = f"%{search}%" + search_term = f'%{search}%' conditions = [ User.first_name.ilike(search_term), User.last_name.ilike(search_term), - User.username.ilike(search_term) + User.username.ilike(search_term), ] - + if search.isdigit(): try: search_int = int(search) @@ -733,7 +719,7 @@ async def get_users_list( except ValueError: # Если не удалось преобразовать в int, просто ищем по текстовым полям pass - + query = query.where(or_(*conditions)) sort_flags = [ @@ -745,7 +731,7 @@ async def get_users_list( ] if sum(int(flag) for flag in sort_flags) > 1: logger.debug( - "Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность" + 'Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность' ) transactions_stats = None @@ -776,40 +762,35 @@ async def get_users_list( query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc()) else: query = query.order_by(User.created_at.desc()) - + query = query.offset(offset).limit(limit) - + result = await db.execute(query) users = result.scalars().all() - + # Загружаем дополнительные зависимости для всех пользователей for user in users: if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return users -async def get_users_count( - db: AsyncSession, - status: Optional[UserStatus] = None, - search: Optional[str] = None -) -> int: - +async def get_users_count(db: AsyncSession, status: UserStatus | None = None, search: str | None = None) -> int: query = select(func.count(User.id)) - + if status: query = query.where(User.status == status.value) - + if search: - search_term = f"%{search}%" + search_term = f'%{search}%' conditions = [ User.first_name.ilike(search_term), User.last_name.ilike(search_term), - User.username.ilike(search_term) + User.username.ilike(search_term), ] - + if search.isdigit(): try: search_int = int(search) @@ -819,17 +800,14 @@ async def get_users_count( except ValueError: # Если не удалось преобразовать в int, просто ищем по текстовым полям pass - + query = query.where(or_(*conditions)) - + result = await db.execute(query) return result.scalar() -async def get_users_spending_stats( - db: AsyncSession, - user_ids: List[int] -) -> Dict[int, Dict[str, int]]: +async def get_users_spending_stats(db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]: """ Получает статистику трат для списка пользователей. @@ -843,8 +821,6 @@ async def get_users_spending_stats( if not user_ids: return {} - from app.database.models import Transaction - stats_query = ( select(*_build_spending_stats_select()) .where( @@ -859,14 +835,14 @@ async def get_users_spending_stats( return { row.user_id: { - "total_spent": int(row.total_spent or 0), - "purchase_count": int(row.purchase_count or 0), + 'total_spent': int(row.total_spent or 0), + 'purchase_count': int(row.purchase_count or 0), } for row in rows } -async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: +async def get_referrals(db: AsyncSession, user_id: int) -> list[User]: result = await db.execute( select(User) .options( @@ -879,17 +855,17 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]: .order_by(User.created_at.desc()) ) users = result.scalars().all() - + # Загружаем дополнительные зависимости для всех пользователей for user in users: if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return users -async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]: +async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[User]: now = datetime.utcnow() base_query = ( @@ -902,61 +878,58 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us .where(User.status == UserStatus.ACTIVE.value) ) - if segment == "no_subscription": - query = ( - base_query.outerjoin(Subscription, Subscription.user_id == User.id) - .where(Subscription.id.is_(None)) - ) + if segment == 'no_subscription': + query = base_query.outerjoin(Subscription, Subscription.user_id == User.id).where(Subscription.id.is_(None)) else: query = base_query.join(Subscription) - if segment == "paid_active": + if segment == 'paid_active': query = query.where( - Subscription.is_trial == False, # noqa: E712 + Subscription.is_trial == False, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date > now, ) - elif segment == "paid_expired": + elif segment == 'paid_expired': query = query.where( - Subscription.is_trial == False, # noqa: E712 + Subscription.is_trial == False, or_( Subscription.status == SubscriptionStatus.EXPIRED.value, Subscription.end_date <= now, ), ) - elif segment == "trial_active": + elif segment == 'trial_active': query = query.where( - Subscription.is_trial == True, # noqa: E712 + Subscription.is_trial == True, Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.end_date > now, ) - elif segment == "trial_expired": + elif segment == 'trial_expired': query = query.where( - Subscription.is_trial == True, # noqa: E712 + Subscription.is_trial == True, or_( Subscription.status == SubscriptionStatus.EXPIRED.value, Subscription.end_date <= now, ), ) else: - logger.warning("Неизвестный сегмент для промо: %s", segment) + logger.warning('Неизвестный сегмент для промо: %s', segment) return [] result = await db.execute(query.order_by(User.id)) users = result.scalars().unique().all() - + # Загружаем дополнительные зависимости для всех пользователей for user in users: if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return users -async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]: +async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]: threshold_date = datetime.utcnow() - timedelta(days=months * 30) - + result = await db.execute( select(User) .options( @@ -965,88 +938,65 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]: selectinload(User.referrer), selectinload(User.promo_group), ) - .where( - and_( - User.last_activity < threshold_date, - User.status == UserStatus.ACTIVE.value - ) - ) + .where(and_(User.last_activity < threshold_date, User.status == UserStatus.ACTIVE.value)) ) users = result.scalars().all() - + # Загружаем дополнительные зависимости для всех пользователей for user in users: if user and user.subscription: # Загружаем дополнительные зависимости для subscription _ = user.subscription.is_active - + return users async def delete_user(db: AsyncSession, user: User) -> bool: user.status = UserStatus.DELETED.value user.updated_at = datetime.utcnow() - + await db.commit() - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"🗑️ Пользователь {user_id_display} помечен как удаленный") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info(f'🗑️ Пользователь {user_id_display} помечен как удаленный') return True async def get_users_statistics(db: AsyncSession) -> dict: - total_result = await db.execute(select(func.count(User.id))) total_users = total_result.scalar() - - active_result = await db.execute( - select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value) - ) + + active_result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)) active_users = active_result.scalar() - + today = datetime.utcnow().date() today_result = await db.execute( - select(func.count(User.id)).where( - and_( - User.created_at >= today, - User.status == UserStatus.ACTIVE.value - ) - ) + select(func.count(User.id)).where(and_(User.created_at >= today, User.status == UserStatus.ACTIVE.value)) ) new_today = today_result.scalar() - + week_ago = datetime.utcnow() - timedelta(days=7) week_result = await db.execute( - select(func.count(User.id)).where( - and_( - User.created_at >= week_ago, - User.status == UserStatus.ACTIVE.value - ) - ) + select(func.count(User.id)).where(and_(User.created_at >= week_ago, User.status == UserStatus.ACTIVE.value)) ) new_week = week_result.scalar() - + month_ago = datetime.utcnow() - timedelta(days=30) month_result = await db.execute( - select(func.count(User.id)).where( - and_( - User.created_at >= month_ago, - User.status == UserStatus.ACTIVE.value - ) - ) + select(func.count(User.id)).where(and_(User.created_at >= month_ago, User.status == UserStatus.ACTIVE.value)) ) new_month = month_result.scalar() - + return { - "total_users": total_users, - "active_users": active_users, - "blocked_users": total_users - active_users, - "new_today": new_today, - "new_week": new_week, - "new_month": new_month + 'total_users': total_users, + 'active_users': active_users, + 'blocked_users': total_users - active_users, + 'new_today': new_today, + 'new_week': new_week, + 'new_month': new_month, } -async def get_users_with_active_subscriptions(db: AsyncSession) -> List[User]: +async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]: """ Получает список пользователей с активными подписками. Используется для мониторинга трафика. @@ -1077,9 +1027,9 @@ async def create_user_by_email( db: AsyncSession, email: str, password_hash: str, - first_name: Optional[str] = None, - language: str = "ru", - referred_by_id: Optional[int] = None, + first_name: str | None = None, + language: str = 'ru', + referred_by_id: int | None = None, ) -> User: """ Создать пользователя через email регистрацию (без Telegram). @@ -1100,7 +1050,7 @@ async def create_user_by_email( user = User( telegram_id=None, # Email-only user - auth_type="email", + auth_type='email', email=email, email_verified=False, password_hash=password_hash, @@ -1121,32 +1071,31 @@ async def create_user_by_email( await db.refresh(user) user.promo_group = default_group - logger.info(f"✅ Создан email-пользователь {email} с id={user.id}") + logger.info(f'✅ Создан email-пользователь {email} с id={user.id}') # Emit event try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "user.created", + 'user.created', { - "user_id": user.id, - "email": user.email, - "auth_type": "email", - "first_name": user.first_name, - "referral_code": user.referral_code, - "referred_by_id": user.referred_by_id, + 'user_id': user.id, + 'email': user.email, + 'auth_type': 'email', + 'first_name': user.first_name, + 'referral_code': user.referral_code, + 'referred_by_id': user.referred_by_id, }, db=db, ) except Exception as error: - logger.warning("Failed to emit user.created event: %s", error) + logger.warning('Failed to emit user.created event: %s', error) return user -async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]: +async def get_user_by_email(db: AsyncSession, email: str) -> User | None: """Get user by email address.""" - result = await db.execute( - select(User).where(User.email == email) - ) + result = await db.execute(select(User).where(User.email == email)) return result.scalar_one_or_none() diff --git a/app/database/crud/user_message.py b/app/database/crud/user_message.py index 29159bf0..d238077e 100644 --- a/app/database/crud/user_message.py +++ b/app/database/crud/user_message.py @@ -1,22 +1,19 @@ import logging import random from datetime import datetime -from typing import Optional, List -from sqlalchemy import select, func, and_ + +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User, UserMessage from app.utils.validators import sanitize_html, validate_html_tags + logger = logging.getLogger(__name__) async def create_user_message( - db: AsyncSession, - message_text: str, - created_by: Optional[int] = None, - is_active: bool = True, - sort_order: int = 0 + db: AsyncSession, message_text: str, created_by: int | None = None, is_active: bool = True, sort_order: int = 0 ) -> UserMessage: is_valid, error_message = validate_html_tags(message_text) if not is_valid: @@ -34,23 +31,21 @@ async def create_user_message( sort_order=sort_order, created_by=resolved_creator, ) - + db.add(message) await db.commit() await db.refresh(message) - - logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}") + + logger.info(f'✅ Создано сообщение ID {message.id} пользователем {created_by}') return message -async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]: - result = await db.execute( - select(UserMessage).where(UserMessage.id == message_id) - ) +async def get_user_message_by_id(db: AsyncSession, message_id: int) -> UserMessage | None: + result = await db.execute(select(UserMessage).where(UserMessage.id == message_id)) return result.scalar_one_or_none() -async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]: +async def get_active_user_messages(db: AsyncSession) -> list[UserMessage]: result = await db.execute( select(UserMessage) .where(UserMessage.is_active == True) @@ -59,12 +54,12 @@ async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]: return result.scalars().all() -async def get_random_active_message(db: AsyncSession) -> Optional[str]: +async def get_random_active_message(db: AsyncSession) -> str | None: active_messages = await get_active_user_messages(db) - + if not active_messages: return None - + random_message = random.choice(active_messages) return sanitize_html(random_message.message_text) @@ -74,16 +69,12 @@ async def get_all_user_messages( offset: int = 0, limit: int = 50, include_inactive: bool = True, -) -> List[UserMessage]: +) -> list[UserMessage]: query = select(UserMessage).order_by(UserMessage.created_at.desc()) if not include_inactive: query = query.where(UserMessage.is_active == True) - result = await db.execute( - query - .offset(offset) - .limit(limit) - ) + result = await db.execute(query.offset(offset).limit(limit)) return result.scalars().all() @@ -99,12 +90,12 @@ async def get_user_messages_count(db: AsyncSession, include_inactive: bool = Tru async def update_user_message( db: AsyncSession, message_id: int, - message_text: Optional[str] = None, - is_active: Optional[bool] = None, - sort_order: Optional[int] = None -) -> Optional[UserMessage]: + message_text: str | None = None, + is_active: bool | None = None, + sort_order: int | None = None, +) -> UserMessage | None: message = await get_user_message_by_id(db, message_id) - + if not message: return None @@ -113,67 +104,62 @@ async def update_user_message( if not is_valid: raise ValueError(error_message) message.message_text = message_text - + if is_active is not None: message.is_active = is_active - + if sort_order is not None: message.sort_order = sort_order - + message.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(message) - - logger.info(f"📝 Обновлено сообщение ID {message_id}") + + logger.info(f'📝 Обновлено сообщение ID {message_id}') return message -async def toggle_user_message_status( - db: AsyncSession, - message_id: int -) -> Optional[UserMessage]: +async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserMessage | None: message = await get_user_message_by_id(db, message_id) - + if not message: return None - + message.is_active = not message.is_active message.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(message) - - status_text = "активировано" if message.is_active else "деактивировано" - logger.info(f"🔄 Сообщение ID {message_id} {status_text}") - + + status_text = 'активировано' if message.is_active else 'деактивировано' + logger.info(f'🔄 Сообщение ID {message_id} {status_text}') + return message async def delete_user_message(db: AsyncSession, message_id: int) -> bool: message = await get_user_message_by_id(db, message_id) - + if not message: return False - + await db.delete(message) await db.commit() - - logger.info(f"🗑️ Удалено сообщение ID {message_id}") + + logger.info(f'🗑️ Удалено сообщение ID {message_id}') return True async def get_user_messages_stats(db: AsyncSession) -> dict: total_result = await db.execute(select(func.count(UserMessage.id))) total_messages = total_result.scalar() - - active_result = await db.execute( - select(func.count(UserMessage.id)).where(UserMessage.is_active == True) - ) + + active_result = await db.execute(select(func.count(UserMessage.id)).where(UserMessage.is_active == True)) active_messages = active_result.scalar() - + return { - "total_messages": total_messages, - "active_messages": active_messages, - "inactive_messages": total_messages - active_messages + 'total_messages': total_messages, + 'active_messages': active_messages, + 'inactive_messages': total_messages - active_messages, } diff --git a/app/database/crud/user_promo_group.py b/app/database/crud/user_promo_group.py index 1787a4a6..ead124e1 100644 --- a/app/database/crud/user_promo_group.py +++ b/app/database/crud/user_promo_group.py @@ -1,13 +1,14 @@ """CRUD операции для связи пользователей с промогруппами (Many-to-Many).""" + import logging from datetime import datetime -from typing import List, Optional -from sqlalchemy import select, and_, desc +from sqlalchemy import and_, desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.database.models import UserPromoGroup, PromoGroup, User +from app.database.models import PromoGroup, User, UserPromoGroup + logger = logging.getLogger(__name__) @@ -39,7 +40,7 @@ async def _sync_user_primary_promo_group( except Exception as error: logger.error( - "Ошибка синхронизации primary промогруппы пользователя %s: %s", + 'Ошибка синхронизации primary промогруппы пользователя %s: %s', user_id, error, ) @@ -55,11 +56,8 @@ async def sync_user_primary_promo_group( async def add_user_to_promo_group( - db: AsyncSession, - user_id: int, - promo_group_id: int, - assigned_by: str = "admin" -) -> Optional[UserPromoGroup]: + db: AsyncSession, user_id: int, promo_group_id: int, assigned_by: str = 'admin' +) -> UserPromoGroup | None: """ Добавляет пользователю промогруппу. @@ -76,7 +74,7 @@ async def add_user_to_promo_group( # Проверяем существование связи existing = await has_user_promo_group(db, user_id, promo_group_id) if existing: - logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}") + logger.info(f'Пользователь {user_id} уже имеет промогруппу {promo_group_id}') return None # Создаем новую связь @@ -93,20 +91,16 @@ async def add_user_to_promo_group( await db.commit() await db.refresh(user_promo_group) - logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})") + logger.info(f'Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})') return user_promo_group except Exception as error: - logger.error(f"Ошибка добавления промогруппы пользователю: {error}") + logger.error(f'Ошибка добавления промогруппы пользователю: {error}') await db.rollback() return None -async def remove_user_from_promo_group( - db: AsyncSession, - user_id: int, - promo_group_id: int -) -> bool: +async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool: """ Удаляет промогруппу у пользователя. @@ -121,16 +115,13 @@ async def remove_user_from_promo_group( try: result = await db.execute( select(UserPromoGroup).where( - and_( - UserPromoGroup.user_id == user_id, - UserPromoGroup.promo_group_id == promo_group_id - ) + and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id) ) ) user_promo_group = result.scalar_one_or_none() if not user_promo_group: - logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена") + logger.warning(f'Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена') return False await db.delete(user_promo_group) @@ -140,19 +131,16 @@ async def remove_user_from_promo_group( await db.commit() - logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}") + logger.info(f'У пользователя {user_id} удалена промогруппа {promo_group_id}') return True except Exception as error: - logger.error(f"Ошибка удаления промогруппы у пользователя: {error}") + logger.error(f'Ошибка удаления промогруппы у пользователя: {error}') await db.rollback() return False -async def get_user_promo_groups( - db: AsyncSession, - user_id: int -) -> List[UserPromoGroup]: +async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserPromoGroup]: """ Получает все промогруппы пользователя, отсортированные по приоритету. @@ -174,14 +162,11 @@ async def get_user_promo_groups( return list(result.scalars().all()) except Exception as error: - logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}") + logger.error(f'Ошибка получения промогрупп пользователя {user_id}: {error}') return [] -async def get_primary_user_promo_group( - db: AsyncSession, - user_id: int -) -> Optional[PromoGroup]: +async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoGroup | None: """ Получает промогруппу пользователя с максимальным приоритетом. @@ -202,15 +187,11 @@ async def get_primary_user_promo_group( return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None except Exception as error: - logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}") + logger.error(f'Ошибка получения primary промогруппы пользователя {user_id}: {error}') return None -async def has_user_promo_group( - db: AsyncSession, - user_id: int, - promo_group_id: int -) -> bool: +async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: int) -> bool: """ Проверяет наличие промогруппы у пользователя. @@ -225,23 +206,17 @@ async def has_user_promo_group( try: result = await db.execute( select(UserPromoGroup).where( - and_( - UserPromoGroup.user_id == user_id, - UserPromoGroup.promo_group_id == promo_group_id - ) + and_(UserPromoGroup.user_id == user_id, UserPromoGroup.promo_group_id == promo_group_id) ) ) return result.scalar_one_or_none() is not None except Exception as error: - logger.error(f"Ошибка проверки промогруппы пользователя: {error}") + logger.error(f'Ошибка проверки промогруппы пользователя: {error}') return False -async def count_user_promo_groups( - db: AsyncSession, - user_id: int -) -> int: +async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int: """ Подсчитывает количество промогрупп у пользователя. @@ -253,21 +228,16 @@ async def count_user_promo_groups( Количество промогрупп """ try: - result = await db.execute( - select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) - ) + result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)) return len(list(result.scalars().all())) except Exception as error: - logger.error(f"Ошибка подсчета промогрупп пользователя: {error}") + logger.error(f'Ошибка подсчета промогрупп пользователя: {error}') return 0 async def replace_user_promo_groups( - db: AsyncSession, - user_id: int, - promo_group_ids: List[int], - assigned_by: str = "admin" + db: AsyncSession, user_id: int, promo_group_ids: list[int], assigned_by: str = 'admin' ) -> bool: """ Заменяет все промогруппы пользователя на новый список. @@ -283,29 +253,21 @@ async def replace_user_promo_groups( """ try: # Удаляем все текущие промогруппы - await db.execute( - select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) - ) - result = await db.execute( - select(UserPromoGroup).where(UserPromoGroup.user_id == user_id) - ) + await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)) + result = await db.execute(select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)) for upg in result.scalars().all(): await db.delete(upg) # Добавляем новые for promo_group_id in promo_group_ids: - user_promo_group = UserPromoGroup( - user_id=user_id, - promo_group_id=promo_group_id, - assigned_by=assigned_by - ) + user_promo_group = UserPromoGroup(user_id=user_id, promo_group_id=promo_group_id, assigned_by=assigned_by) db.add(user_promo_group) await db.commit() - logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}") + logger.info(f'Промогруппы пользователя {user_id} заменены на {promo_group_ids}') return True except Exception as error: - logger.error(f"Ошибка замены промогрупп пользователя: {error}") + logger.error(f'Ошибка замены промогрупп пользователя: {error}') await db.rollback() return False diff --git a/app/database/crud/wata.py b/app/database/crud/wata.py index ac047d32..b4e3df34 100644 --- a/app/database/crud/wata.py +++ b/app/database/crud/wata.py @@ -2,13 +2,14 @@ import logging from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import WataPayment + logger = logging.getLogger(__name__) @@ -19,16 +20,16 @@ async def create_wata_payment( payment_link_id: str, amount_kopeks: int, currency: str, - description: Optional[str], + description: str | None, status: str, - type_: Optional[str], - url: Optional[str], - order_id: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - expires_at: Optional[datetime] = None, - terminal_public_id: Optional[str] = None, - success_redirect_url: Optional[str] = None, - fail_redirect_url: Optional[str] = None, + type_: str | None, + url: str | None, + order_id: str | None = None, + metadata: dict[str, Any] | None = None, + expires_at: datetime | None = None, + terminal_public_id: str | None = None, + success_redirect_url: str | None = None, + fail_redirect_url: str | None = None, ) -> WataPayment: payment = WataPayment( user_id=user_id, @@ -52,7 +53,7 @@ async def create_wata_payment( await db.refresh(payment) logger.info( - "Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)", + 'Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)', payment.id, user_id, amount_kopeks, @@ -65,30 +66,24 @@ async def create_wata_payment( async def get_wata_payment_by_id( db: AsyncSession, payment_id: int, -) -> Optional[WataPayment]: - result = await db.execute( - select(WataPayment).where(WataPayment.id == payment_id) - ) +) -> WataPayment | None: + result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id)) return result.scalar_one_or_none() async def get_wata_payment_by_link_id( db: AsyncSession, payment_link_id: str, -) -> Optional[WataPayment]: - result = await db.execute( - select(WataPayment).where(WataPayment.payment_link_id == payment_link_id) - ) +) -> WataPayment | None: + result = await db.execute(select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)) return result.scalar_one_or_none() async def get_wata_payment_by_order_id( db: AsyncSession, order_id: str, -) -> Optional[WataPayment]: - result = await db.execute( - select(WataPayment).where(WataPayment.order_id == order_id) - ) +) -> WataPayment | None: + result = await db.execute(select(WataPayment).where(WataPayment.order_id == order_id)) return result.scalar_one_or_none() @@ -96,48 +91,44 @@ async def update_wata_payment_status( db: AsyncSession, payment: WataPayment, *, - status: Optional[str] = None, - is_paid: Optional[bool] = None, - paid_at: Optional[datetime] = None, - last_status: Optional[str] = None, - url: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - callback_payload: Optional[Dict[str, Any]] = None, - terminal_public_id: Optional[str] = None, + status: str | None = None, + is_paid: bool | None = None, + paid_at: datetime | None = None, + last_status: str | None = None, + url: str | None = None, + metadata: dict[str, Any] | None = None, + callback_payload: dict[str, Any] | None = None, + terminal_public_id: str | None = None, ) -> WataPayment: - update_values: Dict[str, Any] = {} + update_values: dict[str, Any] = {} if status is not None: - update_values["status"] = status + update_values['status'] = status if is_paid is not None: - update_values["is_paid"] = is_paid + update_values['is_paid'] = is_paid if paid_at is not None: - update_values["paid_at"] = paid_at + update_values['paid_at'] = paid_at if last_status is not None: - update_values["last_status"] = last_status + update_values['last_status'] = last_status if url is not None: - update_values["url"] = url + update_values['url'] = url if metadata is not None: - update_values["metadata_json"] = metadata + update_values['metadata_json'] = metadata if callback_payload is not None: - update_values["callback_payload"] = callback_payload + update_values['callback_payload'] = callback_payload if terminal_public_id is not None: - update_values["terminal_public_id"] = terminal_public_id + update_values['terminal_public_id'] = terminal_public_id if not update_values: return payment - await db.execute( - update(WataPayment) - .where(WataPayment.id == payment.id) - .values(**update_values) - ) + await db.execute(update(WataPayment).where(WataPayment.id == payment.id).values(**update_values)) await db.commit() await db.refresh(payment) logger.info( - "Обновлен Wata платеж %s: статус=%s, is_paid=%s", + 'Обновлен Wata платеж %s: статус=%s, is_paid=%s', payment.payment_link_id, payment.status, payment.is_paid, @@ -151,16 +142,12 @@ async def link_wata_payment_to_transaction( payment: WataPayment, transaction_id: int, ) -> WataPayment: - await db.execute( - update(WataPayment) - .where(WataPayment.id == payment.id) - .values(transaction_id=transaction_id) - ) + await db.execute(update(WataPayment).where(WataPayment.id == payment.id).values(transaction_id=transaction_id)) await db.commit() await db.refresh(payment) logger.info( - "Wata платеж %s привязан к транзакции %s", + 'Wata платеж %s привязан к транзакции %s', payment.payment_link_id, transaction_id, ) diff --git a/app/database/crud/web_api_token.py b/app/database/crud/web_api_token.py index c84b9426..d304d30e 100644 --- a/app/database/crud/web_api_token.py +++ b/app/database/crud/web_api_token.py @@ -1,8 +1,9 @@ """CRUD операции для токенов административного веб-API.""" + from __future__ import annotations +from collections.abc import Iterable from datetime import datetime -from typing import Iterable, List, Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -14,7 +15,7 @@ async def list_tokens( db: AsyncSession, *, include_inactive: bool = False, -) -> List[WebApiToken]: +) -> list[WebApiToken]: query = select(WebApiToken) if not include_inactive: @@ -26,14 +27,12 @@ async def list_tokens( return list(result.scalars().all()) -async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]: +async def get_token_by_id(db: AsyncSession, token_id: int) -> WebApiToken | None: 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 - ) +async def get_token_by_hash(db: AsyncSession, token_hash: str) -> WebApiToken | None: + query = select(WebApiToken).where(WebApiToken.token_hash == token_hash) result = await db.execute(query) return result.scalar_one_or_none() @@ -44,9 +43,9 @@ async def create_token( name: str, token_hash: str, token_prefix: str, - description: Optional[str] = None, - expires_at: Optional[datetime] = None, - created_by: Optional[str] = None, + description: str | None = None, + expires_at: datetime | None = None, + created_by: str | None = None, ) -> WebApiToken: token = WebApiToken( name=name, @@ -96,11 +95,11 @@ async def delete_token(db: AsyncSession, token: WebApiToken) -> None: __all__ = [ - "list_tokens", - "get_token_by_id", - "get_token_by_hash", - "create_token", - "update_token", - "set_tokens_active_status", - "delete_token", + 'create_token', + 'delete_token', + 'get_token_by_hash', + 'get_token_by_id', + 'list_tokens', + 'set_tokens_active_status', + 'update_token', ] diff --git a/app/database/crud/webhook.py b/app/database/crud/webhook.py index 48b9af7c..285b6382 100644 --- a/app/database/crud/webhook.py +++ b/app/database/crud/webhook.py @@ -1,9 +1,8 @@ from __future__ import annotations from datetime import datetime -from typing import Optional -from sqlalchemy import select, func +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import Webhook, WebhookDelivery @@ -14,8 +13,8 @@ async def create_webhook( name: str, url: str, event_type: str, - secret: Optional[str] = None, - description: Optional[str] = None, + secret: str | None = None, + description: str | None = None, ) -> Webhook: """Создать новый webhook.""" webhook = Webhook( @@ -32,7 +31,7 @@ async def create_webhook( return webhook -async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Optional[Webhook]: +async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Webhook | None: """Получить webhook по ID.""" result = await db.execute(select(Webhook).where(Webhook.id == webhook_id)) return result.scalar_one_or_none() @@ -40,8 +39,8 @@ async def get_webhook_by_id(db: AsyncSession, webhook_id: int) -> Optional[Webho async def list_webhooks( db: AsyncSession, - event_type: Optional[str] = None, - is_active: Optional[bool] = None, + event_type: str | None = None, + is_active: bool | None = None, limit: int = 100, offset: int = 0, ) -> tuple[list[Webhook], int]: @@ -70,22 +69,18 @@ async def get_active_webhooks_for_event( event_type: str, ) -> list[Webhook]: """Получить все активные webhooks для конкретного события.""" - result = await db.execute( - select(Webhook) - .where(Webhook.event_type == event_type) - .where(Webhook.is_active == True) - ) + result = await db.execute(select(Webhook).where(Webhook.event_type == event_type).where(Webhook.is_active == True)) return list(result.scalars().all()) async def update_webhook( db: AsyncSession, webhook: Webhook, - name: Optional[str] = None, - url: Optional[str] = None, - secret: Optional[str] = None, - description: Optional[str] = None, - is_active: Optional[bool] = None, + name: str | None = None, + url: str | None = None, + secret: str | None = None, + description: str | None = None, + is_active: bool | None = None, ) -> Webhook: """Обновить webhook.""" if name is not None: @@ -117,9 +112,9 @@ async def record_webhook_delivery( event_type: str, payload: dict, status: str, - response_status: Optional[int] = None, - response_body: Optional[str] = None, - error_message: Optional[str] = None, + response_status: int | None = None, + response_body: str | None = None, + error_message: str | None = None, attempt_number: int = 1, ) -> WebhookDelivery: """Записать попытку доставки webhook.""" @@ -132,7 +127,7 @@ async def record_webhook_delivery( response_body=response_body, error_message=error_message, attempt_number=attempt_number, - delivered_at=datetime.utcnow() if status == "success" else None, + delivered_at=datetime.utcnow() if status == 'success' else None, ) db.add(delivery) await db.commit() @@ -154,4 +149,3 @@ async def update_webhook_stats( await db.commit() await db.refresh(webhook) return webhook - diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index e0639586..6588219a 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,55 +1,46 @@ import logging from datetime import datetime -from typing import Optional -from sqlalchemy import select, update, func + +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User, WelcomeText + logger = logging.getLogger(__name__) -WELCOME_TEXT_KEY = "welcome_text" +WELCOME_TEXT_KEY = 'welcome_text' -async def get_active_welcome_text(db: AsyncSession) -> Optional[str]: + +async def get_active_welcome_text(db: AsyncSession) -> str | None: result = await db.execute( select(WelcomeText) .where(WelcomeText.is_active == True) - .where(WelcomeText.is_enabled == True) + .where(WelcomeText.is_enabled == True) .order_by(WelcomeText.updated_at.desc()) ) welcome_text = result.scalar_one_or_none() - + if welcome_text: return welcome_text.text_content - + return None + async def get_current_welcome_text_settings(db: AsyncSession) -> dict: result = await db.execute( - select(WelcomeText) - .where(WelcomeText.is_active == True) - .order_by(WelcomeText.updated_at.desc()) + select(WelcomeText).where(WelcomeText.is_active == True).order_by(WelcomeText.updated_at.desc()) ) welcome_text = result.scalar_one_or_none() - + if welcome_text: - return { - 'text': welcome_text.text_content, - 'is_enabled': welcome_text.is_enabled, - 'id': welcome_text.id - } - - return { - 'text': await get_current_welcome_text_or_default(), - 'is_enabled': True, - 'id': None - } + return {'text': welcome_text.text_content, 'is_enabled': welcome_text.is_enabled, 'id': welcome_text.id} + + return {'text': await get_current_welcome_text_or_default(), 'is_enabled': True, 'id': None} -async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]: - result = await db.execute( - select(WelcomeText).where(WelcomeText.id == welcome_text_id) - ) +async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> WelcomeText | None: + result = await db.execute(select(WelcomeText).where(WelcomeText.id == welcome_text_id)) return result.scalar_one_or_none() @@ -76,71 +67,60 @@ async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True result = await db.execute(query) return result.scalar() + async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool: try: result = await db.execute( - select(WelcomeText) - .where(WelcomeText.is_active == True) - .order_by(WelcomeText.updated_at.desc()) + select(WelcomeText).where(WelcomeText.is_active == True).order_by(WelcomeText.updated_at.desc()) ) welcome_text = result.scalar_one_or_none() - + if welcome_text: welcome_text.is_enabled = not welcome_text.is_enabled welcome_text.updated_at = datetime.utcnow() - + await db.commit() await db.refresh(welcome_text) - - status = "включен" if welcome_text.is_enabled else "отключен" - logger.info(f"Приветственный текст {status} администратором {admin_id}") + + status = 'включен' if welcome_text.is_enabled else 'отключен' + logger.info(f'Приветственный текст {status} администратором {admin_id}') return welcome_text.is_enabled - else: - default_text = await get_current_welcome_text_or_default() - new_welcome_text = WelcomeText( - text_content=default_text, - is_active=True, - is_enabled=True, - created_by=admin_id - ) - - db.add(new_welcome_text) - await db.commit() - await db.refresh(new_welcome_text) - - logger.info(f"Создан и включен дефолтный приветственный текст администратором {admin_id}") - return True - + default_text = await get_current_welcome_text_or_default() + new_welcome_text = WelcomeText(text_content=default_text, is_active=True, is_enabled=True, created_by=admin_id) + + db.add(new_welcome_text) + await db.commit() + await db.refresh(new_welcome_text) + + logger.info(f'Создан и включен дефолтный приветственный текст администратором {admin_id}') + return True + except Exception as e: - logger.error(f"Ошибка при переключении статуса приветственного текста: {e}") + logger.error(f'Ошибка при переключении статуса приветственного текста: {e}') await db.rollback() return False + async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool: try: current_settings = await get_current_welcome_text_settings(db) current_enabled_status = current_settings.get('is_enabled', True) - - await db.execute( - update(WelcomeText).values(is_active=False) - ) - + + await db.execute(update(WelcomeText).values(is_active=False)) + new_welcome_text = WelcomeText( - text_content=text_content, - is_active=True, - is_enabled=current_enabled_status, - created_by=admin_id + text_content=text_content, is_active=True, is_enabled=current_enabled_status, created_by=admin_id ) - + db.add(new_welcome_text) await db.commit() await db.refresh(new_welcome_text) - - logger.info(f"Установлен новый приветственный текст администратором {admin_id}") + + logger.info(f'Установлен новый приветственный текст администратором {admin_id}') return True - + except Exception as e: - logger.error(f"Ошибка при установке приветственного текста: {e}") + logger.error(f'Ошибка при установке приветственного текста: {e}') await db.rollback() return False @@ -149,7 +129,7 @@ async def create_welcome_text( db: AsyncSession, *, text_content: str, - created_by: Optional[int] = None, + created_by: int | None = None, is_enabled: bool = True, is_active: bool = True, ) -> WelcomeText: @@ -174,7 +154,7 @@ async def create_welcome_text( await db.refresh(welcome_text) logger.info( - "✅ Создан приветственный текст ID %s (активный=%s, включен=%s)", + '✅ Создан приветственный текст ID %s (активный=%s, включен=%s)', welcome_text.id, welcome_text.is_active, welcome_text.is_enabled, @@ -186,16 +166,12 @@ async def update_welcome_text( db: AsyncSession, welcome_text: WelcomeText, *, - text_content: Optional[str] = None, - is_enabled: Optional[bool] = None, - is_active: Optional[bool] = None, + text_content: str | None = None, + is_enabled: bool | None = None, + is_active: bool | None = None, ) -> WelcomeText: if is_active: - await db.execute( - update(WelcomeText) - .where(WelcomeText.id != welcome_text.id) - .values(is_active=False) - ) + await db.execute(update(WelcomeText).where(WelcomeText.id != welcome_text.id).values(is_active=False)) if text_content is not None: welcome_text.text_content = text_content @@ -212,7 +188,7 @@ async def update_welcome_text( await db.refresh(welcome_text) logger.info( - "📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)", + '📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)', welcome_text.id, welcome_text.is_active, welcome_text.is_enabled, @@ -223,64 +199,70 @@ async def update_welcome_text( async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None: await db.delete(welcome_text) await db.commit() - logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id) + logger.info('🗑️ Удален приветственный текст ID %s', welcome_text.id) + async def get_current_welcome_text_or_default() -> str: return ( - f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! " - f"Подключайтесь за минуту и забудьте о блокировках. " - f"✅ До 1 Гбит/с скорость " - f"✅ Умный VPN — можно не отключать для большинства российских сервисов " - f"✅ Современные протоколы — максимум защиты и анонимности " - f"💉 Всего 99₽/мес за 1 устройство " - f"👇 Жмите кнопку и подключайтесь!" + 'Привет, {user_name}! 🎁 3 дней VPN бесплатно! ' + 'Подключайтесь за минуту и забудьте о блокировках. ' + '✅ До 1 Гбит/с скорость ' + '✅ Умный VPN — можно не отключать для большинства российских сервисов ' + '✅ Современные протоколы — максимум защиты и анонимности ' + '💉 Всего 99₽/мес за 1 устройство ' + '👇 Жмите кнопку и подключайтесь!' ) + def replace_placeholders(text: str, user) -> str: first_name = getattr(user, 'first_name', None) username = getattr(user, 'username', None) - + first_name = first_name.strip() if first_name else None username = username.strip() if username else None - - user_name = first_name or username or "друг" - display_first_name = first_name or "друг" - display_username = f"@{username}" if username else (first_name or "друг") - clean_username = username or first_name or "друг" - + + user_name = first_name or username or 'друг' + display_first_name = first_name or 'друг' + display_username = f'@{username}' if username else (first_name or 'друг') + clean_username = username or first_name or 'друг' + replacements = { '{user_name}': user_name, - '{first_name}': display_first_name, + '{first_name}': display_first_name, '{username}': display_username, '{username_clean}': clean_username, - 'Egor': user_name + 'Egor': user_name, } - + result = text for placeholder, value in replacements.items(): result = result.replace(placeholder, value) - + return result + async def get_welcome_text_for_user(db: AsyncSession, user) -> str: welcome_text = await get_active_welcome_text(db) - + if not welcome_text: return None - + if isinstance(user, str): + class SimpleUser: def __init__(self, name): self.first_name = name self.username = None + user = SimpleUser(user) - + return replace_placeholders(welcome_text, user) + def get_available_placeholders() -> dict: return { '{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")', '{first_name}': 'Только имя пользователя (или "друг" если не указано)', '{username}': 'Username с символом @ (или имя если username не указан)', - '{username_clean}': 'Username без символа @ (или имя если username не указан)' + '{username_clean}': 'Username без символа @ (или имя если username не указан)', } diff --git a/app/database/crud/wheel.py b/app/database/crud/wheel.py index 30f7cbdf..0e617653 100644 --- a/app/database/crud/wheel.py +++ b/app/database/crud/wheel.py @@ -1,11 +1,12 @@ """ CRUD операции для колеса удачи (Fortune Wheel). """ -import logging -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any -from sqlalchemy import select, and_, func, desc +import logging +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -13,24 +14,18 @@ from app.database.models import ( WheelConfig, WheelPrize, WheelSpin, - WheelPrizeType, - WheelSpinPaymentType, - User, ) + logger = logging.getLogger(__name__) # ==================== WHEEL CONFIG ==================== -async def get_wheel_config(db: AsyncSession) -> Optional[WheelConfig]: +async def get_wheel_config(db: AsyncSession) -> WheelConfig | None: """Получить текущую конфигурацию колеса (всегда id=1).""" - result = await db.execute( - select(WheelConfig) - .options(selectinload(WheelConfig.prizes)) - .where(WheelConfig.id == 1) - ) + result = await db.execute(select(WheelConfig).options(selectinload(WheelConfig.prizes)).where(WheelConfig.id == 1)) return result.scalar_one_or_none() @@ -44,7 +39,7 @@ async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig: config = WheelConfig( id=1, is_enabled=False, - name="Колесо удачи", + name='Колесо удачи', spin_cost_stars=10, spin_cost_days=1, spin_cost_stars_enabled=True, @@ -52,20 +47,17 @@ async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig: rtp_percent=80, daily_spin_limit=5, min_subscription_days_for_day_payment=3, - promo_prefix="WHEEL", + promo_prefix='WHEEL', promo_validity_days=7, ) db.add(config) await db.commit() await db.refresh(config) - logger.info("🎡 Создана дефолтная конфигурация колеса удачи") + logger.info('🎡 Создана дефолтная конфигурация колеса удачи') return config -async def update_wheel_config( - db: AsyncSession, - **kwargs -) -> WheelConfig: +async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig: """Обновить конфигурацию колеса.""" config = await get_or_create_wheel_config(db) @@ -76,18 +68,14 @@ async def update_wheel_config( config.updated_at = datetime.utcnow() await db.commit() await db.refresh(config) - logger.info(f"🎡 Обновлена конфигурация колеса: {kwargs}") + logger.info(f'🎡 Обновлена конфигурация колеса: {kwargs}') return config # ==================== WHEEL PRIZES ==================== -async def get_wheel_prizes( - db: AsyncSession, - config_id: int = 1, - active_only: bool = True -) -> List[WheelPrize]: +async def get_wheel_prizes(db: AsyncSession, config_id: int = 1, active_only: bool = True) -> list[WheelPrize]: """Получить список призов колеса.""" query = select(WheelPrize).where(WheelPrize.config_id == config_id) @@ -100,11 +88,9 @@ async def get_wheel_prizes( return list(result.scalars().all()) -async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> Optional[WheelPrize]: +async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> WheelPrize | None: """Получить приз по ID.""" - result = await db.execute( - select(WheelPrize).where(WheelPrize.id == prize_id) - ) + result = await db.execute(select(WheelPrize).where(WheelPrize.id == prize_id)) return result.scalar_one_or_none() @@ -115,10 +101,10 @@ async def create_wheel_prize( prize_value: int, display_name: str, prize_value_kopeks: int, - emoji: str = "🎁", - color: str = "#3B82F6", + emoji: str = '🎁', + color: str = '#3B82F6', sort_order: int = 0, - manual_probability: Optional[float] = None, + manual_probability: float | None = None, is_active: bool = True, promo_balance_bonus_kopeks: int = 0, promo_subscription_days: int = 0, @@ -143,15 +129,11 @@ async def create_wheel_prize( db.add(prize) await db.commit() await db.refresh(prize) - logger.info(f"🎁 Создан приз колеса: {display_name} ({prize_type})") + logger.info(f'🎁 Создан приз колеса: {display_name} ({prize_type})') return prize -async def update_wheel_prize( - db: AsyncSession, - prize_id: int, - **kwargs -) -> Optional[WheelPrize]: +async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> WheelPrize | None: """Обновить приз колеса.""" prize = await get_wheel_prize_by_id(db, prize_id) if not prize: @@ -164,7 +146,7 @@ async def update_wheel_prize( prize.updated_at = datetime.utcnow() await db.commit() await db.refresh(prize) - logger.info(f"🎁 Обновлен приз колеса ID={prize_id}: {kwargs}") + logger.info(f'🎁 Обновлен приз колеса ID={prize_id}: {kwargs}') return prize @@ -176,14 +158,11 @@ async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool: await db.delete(prize) await db.commit() - logger.info(f"🗑️ Удален приз колеса ID={prize_id}") + logger.info(f'🗑️ Удален приз колеса ID={prize_id}') return True -async def reorder_wheel_prizes( - db: AsyncSession, - prize_ids: List[int] -) -> bool: +async def reorder_wheel_prizes(db: AsyncSession, prize_ids: list[int]) -> bool: """Переупорядочить призы колеса.""" for index, prize_id in enumerate(prize_ids): prize = await get_wheel_prize_by_id(db, prize_id) @@ -191,7 +170,7 @@ async def reorder_wheel_prizes( prize.sort_order = index await db.commit() - logger.info(f"🔄 Переупорядочены призы колеса: {prize_ids}") + logger.info(f'🔄 Переупорядочены призы колеса: {prize_ids}') return True @@ -209,7 +188,7 @@ async def create_wheel_spin( prize_value: int, prize_display_name: str, prize_value_kopeks: int, - generated_promocode_id: Optional[int] = None, + generated_promocode_id: int | None = None, is_applied: bool = False, ) -> WheelSpin: """Создать запись о спине колеса.""" @@ -234,11 +213,9 @@ async def create_wheel_spin( return spin -async def mark_spin_applied(db: AsyncSession, spin_id: int) -> Optional[WheelSpin]: +async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None: """Отметить спин как примененный.""" - result = await db.execute( - select(WheelSpin).where(WheelSpin.id == spin_id) - ) + result = await db.execute(select(WheelSpin).where(WheelSpin.id == spin_id)) spin = result.scalar_one_or_none() if spin: spin.is_applied = True @@ -253,8 +230,7 @@ async def get_user_spins_today(db: AsyncSession, user_id: int) -> int: today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) result = await db.execute( - select(func.count(WheelSpin.id)) - .where( + select(func.count(WheelSpin.id)).where( and_( WheelSpin.user_id == user_id, WheelSpin.created_at >= today_start, @@ -265,17 +241,11 @@ async def get_user_spins_today(db: AsyncSession, user_id: int) -> int: async def get_user_spin_history( - db: AsyncSession, - user_id: int, - limit: int = 20, - offset: int = 0 -) -> tuple[List[WheelSpin], int]: + db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0 +) -> tuple[list[WheelSpin], int]: """Получить историю спинов пользователя.""" # Общее количество - count_result = await db.execute( - select(func.count(WheelSpin.id)) - .where(WheelSpin.user_id == user_id) - ) + count_result = await db.execute(select(func.count(WheelSpin.id)).where(WheelSpin.user_id == user_id)) total = count_result.scalar() or 0 # Спины с пагинацией (eager load prize relationship) @@ -294,12 +264,12 @@ async def get_user_spin_history( async def get_all_spins( db: AsyncSession, - user_id: Optional[int] = None, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None, + user_id: int | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, limit: int = 50, - offset: int = 0 -) -> tuple[List[WheelSpin], int]: + offset: int = 0, +) -> tuple[list[WheelSpin], int]: """Получить все спины с фильтрами (для админки).""" conditions = [] @@ -333,10 +303,8 @@ async def get_all_spins( async def get_wheel_statistics( - db: AsyncSession, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None -) -> Dict[str, Any]: + db: AsyncSession, date_from: datetime | None = None, date_to: datetime | None = None +) -> dict[str, Any]: """Получить статистику колеса удачи.""" conditions = [] if date_from: @@ -351,9 +319,9 @@ async def get_wheel_statistics( # Общие метрики result = await db.execute( select( - func.count(WheelSpin.id).label("total_spins"), - func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label("total_revenue"), - func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label("total_payout"), + func.count(WheelSpin.id).label('total_spins'), + func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label('total_revenue'), + func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label('total_payout'), ).where(and_(*conditions) if conditions else True) ) row = result.one() @@ -368,15 +336,14 @@ async def get_wheel_statistics( payment_dist = await db.execute( select( WheelSpin.payment_type, - func.count(WheelSpin.id).label("count"), - func.sum(WheelSpin.payment_value_kopeks).label("total"), + func.count(WheelSpin.id).label('count'), + func.sum(WheelSpin.payment_value_kopeks).label('total'), ) .where(and_(*conditions) if conditions else True) .group_by(WheelSpin.payment_type) ) spins_by_payment_type = { - row.payment_type: {"count": row.count, "total_kopeks": row.total or 0} - for row in payment_dist + row.payment_type: {'count': row.count, 'total_kopeks': row.total or 0} for row in payment_dist } # Распределение призов @@ -384,18 +351,18 @@ async def get_wheel_statistics( select( WheelSpin.prize_type, WheelSpin.prize_display_name, - func.count(WheelSpin.id).label("count"), - func.sum(WheelSpin.prize_value_kopeks).label("total"), + func.count(WheelSpin.id).label('count'), + func.sum(WheelSpin.prize_value_kopeks).label('total'), ) .where(and_(*conditions) if conditions else True) .group_by(WheelSpin.prize_type, WheelSpin.prize_display_name) ) prizes_distribution = [ { - "prize_type": row.prize_type, - "display_name": row.prize_display_name, - "count": row.count, - "total_kopeks": row.total or 0, + 'prize_type': row.prize_type, + 'display_name': row.prize_display_name, + 'count': row.count, + 'total_kopeks': row.total or 0, } for row in prizes_dist ] @@ -411,11 +378,11 @@ async def get_wheel_statistics( ) top_wins = [ { - "user_id": spin.user_id, - "username": spin.user.username if spin.user else None, - "prize_display_name": spin.prize_display_name, - "prize_value_kopeks": spin.prize_value_kopeks, - "created_at": spin.created_at.isoformat() if spin.created_at else None, + 'user_id': spin.user_id, + 'username': spin.user.username if spin.user else None, + 'prize_display_name': spin.prize_display_name, + 'prize_value_kopeks': spin.prize_value_kopeks, + 'created_at': spin.created_at.isoformat() if spin.created_at else None, } for spin in top_wins_result.scalars().all() ] @@ -425,14 +392,14 @@ async def get_wheel_statistics( configured_rtp = config.rtp_percent if config else 80 return { - "total_spins": total_spins, - "total_revenue_kopeks": total_revenue, - "total_payout_kopeks": total_payout, - "actual_rtp_percent": round(actual_rtp, 2), - "configured_rtp_percent": configured_rtp, - "spins_by_payment_type": spins_by_payment_type, - "prizes_distribution": prizes_distribution, - "top_wins": top_wins, - "period_from": date_from.isoformat() if date_from else None, - "period_to": date_to.isoformat() if date_to else None, + 'total_spins': total_spins, + 'total_revenue_kopeks': total_revenue, + 'total_payout_kopeks': total_payout, + 'actual_rtp_percent': round(actual_rtp, 2), + 'configured_rtp_percent': configured_rtp, + 'spins_by_payment_type': spins_by_payment_type, + 'prizes_distribution': prizes_distribution, + 'top_wins': top_wins, + 'period_from': date_from.isoformat() if date_from else None, + 'period_to': date_to.isoformat() if date_to else None, } diff --git a/app/database/crud/yookassa.py b/app/database/crud/yookassa.py index 09c52777..be2e1aba 100644 --- a/app/database/crud/yookassa.py +++ b/app/database/crud/yookassa.py @@ -1,11 +1,12 @@ import logging -from typing import Optional, List from datetime import datetime + +from sqlalchemy import and_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update, and_ from sqlalchemy.orm import selectinload -from app.database.models import YooKassaPayment, User, Transaction +from app.database.models import YooKassaPayment + logger = logging.getLogger(__name__) @@ -18,13 +19,12 @@ async def create_yookassa_payment( currency: str, description: str, status: str, - confirmation_url: Optional[str] = None, - metadata_json: Optional[dict] = None, - payment_method_type: Optional[str] = None, - yookassa_created_at: Optional[datetime] = None, - test_mode: bool = False + confirmation_url: str | None = None, + metadata_json: dict | None = None, + payment_method_type: str | None = None, + yookassa_created_at: datetime | None = None, + test_mode: bool = False, ) -> YooKassaPayment: - payment = YooKassaPayment( user_id=user_id, yookassa_payment_id=yookassa_payment_id, @@ -36,22 +36,18 @@ async def create_yookassa_payment( metadata_json=metadata_json, payment_method_type=payment_method_type, yookassa_created_at=yookassa_created_at, - test_mode=test_mode + test_mode=test_mode, ) - + db.add(payment) await db.commit() await db.refresh(payment) - - logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}") + + logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}') return payment -async def get_yookassa_payment_by_id( - db: AsyncSession, - yookassa_payment_id: str -) -> Optional[YooKassaPayment]: - +async def get_yookassa_payment_by_id(db: AsyncSession, yookassa_payment_id: str) -> YooKassaPayment | None: result = await db.execute( select(YooKassaPayment) .options(selectinload(YooKassaPayment.user)) @@ -60,15 +56,9 @@ async def get_yookassa_payment_by_id( return result.scalar_one_or_none() -async def get_yookassa_payment_by_local_id( - db: AsyncSession, - local_id: int -) -> Optional[YooKassaPayment]: - +async def get_yookassa_payment_by_local_id(db: AsyncSession, local_id: int) -> YooKassaPayment | None: result = await db.execute( - select(YooKassaPayment) - .options(selectinload(YooKassaPayment.user)) - .where(YooKassaPayment.id == local_id) + select(YooKassaPayment).options(selectinload(YooKassaPayment.user)).where(YooKassaPayment.id == local_id) ) return result.scalar_one_or_none() @@ -79,76 +69,61 @@ async def update_yookassa_payment_status( status: str, is_paid: bool = False, is_captured: bool = False, - captured_at: Optional[datetime] = None, - payment_method_type: Optional[str] = None -) -> Optional[YooKassaPayment]: - - update_data = { - "status": status, - "is_paid": is_paid, - "is_captured": is_captured, - "updated_at": datetime.utcnow() - } - + captured_at: datetime | None = None, + payment_method_type: str | None = None, +) -> YooKassaPayment | None: + update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.utcnow()} + if captured_at: - update_data["captured_at"] = captured_at - + update_data['captured_at'] = captured_at + if payment_method_type: - update_data["payment_method_type"] = payment_method_type - + update_data['payment_method_type'] = payment_method_type + await db.execute( - update(YooKassaPayment) - .where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id) - .values(**update_data) + update(YooKassaPayment).where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id).values(**update_data) ) await db.commit() - + result = await db.execute( select(YooKassaPayment) .options(selectinload(YooKassaPayment.user)) .where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id) ) payment = result.scalar_one_or_none() - + if payment: - logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}") - + logger.info(f'Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}') + return payment async def link_yookassa_payment_to_transaction( - db: AsyncSession, - yookassa_payment_id: str, - transaction_id: int -) -> Optional[YooKassaPayment]: - + db: AsyncSession, yookassa_payment_id: str, transaction_id: int +) -> YooKassaPayment | None: await db.execute( update(YooKassaPayment) .where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id) .values(transaction_id=transaction_id, updated_at=datetime.utcnow()) ) await db.commit() - + result = await db.execute( select(YooKassaPayment) .options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction)) .where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id) ) payment = result.scalar_one_or_none() - + if payment: - logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}") - + logger.info(f'Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}') + return payment async def get_user_yookassa_payments( - db: AsyncSession, - user_id: int, - limit: int = 50, - offset: int = 0 -) -> List[YooKassaPayment]: - + db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0 +) -> list[YooKassaPayment]: result = await db.execute( select(YooKassaPayment) .options(selectinload(YooKassaPayment.transaction)) @@ -161,38 +136,29 @@ async def get_user_yookassa_payments( async def get_pending_yookassa_payments( - db: AsyncSession, - user_id: Optional[int] = None, - limit: int = 100 -) -> List[YooKassaPayment]: - + db: AsyncSession, user_id: int | None = None, limit: int = 100 +) -> list[YooKassaPayment]: query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user)) - - conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])] + + conditions = [YooKassaPayment.status.in_(['pending', 'waiting_for_capture'])] if user_id: conditions.append(YooKassaPayment.user_id == user_id) - - result = await db.execute( - query.where(and_(*conditions)) - .order_by(YooKassaPayment.created_at.desc()) - .limit(limit) - ) + + result = await db.execute(query.where(and_(*conditions)).order_by(YooKassaPayment.created_at.desc()).limit(limit)) return result.scalars().all() async def get_succeeded_yookassa_payments_without_transaction( - db: AsyncSession, - limit: int = 50 -) -> List[YooKassaPayment]: - + db: AsyncSession, limit: int = 50 +) -> list[YooKassaPayment]: result = await db.execute( select(YooKassaPayment) .options(selectinload(YooKassaPayment.user)) .where( and_( - YooKassaPayment.status == "succeeded", + YooKassaPayment.status == 'succeeded', YooKassaPayment.is_paid == True, - YooKassaPayment.transaction_id == None + YooKassaPayment.transaction_id == None, ) ) .order_by(YooKassaPayment.captured_at.desc()) @@ -201,68 +167,39 @@ async def get_succeeded_yookassa_payments_without_transaction( return result.scalars().all() -async def delete_yookassa_payment( - db: AsyncSession, - yookassa_payment_id: str -) -> bool: - - result = await db.execute( - select(YooKassaPayment) - .where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id) - ) +async def delete_yookassa_payment(db: AsyncSession, yookassa_payment_id: str) -> bool: + result = await db.execute(select(YooKassaPayment).where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)) payment = result.scalar_one_or_none() - + if payment: await db.delete(payment) await db.commit() - logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}") + logger.info(f'Удален платеж YooKassa: {yookassa_payment_id}') return True - + return False -async def get_yookassa_payments_stats( - db: AsyncSession, - user_id: Optional[int] = None -) -> dict: - - from sqlalchemy import func, case - +async def get_yookassa_payments_stats(db: AsyncSession, user_id: int | None = None) -> dict: + from sqlalchemy import case, func + query = select( func.count(YooKassaPayment.id).label('total_payments'), func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'), - func.sum( - case( - (YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks), - else_=0 - ) - ).label('succeeded_amount_kopeks'), - func.count( - case( - (YooKassaPayment.status == 'succeeded', 1), - else_=None - ) - ).label('succeeded_count'), - func.count( - case( - (YooKassaPayment.status == 'pending', 1), - else_=None - ) - ).label('pending_count'), - func.count( - case( - (YooKassaPayment.status.in_(['canceled', 'failed']), 1), - else_=None - ) - ).label('failed_count') + func.sum(case((YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks), else_=0)).label( + 'succeeded_amount_kopeks' + ), + func.count(case((YooKassaPayment.status == 'succeeded', 1), else_=None)).label('succeeded_count'), + func.count(case((YooKassaPayment.status == 'pending', 1), else_=None)).label('pending_count'), + func.count(case((YooKassaPayment.status.in_(['canceled', 'failed']), 1), else_=None)).label('failed_count'), ).select_from(YooKassaPayment) - + if user_id: query = query.where(YooKassaPayment.user_id == user_id) - + result = await db.execute(query) stats = result.first() - + return { 'total_payments': stats.total_payments or 0, 'total_amount_kopeks': stats.total_amount_kopeks or 0, @@ -272,5 +209,5 @@ async def get_yookassa_payments_stats( 'succeeded_count': stats.succeeded_count or 0, 'pending_count': stats.pending_count or 0, 'failed_count': stats.failed_count or 0, - 'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0 + 'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0, } diff --git a/app/database/database.py b/app/database/database.py index 57baa596..9ebb6715 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -1,34 +1,33 @@ import asyncio import logging -from functools import wraps -from typing import AsyncGenerator, Callable, Optional, TypeVar -from contextlib import asynccontextmanager -from sqlalchemy.ext.asyncio import ( - AsyncSession, - create_async_engine, - async_sessionmaker, - AsyncEngine -) -from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool -from sqlalchemy import event, text, bindparam, inspect -from sqlalchemy.exc import ProgrammingError -from sqlalchemy.engine import Engine -from sqlalchemy.exc import OperationalError, InterfaceError import time +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from functools import wraps +from typing import TypeVar + +from sqlalchemy import bindparam, event, inspect, text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool + from app.config import settings from app.database.models import Base + logger = logging.getLogger(__name__) -T = TypeVar("T") +T = TypeVar('T') # ============================================================================ # PRODUCTION-GRADE CONNECTION POOLING # ============================================================================ + def _is_sqlite_url(url: str) -> bool: """Проверка на SQLite URL (поддерживает sqlite:// и sqlite+aiosqlite://)""" - return url.startswith("sqlite") or ":memory:" in url + return url.startswith('sqlite') or ':memory:' in url DATABASE_URL = settings.get_database_url() @@ -40,13 +39,13 @@ if IS_SQLITE: else: poolclass = AsyncAdaptedQueuePool pool_kwargs = { - "pool_size": 30, # Увеличен с 20 - "max_overflow": 50, # Увеличен с 30 - "pool_timeout": 30, - "pool_recycle": 1800, # Уменьшен с 3600 до 30 мин для более быстрого recycling - "pool_pre_ping": True, + 'pool_size': 30, # Увеличен с 20 + 'max_overflow': 50, # Увеличен с 30 + 'pool_timeout': 30, + 'pool_recycle': 1800, # Уменьшен с 3600 до 30 мин для более быстрого recycling + 'pool_pre_ping': True, # Агрессивная очистка мертвых соединений - "pool_reset_on_return": "rollback", + 'pool_reset_on_return': 'rollback', } # ============================================================================ @@ -55,14 +54,14 @@ else: # PostgreSQL-специфичные connect_args _pg_connect_args = { - "server_settings": { - "application_name": "remnawave_bot", - "jit": "on", - "statement_timeout": "60000", # 60 секунд - "idle_in_transaction_session_timeout": "300000", # 5 минут + 'server_settings': { + 'application_name': 'remnawave_bot', + 'jit': 'on', + 'statement_timeout': '60000', # 60 секунд + 'idle_in_transaction_session_timeout': '300000', # 5 минут }, - "command_timeout": 60, - "timeout": 30, # Увеличен с 10 до 30 сек для высокой нагрузки + 'command_timeout': 60, + 'timeout': 30, # Увеличен с 10 до 30 сек для высокой нагрузки } engine = create_async_engine( @@ -74,7 +73,7 @@ engine = create_async_engine( query_cache_size=500, connect_args=_pg_connect_args if not IS_SQLITE else {}, execution_options={ - "isolation_level": "READ COMMITTED", + 'isolation_level': 'READ COMMITTED', }, **pool_kwargs, ) @@ -113,6 +112,7 @@ def with_db_retry( delay: Начальная задержка между попытками (секунды) backoff: Множитель задержки для каждой следующей попытки """ + def decorator(func: Callable) -> Callable: @wraps(func) async def wrapper(*args, **kwargs): @@ -126,19 +126,21 @@ def with_db_retry( last_exception = e if attempt < attempts: logger.warning( - "Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...", - attempt, attempts, str(e)[:100], current_delay + 'Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...', + attempt, + attempts, + str(e)[:100], + current_delay, ) await asyncio.sleep(current_delay) current_delay *= backoff else: - logger.error( - "Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s", - attempts, str(e) - ) + logger.error('Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s', attempts, str(e)) raise last_exception + return wrapper + return decorator @@ -157,10 +159,7 @@ async def execute_with_retry( except RETRYABLE_EXCEPTIONS as e: last_exception = e if attempt < attempts: - logger.warning( - "SQL retry (попытка %d/%d): %s", - attempt, attempts, str(e)[:100] - ) + logger.warning('SQL retry (попытка %d/%d): %s', attempt, attempts, str(e)[:100]) await asyncio.sleep(delay) delay *= 2 @@ -172,18 +171,19 @@ async def execute_with_retry( # ============================================================================ if settings.DEBUG: - @event.listens_for(Engine, "before_cursor_execute") - def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): - conn.info.setdefault("query_start_time", []).append(time.time()) - logger.debug(f"🔍 Executing query: {statement[:100]}...") - @event.listens_for(Engine, "after_cursor_execute") + @event.listens_for(Engine, 'before_cursor_execute') + def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): + conn.info.setdefault('query_start_time', []).append(time.time()) + logger.debug(f'🔍 Executing query: {statement[:100]}...') + + @event.listens_for(Engine, 'after_cursor_execute') def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): - total = time.time() - conn.info["query_start_time"].pop(-1) + total = time.time() - conn.info['query_start_time'].pop(-1) if total > 0.1: # Логируем медленные запросы > 100ms - logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...") + logger.warning(f'🐌 Slow query ({total:.3f}s): {statement[:100]}...') else: - logger.debug(f"⚡ Query executed in {total:.3f}s") + logger.debug(f'⚡ Query executed in {total:.3f}s') # ============================================================================ # ADVANCED SESSION MANAGER WITH READ REPLICAS @@ -192,7 +192,7 @@ if settings.DEBUG: HEALTH_CHECK_TIMEOUT = 5.0 # секунды -def _validate_database_url(url: Optional[str]) -> Optional[str]: +def _validate_database_url(url: str | None) -> str | None: """Валидация URL базы данных.""" if not url: return None @@ -200,8 +200,8 @@ def _validate_database_url(url: Optional[str]) -> Optional[str]: if not url or url.isspace(): return None # Простая проверка на валидный формат - if not ("://" in url or url.startswith("sqlite")): - logger.warning("Невалидный DATABASE_URL: %s", url[:20]) + if not ('://' in url or url.startswith('sqlite')): + logger.warning('Невалидный DATABASE_URL: %s', url[:20]) return None return url @@ -211,13 +211,11 @@ class DatabaseManager: def __init__(self): self.engine = engine - self.read_replica_engine: Optional[AsyncEngine] = None - self._read_replica_session_factory: Optional[async_sessionmaker] = None + self.read_replica_engine: AsyncEngine | None = None + self._read_replica_session_factory: async_sessionmaker | None = None # Валидация и создание read replica engine - replica_url = _validate_database_url( - getattr(settings, 'DATABASE_READ_REPLICA_URL', None) - ) + replica_url = _validate_database_url(getattr(settings, 'DATABASE_READ_REPLICA_URL', None)) if replica_url: try: self.read_replica_engine = create_async_engine( @@ -236,9 +234,9 @@ class DatabaseManager: expire_on_commit=False, autoflush=False, ) - logger.info("Read replica настроена: %s", replica_url[:30] + "...") + logger.info('Read replica настроена: %s', replica_url[:30] + '...') except Exception as e: - logger.error("Не удалось настроить read replica: %s", e) + logger.error('Не удалось настроить read replica: %s', e) self.read_replica_engine = None @asynccontextmanager @@ -267,54 +265,54 @@ class DatabaseManager: timeout: Максимальное время ожидания (секунды) """ pool = self.engine.pool - status = "unhealthy" + status = 'unhealthy' latency = None try: async with asyncio.timeout(timeout): async with AsyncSessionLocal() as session: start = time.time() - await session.execute(text("SELECT 1")) + await session.execute(text('SELECT 1')) latency = (time.time() - start) * 1000 - status = "healthy" - except asyncio.TimeoutError: - logger.error("Health check таймаут (%s сек)", timeout) - status = "timeout" + status = 'healthy' + except TimeoutError: + logger.error('Health check таймаут (%s сек)', timeout) + status = 'timeout' except Exception as e: - logger.error("Database health check failed: %s", e) - status = "unhealthy" + logger.error('Database health check failed: %s', e) + status = 'unhealthy' return { - "status": status, - "latency_ms": round(latency, 2) if latency else None, - "pool": _collect_health_pool_metrics(pool), + 'status': status, + 'latency_ms': round(latency, 2) if latency else None, + 'pool': _collect_health_pool_metrics(pool), } - async def health_check_replica(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> Optional[dict]: + async def health_check_replica(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> dict | None: """Проверка здоровья read replica.""" if not self.read_replica_engine: return None pool = self.read_replica_engine.pool - status = "unhealthy" + status = 'unhealthy' latency = None try: async with asyncio.timeout(timeout): async with self._read_replica_session_factory() as session: start = time.time() - await session.execute(text("SELECT 1")) + await session.execute(text('SELECT 1')) latency = (time.time() - start) * 1000 - status = "healthy" - except asyncio.TimeoutError: - status = "timeout" + status = 'healthy' + except TimeoutError: + status = 'timeout' except Exception as e: - logger.error("Read replica health check failed: %s", e) + logger.error('Read replica health check failed: %s', e) return { - "status": status, - "latency_ms": round(latency, 2) if latency else None, - "pool": _collect_health_pool_metrics(pool), + 'status': status, + 'latency_ms': round(latency, 2) if latency else None, + 'pool': _collect_health_pool_metrics(pool), } @@ -324,7 +322,8 @@ db_manager = DatabaseManager() # SESSION DEPENDENCY FOR FASTAPI/AIOGRAM # ============================================================================ -async def get_db() -> AsyncGenerator[AsyncSession, None]: + +async def get_db() -> AsyncGenerator[AsyncSession]: """Стандартная dependency для FastAPI""" async with AsyncSessionLocal() as session: try: @@ -334,27 +333,30 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: await session.rollback() raise -async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]: + +async def get_db_read_only() -> AsyncGenerator[AsyncSession]: """Read-only dependency для тяжелых SELECT запросов""" async with db_manager.session(read_only=True) as session: yield session + # ============================================================================ # BATCH OPERATIONS FOR PERFORMANCE # ============================================================================ + class BatchOperations: """Утилиты для массовых операций""" - + @staticmethod async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000): """Массовая вставка с чанками""" for i in range(0, len(data), chunk_size): - chunk = data[i:i + chunk_size] + chunk = data[i : i + chunk_size] session.add_all([model(**item) for item in chunk]) await session.flush() await session.commit() - + @staticmethod async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000): """Массовое обновление с чанками""" @@ -363,62 +365,46 @@ class BatchOperations: primary_keys = [column.name for column in model.__table__.primary_key.columns] if not primary_keys: - raise ValueError("Model must have a primary key for bulk_update") + raise ValueError('Model must have a primary key for bulk_update') - updatable_columns = [ - column.name - for column in model.__table__.columns - if column.name not in primary_keys - ] + updatable_columns = [column.name for column in model.__table__.columns if column.name not in primary_keys] if not updatable_columns: - raise ValueError("No columns available for update in bulk_update") + raise ValueError('No columns available for update in bulk_update') stmt = ( model.__table__.update() - .where( - *[ - getattr(model.__table__.c, pk) == bindparam(pk) - for pk in primary_keys - ] - ) - .values( - **{ - column: bindparam(column, required=False) - for column in updatable_columns - } - ) + .where(*[getattr(model.__table__.c, pk) == bindparam(pk) for pk in primary_keys]) + .values(**{column: bindparam(column, required=False) for column in updatable_columns}) ) for i in range(0, len(data), chunk_size): - chunk = data[i:i + chunk_size] + chunk = data[i : i + chunk_size] filtered_chunk = [] for item in chunk: missing_keys = [pk for pk in primary_keys if pk not in item] if missing_keys: - raise ValueError( - f"Missing primary key values {missing_keys} for bulk_update" - ) + raise ValueError(f'Missing primary key values {missing_keys} for bulk_update') filtered_item = { - key: value - for key, value in item.items() - if key in primary_keys or key in updatable_columns + key: value for key, value in item.items() if key in primary_keys or key in updatable_columns } filtered_chunk.append(filtered_item) await session.execute(stmt, filtered_chunk) await session.commit() + batch_ops = BatchOperations() # ============================================================================ # INITIALIZATION AND CLEANUP # ============================================================================ + async def init_db(): """Инициализация БД с оптимизациями""" - logger.info("🚀 Создание таблиц базы данных...") + logger.info('🚀 Создание таблиц базы данных...') try: async with engine.begin() as conn: @@ -430,52 +416,50 @@ async def init_db(): error_type = type(e).__name__.lower() # Проверяем оригинальную ошибку для asyncpg - orig_error = getattr(e, "orig", None) + orig_error = getattr(e, 'orig', None) if orig_error: orig_type = type(orig_error).__name__.lower() - if "duplicatetableerror" in orig_type or "duplicatekeyerror" in orig_type: + if 'duplicatetableerror' in orig_type or 'duplicatekeyerror' in orig_type: logger.warning( - "⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. " - "Продолжаем инициализацию..." + '⚠️ Некоторые индексы/таблицы уже существуют в БД, это нормально. Продолжаем инициализацию...' ) return # Проверяем, является ли это ошибкой дублирования is_duplicate_error = ( - "already exists" in error_str - or "duplicate" in error_str - or "duplicatetableerror" in error_type - or "duplicatekeyerror" in error_type + 'already exists' in error_str + or 'duplicate' in error_str + or 'duplicatetableerror' in error_type + or 'duplicatekeyerror' in error_type ) if is_duplicate_error: logger.warning( - "⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. " - "Продолжаем инициализацию..." + '⚠️ Некоторые объекты БД уже существуют (таблицы/индексы), это нормально. Продолжаем инициализацию...' ) # Продолжаем выполнение, так как основные таблицы могут быть созданы else: # Для других ошибок пробрасываем исключение - logger.error(f"❌ Ошибка при создании таблиц: {e}") + logger.error(f'❌ Ошибка при создании таблиц: {e}') raise if not IS_SQLITE: - logger.info("Создание индексов для оптимизации...") + logger.info('Создание индексов для оптимизации...') async with engine.begin() as conn: indexes = [ - ("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"), + ('users', 'CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)'), ( - "subscriptions", - "CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)", + 'subscriptions', + 'CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)', ), ( - "subscriptions", + 'subscriptions', "CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'", ), ( - "payments", - "CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)", + 'payments', + 'CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)', ), ] @@ -484,7 +468,7 @@ async def init_db(): if not table_exists: logger.debug( - "Пропускаем создание индекса %s: таблица %s отсутствует", + 'Пропускаем создание индекса %s: таблица %s отсутствует', index_sql, table_name, ) @@ -493,33 +477,35 @@ async def init_db(): try: await conn.execute(text(index_sql)) except Exception as e: - logger.debug("Index creation skipped for %s: %s", table_name, e) + logger.debug('Index creation skipped for %s: %s', table_name, e) - logger.info("База данных успешно инициализирована") + logger.info('База данных успешно инициализирована') health = await db_manager.health_check() - logger.info("Database health: %s", health) + logger.info('Database health: %s', health) async def close_db(): """Корректное закрытие всех соединений""" - logger.info("Закрытие соединений с БД...") + logger.info('Закрытие соединений с БД...') await engine.dispose() if db_manager.read_replica_engine: await db_manager.read_replica_engine.dispose() - logger.info("Все подключения к базе данных закрыты") + logger.info('Все подключения к базе данных закрыты') + # ============================================================================ # CONNECTION POOL METRICS (для мониторинга) # ============================================================================ + def _pool_counters(pool): """Return basic pool counters or ``None`` when unsupported.""" - required_methods = ("size", "checkedin", "checkedout", "overflow") + required_methods = ('size', 'checkedin', 'checkedout', 'overflow') for method_name in required_methods: method = getattr(pool, method_name, None) @@ -534,12 +520,12 @@ def _pool_counters(pool): total_connections = size + overflow return { - "size": size, - "checked_in": checked_in, - "checked_out": checked_out, - "overflow": overflow, - "total_connections": total_connections, - "utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0, + 'size': size, + 'checked_in': checked_in, + 'checked_out': checked_out, + 'overflow': overflow, + 'total_connections': total_connections, + 'utilization_percent': (checked_out / total_connections * 100) if total_connections else 0.0, } @@ -548,23 +534,23 @@ def _collect_health_pool_metrics(pool) -> dict: if counters is None: return { - "metrics_available": False, - "size": 0, - "checked_in": 0, - "checked_out": 0, - "overflow": 0, - "total_connections": 0, - "utilization": "0.0%", + 'metrics_available': False, + 'size': 0, + 'checked_in': 0, + 'checked_out': 0, + 'overflow': 0, + 'total_connections': 0, + 'utilization': '0.0%', } return { - "metrics_available": True, - "size": counters["size"], - "checked_in": counters["checked_in"], - "checked_out": counters["checked_out"], - "overflow": counters["overflow"], - "total_connections": counters["total_connections"], - "utilization": f"{counters['utilization_percent']:.1f}%", + 'metrics_available': True, + 'size': counters['size'], + 'checked_in': counters['checked_in'], + 'checked_out': counters['checked_out'], + 'overflow': counters['overflow'], + 'total_connections': counters['total_connections'], + 'utilization': f'{counters["utilization_percent"]:.1f}%', } @@ -576,23 +562,23 @@ async def get_pool_metrics() -> dict: if counters is None: return { - "metrics_available": False, - "pool_size": 0, - "checked_in_connections": 0, - "checked_out_connections": 0, - "overflow_connections": 0, - "total_connections": 0, - "max_possible_connections": 0, - "pool_utilization_percent": 0.0, + 'metrics_available': False, + 'pool_size': 0, + 'checked_in_connections': 0, + 'checked_out_connections': 0, + 'overflow_connections': 0, + 'total_connections': 0, + 'max_possible_connections': 0, + 'pool_utilization_percent': 0.0, } return { - "metrics_available": True, - "pool_size": counters["size"], - "checked_in_connections": counters["checked_in"], - "checked_out_connections": counters["checked_out"], - "overflow_connections": counters["overflow"], - "total_connections": counters["total_connections"], - "max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0), - "pool_utilization_percent": round(counters["utilization_percent"], 2), + 'metrics_available': True, + 'pool_size': counters['size'], + 'checked_in_connections': counters['checked_in'], + 'checked_out_connections': counters['checked_out'], + 'overflow_connections': counters['overflow'], + 'total_connections': counters['total_connections'], + 'max_possible_connections': counters['total_connections'] + (getattr(pool, '_max_overflow', 0) or 0), + 'pool_utilization_percent': round(counters['utilization_percent'], 2), } diff --git a/app/database/models.py b/app/database/models.py index 9ea2aad7..fb241f8f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,27 +1,25 @@ -from datetime import datetime, timedelta, time, date -from typing import Optional, List, Dict, Any +from datetime import datetime, time, timedelta from enum import Enum from sqlalchemy import ( - Column, - Integer, - String, - DateTime, - Date, - Time, - Boolean, - Text, - ForeignKey, - Float, JSON, BigInteger, - UniqueConstraint, + Boolean, + Column, + Date, + DateTime, + Float, + ForeignKey, Index, + Integer, + String, Table, - SmallInteger, + Text, + Time, + UniqueConstraint, ) from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship, Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -29,18 +27,18 @@ Base = declarative_base() server_squad_promo_groups = Table( - "server_squad_promo_groups", + 'server_squad_promo_groups', Base.metadata, Column( - "server_squad_id", + 'server_squad_id', Integer, - ForeignKey("server_squads.id", ondelete="CASCADE"), + ForeignKey('server_squads.id', ondelete='CASCADE'), primary_key=True, ), Column( - "promo_group_id", + 'promo_group_id', Integer, - ForeignKey("promo_groups.id", ondelete="CASCADE"), + ForeignKey('promo_groups.id', ondelete='CASCADE'), primary_key=True, ), ) @@ -48,199 +46,203 @@ server_squad_promo_groups = Table( # M2M таблица для связи тарифов с промогруппами (доступ к тарифу) tariff_promo_groups = Table( - "tariff_promo_groups", + 'tariff_promo_groups', Base.metadata, Column( - "tariff_id", + 'tariff_id', Integer, - ForeignKey("tariffs.id", ondelete="CASCADE"), + ForeignKey('tariffs.id', ondelete='CASCADE'), primary_key=True, ), Column( - "promo_group_id", + 'promo_group_id', Integer, - ForeignKey("promo_groups.id", ondelete="CASCADE"), + ForeignKey('promo_groups.id', ondelete='CASCADE'), primary_key=True, ), ) class UserStatus(Enum): - ACTIVE = "active" - BLOCKED = "blocked" - DELETED = "deleted" + ACTIVE = 'active' + BLOCKED = 'blocked' + DELETED = 'deleted' class SubscriptionStatus(Enum): - TRIAL = "trial" - ACTIVE = "active" - EXPIRED = "expired" - DISABLED = "disabled" - PENDING = "pending" + TRIAL = 'trial' + ACTIVE = 'active' + EXPIRED = 'expired' + DISABLED = 'disabled' + PENDING = 'pending' class TransactionType(Enum): - DEPOSIT = "deposit" - WITHDRAWAL = "withdrawal" - SUBSCRIPTION_PAYMENT = "subscription_payment" - REFUND = "refund" - REFERRAL_REWARD = "referral_reward" - POLL_REWARD = "poll_reward" + DEPOSIT = 'deposit' + WITHDRAWAL = 'withdrawal' + SUBSCRIPTION_PAYMENT = 'subscription_payment' + REFUND = 'refund' + REFERRAL_REWARD = 'referral_reward' + POLL_REWARD = 'poll_reward' class PromoCodeType(Enum): - BALANCE = "balance" - SUBSCRIPTION_DAYS = "subscription_days" - TRIAL_SUBSCRIPTION = "trial_subscription" - PROMO_GROUP = "promo_group" - DISCOUNT = "discount" # Одноразовая процентная скидка (balance_bonus_kopeks = процент, subscription_days = часы) + BALANCE = 'balance' + SUBSCRIPTION_DAYS = 'subscription_days' + TRIAL_SUBSCRIPTION = 'trial_subscription' + PROMO_GROUP = 'promo_group' + DISCOUNT = 'discount' # Одноразовая процентная скидка (balance_bonus_kopeks = процент, subscription_days = часы) class PaymentMethod(Enum): - TELEGRAM_STARS = "telegram_stars" - TRIBUTE = "tribute" - YOOKASSA = "yookassa" - CRYPTOBOT = "cryptobot" - HELEKET = "heleket" - MULENPAY = "mulenpay" - PAL24 = "pal24" - WATA = "wata" - PLATEGA = "platega" - CLOUDPAYMENTS = "cloudpayments" - FREEKASSA = "freekassa" - KASSA_AI = "kassa_ai" - MANUAL = "manual" - BALANCE = "balance" + TELEGRAM_STARS = 'telegram_stars' + TRIBUTE = 'tribute' + YOOKASSA = 'yookassa' + CRYPTOBOT = 'cryptobot' + HELEKET = 'heleket' + MULENPAY = 'mulenpay' + PAL24 = 'pal24' + WATA = 'wata' + PLATEGA = 'platega' + CLOUDPAYMENTS = 'cloudpayments' + FREEKASSA = 'freekassa' + KASSA_AI = 'kassa_ai' + MANUAL = 'manual' + BALANCE = 'balance' class MainMenuButtonActionType(Enum): - URL = "url" - MINI_APP = "mini_app" + URL = 'url' + MINI_APP = 'mini_app' class MainMenuButtonVisibility(Enum): - ALL = "all" - ADMINS = "admins" - SUBSCRIBERS = "subscribers" + ALL = 'all' + ADMINS = 'admins' + SUBSCRIBERS = 'subscribers' class WheelPrizeType(Enum): """Типы призов на колесе удачи.""" - SUBSCRIPTION_DAYS = "subscription_days" - BALANCE_BONUS = "balance_bonus" - TRAFFIC_GB = "traffic_gb" - PROMOCODE = "promocode" - NOTHING = "nothing" + + SUBSCRIPTION_DAYS = 'subscription_days' + BALANCE_BONUS = 'balance_bonus' + TRAFFIC_GB = 'traffic_gb' + PROMOCODE = 'promocode' + NOTHING = 'nothing' class WheelSpinPaymentType(Enum): """Способы оплаты спина колеса.""" - TELEGRAM_STARS = "telegram_stars" - SUBSCRIPTION_DAYS = "subscription_days" + + TELEGRAM_STARS = 'telegram_stars' + SUBSCRIPTION_DAYS = 'subscription_days' + class YooKassaPayment(Base): - __tablename__ = "yookassa_payments" - + __tablename__ = 'yookassa_payments' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True) amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(3), default="RUB", nullable=False) + currency = Column(String(3), default='RUB', nullable=False) description = Column(Text, nullable=True) - status = Column(String(50), nullable=False) + status = Column(String(50), nullable=False) is_paid = Column(Boolean, default=False) is_captured = Column(Boolean, default=False) confirmation_url = Column(Text, nullable=True) metadata_json = Column(JSON, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) - payment_method_type = Column(String(50), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + payment_method_type = Column(String(50), nullable=True) refundable = Column(Boolean, default=False) test_mode = Column(Boolean, default=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - yookassa_created_at = Column(DateTime, nullable=True) - captured_at = Column(DateTime, nullable=True) - user = relationship("User", backref="yookassa_payments") - transaction = relationship("Transaction", backref="yookassa_payment") - + yookassa_created_at = Column(DateTime, nullable=True) + captured_at = Column(DateTime, nullable=True) + user = relationship('User', backref='yookassa_payments') + transaction = relationship('Transaction', backref='yookassa_payment') + @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 - + @property def is_pending(self) -> bool: - return self.status == "pending" - + return self.status == 'pending' + @property def is_succeeded(self) -> bool: - return self.status == "succeeded" and self.is_paid - + return self.status == 'succeeded' and self.is_paid + @property def is_failed(self) -> bool: - return self.status in ["canceled", "failed"] - + return self.status in ['canceled', 'failed'] + @property def can_be_captured(self) -> bool: - return self.status == "waiting_for_capture" - + return self.status == 'waiting_for_capture' + def __repr__(self): - return f"" + return f'' + class CryptoBotPayment(Base): - __tablename__ = "cryptobot_payments" + __tablename__ = 'cryptobot_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + invoice_id = Column(String(255), unique=True, nullable=False, index=True) amount = Column(String(50), nullable=False) asset = Column(String(10), nullable=False) - + status = Column(String(50), nullable=False) description = Column(Text, nullable=True) payload = Column(Text, nullable=True) - + bot_invoice_url = Column(Text, nullable=True) mini_app_invoice_url = Column(Text, nullable=True) web_app_invoice_url = Column(Text, nullable=True) - + paid_at = Column(DateTime, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) - + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - user = relationship("User", backref="cryptobot_payments") - transaction = relationship("Transaction", backref="cryptobot_payment") - + + user = relationship('User', backref='cryptobot_payments') + transaction = relationship('Transaction', backref='cryptobot_payment') + @property def amount_float(self) -> float: try: return float(self.amount) except (ValueError, TypeError): return 0.0 - + @property def is_paid(self) -> bool: - return self.status == "paid" - + return self.status == 'paid' + @property def is_pending(self) -> bool: - return self.status == "active" - + return self.status == 'active' + @property def is_expired(self) -> bool: - return self.status == "expired" - + return self.status == 'expired' + def __repr__(self): - return f"" + return f'' class HeleketPayment(Base): - __tablename__ = "heleket_payments" + __tablename__ = 'heleket_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) uuid = Column(String(255), unique=True, nullable=False, index=True) order_id = Column(String(128), unique=True, nullable=False, index=True) @@ -258,13 +260,13 @@ class HeleketPayment(Base): paid_at = Column(DateTime, nullable=True) expires_at = Column(DateTime, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="heleket_payments") - transaction = relationship("Transaction", backref="heleket_payment") + user = relationship('User', backref='heleket_payments') + transaction = relationship('Transaction', backref='heleket_payment') @property def amount_float(self) -> float: @@ -286,35 +288,28 @@ class HeleketPayment(Base): @property def is_paid(self) -> bool: - return self.status in {"paid", "paid_over"} + return self.status in {'paid', 'paid_over'} def __repr__(self): return ( - "" - ).format( - id=self.id, - uuid=self.uuid, - order_id=self.order_id, - amount=self.amount, - currency=self.currency, - status=self.status, + f'' ) class MulenPayPayment(Base): - __tablename__ = "mulenpay_payments" + __tablename__ = 'mulenpay_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) mulen_payment_id = Column(Integer, nullable=True, index=True) uuid = Column(String(255), unique=True, nullable=False, index=True) amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) - status = Column(String(50), nullable=False, default="created") + status = Column(String(50), nullable=False, default='created') is_paid = Column(Boolean, default=False) paid_at = Column(DateTime, nullable=True) @@ -322,43 +317,36 @@ class MulenPayPayment(Base): metadata_json = Column(JSON, nullable=True) callback_payload = Column(JSON, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="mulenpay_payments") - transaction = relationship("Transaction", backref="mulenpay_payment") + user = relationship('User', backref='mulenpay_payments') + transaction = relationship('Transaction', backref='mulenpay_payment') @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.mulen_payment_id, - self.amount_rubles, - self.status, - ) - ) + return f'' class Pal24Payment(Base): - __tablename__ = "pal24_payments" + __tablename__ = 'pal24_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) bill_id = Column(String(255), unique=True, nullable=False, index=True) order_id = Column(String(255), nullable=True, index=True) amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) - type = Column(String(20), nullable=False, default="normal") + type = Column(String(20), nullable=False, default='normal') - status = Column(String(50), nullable=False, default="NEW") + status = Column(String(50), nullable=False, default='NEW') is_active = Column(Boolean, default=True) is_paid = Column(Boolean, default=False) paid_at = Column(DateTime, nullable=True) @@ -380,13 +368,13 @@ class Pal24Payment(Base): ttl = Column(Integer, nullable=True) expires_at = Column(DateTime, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="pal24_payments") - transaction = relationship("Transaction", backref="pal24_payment") + user = relationship('User', backref='pal24_payments') + transaction = relationship('Transaction', backref='pal24_payment') @property def amount_rubles(self) -> float: @@ -394,33 +382,28 @@ class Pal24Payment(Base): @property def is_pending(self) -> bool: - return self.status in {"NEW", "PROCESS"} + return self.status in {'NEW', 'PROCESS'} def __repr__(self) -> str: # pragma: no cover - debug helper return ( - "".format( - self.id, - self.bill_id, - self.amount_rubles, - self.status, - ) + f'' ) class WataPayment(Base): - __tablename__ = "wata_payments" + __tablename__ = 'wata_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) payment_link_id = Column(String(64), unique=True, nullable=False, index=True) order_id = Column(String(255), nullable=True, index=True) amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) type = Column(String(50), nullable=True) - status = Column(String(50), nullable=False, default="Opened") + status = Column(String(50), nullable=False, default='Opened') is_paid = Column(Boolean, default=False) paid_at = Column(DateTime, nullable=True) last_status = Column(String(50), nullable=True) @@ -434,43 +417,36 @@ class WataPayment(Base): expires_at = Column(DateTime, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="wata_payments") - transaction = relationship("Transaction", backref="wata_payment") + user = relationship('User', backref='wata_payments') + transaction = relationship('Transaction', backref='wata_payment') @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.payment_link_id, - self.amount_rubles, - self.status, - ) - ) + return f'' class PlategaPayment(Base): - __tablename__ = "platega_payments" + __tablename__ = 'platega_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True) correlation_id = Column(String(64), unique=True, nullable=False, index=True) amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) payment_method_code = Column(Integer, nullable=False) - status = Column(String(50), nullable=False, default="PENDING") + status = Column(String(50), nullable=False, default='PENDING') is_paid = Column(Boolean, default=False) paid_at = Column(DateTime, nullable=True) @@ -483,45 +459,37 @@ class PlategaPayment(Base): expires_at = Column(DateTime, nullable=True) - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="platega_payments") - transaction = relationship("Transaction", backref="platega_payment") + user = relationship('User', backref='platega_payments') + transaction = relationship('Transaction', backref='platega_payment') @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.platega_transaction_id, - self.amount_rubles, - self.status, - self.payment_method_code, - ) - ) + return f'' class CloudPaymentsPayment(Base): - __tablename__ = "cloudpayments_payments" + __tablename__ = 'cloudpayments_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # CloudPayments идентификаторы transaction_id_cp = Column(Integer, unique=True, nullable=True, index=True) # TransactionId от CloudPayments invoice_id = Column(String(255), unique=True, nullable=False, index=True) # Наш InvoiceId amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) - status = Column(String(50), nullable=False, default="pending") # pending, completed, failed, authorized + status = Column(String(50), nullable=False, default='pending') # pending, completed, failed, authorized is_paid = Column(Boolean, default=False) paid_at = Column(DateTime, nullable=True) @@ -548,13 +516,13 @@ class CloudPaymentsPayment(Base): callback_payload = Column(JSON, nullable=True) # Связь с транзакцией в нашей системе - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", backref="cloudpayments_payments") - transaction = relationship("Transaction", backref="cloudpayments_payment") + user = relationship('User', backref='cloudpayments_payments') + transaction = relationship('Transaction', backref='cloudpayments_payment') @property def amount_rubles(self) -> float: @@ -562,32 +530,25 @@ class CloudPaymentsPayment(Base): @property def is_pending(self) -> bool: - return self.status == "pending" + return self.status == 'pending' @property def is_completed(self) -> bool: - return self.status == "completed" and self.is_paid + return self.status == 'completed' and self.is_paid @property def is_failed(self) -> bool: - return self.status == "failed" + return self.status == 'failed' def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.invoice_id, - self.amount_rubles, - self.status, - ) - ) + return f'' class FreekassaPayment(Base): - __tablename__ = "freekassa_payments" + __tablename__ = 'freekassa_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # Идентификаторы order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа @@ -595,11 +556,11 @@ class FreekassaPayment(Base): # Суммы amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) # Статусы - status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired + status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired is_paid = Column(Boolean, default=False) # Данные платежа @@ -617,11 +578,11 @@ class FreekassaPayment(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) # Связь с транзакцией - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) # Relationships - user = relationship("User", backref="freekassa_payments") - transaction = relationship("Transaction", backref="freekassa_payment") + user = relationship('User', backref='freekassa_payments') + transaction = relationship('Transaction', backref='freekassa_payment') @property def amount_rubles(self) -> float: @@ -629,33 +590,27 @@ class FreekassaPayment(Base): @property def is_pending(self) -> bool: - return self.status == "pending" + return self.status == 'pending' @property def is_success(self) -> bool: - return self.status == "success" and self.is_paid + return self.status == 'success' and self.is_paid @property def is_failed(self) -> bool: - return self.status in ["failed", "expired"] + return self.status in ['failed', 'expired'] def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.order_id, - self.amount_rubles, - self.status, - ) - ) + return f'' class KassaAiPayment(Base): """Платежи через KassaAI (api.fk.life).""" - __tablename__ = "kassa_ai_payments" + + __tablename__ = 'kassa_ai_payments' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # Идентификаторы order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа @@ -663,11 +618,11 @@ class KassaAiPayment(Base): # Суммы amount_kopeks = Column(Integer, nullable=False) - currency = Column(String(10), nullable=False, default="RUB") + currency = Column(String(10), nullable=False, default='RUB') description = Column(Text, nullable=True) # Статусы - status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired + status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired is_paid = Column(Boolean, default=False) # Данные платежа @@ -685,11 +640,11 @@ class KassaAiPayment(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) # Связь с транзакцией - transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) # Relationships - user = relationship("User", backref="kassa_ai_payments") - transaction = relationship("Transaction", backref="kassa_ai_payment") + user = relationship('User', backref='kassa_ai_payments') + transaction = relationship('Transaction', backref='kassa_ai_payment') @property def amount_rubles(self) -> float: @@ -697,29 +652,22 @@ class KassaAiPayment(Base): @property def is_pending(self) -> bool: - return self.status == "pending" + return self.status == 'pending' @property def is_success(self) -> bool: - return self.status == "success" and self.is_paid + return self.status == 'success' and self.is_paid @property def is_failed(self) -> bool: - return self.status in ["failed", "expired"] + return self.status in ['failed', 'expired'] def __repr__(self) -> str: # pragma: no cover - debug helper - return ( - "".format( - self.id, - self.order_id, - self.amount_rubles, - self.status, - ) - ) + return f'' class PromoGroup(Base): - __tablename__ = "promo_groups" + __tablename__ = 'promo_groups' id = Column(Integer, primary_key=True, index=True) name = Column(String(255), unique=True, nullable=False) @@ -734,16 +682,16 @@ class PromoGroup(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - users = relationship("User", back_populates="promo_group") - user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan") + users = relationship('User', back_populates='promo_group') + user_promo_groups = relationship('UserPromoGroup', back_populates='promo_group', cascade='all, delete-orphan') server_squads = relationship( - "ServerSquad", + 'ServerSquad', secondary=server_squad_promo_groups, - back_populates="allowed_promo_groups", - lazy="selectin", + back_populates='allowed_promo_groups', + lazy='selectin', ) - def _get_period_discounts_map(self) -> Dict[int, int]: + def _get_period_discounts_map(self) -> dict[int, int]: raw_discounts = self.period_discounts or {} if isinstance(raw_discounts, dict): @@ -751,7 +699,7 @@ class PromoGroup(Base): else: items = [] - normalized: Dict[int, int] = {} + normalized: dict[int, int] = {} for key, value in items: try: @@ -764,7 +712,7 @@ class PromoGroup(Base): return normalized - def _get_period_discount(self, period_days: Optional[int]) -> int: + def _get_period_discount(self, period_days: int | None) -> int: if not period_days: return 0 @@ -785,14 +733,14 @@ class PromoGroup(Base): return 0 - def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int: - if category == "period": + def get_discount_percent(self, category: str, period_days: int | None = None) -> int: + if category == 'period': return max(0, min(100, self._get_period_discount(period_days))) mapping = { - "servers": self.server_discount_percent, - "traffic": self.traffic_discount_percent, - "devices": self.device_discount_percent, + 'servers': self.server_discount_percent, + 'traffic': self.traffic_discount_percent, + 'devices': self.device_discount_percent, } percent = mapping.get(category) or 0 @@ -805,15 +753,16 @@ class PromoGroup(Base): class UserPromoGroup(Base): """Таблица связи Many-to-Many между пользователями и промогруппами.""" - __tablename__ = "user_promo_groups" - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) - promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True) + __tablename__ = 'user_promo_groups' + + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) + promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='CASCADE'), primary_key=True) assigned_at = Column(DateTime, default=func.now()) - assigned_by = Column(String(50), default="system") + assigned_by = Column(String(50), default='system') - user = relationship("User", back_populates="user_promo_groups") - promo_group = relationship("PromoGroup", back_populates="user_promo_groups") + user = relationship('User', back_populates='user_promo_groups') + promo_group = relationship('PromoGroup', back_populates='user_promo_groups') def __repr__(self): return f"" @@ -821,7 +770,8 @@ class UserPromoGroup(Base): class Tariff(Base): """Тарифный план для режима продаж 'Тарифы'.""" - __tablename__ = "tariffs" + + __tablename__ = 'tariffs' id = Column(Integer, primary_key=True, index=True) @@ -834,7 +784,9 @@ class Tariff(Base): # Параметры тарифа traffic_limit_gb = Column(Integer, nullable=False, default=100) # 0 = безлимит device_limit = Column(Integer, nullable=False, default=1) - device_price_kopeks = Column(Integer, nullable=True, default=None) # Цена за доп. устройство (None = нельзя докупить) + device_price_kopeks = Column( + Integer, nullable=True, default=None + ) # Цена за доп. устройство (None = нельзя докупить) max_device_limit = Column(Integer, nullable=True, default=None) # Макс. устройств (None = без ограничений) # Сквады (серверы) доступные в тарифе @@ -885,30 +837,30 @@ class Tariff(Base): # M2M связь с промогруппами (какие промогруппы имеют доступ к тарифу) allowed_promo_groups = relationship( - "PromoGroup", + 'PromoGroup', secondary=tariff_promo_groups, - lazy="selectin", + lazy='selectin', ) # Подписки на этом тарифе - subscriptions = relationship("Subscription", back_populates="tariff") + subscriptions = relationship('Subscription', back_populates='tariff') @property def is_unlimited_traffic(self) -> bool: """Проверяет, безлимитный ли трафик.""" return self.traffic_limit_gb == 0 - def get_price_for_period(self, period_days: int) -> Optional[int]: + def get_price_for_period(self, period_days: int) -> int | None: """Возвращает цену в копейках для указанного периода.""" prices = self.period_prices or {} return prices.get(str(period_days)) - def get_available_periods(self) -> List[int]: + def get_available_periods(self) -> list[int]: """Возвращает список доступных периодов в днях.""" prices = self.period_prices or {} return sorted([int(p) for p in prices.keys()]) - def get_price_rubles(self, period_days: int) -> Optional[float]: + def get_price_rubles(self, period_days: int) -> float | None: """Возвращает цену в рублях для указанного периода.""" price_kopeks = self.get_price_for_period(period_days) if price_kopeks is not None: @@ -926,11 +878,11 @@ class Tariff(Base): server_limit = limits[squad_uuid] if isinstance(server_limit, dict) and 'traffic_limit_gb' in server_limit: return server_limit['traffic_limit_gb'] - elif isinstance(server_limit, int): + if isinstance(server_limit, int): return server_limit return self.traffic_limit_gb - def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool: + def is_available_for_promo_group(self, promo_group_id: int | None) -> bool: """Проверяет, доступен ли тариф для указанной промогруппы.""" if not self.allowed_promo_groups: return True # Если нет ограничений - доступен всем @@ -938,34 +890,30 @@ class Tariff(Base): return True # Если у пользователя нет группы - доступен return any(pg.id == promo_group_id for pg in self.allowed_promo_groups) - def get_traffic_topup_packages(self) -> Dict[int, int]: + def get_traffic_topup_packages(self) -> dict[int, int]: """Возвращает пакеты трафика для докупки: {ГБ: цена в копейках}.""" packages = self.traffic_topup_packages or {} return {int(gb): int(price) for gb, price in packages.items()} - def get_traffic_topup_price(self, gb: int) -> Optional[int]: + def get_traffic_topup_price(self, gb: int) -> int | None: """Возвращает цену в копейках для указанного пакета трафика.""" packages = self.get_traffic_topup_packages() return packages.get(gb) - def get_available_traffic_packages(self) -> List[int]: + def get_available_traffic_packages(self) -> list[int]: """Возвращает список доступных пакетов трафика в ГБ.""" packages = self.get_traffic_topup_packages() return sorted(packages.keys()) def can_topup_traffic(self) -> bool: """Проверяет, можно ли докупить трафик на этом тарифе.""" - return ( - self.traffic_topup_enabled - and bool(self.traffic_topup_packages) - and not self.is_unlimited_traffic - ) + return self.traffic_topup_enabled and bool(self.traffic_topup_packages) and not self.is_unlimited_traffic def get_daily_price_rubles(self) -> float: """Возвращает суточную цену в рублях.""" return self.daily_price_kopeks / 100 if self.daily_price_kopeks else 0 - def get_price_for_custom_days(self, days: int) -> Optional[int]: + def get_price_for_custom_days(self, days: int) -> int | None: """Возвращает цену для произвольного количества дней.""" if not self.custom_days_enabled or not self.price_per_day_kopeks: return None @@ -973,7 +921,7 @@ class Tariff(Base): return None return self.price_per_day_kopeks * days - def get_price_for_custom_traffic(self, gb: int) -> Optional[int]: + def get_price_for_custom_traffic(self, gb: int) -> int | None: """Возвращает цену для произвольного количества трафика.""" if not self.custom_traffic_enabled or not self.traffic_price_per_gb_kopeks: return None @@ -994,20 +942,20 @@ class Tariff(Base): class User(Base): - __tablename__ = "users" + __tablename__ = 'users' id = Column(Integer, primary_key=True, index=True) telegram_id = Column(BigInteger, unique=True, index=True, nullable=True) # Nullable для email-only пользователей - auth_type = Column(String(20), default="telegram", nullable=False) # "telegram" или "email" + auth_type = Column(String(20), default='telegram', nullable=False) # "telegram" или "email" username = Column(String(255), nullable=True) first_name = Column(String(255), nullable=True) last_name = Column(String(255), nullable=True) status = Column(String(20), default=UserStatus.ACTIVE.value) - language = Column(String(5), default="ru") + language = Column(String(5), default='ru') balance_kopeks = Column(Integer, default=0) used_promocodes = Column(Integer, default=0) has_had_paid_subscription = Column(Boolean, default=False, nullable=False) - referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + referred_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) referral_code = Column(String(20), unique=True, nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) @@ -1024,13 +972,13 @@ class User(Base): password_reset_token = Column(String(255), nullable=True) password_reset_expires = Column(DateTime, nullable=True) cabinet_last_login = Column(DateTime, nullable=True) - broadcasts = relationship("BroadcastHistory", back_populates="admin") - referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id") - subscription = relationship("Subscription", back_populates="user", uselist=False) - transactions = relationship("Transaction", back_populates="user") - referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") - discount_offers = relationship("DiscountOffer", back_populates="user") - promo_offer_logs = relationship("PromoOfferLog", back_populates="user") + broadcasts = relationship('BroadcastHistory', back_populates='admin') + referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id') + subscription = relationship('Subscription', back_populates='user', uselist=False) + transactions = relationship('Transaction', back_populates='user') + referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user') + discount_offers = relationship('DiscountOffer', back_populates='user') + promo_offer_logs = relationship('PromoOfferLog', 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) @@ -1043,10 +991,10 @@ class User(Base): vless_uuid = Column(String(255), nullable=True) ss_password = Column(String(255), nullable=True) has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True) - promo_group = relationship("PromoGroup", back_populates="users") - user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan") - poll_responses = relationship("PollResponse", back_populates="user") + promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='RESTRICT'), nullable=True, index=True) + promo_group = relationship('PromoGroup', back_populates='users') + user_promo_groups = relationship('UserPromoGroup', back_populates='user', cascade='all, delete-orphan') + poll_responses = relationship('PollResponse', back_populates='user') notification_settings = Column(JSON, nullable=True, default=dict) last_pinned_message_id = Column(Integer, nullable=True) @@ -1068,37 +1016,34 @@ class User(Base): def full_name(self) -> str: """Полное имя пользователя с поддержкой email-only юзеров.""" parts = [self.first_name, self.last_name] - name = " ".join(filter(None, parts)) + name = ' '.join(filter(None, parts)) if name: return name if self.username: return self.username if self.telegram_id: - return f"ID{self.telegram_id}" + return f'ID{self.telegram_id}' if self.email: return self.email.split('@')[0] - return f"User{self.id}" + return f'User{self.id}' @property def is_email_user(self) -> bool: """Пользователь зарегистрирован через email (без Telegram).""" - return self.auth_type == "email" and self.telegram_id is None + return self.auth_type == 'email' and self.telegram_id is None def get_primary_promo_group(self): """Возвращает промогруппу с максимальным приоритетом.""" if not self.user_promo_groups: - return getattr(self, "promo_group", None) + return getattr(self, 'promo_group', None) try: # Сортируем по приоритету группы (убывание), затем по ID группы # Используем getattr для защиты от ленивой загрузки sorted_groups = sorted( self.user_promo_groups, - key=lambda upg: ( - getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0, - upg.promo_group_id - ), - reverse=True + key=lambda upg: (getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0, upg.promo_group_id), + reverse=True, ) if sorted_groups and sorted_groups[0].promo_group: @@ -1108,17 +1053,17 @@ class User(Base): pass # Fallback на старую связь если новая пустая или возникла ошибка - return getattr(self, "promo_group", None) + return getattr(self, 'promo_group', None) - def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int: + def get_promo_discount(self, category: str, period_days: int | None = None) -> int: primary_group = self.get_primary_promo_group() if not primary_group: return 0 return primary_group.get_discount_percent(category, period_days) - + def add_balance(self, kopeks: int) -> None: self.balance_kopeks += kopeks - + def subtract_balance(self, kopeks: int) -> bool: if self.balance_kopeks >= kopeks: self.balance_kopeks -= kopeks @@ -1127,59 +1072,60 @@ class User(Base): class Subscription(Base): - __tablename__ = "subscriptions" - + __tablename__ = 'subscriptions' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True) - + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True) + status = Column(String(20), default=SubscriptionStatus.TRIAL.value) is_trial = Column(Boolean, default=True) - + start_date = Column(DateTime, default=func.now()) end_date = Column(DateTime, nullable=False) - + traffic_limit_gb = Column(Integer, default=0) traffic_used_gb = Column(Float, default=0.0) purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик - traffic_reset_at = Column(DateTime, nullable=True) # Дата сброса докупленного трафика (30 дней после первой докупки) + traffic_reset_at = Column( + DateTime, nullable=True + ) # Дата сброса докупленного трафика (30 дней после первой докупки) subscription_url = Column(String, nullable=True) subscription_crypto_link = Column(String, nullable=True) device_limit = Column(Integer, default=1) modem_enabled = Column(Boolean, default=False) - + connected_squads = Column(JSON, default=list) - + autopay_enabled = Column(Boolean, default=False) autopay_days_before = Column(Integer, default=3) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) # Тариф (для режима продаж "Тарифы") - tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True, index=True) + tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=True) # Суточная подписка - is_daily_paused = Column(Boolean, default=False, nullable=False) # Приостановлена ли суточная подписка пользователем + is_daily_paused = Column( + Boolean, default=False, nullable=False + ) # Приостановлена ли суточная подписка пользователем last_daily_charge_at = Column(DateTime, nullable=True) # Время последнего суточного списания - user = relationship("User", back_populates="subscription") - tariff = relationship("Tariff", back_populates="subscriptions") - discount_offers = relationship("DiscountOffer", back_populates="subscription") - temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription") - traffic_purchases = relationship("TrafficPurchase", back_populates="subscription", cascade="all, delete-orphan") - + user = relationship('User', back_populates='subscription') + tariff = relationship('Tariff', back_populates='subscriptions') + discount_offers = relationship('DiscountOffer', back_populates='subscription') + temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription') + traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan') + @property def is_active(self) -> bool: current_time = datetime.utcnow() - return ( - self.status == SubscriptionStatus.ACTIVE.value and - self.end_date > current_time - ) - + return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time + @property def is_expired(self) -> bool: """Проверяет, истёк ли срок подписки""" @@ -1188,71 +1134,64 @@ class Subscription(Base): @property def should_be_expired(self) -> bool: current_time = datetime.utcnow() - return ( - self.status == SubscriptionStatus.ACTIVE.value and - self.end_date <= current_time - ) + return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time @property def actual_status(self) -> str: current_time = datetime.utcnow() - + if self.status == SubscriptionStatus.EXPIRED.value: - return "expired" - + return 'expired' + if self.status == SubscriptionStatus.DISABLED.value: - return "disabled" - + return 'disabled' + if self.status == SubscriptionStatus.ACTIVE.value: if self.end_date <= current_time: - return "expired" - else: - return "active" - + return 'expired' + return 'active' + if self.status == SubscriptionStatus.TRIAL.value: if self.end_date <= current_time: - return "expired" - else: - return "trial" - + return 'expired' + return 'trial' + return self.status @property def status_display(self) -> str: actual_status = self.actual_status - current_time = datetime.utcnow() - - if actual_status == "expired": - return "🔴 Истекла" - elif actual_status == "active": + datetime.utcnow() + + if actual_status == 'expired': + return '🔴 Истекла' + if actual_status == 'active': if self.is_trial: - return "🎯 Тестовая" - else: - return "🟢 Активна" - elif actual_status == "disabled": - return "⚫ Отключена" - elif actual_status == "trial": - return "🎯 Тестовая" - - return "❓ Неизвестно" + return '🎯 Тестовая' + return '🟢 Активна' + if actual_status == 'disabled': + return '⚫ Отключена' + if actual_status == 'trial': + return '🎯 Тестовая' + + return '❓ Неизвестно' @property def status_emoji(self) -> str: actual_status = self.actual_status - - if actual_status == "expired": - return "🔴" - elif actual_status == "active": + + if actual_status == 'expired': + return '🔴' + if actual_status == 'active': if self.is_trial: - return "🎁" - else: - return "💎" - elif actual_status == "disabled": - return "⚫" - elif actual_status == "trial": - return "🎁" - - return "❓" + return '🎁' + return '💎' + if actual_status == 'disabled': + return '⚫' + if actual_status == 'trial': + return '🎁' + + return '❓' @property def days_left(self) -> int: @@ -1266,38 +1205,36 @@ class Subscription(Base): def time_left_display(self) -> str: current_time = datetime.utcnow() if self.end_date <= current_time: - return "истёк" - + return 'истёк' + delta = self.end_date - current_time days = delta.days hours = delta.seconds // 3600 minutes = (delta.seconds % 3600) // 60 - + if days > 0: - return f"{days} дн." - elif hours > 0: - return f"{hours} ч." - else: - return f"{minutes} мин." - + return f'{days} дн.' + if hours > 0: + return f'{hours} ч.' + return f'{minutes} мин.' + @property def traffic_used_percent(self) -> float: - if self.traffic_limit_gb == 0: + if self.traffic_limit_gb == 0: return 0.0 if self.traffic_limit_gb > 0: return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0) return 0.0 - - def extend_subscription(self, days: int): + def extend_subscription(self, days: int): if self.end_date > datetime.utcnow(): self.end_date = self.end_date + timedelta(days=days) else: self.end_date = datetime.utcnow() + timedelta(days=days) - + if self.status == SubscriptionStatus.EXPIRED.value: self.status = SubscriptionStatus.ACTIVE.value - + def add_traffic(self, gb: int): if self.traffic_limit_gb == 0: return @@ -1331,17 +1268,18 @@ class Subscription(Base): class TrafficPurchase(Base): """Докупка трафика с индивидуальной датой истечения.""" - __tablename__ = "traffic_purchases" + + __tablename__ = 'traffic_purchases' id = Column(Integer, primary_key=True, index=True) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True) traffic_gb = Column(Integer, nullable=False) # Количество ГБ в покупке expires_at = Column(DateTime, nullable=False, index=True) # Дата истечения (покупка + 30 дней) created_at = Column(DateTime, default=func.now()) - subscription = relationship("Subscription", back_populates="traffic_purchases") + subscription = relationship('Subscription', back_populates='traffic_purchases') @property def is_expired(self) -> bool: @@ -1350,15 +1288,15 @@ class TrafficPurchase(Base): class Transaction(Base): - __tablename__ = "transactions" - + __tablename__ = 'transactions' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + type = Column(String(50), nullable=False) amount_kopeks = Column(Integer, nullable=False) description = Column(Text, nullable=True) - + payment_method = Column(String(50), nullable=True) external_id = Column(String(255), nullable=True) @@ -1370,116 +1308,117 @@ class Transaction(Base): created_at = Column(DateTime, default=func.now()) completed_at = Column(DateTime, nullable=True) - - user = relationship("User", back_populates="transactions") - + + user = relationship('User', back_populates='transactions') + @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 + class SubscriptionConversion(Base): - __tablename__ = "subscription_conversions" - + __tablename__ = 'subscription_conversions' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + converted_at = Column(DateTime, default=func.now()) - + trial_duration_days = Column(Integer, nullable=True) - + payment_method = Column(String(50), nullable=True) - + first_payment_amount_kopeks = Column(Integer, nullable=True) - + first_paid_period_days = Column(Integer, nullable=True) - + created_at = Column(DateTime, default=func.now()) - - user = relationship("User", backref="subscription_conversions") - + + user = relationship('User', backref='subscription_conversions') + @property def first_payment_amount_rubles(self) -> float: return (self.first_payment_amount_kopeks or 0) / 100 - + def __repr__(self): - return f"" + return f'' class PromoCode(Base): - __tablename__ = "promocodes" - + __tablename__ = 'promocodes' + id = Column(Integer, primary_key=True, index=True) - + code = Column(String(50), unique=True, nullable=False, index=True) type = Column(String(50), nullable=False) - - balance_bonus_kopeks = Column(Integer, default=0) - subscription_days = Column(Integer, default=0) - - max_uses = Column(Integer, default=1) + + balance_bonus_kopeks = Column(Integer, default=0) + subscription_days = Column(Integer, default=0) + + max_uses = Column(Integer, default=1) current_uses = Column(Integer, default=0) - + valid_from = Column(DateTime, default=func.now()) valid_until = Column(DateTime, nullable=True) - + is_active = Column(Boolean, default=True) first_purchase_only = Column(Boolean, default=False) # Только для первой покупки - created_by = Column(Integer, ForeignKey("users.id"), nullable=True) - promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True) + created_by = Column(Integer, ForeignKey('users.id'), nullable=True) + promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - uses = relationship("PromoCodeUse", back_populates="promocode") - promo_group = relationship("PromoGroup") - + uses = relationship('PromoCodeUse', back_populates='promocode') + promo_group = relationship('PromoGroup') + @property def is_valid(self) -> bool: now = datetime.utcnow() return ( - self.is_active and - self.current_uses < self.max_uses and - self.valid_from <= now and - (self.valid_until is None or self.valid_until >= now) + self.is_active + and self.current_uses < self.max_uses + and self.valid_from <= now + and (self.valid_until is None or self.valid_until >= now) ) - + @property def uses_left(self) -> int: return max(0, self.max_uses - self.current_uses) class PromoCodeUse(Base): - __tablename__ = "promocode_uses" - + __tablename__ = 'promocode_uses' + id = Column(Integer, primary_key=True, index=True) - promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - + promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + used_at = Column(DateTime, default=func.now()) - - promocode = relationship("PromoCode", back_populates="uses") - user = relationship("User") + + promocode = relationship('PromoCode', back_populates='uses') + user = relationship('User') class ReferralEarning(Base): - __tablename__ = "referral_earnings" - + __tablename__ = 'referral_earnings' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - referral_id = Column(Integer, ForeignKey("users.id"), nullable=False) - + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + referral_id = Column(Integer, ForeignKey('users.id'), nullable=False) + amount_kopeks = Column(Integer, nullable=False) - reason = Column(String(100), nullable=False) - - referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True) - + reason = Column(String(100), nullable=False) + + referral_transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True) + created_at = Column(DateTime, default=func.now()) - - user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings") - referral = relationship("User", foreign_keys=[referral_id]) - referral_transaction = relationship("Transaction") - + + user = relationship('User', foreign_keys=[user_id], back_populates='referral_earnings') + referral = relationship('User', foreign_keys=[referral_id]) + referral_transaction = relationship('Transaction') + @property def amount_rubles(self) -> float: return self.amount_kopeks / 100 @@ -1487,19 +1426,21 @@ class ReferralEarning(Base): class WithdrawalRequestStatus(Enum): """Статусы заявки на вывод реферального баланса.""" - PENDING = "pending" # Ожидает рассмотрения - APPROVED = "approved" # Одобрена - REJECTED = "rejected" # Отклонена - COMPLETED = "completed" # Выполнена (деньги переведены) - CANCELLED = "cancelled" # Отменена пользователем + + PENDING = 'pending' # Ожидает рассмотрения + APPROVED = 'approved' # Одобрена + REJECTED = 'rejected' # Отклонена + COMPLETED = 'completed' # Выполнена (деньги переведены) + CANCELLED = 'cancelled' # Отменена пользователем class WithdrawalRequest(Base): """Заявка на вывод реферального баланса.""" - __tablename__ = "withdrawal_requests" + + __tablename__ = 'withdrawal_requests' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) amount_kopeks = Column(Integer, nullable=False) # Сумма к выводу status = Column(String(50), default=WithdrawalRequestStatus.PENDING.value, nullable=False) @@ -1512,15 +1453,15 @@ class WithdrawalRequest(Base): risk_analysis = Column(Text, nullable=True) # JSON с деталями анализа # Обработка админом - processed_by = Column(Integer, ForeignKey("users.id"), nullable=True) + processed_by = Column(Integer, ForeignKey('users.id'), nullable=True) processed_at = Column(DateTime, nullable=True) admin_comment = Column(Text, nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", foreign_keys=[user_id], backref="withdrawal_requests") - admin = relationship("User", foreign_keys=[processed_by]) + user = relationship('User', foreign_keys=[user_id], backref='withdrawal_requests') + admin = relationship('User', foreign_keys=[processed_by]) @property def amount_rubles(self) -> float: @@ -1528,31 +1469,31 @@ class WithdrawalRequest(Base): class ReferralContest(Base): - __tablename__ = "referral_contests" + __tablename__ = 'referral_contests' id = Column(Integer, primary_key=True, index=True) title = Column(String(255), nullable=False) description = Column(Text, nullable=True) prize_text = Column(Text, nullable=True) - contest_type = Column(String(50), nullable=False, default="referral_paid") + contest_type = Column(String(50), nullable=False, default='referral_paid') start_at = Column(DateTime, nullable=False) end_at = Column(DateTime, nullable=False) daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0)) daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM - timezone = Column(String(64), nullable=False, default="UTC") + timezone = Column(String(64), nullable=False, default='UTC') is_active = Column(Boolean, nullable=False, default=True) last_daily_summary_date = Column(Date, nullable=True) last_daily_summary_at = Column(DateTime, nullable=True) final_summary_sent = Column(Boolean, nullable=False, default=False) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - creator = relationship("User", backref="created_referral_contests") + creator = relationship('User', backref='created_referral_contests') events = relationship( - "ReferralContestEvent", - back_populates="contest", - cascade="all, delete-orphan", + 'ReferralContestEvent', + back_populates='contest', + cascade='all, delete-orphan', ) def __repr__(self): @@ -1560,44 +1501,43 @@ class ReferralContest(Base): class ReferralContestEvent(Base): - __tablename__ = "referral_contest_events" + __tablename__ = 'referral_contest_events' __table_args__ = ( UniqueConstraint( - "contest_id", - "referral_id", - name="uq_referral_contest_referral", + 'contest_id', + 'referral_id', + name='uq_referral_contest_referral', ), - Index("idx_referral_contest_referrer", "contest_id", "referrer_id"), + Index('idx_referral_contest_referrer', 'contest_id', 'referrer_id'), ) id = Column(Integer, primary_key=True, index=True) - contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False) - referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False) + referrer_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + referral_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) event_type = Column(String(50), nullable=False) amount_kopeks = Column(Integer, nullable=False, default=0) occurred_at = Column(DateTime, nullable=False, default=func.now()) - contest = relationship("ReferralContest", back_populates="events") - referrer = relationship("User", foreign_keys=[referrer_id]) - referral = relationship("User", foreign_keys=[referral_id]) + contest = relationship('ReferralContest', back_populates='events') + referrer = relationship('User', foreign_keys=[referrer_id]) + referral = relationship('User', foreign_keys=[referral_id]) def __repr__(self): return ( - f"" + f'' ) class ContestTemplate(Base): - __tablename__ = "contest_templates" + __tablename__ = 'contest_templates' id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) slug = Column(String(50), nullable=False, unique=True, index=True) description = Column(Text, nullable=True) - prize_type = Column(String(20), nullable=False, default="days") - prize_value = Column(String(50), nullable=False, default="1") + prize_type = Column(String(20), nullable=False, default='days') + prize_value = Column(String(50), nullable=False, default='1') max_winners = Column(Integer, nullable=False, default=1) attempts_per_user = Column(Integer, nullable=False, default=1) times_per_day = Column(Integer, nullable=False, default=1) @@ -1608,21 +1548,21 @@ class ContestTemplate(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - rounds = relationship("ContestRound", back_populates="template") + rounds = relationship('ContestRound', back_populates='template') class ContestRound(Base): - __tablename__ = "contest_rounds" + __tablename__ = 'contest_rounds' __table_args__ = ( - Index("idx_contest_round_status", "status"), - Index("idx_contest_round_template", "template_id"), + Index('idx_contest_round_status', 'status'), + Index('idx_contest_round_template', 'template_id'), ) id = Column(Integer, primary_key=True, index=True) - template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False) + template_id = Column(Integer, ForeignKey('contest_templates.id', ondelete='CASCADE'), nullable=False) starts_at = Column(DateTime, nullable=False) ends_at = Column(DateTime, nullable=False) - status = Column(String(20), nullable=False, default="active") # active, finished + status = Column(String(20), nullable=False, default='active') # active, finished payload = Column(JSON, nullable=True) winners_count = Column(Integer, nullable=False, default=0) max_winners = Column(Integer, nullable=False, default=1) @@ -1632,70 +1572,70 @@ class ContestRound(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - template = relationship("ContestTemplate", back_populates="rounds") - attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan") + template = relationship('ContestTemplate', back_populates='rounds') + attempts = relationship('ContestAttempt', back_populates='round', cascade='all, delete-orphan') class ContestAttempt(Base): - __tablename__ = "contest_attempts" + __tablename__ = 'contest_attempts' __table_args__ = ( - UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"), - Index("idx_contest_attempt_round", "round_id"), + UniqueConstraint('round_id', 'user_id', name='uq_round_user_attempt'), + Index('idx_contest_attempt_round', 'round_id'), ) id = Column(Integer, primary_key=True, index=True) - round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + round_id = Column(Integer, ForeignKey('contest_rounds.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) answer = Column(Text, nullable=True) is_winner = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime, default=func.now()) - round = relationship("ContestRound", back_populates="attempts") - user = relationship("User") + round = relationship('ContestRound', back_populates='attempts') + user = relationship('User') class Squad(Base): - __tablename__ = "squads" - + __tablename__ = 'squads' + id = Column(Integer, primary_key=True, index=True) - + uuid = Column(String(255), unique=True, nullable=False) name = Column(String(255), nullable=False) country_code = Column(String(5), nullable=True) - + is_available = Column(Boolean, default=True) - price_kopeks = Column(Integer, default=0) - + price_kopeks = Column(Integer, default=0) + description = Column(Text, nullable=True) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - + @property def price_rubles(self) -> float: return self.price_kopeks / 100 class ServiceRule(Base): - __tablename__ = "service_rules" + __tablename__ = 'service_rules' id = Column(Integer, primary_key=True, index=True) order = Column(Integer, default=0) title = Column(String(255), nullable=False) - + content = Column(Text, nullable=False) - + is_active = Column(Boolean, default=True) - - language = Column(String(5), default="ru") - + + language = Column(String(5), default='ru') + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) class PrivacyPolicy(Base): - __tablename__ = "privacy_policies" + __tablename__ = 'privacy_policies' id = Column(Integer, primary_key=True, index=True) language = Column(String(10), nullable=False, unique=True) @@ -1706,7 +1646,7 @@ class PrivacyPolicy(Base): class PublicOffer(Base): - __tablename__ = "public_offers" + __tablename__ = 'public_offers' id = Column(Integer, primary_key=True, index=True) language = Column(String(10), nullable=False, unique=True) @@ -1717,7 +1657,7 @@ class PublicOffer(Base): class FaqSetting(Base): - __tablename__ = "faq_settings" + __tablename__ = 'faq_settings' id = Column(Integer, primary_key=True, index=True) language = Column(String(10), nullable=False, unique=True) @@ -1727,7 +1667,7 @@ class FaqSetting(Base): class FaqPage(Base): - __tablename__ = "faq_pages" + __tablename__ = 'faq_pages' id = Column(Integer, primary_key=True, index=True) language = Column(String(10), nullable=False, index=True) @@ -1740,58 +1680,54 @@ class FaqPage(Base): class SystemSetting(Base): - __tablename__ = "system_settings" + __tablename__ = 'system_settings' id = Column(Integer, primary_key=True, index=True) key = Column(String(255), unique=True, nullable=False) value = Column(Text, nullable=True) description = Column(Text, nullable=True) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) class MonitoringLog(Base): - __tablename__ = "monitoring_logs" - + __tablename__ = 'monitoring_logs' + id = Column(Integer, primary_key=True, index=True) - + event_type = Column(String(100), nullable=False) - + message = Column(Text, nullable=False) data = Column(JSON, nullable=True) - + is_success = Column(Boolean, default=True) - + created_at = Column(DateTime, default=func.now()) class SentNotification(Base): - __tablename__ = "sent_notifications" + __tablename__ = 'sent_notifications' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False) notification_type = Column(String(50), nullable=False) days_before = Column(Integer, nullable=True) created_at = Column(DateTime, default=func.now()) - user = relationship("User", backref="sent_notifications") - subscription = relationship("Subscription", backref="sent_notifications") + user = relationship('User', backref='sent_notifications') + subscription = relationship('Subscription', backref='sent_notifications') class SubscriptionEvent(Base): - __tablename__ = "subscription_events" + __tablename__ = 'subscription_events' id = Column(Integer, primary_key=True, index=True) event_type = Column(String(50), nullable=False) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - subscription_id = Column( - Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True - ) - transaction_id = Column( - Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True - ) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='SET NULL'), nullable=True) + transaction_id = Column(Integer, ForeignKey('transactions.id', ondelete='SET NULL'), nullable=True) amount_kopeks = Column(Integer, nullable=True) currency = Column(String(16), nullable=True) message = Column(Text, nullable=True) @@ -1799,41 +1735,37 @@ class SubscriptionEvent(Base): extra = Column(JSON, nullable=True) created_at = Column(DateTime, default=func.now()) - user = relationship("User", backref="subscription_events") - subscription = relationship("Subscription", backref="subscription_events") - transaction = relationship("Transaction", backref="subscription_events") + user = relationship('User', backref='subscription_events') + subscription = relationship('Subscription', backref='subscription_events') + transaction = relationship('Transaction', backref='subscription_events') class DiscountOffer(Base): - __tablename__ = "discount_offers" - __table_args__ = ( - Index("ix_discount_offers_user_type", "user_id", "notification_type"), - ) + __tablename__ = 'discount_offers' + __table_args__ = (Index('ix_discount_offers_user_type', 'user_id', 'notification_type'),) id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='SET NULL'), nullable=True) notification_type = Column(String(50), nullable=False) discount_percent = Column(Integer, nullable=False, default=0) bonus_amount_kopeks = Column(Integer, nullable=False, default=0) expires_at = Column(DateTime, nullable=False) claimed_at = Column(DateTime, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - effect_type = Column(String(50), nullable=False, default="percent_discount") + effect_type = Column(String(50), nullable=False, default='percent_discount') extra_data = Column(JSON, nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - user = relationship("User", back_populates="discount_offers") - subscription = relationship("Subscription", back_populates="discount_offers") - logs = relationship("PromoOfferLog", back_populates="offer") + user = relationship('User', back_populates='discount_offers') + subscription = relationship('Subscription', back_populates='discount_offers') + logs = relationship('PromoOfferLog', back_populates='offer') class PromoOfferTemplate(Base): - __tablename__ = "promo_offer_templates" - __table_args__ = ( - Index("ix_promo_offer_templates_type", "offer_type"), - ) + __tablename__ = 'promo_offer_templates' + __table_args__ = (Index('ix_promo_offer_templates_type', 'offer_type'),) id = Column(Integer, primary_key=True, index=True) name = Column(String(255), nullable=False) @@ -1847,19 +1779,19 @@ class PromoOfferTemplate(Base): test_duration_hours = Column(Integer, nullable=True) test_squad_uuids = Column(JSON, default=list) is_active = Column(Boolean, default=True, nullable=False) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - creator = relationship("User") + creator = relationship('User') class SubscriptionTemporaryAccess(Base): - __tablename__ = "subscription_temporary_access" + __tablename__ = 'subscription_temporary_access' id = Column(Integer, primary_key=True, index=True) - subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False) - offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False) + offer_id = Column(Integer, ForeignKey('discount_offers.id', ondelete='CASCADE'), nullable=False) squad_uuid = Column(String(255), nullable=False) expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=func.now()) @@ -1867,16 +1799,16 @@ class SubscriptionTemporaryAccess(Base): is_active = Column(Boolean, default=True, nullable=False) was_already_connected = Column(Boolean, default=False, nullable=False) - subscription = relationship("Subscription", back_populates="temporary_accesses") - offer = relationship("DiscountOffer") + subscription = relationship('Subscription', back_populates='temporary_accesses') + offer = relationship('DiscountOffer') class PromoOfferLog(Base): - __tablename__ = "promo_offer_logs" + __tablename__ = 'promo_offer_logs' id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) - offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) + offer_id = Column(Integer, ForeignKey('discount_offers.id', ondelete='SET NULL'), nullable=True, index=True) action = Column(String(50), nullable=False) source = Column(String(100), nullable=True) percent = Column(Integer, nullable=True) @@ -1884,262 +1816,260 @@ class PromoOfferLog(Base): details = Column(JSON, nullable=True) created_at = Column(DateTime, default=func.now()) - user = relationship("User", back_populates="promo_offer_logs") - offer = relationship("DiscountOffer", back_populates="logs") + user = relationship('User', back_populates='promo_offer_logs') + offer = relationship('DiscountOffer', back_populates='logs') + class BroadcastHistory(Base): - __tablename__ = "broadcast_history" + __tablename__ = 'broadcast_history' id = Column(Integer, primary_key=True, index=True) target_type = Column(String(100), nullable=False) - message_text = Column(Text, nullable=False) + message_text = Column(Text, nullable=False) has_media = Column(Boolean, default=False) - media_type = Column(String(20), nullable=True) + media_type = Column(String(20), nullable=True) media_file_id = Column(String(255), nullable=True) media_caption = Column(Text, nullable=True) - total_count = Column(Integer, default=0) - sent_count = Column(Integer, default=0) - failed_count = Column(Integer, default=0) - status = Column(String(50), default="in_progress") - admin_id = Column(Integer, ForeignKey("users.id")) - admin_name = Column(String(255)) + total_count = Column(Integer, default=0) + sent_count = Column(Integer, default=0) + failed_count = Column(Integer, default=0) + status = Column(String(50), default='in_progress') + admin_id = Column(Integer, ForeignKey('users.id')) + admin_name = Column(String(255)) created_at = Column(DateTime(timezone=True), server_default=func.now()) completed_at = Column(DateTime(timezone=True), nullable=True) - admin = relationship("User", back_populates="broadcasts") + admin = relationship('User', back_populates='broadcasts') class Poll(Base): - __tablename__ = "polls" + __tablename__ = 'polls' id = Column(Integer, primary_key=True, index=True) title = Column(String(255), nullable=False) description = Column(Text, nullable=True) reward_enabled = Column(Boolean, nullable=False, default=False) reward_amount_kopeks = Column(Integer, nullable=False, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_at = Column(DateTime, default=func.now(), nullable=False) updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False) - creator = relationship("User", backref="created_polls", foreign_keys=[created_by]) + creator = relationship('User', backref='created_polls', foreign_keys=[created_by]) questions = relationship( - "PollQuestion", - back_populates="poll", - cascade="all, delete-orphan", - order_by="PollQuestion.order", + 'PollQuestion', + back_populates='poll', + cascade='all, delete-orphan', + order_by='PollQuestion.order', ) responses = relationship( - "PollResponse", - back_populates="poll", - cascade="all, delete-orphan", + 'PollResponse', + back_populates='poll', + cascade='all, delete-orphan', ) class PollQuestion(Base): - __tablename__ = "poll_questions" + __tablename__ = 'poll_questions' id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) + poll_id = Column(Integer, ForeignKey('polls.id', ondelete='CASCADE'), nullable=False, index=True) text = Column(Text, nullable=False) order = Column(Integer, nullable=False, default=0) - poll = relationship("Poll", back_populates="questions") + poll = relationship('Poll', back_populates='questions') options = relationship( - "PollOption", - back_populates="question", - cascade="all, delete-orphan", - order_by="PollOption.order", + 'PollOption', + back_populates='question', + cascade='all, delete-orphan', + order_by='PollOption.order', ) - answers = relationship("PollAnswer", back_populates="question") + answers = relationship('PollAnswer', back_populates='question') class PollOption(Base): - __tablename__ = "poll_options" + __tablename__ = 'poll_options' id = Column(Integer, primary_key=True, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey('poll_questions.id', ondelete='CASCADE'), nullable=False, index=True) text = Column(Text, nullable=False) order = Column(Integer, nullable=False, default=0) - question = relationship("PollQuestion", back_populates="options") - answers = relationship("PollAnswer", back_populates="option") + question = relationship('PollQuestion', back_populates='options') + answers = relationship('PollAnswer', back_populates='option') class PollResponse(Base): - __tablename__ = "poll_responses" + __tablename__ = 'poll_responses' id = Column(Integer, primary_key=True, index=True) - poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + poll_id = Column(Integer, ForeignKey('polls.id', ondelete='CASCADE'), nullable=False, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) sent_at = Column(DateTime, default=func.now(), nullable=False) started_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) reward_given = Column(Boolean, nullable=False, default=False) reward_amount_kopeks = Column(Integer, nullable=False, default=0) - poll = relationship("Poll", back_populates="responses") - user = relationship("User", back_populates="poll_responses") + poll = relationship('Poll', back_populates='responses') + user = relationship('User', back_populates='poll_responses') answers = relationship( - "PollAnswer", - back_populates="response", - cascade="all, delete-orphan", + 'PollAnswer', + back_populates='response', + cascade='all, delete-orphan', ) - __table_args__ = ( - UniqueConstraint("poll_id", "user_id", name="uq_poll_user"), - ) + __table_args__ = (UniqueConstraint('poll_id', 'user_id', name='uq_poll_user'),) class PollAnswer(Base): - __tablename__ = "poll_answers" + __tablename__ = 'poll_answers' id = Column(Integer, primary_key=True, index=True) - response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True) - question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True) - option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True) + response_id = Column(Integer, ForeignKey('poll_responses.id', ondelete='CASCADE'), nullable=False, index=True) + question_id = Column(Integer, ForeignKey('poll_questions.id', ondelete='CASCADE'), nullable=False, index=True) + option_id = Column(Integer, ForeignKey('poll_options.id', ondelete='CASCADE'), nullable=False, index=True) created_at = Column(DateTime, default=func.now(), nullable=False) - response = relationship("PollResponse", back_populates="answers") - question = relationship("PollQuestion", back_populates="answers") - option = relationship("PollOption", back_populates="answers") + response = relationship('PollResponse', back_populates='answers') + question = relationship('PollQuestion', back_populates='answers') + option = relationship('PollOption', back_populates='answers') - __table_args__ = ( - UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"), - ) + __table_args__ = (UniqueConstraint('response_id', 'question_id', name='uq_poll_answer_unique'),) class ServerSquad(Base): - __tablename__ = "server_squads" + __tablename__ = 'server_squads' id = Column(Integer, primary_key=True, index=True) squad_uuid = Column(String(255), unique=True, nullable=False, index=True) - + display_name = Column(String(255), nullable=False) - + original_name = Column(String(255), nullable=True) - + country_code = Column(String(5), nullable=True) - + is_available = Column(Boolean, default=True) is_trial_eligible = Column(Boolean, default=False, nullable=False) - + price_kopeks = Column(Integer, default=0) - + description = Column(Text, nullable=True) - + sort_order = Column(Integer, default=0) - - max_users = Column(Integer, nullable=True) + + max_users = Column(Integer, nullable=True) current_users = Column(Integer, default=0) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) allowed_promo_groups = relationship( - "PromoGroup", + 'PromoGroup', secondary=server_squad_promo_groups, - back_populates="server_squads", - lazy="selectin", + back_populates='server_squads', + lazy='selectin', ) - + @property def price_rubles(self) -> float: return self.price_kopeks / 100 - + @property def is_full(self) -> bool: if self.max_users is None: return False return self.current_users >= self.max_users - + @property def availability_status(self) -> str: if not self.is_available: - return "Недоступен" - elif self.is_full: - return "Переполнен" - else: - return "Доступен" + return 'Недоступен' + if self.is_full: + return 'Переполнен' + return 'Доступен' class SubscriptionServer(Base): - __tablename__ = "subscription_servers" - + __tablename__ = 'subscription_servers' + id = Column(Integer, primary_key=True, index=True) - subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) - server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False) - + subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False) + server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False) + connected_at = Column(DateTime, default=func.now()) - + paid_price_kopeks = Column(Integer, default=0) - - subscription = relationship("Subscription", backref="subscription_servers") - server_squad = relationship("ServerSquad", backref="subscription_servers") + + subscription = relationship('Subscription', backref='subscription_servers') + server_squad = relationship('ServerSquad', backref='subscription_servers') class SupportAuditLog(Base): - __tablename__ = "support_audit_logs" + __tablename__ = 'support_audit_logs' id = Column(Integer, primary_key=True, index=True) - actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + actor_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) actor_telegram_id = Column(BigInteger, nullable=True) # Can be None for email-only users is_moderator = Column(Boolean, default=False) action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user - ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) - target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='SET NULL'), nullable=True) + target_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) details = Column(JSON, nullable=True) created_at = Column(DateTime, default=func.now()) - actor = relationship("User", foreign_keys=[actor_user_id]) - ticket = relationship("Ticket", foreign_keys=[ticket_id]) + actor = relationship('User', foreign_keys=[actor_user_id]) + ticket = relationship('Ticket', foreign_keys=[ticket_id]) + class UserMessage(Base): - __tablename__ = "user_messages" + __tablename__ = 'user_messages' id = Column(Integer, primary_key=True, index=True) message_text = Column(Text, nullable=False) is_active = Column(Boolean, default=True) sort_order = Column(Integer, default=0) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - creator = relationship("User", backref="created_messages") - + creator = relationship('User', backref='created_messages') + def __repr__(self): return f"" + class WelcomeText(Base): - __tablename__ = "welcome_texts" + __tablename__ = 'welcome_texts' id = Column(Integer, primary_key=True, index=True) text_content = Column(Text, nullable=False) is_active = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True) - created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - creator = relationship("User", backref="created_welcome_texts") + creator = relationship('User', backref='created_welcome_texts') class PinnedMessage(Base): - __tablename__ = "pinned_messages" + __tablename__ = 'pinned_messages' id = Column(Integer, primary_key=True, index=True) - content = Column(Text, nullable=False, default="") + content = Column(Text, nullable=False, default='') media_type = Column(String(32), nullable=True) media_file_id = Column(String(255), nullable=True) - send_before_menu = Column(Boolean, nullable=False, server_default="1", default=True) - send_on_every_start = Column(Boolean, nullable=False, server_default="1", default=True) + send_before_menu = Column(Boolean, nullable=False, server_default='1', default=True) + send_on_every_start = Column(Boolean, nullable=False, server_default='1', default=True) is_active = Column(Boolean, default=True) - created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - creator = relationship("User", backref="pinned_messages") + creator = relationship('User', backref='pinned_messages') class AdvertisingCampaign(Base): - __tablename__ = "advertising_campaigns" + __tablename__ = 'advertising_campaigns' id = Column(Integer, primary_key=True, index=True) name = Column(String(255), nullable=False) @@ -2154,60 +2084,58 @@ class AdvertisingCampaign(Base): subscription_squads = Column(JSON, default=list) # Поля для типа "tariff" - выдача тарифа - tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True) + tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True) tariff_duration_days = Column(Integer, nullable=True) is_active = Column(Boolean, default=True) - created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_by = Column(Integer, ForeignKey('users.id'), nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign") - tariff = relationship("Tariff", foreign_keys=[tariff_id]) + registrations = relationship('AdvertisingCampaignRegistration', back_populates='campaign') + tariff = relationship('Tariff', foreign_keys=[tariff_id]) @property def is_balance_bonus(self) -> bool: - return self.bonus_type == "balance" + return self.bonus_type == 'balance' @property def is_subscription_bonus(self) -> bool: - return self.bonus_type == "subscription" + return self.bonus_type == 'subscription' @property def is_none_bonus(self) -> bool: """Ссылка без награды - только для отслеживания.""" - return self.bonus_type == "none" + return self.bonus_type == 'none' @property def is_tariff_bonus(self) -> bool: """Выдача тарифа на определённое время.""" - return self.bonus_type == "tariff" + return self.bonus_type == 'tariff' class AdvertisingCampaignRegistration(Base): - __tablename__ = "advertising_campaign_registrations" - __table_args__ = ( - UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"), - ) + __tablename__ = 'advertising_campaign_registrations' + __table_args__ = (UniqueConstraint('campaign_id', 'user_id', name='uq_campaign_user'),) id = Column(Integer, primary_key=True, index=True) - campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + campaign_id = Column(Integer, ForeignKey('advertising_campaigns.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) bonus_type = Column(String(20), nullable=False) balance_bonus_kopeks = Column(Integer, default=0) subscription_duration_days = Column(Integer, nullable=True) # Поля для типа "tariff" - tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"), nullable=True) + tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True) tariff_duration_days = Column(Integer, nullable=True) created_at = Column(DateTime, default=func.now()) - campaign = relationship("AdvertisingCampaign", back_populates="registrations") - user = relationship("User") - tariff = relationship("Tariff") + campaign = relationship('AdvertisingCampaign', back_populates='registrations') + user = relationship('User') + tariff = relationship('Tariff') @property def balance_bonus_rubles(self) -> float: @@ -2215,47 +2143,47 @@ class AdvertisingCampaignRegistration(Base): class TicketStatus(Enum): - OPEN = "open" - ANSWERED = "answered" - CLOSED = "closed" - PENDING = "pending" + OPEN = 'open' + ANSWERED = 'answered' + CLOSED = 'closed' + PENDING = 'pending' class Ticket(Base): - __tablename__ = "tickets" - + __tablename__ = 'tickets' + id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + title = Column(String(255), nullable=False) status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False) - priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent + priority = Column(String(20), default='normal', nullable=False) # low, normal, high, urgent # Блокировка ответов пользователя в этом тикете user_reply_block_permanent = Column(Boolean, default=False, nullable=False) user_reply_block_until = Column(DateTime, nullable=True) - + created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) closed_at = Column(DateTime, nullable=True) # SLA reminders last_sla_reminder_at = Column(DateTime, nullable=True) - + # Связи - user = relationship("User", backref="tickets") - messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan") - + user = relationship('User', backref='tickets') + messages = relationship('TicketMessage', back_populates='ticket', cascade='all, delete-orphan') + @property def is_open(self) -> bool: return self.status == TicketStatus.OPEN.value - + @property def is_answered(self) -> bool: return self.status == TicketStatus.ANSWERED.value - + @property def is_closed(self) -> bool: return self.status == TicketStatus.CLOSED.value - + @property def is_pending(self) -> bool: return self.status == TicketStatus.PENDING.value @@ -2267,61 +2195,57 @@ class Ticket(Base): if self.user_reply_block_until: try: from datetime import datetime + return self.user_reply_block_until > datetime.utcnow() except Exception: return True return False - + @property def status_emoji(self) -> str: status_emojis = { - TicketStatus.OPEN.value: "🔴", - TicketStatus.ANSWERED.value: "🟡", - TicketStatus.CLOSED.value: "🟢", - TicketStatus.PENDING.value: "⏳" + TicketStatus.OPEN.value: '🔴', + TicketStatus.ANSWERED.value: '🟡', + TicketStatus.CLOSED.value: '🟢', + TicketStatus.PENDING.value: '⏳', } - return status_emojis.get(self.status, "❓") - + return status_emojis.get(self.status, '❓') + @property def priority_emoji(self) -> str: - priority_emojis = { - "low": "🟢", - "normal": "🟡", - "high": "🟠", - "urgent": "🔴" - } - return priority_emojis.get(self.priority, "🟡") - + priority_emojis = {'low': '🟢', 'normal': '🟡', 'high': '🟠', 'urgent': '🔴'} + return priority_emojis.get(self.priority, '🟡') + def __repr__(self): return f"" class TicketMessage(Base): - __tablename__ = "ticket_messages" + __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) - + ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + message_text = Column(Text, nullable=False) is_from_admin = Column(Boolean, default=False, nullable=False) - + # Для медиа файлов has_media = Column(Boolean, default=False) media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc. media_file_id = Column(String(255), nullable=True) media_caption = Column(Text, nullable=True) - + created_at = Column(DateTime, default=func.now()) - + # Связи - ticket = relationship("Ticket", back_populates="messages") - user = relationship("User") - + ticket = relationship('Ticket', back_populates='messages') + user = relationship('User') + @property def is_user_message(self) -> bool: return not self.is_from_admin - + @property def is_admin_message(self) -> bool: return self.is_from_admin @@ -2331,7 +2255,7 @@ class TicketMessage(Base): class WebApiToken(Base): - __tablename__ = "web_api_tokens" + __tablename__ = 'web_api_tokens' id = Column(Integer, primary_key=True, index=True) name = Column(String(255), nullable=False) @@ -2347,12 +2271,12 @@ class WebApiToken(Base): created_by = Column(String(255), nullable=True) def __repr__(self) -> str: - status = "active" if self.is_active else "revoked" + status = 'active' if self.is_active else 'revoked' return f"" class MainMenuButton(Base): - __tablename__ = "main_menu_buttons" + __tablename__ = 'main_menu_buttons' id = Column(Integer, primary_key=True, index=True) text = Column(String(64), nullable=False) @@ -2364,9 +2288,7 @@ class MainMenuButton(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - __table_args__ = ( - Index("ix_main_menu_buttons_order", "display_order", "id"), - ) + __table_args__ = (Index('ix_main_menu_buttons_order', 'display_order', 'id'),) @property def action_type_enum(self) -> MainMenuButtonActionType: @@ -2385,13 +2307,14 @@ class MainMenuButton(Base): def __repr__(self) -> str: return ( f"" + f'action={self.action_type} visibility={self.visibility} active={self.is_active}>' ) class MenuLayoutHistory(Base): """История изменений конфигурации меню.""" - __tablename__ = "menu_layout_history" + + __tablename__ = 'menu_layout_history' id = Column(Integer, primary_key=True, index=True) config_json = Column(Text, nullable=False) # Полная конфигурация в JSON @@ -2400,9 +2323,7 @@ class MenuLayoutHistory(Base): user_info = Column(String(255), nullable=True) # Информация о пользователе/токене created_at = Column(DateTime, default=func.now(), index=True) - __table_args__ = ( - Index("ix_menu_layout_history_created", "created_at"), - ) + __table_args__ = (Index('ix_menu_layout_history_created', 'created_at'),) def __repr__(self) -> str: return f"" @@ -2410,11 +2331,12 @@ class MenuLayoutHistory(Base): class ButtonClickLog(Base): """Логи кликов по кнопкам меню.""" - __tablename__ = "button_click_logs" + + __tablename__ = 'button_click_logs' id = Column(Integer, primary_key=True, index=True) button_id = Column(String(100), nullable=False, index=True) # ID кнопки - user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) callback_data = Column(String(255), nullable=True) # callback_data кнопки clicked_at = Column(DateTime, default=func.now(), index=True) @@ -2423,12 +2345,12 @@ class ButtonClickLog(Base): button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика __table_args__ = ( - Index("ix_button_click_logs_button_date", "button_id", "clicked_at"), - Index("ix_button_click_logs_user_date", "user_id", "clicked_at"), + Index('ix_button_click_logs_button_date', 'button_id', 'clicked_at'), + Index('ix_button_click_logs_user_date', 'user_id', 'clicked_at'), ) # Связи - user = relationship("User", foreign_keys=[user_id]) + user = relationship('User', foreign_keys=[user_id]) def __repr__(self) -> str: return f"" @@ -2436,10 +2358,11 @@ class ButtonClickLog(Base): class Webhook(Base): """Webhook конфигурация для подписки на события.""" - __tablename__ = "webhooks" + + __tablename__ = 'webhooks' __table_args__ = ( - Index("ix_webhooks_event_type", "event_type"), - Index("ix_webhooks_is_active", "is_active"), + Index('ix_webhooks_event_type', 'event_type'), + Index('ix_webhooks_is_active', 'is_active'), ) id = Column(Integer, primary_key=True, index=True) @@ -2455,23 +2378,24 @@ class Webhook(Base): failure_count = Column(Integer, default=0, nullable=False) success_count = Column(Integer, default=0, nullable=False) - deliveries = relationship("WebhookDelivery", back_populates="webhook", cascade="all, delete-orphan") + deliveries = relationship('WebhookDelivery', back_populates='webhook', cascade='all, delete-orphan') def __repr__(self) -> str: - status = "active" if self.is_active else "inactive" + status = 'active' if self.is_active else 'inactive' return f"" class WebhookDelivery(Base): """История доставки webhooks.""" - __tablename__ = "webhook_deliveries" + + __tablename__ = 'webhook_deliveries' __table_args__ = ( - Index("ix_webhook_deliveries_webhook_created", "webhook_id", "created_at"), - Index("ix_webhook_deliveries_status", "status"), + Index('ix_webhook_deliveries_webhook_created', 'webhook_id', 'created_at'), + Index('ix_webhook_deliveries_status', 'status'), ) id = Column(Integer, primary_key=True, index=True) - webhook_id = Column(Integer, ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False) + webhook_id = Column(Integer, ForeignKey('webhooks.id', ondelete='CASCADE'), nullable=False) event_type = Column(String(50), nullable=False) payload = Column(JSON, nullable=False) # Отправленный payload response_status = Column(Integer, nullable=True) # HTTP статус ответа @@ -2483,7 +2407,7 @@ class WebhookDelivery(Base): delivered_at = Column(DateTime, nullable=True) next_retry_at = Column(DateTime, nullable=True) - webhook = relationship("Webhook", back_populates="deliveries") + webhook = relationship('Webhook', back_populates='deliveries') def __repr__(self) -> str: return f"" @@ -2491,20 +2415,19 @@ class WebhookDelivery(Base): class CabinetRefreshToken(Base): """Refresh tokens for cabinet JWT authentication.""" - __tablename__ = "cabinet_refresh_tokens" - __table_args__ = ( - Index("ix_cabinet_refresh_tokens_user", "user_id"), - ) + + __tablename__ = 'cabinet_refresh_tokens' + __table_args__ = (Index('ix_cabinet_refresh_tokens_user', 'user_id'),) id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) token_hash = Column(String(255), unique=True, nullable=False, index=True) device_info = Column(String(500), nullable=True) expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=func.now()) revoked_at = Column(DateTime, nullable=True) - user = relationship("User", backref="cabinet_tokens") + user = relationship('User', backref='cabinet_tokens') @property def is_expired(self) -> bool: @@ -2519,8 +2442,8 @@ class CabinetRefreshToken(Base): return not self.is_expired and not self.is_revoked def __repr__(self) -> str: - status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired") - return f"" + status = 'valid' if self.is_valid else ('revoked' if self.is_revoked else 'expired') + return f'' # ==================== FORTUNE WHEEL ==================== @@ -2528,17 +2451,18 @@ class CabinetRefreshToken(Base): class WheelConfig(Base): """Глобальная конфигурация колеса удачи.""" - __tablename__ = "wheel_configs" + + __tablename__ = 'wheel_configs' id = Column(Integer, primary_key=True, index=True) # Основные настройки is_enabled = Column(Boolean, default=False, nullable=False) - name = Column(String(255), default="Колесо удачи", nullable=False) + name = Column(String(255), default='Колесо удачи', nullable=False) # Стоимость спина spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars - spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки + spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False) spin_cost_days_enabled = Column(Boolean, default=True, nullable=False) @@ -2550,24 +2474,25 @@ class WheelConfig(Base): min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False) # Генерация промокодов - promo_prefix = Column(String(20), default="WHEEL", nullable=False) + promo_prefix = Column(String(20), default='WHEEL', nullable=False) promo_validity_days = Column(Integer, default=7, nullable=False) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - prizes = relationship("WheelPrize", back_populates="config", cascade="all, delete-orphan") + prizes = relationship('WheelPrize', back_populates='config', cascade='all, delete-orphan') def __repr__(self) -> str: - return f"" + return f'' class WheelPrize(Base): """Приз на колесе удачи.""" - __tablename__ = "wheel_prizes" + + __tablename__ = 'wheel_prizes' id = Column(Integer, primary_key=True, index=True) - config_id = Column(Integer, ForeignKey("wheel_configs.id", ondelete="CASCADE"), nullable=False) + config_id = Column(Integer, ForeignKey('wheel_configs.id', ondelete='CASCADE'), nullable=False) # Тип и значение приза prize_type = Column(String(50), nullable=False) # WheelPrizeType @@ -2575,8 +2500,8 @@ class WheelPrize(Base): # Отображение display_name = Column(String(100), nullable=False) - emoji = Column(String(10), default="🎁", nullable=False) - color = Column(String(20), default="#3B82F6", nullable=False) # HEX цвет сектора + emoji = Column(String(10), default='🎁', nullable=False) + color = Column(String(20), default='#3B82F6', nullable=False) # HEX цвет сектора # Стоимость приза для расчета RTP (в копейках) prize_value_kopeks = Column(Integer, default=0, nullable=False) @@ -2594,8 +2519,8 @@ class WheelPrize(Base): created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - config = relationship("WheelConfig", back_populates="prizes") - spins = relationship("WheelSpin", back_populates="prize") + config = relationship('WheelConfig', back_populates='prizes') + spins = relationship('WheelSpin', back_populates='prize') def __repr__(self) -> str: return f"" @@ -2603,14 +2528,13 @@ class WheelPrize(Base): class WheelSpin(Base): """История спинов колеса удачи.""" - __tablename__ = "wheel_spins" - __table_args__ = ( - Index("ix_wheel_spins_user_created", "user_id", "created_at"), - ) + + __tablename__ = 'wheel_spins' + __table_args__ = (Index('ix_wheel_spins_user_created', 'user_id', 'created_at'),) id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - prize_id = Column(Integer, ForeignKey("wheel_prizes.id", ondelete="SET NULL"), nullable=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + prize_id = Column(Integer, ForeignKey('wheel_prizes.id', ondelete='SET NULL'), nullable=True) # Способ оплаты payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType @@ -2624,7 +2548,7 @@ class WheelSpin(Base): prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках # Сгенерированный промокод (если приз - промокод) - generated_promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=True) + generated_promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=True) # Флаг успешного начисления is_applied = Column(Boolean, default=False, nullable=False) @@ -2632,9 +2556,9 @@ class WheelSpin(Base): created_at = Column(DateTime, default=func.now()) - user = relationship("User", backref="wheel_spins") - prize = relationship("WheelPrize", back_populates="spins") - generated_promocode = relationship("PromoCode") + user = relationship('User', backref='wheel_spins') + prize = relationship('WheelPrize', back_populates='spins') + generated_promocode = relationship('PromoCode') @property def prize_value_rubles(self) -> float: @@ -2652,15 +2576,16 @@ class WheelSpin(Base): class TicketNotification(Base): """Уведомления о тикетах для кабинета (веб-интерфейс).""" - __tablename__ = "ticket_notifications" + + __tablename__ = 'ticket_notifications' __table_args__ = ( - Index("ix_ticket_notifications_user_read", "user_id", "is_read"), - Index("ix_ticket_notifications_admin_read", "is_for_admin", "is_read"), + Index('ix_ticket_notifications_user_read', 'user_id', 'is_read'), + Index('ix_ticket_notifications_admin_read', 'is_for_admin', 'is_read'), ) id = Column(Integer, primary_key=True, index=True) - ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + ticket_id = Column(Integer, ForeignKey('tickets.id', ondelete='CASCADE'), nullable=False, index=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) # Тип уведомления: new_ticket, admin_reply, user_reply notification_type = Column(String(50), nullable=False) @@ -2677,8 +2602,8 @@ class TicketNotification(Base): created_at = Column(DateTime, default=func.now()) read_at = Column(DateTime, nullable=True) - ticket = relationship("Ticket", backref="notifications") - user = relationship("User", backref="ticket_notifications") + ticket = relationship('Ticket', backref='notifications') + user = relationship('User', backref='ticket_notifications') def __repr__(self) -> str: - return f"" + return f'' diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6958212f..8da3be2e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1,17 +1,17 @@ import logging from datetime import datetime -from typing import List, Tuple -from sqlalchemy import inspect, select, text -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, text 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__) + async def get_database_type(): return engine.dialect.name @@ -21,8 +21,8 @@ async def sync_postgres_sequences() -> bool: db_type = await get_database_type() - if db_type != "postgresql": - logger.debug("Пропускаем синхронизацию последовательностей: тип БД %s", db_type) + if db_type != 'postgresql': + logger.debug('Пропускаем синхронизацию последовательностей: тип БД %s', db_type) return True try: @@ -48,7 +48,7 @@ async def sync_postgres_sequences() -> bool: sequences = result.fetchall() if not sequences: - logger.info("ℹ️ Не найдено последовательностей PostgreSQL для синхронизации") + logger.info('ℹ️ Не найдено последовательностей PostgreSQL для синхронизации') return True for table_schema, table_name, column_name, sequence_path in sequences: @@ -56,10 +56,7 @@ async def sync_postgres_sequences() -> bool: continue max_result = await conn.execute( - text( - f'SELECT COALESCE(MAX("{column_name}"), 0) ' - f'FROM "{table_schema}"."{table_name}"' - ) + text(f'SELECT COALESCE(MAX("{column_name}"), 0) FROM "{table_schema}"."{table_name}"') ) max_value = max_result.scalar() or 0 @@ -72,9 +69,7 @@ async def sync_postgres_sequences() -> bool: seq_schema = seq_schema.strip('"') seq_name = seq_name.strip('"') current_result = await conn.execute( - text( - f'SELECT last_value, is_called FROM "{seq_schema}"."{seq_name}"' - ) + text(f'SELECT last_value, is_called FROM "{seq_schema}"."{seq_name}"') ) current_row = current_result.fetchone() @@ -90,10 +85,10 @@ async def sync_postgres_sequences() -> bool: SELECT setval(:sequence_name, :new_value, TRUE) """ ), - {"sequence_name": sequence_path, "new_value": max_value}, + {'sequence_name': sequence_path, 'new_value': max_value}, ) logger.info( - "🔄 Последовательность %s синхронизирована: MAX=%s, следующий ID=%s", + '🔄 Последовательность %s синхронизирована: MAX=%s, следующий ID=%s', sequence_path, max_value, max_value + 1, @@ -102,73 +97,89 @@ async def sync_postgres_sequences() -> bool: return True except Exception as error: - logger.error("❌ Ошибка синхронизации последовательностей PostgreSQL: %s", error) + logger.error('❌ Ошибка синхронизации последовательностей PostgreSQL: %s', error) return False + async def check_table_exists(table_name: str) -> bool: try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': - result = await conn.execute(text(f""" - SELECT name FROM sqlite_master + result = await conn.execute( + text(f""" + SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}' - """)) + """) + ) return result.fetchone() is not None - - elif db_type == 'postgresql': - result = await conn.execute(text(""" - SELECT table_name FROM information_schema.tables + + if db_type == 'postgresql': + result = await conn.execute( + text(""" + SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = :table_name - """), {"table_name": table_name}) + """), + {'table_name': table_name}, + ) return result.fetchone() is not None - - elif db_type == 'mysql': - result = await conn.execute(text(""" - SELECT table_name FROM information_schema.tables + + if db_type == 'mysql': + result = await conn.execute( + text(""" + SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table_name - """), {"table_name": table_name}) + """), + {'table_name': table_name}, + ) return result.fetchone() is not None - + return False - + except Exception as e: - logger.error(f"Ошибка проверки существования таблицы {table_name}: {e}") + logger.error(f'Ошибка проверки существования таблицы {table_name}: {e}') return False + async def check_column_exists(table_name: str, column_name: str) -> bool: try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': - result = await conn.execute(text(f"PRAGMA table_info({table_name})")) + result = await conn.execute(text(f'PRAGMA table_info({table_name})')) columns = result.fetchall() return any(col[1] == column_name for col in columns) - - elif db_type == 'postgresql': - result = await conn.execute(text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = :table_name + + if db_type == 'postgresql': + result = await conn.execute( + text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = :table_name AND column_name = :column_name - """), {"table_name": table_name, "column_name": column_name}) + """), + {'table_name': table_name, 'column_name': column_name}, + ) return result.fetchone() is not None - - elif db_type == 'mysql': - result = await conn.execute(text(""" - SELECT COLUMN_NAME - FROM information_schema.COLUMNS - WHERE TABLE_NAME = :table_name + + if db_type == 'mysql': + result = await conn.execute( + text(""" + SELECT COLUMN_NAME + FROM information_schema.COLUMNS + WHERE TABLE_NAME = :table_name AND COLUMN_NAME = :column_name - """), {"table_name": table_name, "column_name": column_name}) + """), + {'table_name': table_name, 'column_name': column_name}, + ) return result.fetchone() is not None - + return False - + except Exception as e: - logger.error(f"Ошибка проверки существования колонки {column_name}: {e}") + logger.error(f'Ошибка проверки существования колонки {column_name}: {e}') return False @@ -177,7 +188,7 @@ async def check_constraint_exists(table_name: str, constraint_name: str) -> bool async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "postgresql": + if db_type == 'postgresql': result = await conn.execute( text( """ @@ -188,11 +199,11 @@ async def check_constraint_exists(table_name: str, constraint_name: str) -> bool AND constraint_name = :constraint_name """ ), - {"table_name": table_name, "constraint_name": constraint_name}, + {'table_name': table_name, 'constraint_name': constraint_name}, ) return result.fetchone() is not None - if db_type == "mysql": + if db_type == 'mysql': result = await conn.execute( text( """ @@ -203,21 +214,19 @@ async def check_constraint_exists(table_name: str, constraint_name: str) -> bool AND constraint_name = :constraint_name """ ), - {"table_name": table_name, "constraint_name": constraint_name}, + {'table_name': table_name, 'constraint_name': constraint_name}, ) return result.fetchone() is not None - if db_type == "sqlite": - result = await conn.execute(text(f"PRAGMA foreign_key_list({table_name})")) + if db_type == 'sqlite': + result = await conn.execute(text(f'PRAGMA foreign_key_list({table_name})')) rows = result.fetchall() return any(row[5] == constraint_name for row in rows) return False except Exception as e: - logger.error( - f"Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}" - ) + logger.error(f'Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}') return False @@ -226,7 +235,7 @@ async def check_index_exists(table_name: str, index_name: str) -> bool: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "postgresql": + if db_type == 'postgresql': result = await conn.execute( text( """ @@ -237,11 +246,11 @@ async def check_index_exists(table_name: str, index_name: str) -> bool: AND indexname = :index_name """ ), - {"table_name": table_name, "index_name": index_name}, + {'table_name': table_name, 'index_name': index_name}, ) return result.fetchone() is not None - if db_type == "mysql": + if db_type == 'mysql': result = await conn.execute( text( """ @@ -252,43 +261,41 @@ async def check_index_exists(table_name: str, index_name: str) -> bool: AND index_name = :index_name """ ), - {"table_name": table_name, "index_name": index_name}, + {'table_name': table_name, 'index_name': index_name}, ) return result.fetchone() is not None - if db_type == "sqlite": - result = await conn.execute(text(f"PRAGMA index_list({table_name})")) + if db_type == 'sqlite': + result = await conn.execute(text(f'PRAGMA index_list({table_name})')) rows = result.fetchall() return any(row[1] == index_name for row in rows) return False except Exception as e: - logger.error( - f"Ошибка проверки существования индекса {index_name} для {table_name}: {e}" - ) + logger.error(f'Ошибка проверки существования индекса {index_name} для {table_name}: {e}') return False -async def fetch_duplicate_payment_links(conn) -> List[Tuple[str, int]]: +async def fetch_duplicate_payment_links(conn) -> list[tuple[str, int]]: result = await conn.execute( text( - "SELECT payment_link_id, COUNT(*) AS cnt " - "FROM wata_payments " + 'SELECT payment_link_id, COUNT(*) AS cnt ' + 'FROM wata_payments ' "WHERE payment_link_id IS NOT NULL AND payment_link_id <> '' " - "GROUP BY payment_link_id " - "HAVING COUNT(*) > 1" + 'GROUP BY payment_link_id ' + 'HAVING COUNT(*) > 1' ) ) return [(row[0], row[1]) for row in result.fetchall()] -def _build_dedup_suffix(base_suffix: str, record_id: int, max_length: int = 64) -> Tuple[str, int]: - suffix = f"{base_suffix}{record_id}" +def _build_dedup_suffix(base_suffix: str, record_id: int, max_length: int = 64) -> tuple[str, int]: + suffix = f'{base_suffix}{record_id}' trimmed_length = max_length - len(suffix) if trimmed_length < 1: # Fallback: use the record id only to stay within the limit. - suffix = f"dup-{record_id}" + suffix = f'dup-{record_id}' trimmed_length = max_length - len(suffix) return suffix, trimmed_length @@ -300,18 +307,14 @@ async def resolve_duplicate_payment_links(conn, db_type: str) -> bool: return True logger.warning( - "Найдены дубликаты payment_link_id в wata_payments: %s", - ", ".join(f"{link}×{count}" for link, count in duplicates[:5]), + 'Найдены дубликаты payment_link_id в wata_payments: %s', + ', '.join(f'{link}×{count}' for link, count in duplicates[:5]), ) for payment_link_id, _ in duplicates: result = await conn.execute( - text( - "SELECT id, payment_link_id FROM wata_payments " - "WHERE payment_link_id = :payment_link_id " - "ORDER BY id" - ), - {"payment_link_id": payment_link_id}, + text('SELECT id, payment_link_id FROM wata_payments WHERE payment_link_id = :payment_link_id ORDER BY id'), + {'payment_link_id': payment_link_id}, ) rows = result.fetchall() @@ -322,29 +325,26 @@ async def resolve_duplicate_payment_links(conn, db_type: str) -> bool: # Skip the first occurrence to preserve the original link value. for duplicate_row in rows[1:]: record_id = duplicate_row[0] - original_link = duplicate_row[1] or "" - suffix, trimmed_length = _build_dedup_suffix("-dup-", record_id) - new_base = original_link[:trimmed_length] if trimmed_length > 0 else "" - new_link = f"{new_base}{suffix}" if new_base else suffix + original_link = duplicate_row[1] or '' + suffix, trimmed_length = _build_dedup_suffix('-dup-', record_id) + new_base = original_link[:trimmed_length] if trimmed_length > 0 else '' + new_link = f'{new_base}{suffix}' if new_base else suffix await conn.execute( - text( - "UPDATE wata_payments SET payment_link_id = :new_link " - "WHERE id = :record_id" - ), - {"new_link": new_link, "record_id": record_id}, + text('UPDATE wata_payments SET payment_link_id = :new_link WHERE id = :record_id'), + {'new_link': new_link, 'record_id': record_id}, ) remaining_duplicates = await fetch_duplicate_payment_links(conn) if remaining_duplicates: logger.error( - "Не удалось устранить дубликаты payment_link_id: %s", - ", ".join(f"{link}×{count}" for link, count in remaining_duplicates[:5]), + 'Не удалось устранить дубликаты payment_link_id: %s', + ', '.join(f'{link}×{count}' for link, count in remaining_duplicates[:5]), ) return False - logger.info("✅ Дубликаты payment_link_id устранены") + logger.info('✅ Дубликаты payment_link_id устранены') return True @@ -353,12 +353,12 @@ async def enforce_wata_payment_link_constraints( db_type: str, unique_index_exists: bool, legacy_index_exists: bool, -) -> Tuple[bool, bool]: +) -> tuple[bool, bool]: try: - if db_type == "sqlite": + if db_type == 'sqlite': await conn.execute( text( - "UPDATE wata_payments " + 'UPDATE wata_payments ' "SET payment_link_id = 'legacy-' || id " "WHERE payment_link_id IS NULL OR payment_link_id = ''" ) @@ -369,120 +369,98 @@ async def enforce_wata_payment_link_constraints( if not unique_index_exists: await conn.execute( - text( - "CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link " - "ON wata_payments(payment_link_id)" - ) + text('CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link ON wata_payments(payment_link_id)') ) - logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id") + logger.info('✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id') unique_index_exists = True else: - logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует") + logger.info('ℹ️ Уникальный индекс для payment_link_id уже существует') if legacy_index_exists and unique_index_exists: - await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id")) - logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id") + await conn.execute(text('DROP INDEX IF EXISTS idx_wata_link_id')) + logger.info('ℹ️ Удалён устаревший индекс idx_wata_link_id') legacy_index_exists = False return unique_index_exists, legacy_index_exists - if db_type == "postgresql": + if db_type == 'postgresql': await conn.execute( text( - "UPDATE wata_payments " + 'UPDATE wata_payments ' "SET payment_link_id = 'legacy-' || id::text " "WHERE payment_link_id IS NULL OR payment_link_id = ''" ) ) - await conn.execute( - text( - "ALTER TABLE wata_payments " - "ALTER COLUMN payment_link_id SET NOT NULL" - ) - ) - logger.info("✅ Колонка payment_link_id теперь NOT NULL") + await conn.execute(text('ALTER TABLE wata_payments ALTER COLUMN payment_link_id SET NOT NULL')) + logger.info('✅ Колонка payment_link_id теперь NOT NULL') if not await resolve_duplicate_payment_links(conn, db_type): return unique_index_exists, legacy_index_exists if not unique_index_exists: await conn.execute( - text( - "CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link " - "ON wata_payments(payment_link_id)" - ) + text('CREATE UNIQUE INDEX IF NOT EXISTS uq_wata_payment_link ON wata_payments(payment_link_id)') ) - logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id") + logger.info('✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id') unique_index_exists = True else: - logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует") + logger.info('ℹ️ Уникальный индекс для payment_link_id уже существует') if legacy_index_exists and unique_index_exists: - await conn.execute(text("DROP INDEX IF EXISTS idx_wata_link_id")) - logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id") + await conn.execute(text('DROP INDEX IF EXISTS idx_wata_link_id')) + logger.info('ℹ️ Удалён устаревший индекс idx_wata_link_id') legacy_index_exists = False return unique_index_exists, legacy_index_exists - if db_type == "mysql": + if db_type == 'mysql': await conn.execute( text( - "UPDATE wata_payments " + 'UPDATE wata_payments ' "SET payment_link_id = CONCAT('legacy-', id) " "WHERE payment_link_id IS NULL OR payment_link_id = ''" ) ) - await conn.execute( - text( - "ALTER TABLE wata_payments " - "MODIFY COLUMN payment_link_id VARCHAR(64) NOT NULL" - ) - ) - logger.info("✅ Колонка payment_link_id теперь NOT NULL") + await conn.execute(text('ALTER TABLE wata_payments MODIFY COLUMN payment_link_id VARCHAR(64) NOT NULL')) + logger.info('✅ Колонка payment_link_id теперь NOT NULL') if not await resolve_duplicate_payment_links(conn, db_type): return unique_index_exists, legacy_index_exists if not unique_index_exists: - await conn.execute( - text( - "CREATE UNIQUE INDEX uq_wata_payment_link " - "ON wata_payments(payment_link_id)" - ) - ) - logger.info("✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id") + await conn.execute(text('CREATE UNIQUE INDEX uq_wata_payment_link ON wata_payments(payment_link_id)')) + logger.info('✅ Создан уникальный индекс uq_wata_payment_link для payment_link_id') unique_index_exists = True else: - logger.info("ℹ️ Уникальный индекс для payment_link_id уже существует") + logger.info('ℹ️ Уникальный индекс для payment_link_id уже существует') if legacy_index_exists and unique_index_exists: - await conn.execute(text("DROP INDEX idx_wata_link_id ON wata_payments")) - logger.info("ℹ️ Удалён устаревший индекс idx_wata_link_id") + await conn.execute(text('DROP INDEX idx_wata_link_id ON wata_payments')) + logger.info('ℹ️ Удалён устаревший индекс idx_wata_link_id') legacy_index_exists = False return unique_index_exists, legacy_index_exists - logger.warning( - "⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id", db_type - ) + logger.warning('⚠️ Неизвестный тип БД %s — не удалось усилить ограничения payment_link_id', db_type) return unique_index_exists, legacy_index_exists except Exception as e: - logger.error(f"Ошибка настройки ограничений payment_link_id: {e}") + logger.error(f'Ошибка настройки ограничений payment_link_id: {e}') return unique_index_exists, legacy_index_exists + async def create_cryptobot_payments_table(): table_exists = await check_table_exists('cryptobot_payments') if table_exists: - logger.info("Таблица cryptobot_payments уже существует") + logger.info('Таблица cryptobot_payments уже существует') return True - + try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': create_sql = """ CREATE TABLE cryptobot_payments ( @@ -504,12 +482,12 @@ async def create_cryptobot_payments_table(): FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (transaction_id) REFERENCES transactions(id) ); - + CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id); CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id); CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status); """ - + elif db_type == 'postgresql': create_sql = """ CREATE TABLE cryptobot_payments ( @@ -531,12 +509,12 @@ async def create_cryptobot_payments_table(): FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (transaction_id) REFERENCES transactions(id) ); - + CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id); CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id); CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status); """ - + elif db_type == 'mysql': create_sql = """ CREATE TABLE cryptobot_payments ( @@ -558,28 +536,28 @@ async def create_cryptobot_payments_table(): FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (transaction_id) REFERENCES transactions(id) ); - + CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id); CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id); CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status); """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') return False - + await conn.execute(text(create_sql)) - logger.info("Таблица cryptobot_payments успешно создана") + logger.info('Таблица cryptobot_payments успешно создана') return True - + except Exception as e: - logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}") + logger.error(f'Ошибка создания таблицы cryptobot_payments: {e}') return False async def create_heleket_payments_table(): table_exists = await check_table_exists('heleket_payments') if table_exists: - logger.info("Таблица heleket_payments уже существует") + logger.info('Таблица heleket_payments уже существует') return True try: @@ -678,22 +656,22 @@ async def create_heleket_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы heleket_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица heleket_payments успешно создана") + logger.info('Таблица heleket_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы heleket_payments: {e}") + logger.error(f'Ошибка создания таблицы heleket_payments: {e}') return False async def create_mulenpay_payments_table(): table_exists = await check_table_exists('mulenpay_payments') if table_exists: - logger.info("Таблица mulenpay_payments уже существует") + logger.info('Таблица mulenpay_payments уже существует') return True try: @@ -780,111 +758,100 @@ async def create_mulenpay_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы mulenpay_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица mulenpay_payments успешно создана") + logger.info('Таблица mulenpay_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы mulenpay_payments: {e}") + logger.error(f'Ошибка создания таблицы mulenpay_payments: {e}') return False async def ensure_mulenpay_payment_schema() -> bool: - logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ MULEN PAY ===") + logger.info('=== ОБНОВЛЕНИЕ СХЕМЫ MULEN PAY ===') - table_exists = await check_table_exists("mulenpay_payments") + table_exists = await check_table_exists('mulenpay_payments') if not table_exists: - logger.warning("⚠️ Таблица mulenpay_payments отсутствует — создаём заново") + logger.warning('⚠️ Таблица mulenpay_payments отсутствует — создаём заново') return await create_mulenpay_payments_table() try: - column_exists = await check_column_exists("mulenpay_payments", "mulen_payment_id") - paid_at_column_exists = await check_column_exists("mulenpay_payments", "paid_at") - index_exists = await check_index_exists("mulenpay_payments", "idx_mulenpay_payment_id") + column_exists = await check_column_exists('mulenpay_payments', 'mulen_payment_id') + paid_at_column_exists = await check_column_exists('mulenpay_payments', 'paid_at') + index_exists = await check_index_exists('mulenpay_payments', 'idx_mulenpay_payment_id') async with engine.begin() as conn: db_type = await get_database_type() if not column_exists: - if db_type == "sqlite": - alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" - elif db_type == "postgresql": - alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL" - elif db_type == "mysql": - alter_sql = "ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL" + if db_type == 'sqlite' or db_type == 'postgresql': + alter_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INTEGER NULL' + elif db_type == 'mysql': + alter_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN mulen_payment_id INT NULL' else: logger.error( - "Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s", + 'Неподдерживаемый тип БД для добавления mulen_payment_id в mulenpay_payments: %s', db_type, ) return False await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка mulenpay_payments.mulen_payment_id") + logger.info('✅ Добавлена колонка mulenpay_payments.mulen_payment_id') else: - logger.info("ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует") + logger.info('ℹ️ Колонка mulenpay_payments.mulen_payment_id уже существует') if not paid_at_column_exists: - if db_type == "sqlite": - alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" - elif db_type == "postgresql": - alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at TIMESTAMP NULL" - elif db_type == "mysql": - alter_paid_at_sql = "ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL" + if db_type == 'sqlite': + alter_paid_at_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL' + elif db_type == 'postgresql': + alter_paid_at_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN paid_at TIMESTAMP NULL' + elif db_type == 'mysql': + alter_paid_at_sql = 'ALTER TABLE mulenpay_payments ADD COLUMN paid_at DATETIME NULL' else: logger.error( - "Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s", + 'Неподдерживаемый тип БД для добавления paid_at в mulenpay_payments: %s', db_type, ) return False await conn.execute(text(alter_paid_at_sql)) - logger.info("✅ Добавлена колонка mulenpay_payments.paid_at") + logger.info('✅ Добавлена колонка mulenpay_payments.paid_at') else: - logger.info("ℹ️ Колонка mulenpay_payments.paid_at уже существует") + logger.info('ℹ️ Колонка mulenpay_payments.paid_at уже существует') if not index_exists: - if db_type == "sqlite": + if db_type == 'sqlite' or db_type == 'postgresql': create_index_sql = ( - "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " - "ON mulenpay_payments(mulen_payment_id)" - ) - elif db_type == "postgresql": - create_index_sql = ( - "CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id " - "ON mulenpay_payments(mulen_payment_id)" - ) - elif db_type == "mysql": - create_index_sql = ( - "CREATE INDEX idx_mulenpay_payment_id " - "ON mulenpay_payments(mulen_payment_id)" + 'CREATE INDEX IF NOT EXISTS idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id)' ) + elif db_type == 'mysql': + create_index_sql = 'CREATE INDEX idx_mulenpay_payment_id ON mulenpay_payments(mulen_payment_id)' else: logger.error( - "Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s", + 'Неподдерживаемый тип БД для создания индекса mulenpay_payment_id: %s', db_type, ) return False await conn.execute(text(create_index_sql)) - logger.info("✅ Создан индекс idx_mulenpay_payment_id") + logger.info('✅ Создан индекс idx_mulenpay_payment_id') else: - logger.info("ℹ️ Индекс idx_mulenpay_payment_id уже существует") + logger.info('ℹ️ Индекс idx_mulenpay_payment_id уже существует') return True except Exception as e: - logger.error(f"Ошибка обновления схемы mulenpay_payments: {e}") + logger.error(f'Ошибка обновления схемы mulenpay_payments: {e}') return False async def create_pal24_payments_table(): table_exists = await check_table_exists('pal24_payments') if table_exists: - logger.info("Таблица pal24_payments уже существует") + logger.info('Таблица pal24_payments уже существует') return True try: @@ -1013,22 +980,22 @@ async def create_pal24_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы pal24_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица pal24_payments успешно создана") + logger.info('Таблица pal24_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы pal24_payments: {e}") + logger.error(f'Ошибка создания таблицы pal24_payments: {e}') return False async def create_wata_payments_table(): table_exists = await check_table_exists('wata_payments') if table_exists: - logger.info("Таблица wata_payments уже существует") + logger.info('Таблица wata_payments уже существует') return True try: @@ -1133,159 +1100,122 @@ async def create_wata_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы wata_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы wata_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица wata_payments успешно создана") + logger.info('Таблица wata_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы wata_payments: {e}") + logger.error(f'Ошибка создания таблицы wata_payments: {e}') return False async def ensure_wata_payment_schema() -> bool: try: - table_exists = await check_table_exists("wata_payments") + table_exists = await check_table_exists('wata_payments') if not table_exists: - logger.warning("⚠️ Таблица wata_payments отсутствует — создаём заново") + logger.warning('⚠️ Таблица wata_payments отсутствует — создаём заново') return await create_wata_payments_table() db_type = await get_database_type() - legacy_link_index_exists = await check_index_exists( - "wata_payments", "idx_wata_link_id" - ) - unique_link_index_exists = await check_index_exists( - "wata_payments", "uq_wata_payment_link" - ) - builtin_unique_index_exists = await check_index_exists( - "wata_payments", "wata_payments_payment_link_id_key" - ) + legacy_link_index_exists = await check_index_exists('wata_payments', 'idx_wata_link_id') + unique_link_index_exists = await check_index_exists('wata_payments', 'uq_wata_payment_link') + builtin_unique_index_exists = await check_index_exists('wata_payments', 'wata_payments_payment_link_id_key') sqlite_auto_unique_exists = ( - await check_index_exists("wata_payments", "sqlite_autoindex_wata_payments_1") - if db_type == "sqlite" + await check_index_exists('wata_payments', 'sqlite_autoindex_wata_payments_1') + if db_type == 'sqlite' else False ) - order_index_exists = await check_index_exists("wata_payments", "idx_wata_order_id") + order_index_exists = await check_index_exists('wata_payments', 'idx_wata_order_id') - payment_link_column_exists = await check_column_exists( - "wata_payments", "payment_link_id" - ) - order_id_column_exists = await check_column_exists("wata_payments", "order_id") + payment_link_column_exists = await check_column_exists('wata_payments', 'payment_link_id') + order_id_column_exists = await check_column_exists('wata_payments', 'order_id') - unique_index_exists = ( - unique_link_index_exists - or builtin_unique_index_exists - or sqlite_auto_unique_exists - ) + unique_index_exists = unique_link_index_exists or builtin_unique_index_exists or sqlite_auto_unique_exists async with engine.begin() as conn: if not payment_link_column_exists: - if db_type == "sqlite": + if db_type == 'sqlite': await conn.execute( - text( - "ALTER TABLE wata_payments " - "ADD COLUMN payment_link_id VARCHAR(64) NOT NULL DEFAULT ''" - ) + text("ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64) NOT NULL DEFAULT ''") ) payment_link_column_exists = True unique_index_exists = False - elif db_type == "postgresql": + elif db_type == 'postgresql': await conn.execute( - text( - "ALTER TABLE wata_payments " - "ADD COLUMN IF NOT EXISTS payment_link_id VARCHAR(64)" - ) + text('ALTER TABLE wata_payments ADD COLUMN IF NOT EXISTS payment_link_id VARCHAR(64)') ) payment_link_column_exists = True - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)") - ) + elif db_type == 'mysql': + await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN payment_link_id VARCHAR(64)')) payment_link_column_exists = True else: logger.warning( - "⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id", + '⚠️ Неизвестный тип БД %s — пропущено добавление payment_link_id', db_type, ) if payment_link_column_exists: - logger.info("✅ Добавлена колонка payment_link_id в wata_payments") + logger.info('✅ Добавлена колонка payment_link_id в wata_payments') if payment_link_column_exists: - unique_index_exists, legacy_link_index_exists = ( - await enforce_wata_payment_link_constraints( - conn, - db_type, - unique_index_exists, - legacy_link_index_exists, - ) + unique_index_exists, legacy_link_index_exists = await enforce_wata_payment_link_constraints( + conn, + db_type, + unique_index_exists, + legacy_link_index_exists, ) if not order_id_column_exists: - if db_type == "sqlite": - await conn.execute( - text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)") - ) + if db_type == 'sqlite': + await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)')) order_id_column_exists = True - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE wata_payments " - "ADD COLUMN IF NOT EXISTS order_id VARCHAR(255)" - ) - ) + elif db_type == 'postgresql': + await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN IF NOT EXISTS order_id VARCHAR(255)')) order_id_column_exists = True - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)") - ) + elif db_type == 'mysql': + await conn.execute(text('ALTER TABLE wata_payments ADD COLUMN order_id VARCHAR(255)')) order_id_column_exists = True else: logger.warning( - "⚠️ Неизвестный тип БД %s — пропущено добавление order_id", + '⚠️ Неизвестный тип БД %s — пропущено добавление order_id', db_type, ) if order_id_column_exists: - logger.info("✅ Добавлена колонка order_id в wata_payments") + logger.info('✅ Добавлена колонка order_id в wata_payments') if not order_index_exists: if not order_id_column_exists: - logger.warning( - "⚠️ Пропущено создание индекса idx_wata_order_id — колонка order_id отсутствует" - ) + logger.warning('⚠️ Пропущено создание индекса idx_wata_order_id — колонка order_id отсутствует') else: index_created = False - if db_type in {"sqlite", "postgresql"}: + if db_type in {'sqlite', 'postgresql'}: await conn.execute( - text( - "CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)" - ) + text('CREATE INDEX IF NOT EXISTS idx_wata_order_id ON wata_payments(order_id)') ) index_created = True - elif db_type == "mysql": - await conn.execute( - text("CREATE INDEX idx_wata_order_id ON wata_payments(order_id)") - ) + elif db_type == 'mysql': + await conn.execute(text('CREATE INDEX idx_wata_order_id ON wata_payments(order_id)')) index_created = True else: logger.warning( - "⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id", + '⚠️ Неизвестный тип БД %s — пропущено создание индекса idx_wata_order_id', db_type, ) if index_created: - logger.info("✅ Создан индекс idx_wata_order_id") + logger.info('✅ Создан индекс idx_wata_order_id') else: - logger.info("ℹ️ Индекс idx_wata_order_id уже существует") + logger.info('ℹ️ Индекс idx_wata_order_id уже существует') return True except Exception as e: - logger.error(f"Ошибка обновления схемы wata_payments: {e}") + logger.error(f'Ошибка обновления схемы wata_payments: {e}') return False @@ -1293,7 +1223,7 @@ async def create_freekassa_payments_table(): """Создаёт таблицу freekassa_payments для платежей через Freekassa.""" table_exists = await check_table_exists('freekassa_payments') if table_exists: - logger.info("Таблица freekassa_payments уже существует") + logger.info('Таблица freekassa_payments уже существует') return True try: @@ -1389,15 +1319,15 @@ async def create_freekassa_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица freekassa_payments успешно создана") + logger.info('Таблица freekassa_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы freekassa_payments: {e}") + logger.error(f'Ошибка создания таблицы freekassa_payments: {e}') return False @@ -1405,7 +1335,7 @@ async def create_kassa_ai_payments_table(): """Создаёт таблицу kassa_ai_payments для платежей через KassaAI.""" table_exists = await check_table_exists('kassa_ai_payments') if table_exists: - logger.info("Таблица kassa_ai_payments уже существует") + logger.info('Таблица kassa_ai_payments уже существует') return True try: @@ -1501,22 +1431,22 @@ async def create_kassa_ai_payments_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("Таблица kassa_ai_payments успешно создана") + logger.info('Таблица kassa_ai_payments успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы kassa_ai_payments: {e}") + logger.error(f'Ошибка создания таблицы kassa_ai_payments: {e}') return False async def create_discount_offers_table(): table_exists = await check_table_exists('discount_offers') if table_exists: - logger.info("Таблица discount_offers уже существует") + logger.info('Таблица discount_offers уже существует') return True try: @@ -1524,7 +1454,8 @@ async def create_discount_offers_table(): db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE discount_offers ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -1542,14 +1473,18 @@ async def create_discount_offers_table(): FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type ON discount_offers (user_id, notification_type) - """)) + """) + ) elif db_type == 'postgresql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE IF NOT EXISTS discount_offers ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -1565,14 +1500,18 @@ async def create_discount_offers_table(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type ON discount_offers (user_id, notification_type) - """)) + """) + ) elif db_type == 'mysql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE IF NOT EXISTS discount_offers ( id INTEGER PRIMARY KEY AUTO_INCREMENT, user_id INTEGER NOT NULL, @@ -1590,35 +1529,39 @@ async def create_discount_offers_table(): CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX ix_discount_offers_user_type ON discount_offers (user_id, notification_type) - """)) + """) + ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица discount_offers успешно создана") + logger.info('✅ Таблица discount_offers успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы discount_offers: {e}") + logger.error(f'Ошибка создания таблицы discount_offers: {e}') return False async def create_referral_contests_table() -> bool: - table_exists = await check_table_exists("referral_contests") + table_exists = await check_table_exists('referral_contests') if table_exists: - logger.info("Таблица referral_contests уже существует") + logger.info('Таблица referral_contests уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": - await conn.execute(text(""" + if db_type == 'sqlite': + await conn.execute( + text(""" CREATE TABLE referral_contests ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(255) NOT NULL, @@ -1638,9 +1581,11 @@ async def create_referral_contests_table() -> bool: created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - """)) - elif db_type == "postgresql": - await conn.execute(text(""" + """) + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" CREATE TABLE referral_contests ( id SERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, @@ -1660,9 +1605,11 @@ async def create_referral_contests_table() -> bool: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) - elif db_type == "mysql": - await conn.execute(text(""" + """) + ) + elif db_type == 'mysql': + await conn.execute( + text(""" CREATE TABLE referral_contests ( id INTEGER PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, @@ -1683,29 +1630,31 @@ async def create_referral_contests_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_referral_contest_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL ) - """)) + """) + ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица referral_contests создана") + logger.info('✅ Таблица referral_contests создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы referral_contests: {error}") + logger.error(f'Ошибка создания таблицы referral_contests: {error}') return False async def create_referral_contest_events_table() -> bool: - table_exists = await check_table_exists("referral_contest_events") + table_exists = await check_table_exists('referral_contest_events') if table_exists: - logger.info("Таблица referral_contest_events уже существует") + logger.info('Таблица referral_contest_events уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": - await conn.execute(text(""" + if db_type == 'sqlite': + await conn.execute( + text(""" CREATE TABLE referral_contest_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, contest_id INTEGER NOT NULL, @@ -1719,13 +1668,17 @@ async def create_referral_contest_events_table() -> bool: FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(contest_id, referral_id) ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer ON referral_contest_events (contest_id, referrer_id) - """)) - elif db_type == "postgresql": - await conn.execute(text(""" + """) + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" CREATE TABLE referral_contest_events ( id SERIAL PRIMARY KEY, contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE, @@ -1736,13 +1689,17 @@ async def create_referral_contest_events_table() -> bool: occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id) ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer ON referral_contest_events (contest_id, referrer_id) - """)) - elif db_type == "mysql": - await conn.execute(text(""" + """) + ) + elif db_type == 'mysql': + await conn.execute( + text(""" CREATE TABLE referral_contest_events ( id INTEGER PRIMARY KEY AUTO_INCREMENT, contest_id INTEGER NOT NULL, @@ -1756,64 +1713,68 @@ async def create_referral_contest_events_table() -> bool: CONSTRAINT fk_referral_contest_referral FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id) ) - """)) - await conn.execute(text(""" + """) + ) + await conn.execute( + text(""" CREATE INDEX idx_referral_contest_referrer ON referral_contest_events (contest_id, referrer_id) - """)) + """) + ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица referral_contest_events создана") + logger.info('✅ Таблица referral_contest_events создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы referral_contest_events: {error}") + logger.error(f'Ошибка создания таблицы referral_contest_events: {error}') return False async def ensure_referral_contest_summary_columns() -> bool: ok = True - for column in ["daily_summary_times", "last_daily_summary_at"]: - exists = await check_column_exists("referral_contests", column) + for column in ['daily_summary_times', 'last_daily_summary_at']: + exists = await check_column_exists('referral_contests', column) if exists: - logger.info("Колонка %s в referral_contests уже существует", column) + logger.info('Колонка %s в referral_contests уже существует', column) continue try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "postgresql": + if db_type == 'postgresql': await conn.execute( text( - f"ALTER TABLE referral_contests ADD COLUMN {column} " - + ("VARCHAR(255)" if column == "daily_summary_times" else "TIMESTAMP") + f'ALTER TABLE referral_contests ADD COLUMN {column} ' + + ('VARCHAR(255)' if column == 'daily_summary_times' else 'TIMESTAMP') ) ) else: await conn.execute( text( - f"ALTER TABLE referral_contests ADD COLUMN {column} " - + ("VARCHAR(255)" if column == "daily_summary_times" else "DATETIME") + f'ALTER TABLE referral_contests ADD COLUMN {column} ' + + ('VARCHAR(255)' if column == 'daily_summary_times' else 'DATETIME') ) ) - logger.info("✅ Колонка %s в referral_contests добавлена", column) + logger.info('✅ Колонка %s в referral_contests добавлена', column) except Exception as error: ok = False - logger.error("Ошибка добавления %s в referral_contests: %s", column, error) + logger.error('Ошибка добавления %s в referral_contests: %s', column, error) return ok async def create_contest_templates_table() -> bool: - table_exists = await check_table_exists("contest_templates") + table_exists = await check_table_exists('contest_templates') if table_exists: - logger.info("Таблица contest_templates уже существует") + logger.info('Таблица contest_templates уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": - await conn.execute(text(""" + if db_type == 'sqlite': + await conn.execute( + text(""" CREATE TABLE contest_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL, @@ -1830,9 +1791,11 @@ async def create_contest_templates_table() -> bool: created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - """)) - elif db_type == "postgresql": - await conn.execute(text(""" + """) + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" CREATE TABLE contest_templates ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, @@ -1849,9 +1812,11 @@ async def create_contest_templates_table() -> bool: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) - elif db_type == "mysql": - await conn.execute(text(""" + """) + ) + elif db_type == 'mysql': + await conn.execute( + text(""" CREATE TABLE contest_templates ( id INTEGER PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, @@ -1868,29 +1833,31 @@ async def create_contest_templates_table() -> bool: created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) - """)) + """) + ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица contest_templates создана") + logger.info('✅ Таблица contest_templates создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы contest_templates: {error}") + logger.error(f'Ошибка создания таблицы contest_templates: {error}') return False async def create_contest_rounds_table() -> bool: - table_exists = await check_table_exists("contest_rounds") + table_exists = await check_table_exists('contest_rounds') if table_exists: - logger.info("Таблица contest_rounds уже существует") + logger.info('Таблица contest_rounds уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": - await conn.execute(text(""" + if db_type == 'sqlite': + await conn.execute( + text(""" CREATE TABLE contest_rounds ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, @@ -1907,11 +1874,17 @@ async def create_contest_rounds_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE ) - """)) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)")) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)")) - elif db_type == "postgresql": - await conn.execute(text(""" + """) + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)') + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)') + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" CREATE TABLE contest_rounds ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL REFERENCES contest_templates(id) ON DELETE CASCADE, @@ -1927,11 +1900,17 @@ async def create_contest_rounds_table() -> bool: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """)) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)")) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)")) - elif db_type == "mysql": - await conn.execute(text(""" + """) + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)') + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)') + ) + elif db_type == 'mysql': + await conn.execute( + text(""" CREATE TABLE contest_rounds ( id INTEGER PRIMARY KEY AUTO_INCREMENT, template_id INTEGER NOT NULL, @@ -1948,31 +1927,33 @@ async def create_contest_rounds_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_contest_round_template FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE ) - """)) - await conn.execute(text("CREATE INDEX idx_contest_round_status ON contest_rounds(status)")) - await conn.execute(text("CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)")) + """) + ) + await conn.execute(text('CREATE INDEX idx_contest_round_status ON contest_rounds(status)')) + await conn.execute(text('CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)')) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица contest_rounds создана") + logger.info('✅ Таблица contest_rounds создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы contest_rounds: {error}") + logger.error(f'Ошибка создания таблицы contest_rounds: {error}') return False async def create_contest_attempts_table() -> bool: - table_exists = await check_table_exists("contest_attempts") + table_exists = await check_table_exists('contest_attempts') if table_exists: - logger.info("Таблица contest_attempts уже существует") + logger.info('Таблица contest_attempts уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": - await conn.execute(text(""" + if db_type == 'sqlite': + await conn.execute( + text(""" CREATE TABLE contest_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, round_id INTEGER NOT NULL, @@ -1984,10 +1965,14 @@ async def create_contest_attempts_table() -> bool: FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(round_id, user_id) ) - """)) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)")) - elif db_type == "postgresql": - await conn.execute(text(""" + """) + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)') + ) + elif db_type == 'postgresql': + await conn.execute( + text(""" CREATE TABLE contest_attempts ( id SERIAL PRIMARY KEY, round_id INTEGER NOT NULL REFERENCES contest_rounds(id) ON DELETE CASCADE, @@ -1997,10 +1982,14 @@ async def create_contest_attempts_table() -> bool: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id) ) - """)) - await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)")) - elif db_type == "mysql": - await conn.execute(text(""" + """) + ) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)') + ) + elif db_type == 'mysql': + await conn.execute( + text(""" CREATE TABLE contest_attempts ( id INTEGER PRIMARY KEY AUTO_INCREMENT, round_id INTEGER NOT NULL, @@ -2012,56 +2001,43 @@ async def create_contest_attempts_table() -> bool: CONSTRAINT fk_contest_attempt_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id) ) - """)) - await conn.execute(text("CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)")) + """) + ) + await conn.execute(text('CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)')) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Таблица contest_attempts создана") + logger.info('✅ Таблица contest_attempts создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы contest_attempts: {error}") + logger.error(f'Ошибка создания таблицы contest_attempts: {error}') return False async def ensure_referral_contest_type_column() -> bool: - column_exists = await check_column_exists("referral_contests", "contest_type") + column_exists = await check_column_exists('referral_contests', 'contest_type') if column_exists: - logger.info("Колонка contest_type в referral_contests уже существует") + logger.info('Колонка contest_type в referral_contests уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite' or db_type == 'postgresql' or db_type == 'mysql': await conn.execute( text( - "ALTER TABLE referral_contests " - "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" - ) - ) - elif db_type == "postgresql": - await conn.execute( - text( - "ALTER TABLE referral_contests " - "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE referral_contests " + 'ALTER TABLE referral_contests ' "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" ) ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Колонка contest_type в referral_contests добавлена") + logger.info('✅ Колонка contest_type в referral_contests добавлена') return True except Exception as error: - logger.error(f"Ошибка добавления contest_type в referral_contests: {error}") + logger.error(f'Ошибка добавления contest_type в referral_contests: {error}') return False @@ -2077,42 +2053,28 @@ async def ensure_discount_offer_columns(): db_type = await get_database_type() if not effect_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" - )) + if db_type == 'sqlite' or db_type == 'postgresql' or db_type == 'mysql': + await conn.execute( + text( + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" + ) + ) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') if not extra_exists: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN extra_data TEXT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL" - )) + await conn.execute(text('ALTER TABLE discount_offers ADD COLUMN extra_data TEXT NULL')) + elif db_type == 'postgresql' or db_type == 'mysql': + await conn.execute(text('ALTER TABLE discount_offers ADD COLUMN extra_data JSON NULL')) else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены") + logger.info('✅ Колонки effect_type и extra_data для discount_offers проверены') return True except Exception as e: - logger.error(f"Ошибка обновления колонок discount_offers: {e}") + logger.error(f'Ошибка обновления колонок discount_offers: {e}') return False @@ -2132,23 +2094,17 @@ async def ensure_user_promo_offer_discount_columns(): column_def = 'INTEGER NOT NULL DEFAULT 0' if db_type == 'mysql': column_def = 'INT NOT NULL DEFAULT 0' - await conn.execute(text( - f"ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}" - )) + await conn.execute(text(f'ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}')) if not source_exists: if db_type == 'sqlite': column_def = 'TEXT NULL' - elif db_type == 'postgresql': - column_def = 'VARCHAR(100) NULL' - elif db_type == 'mysql': + elif db_type == 'postgresql' or db_type == 'mysql': column_def = 'VARCHAR(100) NULL' else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - await conn.execute(text( - f"ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}" - )) + await conn.execute(text(f'ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}')) if not expires_exists: if db_type == 'sqlite': @@ -2158,16 +2114,14 @@ async def ensure_user_promo_offer_discount_columns(): elif db_type == 'mysql': column_def = 'DATETIME NULL' else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - await conn.execute(text( - f"ALTER TABLE users ADD COLUMN promo_offer_discount_expires_at {column_def}" - )) + await conn.execute(text(f'ALTER TABLE users ADD COLUMN promo_offer_discount_expires_at {column_def}')) - logger.info("✅ Колонки promo_offer_discount_* для users проверены") + logger.info('✅ Колонки promo_offer_discount_* для users проверены') return True except Exception as e: - logger.error(f"Ошибка обновления колонок promo_offer_discount_*: {e}") + logger.error(f'Ошибка обновления колонок promo_offer_discount_*: {e}') return False @@ -2191,14 +2145,12 @@ async def ensure_user_notification_settings_column() -> bool: else: column_def = 'TEXT NULL' - await conn.execute(text( - f"ALTER TABLE users ADD COLUMN notification_settings {column_def}" - )) + await conn.execute(text(f'ALTER TABLE users ADD COLUMN notification_settings {column_def}')) - logger.info("✅ Колонка notification_settings для users добавлена") + logger.info('✅ Колонка notification_settings для users добавлена') return True except Exception as e: - logger.error(f"Ошибка добавления колонки notification_settings: {e}") + logger.error(f'Ошибка добавления колонки notification_settings: {e}') return False @@ -2210,67 +2162,66 @@ async def ensure_promo_offer_template_active_duration_column() -> bool: db_type = await get_database_type() if not column_exists: - if db_type == 'sqlite': - column_def = 'INTEGER NULL' - elif db_type == 'postgresql': + if db_type == 'sqlite' or db_type == 'postgresql': column_def = 'INTEGER NULL' elif db_type == 'mysql': column_def = 'INT NULL' else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') - await conn.execute(text( - f"ALTER TABLE promo_offer_templates ADD COLUMN active_discount_hours {column_def}" - )) + await conn.execute( + text(f'ALTER TABLE promo_offer_templates ADD COLUMN active_discount_hours {column_def}') + ) - await conn.execute(text( - "UPDATE promo_offer_templates " - "SET active_discount_hours = valid_hours " - "WHERE offer_type IN ('extend_discount', 'purchase_discount') " - "AND (active_discount_hours IS NULL OR active_discount_hours <= 0)" - )) + await conn.execute( + text( + 'UPDATE promo_offer_templates ' + 'SET active_discount_hours = valid_hours ' + "WHERE offer_type IN ('extend_discount', 'purchase_discount') " + 'AND (active_discount_hours IS NULL OR active_discount_hours <= 0)' + ) + ) - logger.info("✅ Колонка active_discount_hours в promo_offer_templates актуальна") + logger.info('✅ Колонка active_discount_hours в promo_offer_templates актуальна') return True except Exception as e: - logger.error(f"Ошибка обновления active_discount_hours в promo_offer_templates: {e}") + logger.error(f'Ошибка обновления active_discount_hours в promo_offer_templates: {e}') return False async def migrate_discount_offer_effect_types(): try: async with engine.begin() as conn: - await conn.execute(text( - "UPDATE discount_offers SET effect_type = 'percent_discount' " - "WHERE effect_type = 'balance_bonus'" - )) - logger.info("✅ Типы эффектов discount_offers обновлены на percent_discount") + await conn.execute( + text("UPDATE discount_offers SET effect_type = 'percent_discount' WHERE effect_type = 'balance_bonus'") + ) + logger.info('✅ Типы эффектов discount_offers обновлены на percent_discount') return True except Exception as e: - logger.error(f"Ошибка обновления типов эффектов discount_offers: {e}") + logger.error(f'Ошибка обновления типов эффектов discount_offers: {e}') return False async def reset_discount_offer_bonuses(): try: async with engine.begin() as conn: - await conn.execute(text( - "UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0" - )) - await conn.execute(text( - "UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0" - )) - logger.info("✅ Бонусы промо-предложений сброшены до нуля") + await conn.execute( + text('UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0') + ) + await conn.execute( + text('UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0') + ) + logger.info('✅ Бонусы промо-предложений сброшены до нуля') return True except Exception as e: - logger.error(f"Ошибка обнуления бонусов промо-предложений: {e}") + logger.error(f'Ошибка обнуления бонусов промо-предложений: {e}') return False async def create_promo_offer_templates_table(): table_exists = await check_table_exists('promo_offer_templates') if table_exists: - logger.info("Таблица promo_offer_templates уже существует") + logger.info('Таблица promo_offer_templates уже существует') return True try: @@ -2346,22 +2297,22 @@ async def create_promo_offer_templates_table(): CREATE INDEX ix_promo_offer_templates_type ON promo_offer_templates(offer_type); """ else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') await conn.execute(text(create_sql)) - logger.info("✅ Таблица promo_offer_templates успешно создана") + logger.info('✅ Таблица promo_offer_templates успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы promo_offer_templates: {e}") + logger.error(f'Ошибка создания таблицы promo_offer_templates: {e}') return False async def create_main_menu_buttons_table() -> bool: table_exists = await check_table_exists('main_menu_buttons') if table_exists: - logger.info("Таблица main_menu_buttons уже существует") + logger.info('Таблица main_menu_buttons уже существует') return True try: @@ -2417,30 +2368,31 @@ async def create_main_menu_buttons_table() -> bool: CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id); """ else: - logger.error(f"Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}") + logger.error(f'Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("✅ Таблица main_menu_buttons успешно создана") + logger.info('✅ Таблица main_menu_buttons успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы main_menu_buttons: {e}") + logger.error(f'Ошибка создания таблицы main_menu_buttons: {e}') return False async def create_promo_offer_logs_table() -> bool: table_exists = await check_table_exists('promo_offer_logs') if table_exists: - logger.info("Таблица promo_offer_logs уже существует") + logger.info('Таблица promo_offer_logs уже существует') return True try: db_type = await get_database_type() async with engine.begin() as conn: if db_type == 'sqlite': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE IF NOT EXISTS promo_offer_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, @@ -2455,9 +2407,11 @@ async def create_promo_offer_logs_table() -> bool: CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); - """)) + """) + ) elif db_type == 'postgresql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE IF NOT EXISTS promo_offer_logs ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, @@ -2472,9 +2426,11 @@ async def create_promo_offer_logs_table() -> bool: CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); CREATE INDEX IF NOT EXISTS ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); - """)) + """) + ) elif db_type == 'mysql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE IF NOT EXISTS promo_offer_logs ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, @@ -2491,22 +2447,23 @@ async def create_promo_offer_logs_table() -> bool: CREATE INDEX ix_promo_offer_logs_created_at ON promo_offer_logs(created_at DESC); CREATE INDEX ix_promo_offer_logs_user_id ON promo_offer_logs(user_id); - """)) + """) + ) else: - logger.warning("Неизвестный тип БД для создания promo_offer_logs: %s", db_type) + logger.warning('Неизвестный тип БД для создания promo_offer_logs: %s', db_type) return False - logger.info("✅ Таблица promo_offer_logs успешно создана") + logger.info('✅ Таблица promo_offer_logs успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы promo_offer_logs: {e}") + logger.error(f'Ошибка создания таблицы promo_offer_logs: {e}') return False async def create_subscription_temporary_access_table(): table_exists = await check_table_exists('subscription_temporary_access') if table_exists: - logger.info("Таблица subscription_temporary_access уже существует") + logger.info('Таблица subscription_temporary_access уже существует') return True try: @@ -2572,27 +2529,28 @@ async def create_subscription_temporary_access_table(): CREATE INDEX ix_temp_access_active ON subscription_temporary_access(is_active, expires_at); """ else: - raise ValueError(f"Unsupported database type: {db_type}") + raise ValueError(f'Unsupported database type: {db_type}') await conn.execute(text(create_sql)) - logger.info("✅ Таблица subscription_temporary_access успешно создана") + logger.info('✅ Таблица subscription_temporary_access успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы subscription_temporary_access: {e}") + logger.error(f'Ошибка создания таблицы subscription_temporary_access: {e}') return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: - logger.info("Таблица user_messages уже существует") + logger.info('Таблица user_messages уже существует') return True - + try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': create_sql = """ CREATE TABLE user_messages ( @@ -2605,11 +2563,11 @@ async def create_user_messages_table(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_user_messages_active ON user_messages(is_active); CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at); """ - + elif db_type == 'postgresql': create_sql = """ CREATE TABLE user_messages ( @@ -2622,11 +2580,11 @@ async def create_user_messages_table(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_user_messages_active ON user_messages(is_active); CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at); """ - + elif db_type == 'mysql': create_sql = """ CREATE TABLE user_messages ( @@ -2639,34 +2597,34 @@ async def create_user_messages_table(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_user_messages_active ON user_messages(is_active); CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at); """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') return False - + await conn.execute(text(create_sql)) - logger.info("Таблица user_messages успешно создана") + logger.info('Таблица user_messages успешно создана') return True - + except Exception as e: - logger.error(f"Ошибка создания таблицы user_messages: {e}") + logger.error(f'Ошибка создания таблицы user_messages: {e}') return False async def ensure_promo_groups_setup(): - logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") + logger.info('=== НАСТРОЙКА ПРОМО ГРУПП ===') try: - promo_table_exists = await check_table_exists("promo_groups") + promo_table_exists = await check_table_exists('promo_groups') async with engine.begin() as conn: db_type = await get_database_type() if not promo_table_exists: - if db_type == "sqlite": + if db_type == 'sqlite': await conn.execute( text( """ @@ -2684,11 +2642,9 @@ async def ensure_promo_groups_setup(): ) ) await conn.execute( - text( - "CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)" - ) + text('CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)') ) - elif db_type == "postgresql": + elif db_type == 'postgresql': await conn.execute( text( """ @@ -2706,7 +2662,7 @@ async def ensure_promo_groups_setup(): """ ) ) - elif db_type == "mysql": + elif db_type == 'mysql': await conn.execute( text( """ @@ -2725,257 +2681,173 @@ async def ensure_promo_groups_setup(): ) ) else: - logger.error(f"Неподдерживаемый тип БД для promo_groups: {db_type}") + logger.error(f'Неподдерживаемый тип БД для promo_groups: {db_type}') return False - logger.info("Создана таблица promo_groups") + logger.info('Создана таблица promo_groups') - if db_type == "postgresql" and not await check_constraint_exists( - "promo_groups", "uq_promo_groups_name" - ): + if db_type == 'postgresql' and not await check_constraint_exists('promo_groups', 'uq_promo_groups_name'): try: await conn.execute( - text( - "ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)" - ) + text('ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)') ) except Exception as e: - logger.warning( - f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}" - ) + logger.warning(f'Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}') - period_discounts_column_exists = await check_column_exists( - "promo_groups", "period_discounts" - ) + period_discounts_column_exists = await check_column_exists('promo_groups', 'period_discounts') if not period_discounts_column_exists: - if db_type == "sqlite": - await conn.execute( - text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") - ) + if db_type == 'sqlite': + await conn.execute(text('ALTER TABLE promo_groups ADD COLUMN period_discounts JSON')) await conn.execute( text("UPDATE promo_groups SET period_discounts = '{}' WHERE period_discounts IS NULL") ) - elif db_type == "postgresql": + elif db_type == 'postgresql': + await conn.execute(text('ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB')) await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN period_discounts JSONB" - ) + text("UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL") ) + elif db_type == 'mysql': + await conn.execute(text('ALTER TABLE promo_groups ADD COLUMN period_discounts JSON')) await conn.execute( - text( - "UPDATE promo_groups SET period_discounts = '{}'::jsonb WHERE period_discounts IS NULL" - ) - ) - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE promo_groups ADD COLUMN period_discounts JSON") - ) - await conn.execute( - text( - "UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL" - ) + text('UPDATE promo_groups SET period_discounts = JSON_OBJECT() WHERE period_discounts IS NULL') ) else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}" - ) + logger.error(f'Неподдерживаемый тип БД для promo_groups.period_discounts: {db_type}') return False - logger.info("Добавлена колонка promo_groups.period_discounts") + logger.info('Добавлена колонка promo_groups.period_discounts') - auto_assign_column_exists = await check_column_exists( - "promo_groups", "auto_assign_total_spent_kopeks" - ) + auto_assign_column_exists = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks') if not auto_assign_column_exists: - if db_type == "sqlite": + if db_type == 'sqlite' or db_type == 'postgresql': await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" - ) + text('ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0') ) - elif db_type == "postgresql": + elif db_type == 'mysql': await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INTEGER DEFAULT 0" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0" - ) + text('ALTER TABLE promo_groups ADD COLUMN auto_assign_total_spent_kopeks INT DEFAULT 0') ) else: - logger.error( - f"Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}" - ) + logger.error(f'Неподдерживаемый тип БД для promo_groups.auto_assign_total_spent_kopeks: {db_type}') return False - logger.info( - "Добавлена колонка promo_groups.auto_assign_total_spent_kopeks" - ) + logger.info('Добавлена колонка promo_groups.auto_assign_total_spent_kopeks') - addon_discount_column_exists = await check_column_exists( - "promo_groups", "apply_discounts_to_addons" - ) - priority_column_exists = await check_column_exists( - "promo_groups", "priority" - ) + addon_discount_column_exists = await check_column_exists('promo_groups', 'apply_discounts_to_addons') + priority_column_exists = await check_column_exists('promo_groups', 'priority') if not addon_discount_column_exists: - if db_type == "sqlite": + 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( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1" + '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 = 1 WHERE apply_discounts_to_addons IS NULL" + 'UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL' ) ) - elif db_type == "postgresql": + elif db_type == 'mysql': await conn.execute( text( - "ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE" + '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 = 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" + '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}" - ) + logger.error(f'Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}') return False - logger.info( - "Добавлена колонка promo_groups.apply_discounts_to_addons" - ) + logger.info('Добавлена колонка promo_groups.apply_discounts_to_addons') addon_discount_column_exists = True - column_exists = await check_column_exists("users", "promo_group_id") + column_exists = await check_column_exists('users', 'promo_group_id') if not column_exists: - if db_type == "sqlite": - await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER")) - elif db_type == "postgresql": - await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER")) - elif db_type == "mysql": - await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INT")) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute(text('ALTER TABLE users ADD COLUMN promo_group_id INTEGER')) + elif db_type == 'mysql': + await conn.execute(text('ALTER TABLE users ADD COLUMN promo_group_id INT')) else: - logger.error(f"Неподдерживаемый тип БД для promo_group_id: {db_type}") + logger.error(f'Неподдерживаемый тип БД для promo_group_id: {db_type}') return False - logger.info("Добавлена колонка users.promo_group_id") + logger.info('Добавлена колонка users.promo_group_id') - auto_promo_flag_exists = await check_column_exists( - "users", "auto_promo_group_assigned" - ) + auto_promo_flag_exists = await check_column_exists('users', 'auto_promo_group_assigned') if not auto_promo_flag_exists: - if db_type == "sqlite": + if db_type == 'sqlite': + await conn.execute(text('ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0')) + elif db_type == 'postgresql': await conn.execute( - text( - "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT 0" - ) + text('ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE') ) - elif db_type == "postgresql": + elif db_type == 'mysql': await conn.execute( - text( - "ALTER TABLE users ADD COLUMN auto_promo_group_assigned BOOLEAN DEFAULT FALSE" - ) - ) - elif db_type == "mysql": - await conn.execute( - text( - "ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0" - ) + text('ALTER TABLE users ADD COLUMN auto_promo_group_assigned TINYINT(1) DEFAULT 0') ) else: - logger.error( - f"Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}" - ) + logger.error(f'Неподдерживаемый тип БД для users.auto_promo_group_assigned: {db_type}') return False - logger.info("Добавлена колонка users.auto_promo_group_assigned") + logger.info('Добавлена колонка users.auto_promo_group_assigned') - threshold_column_exists = await check_column_exists( - "users", "auto_promo_group_threshold_kopeks" - ) + threshold_column_exists = await check_column_exists('users', 'auto_promo_group_threshold_kopeks') if not threshold_column_exists: - if db_type == "sqlite": + if db_type == 'sqlite': await conn.execute( text( - "ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0" + 'ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0' ) ) - elif db_type == "postgresql": + elif db_type == 'postgresql' or db_type == 'mysql': 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" - ) + 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}" - ) + logger.error(f'Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}') return False - logger.info( - "Добавлена колонка users.auto_promo_group_threshold_kopeks" - ) + logger.info('Добавлена колонка users.auto_promo_group_threshold_kopeks') - index_exists = await check_index_exists("users", "ix_users_promo_group_id") + index_exists = await check_index_exists('users', 'ix_users_promo_group_id') if not index_exists: try: - if db_type == "sqlite": + if db_type == 'sqlite' or db_type == 'postgresql': await conn.execute( - text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)") + text('CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)') ) - elif db_type == "postgresql": - await conn.execute( - text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)") - ) - elif db_type == "mysql": - await conn.execute( - text("CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)") - ) - logger.info("Создан индекс ix_users_promo_group_id") + elif db_type == 'mysql': + await conn.execute(text('CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)')) + logger.info('Создан индекс ix_users_promo_group_id') except Exception as e: - logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}") + logger.warning(f'Не удалось создать индекс ix_users_promo_group_id: {e}') - default_group_name = "Базовый юзер" + default_group_name = 'Базовый юзер' default_group_id = None result = await conn.execute( - text( - "SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1" - ), - {"name": default_group_name}, + text('SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1'), + {'name': default_group_name}, ) row = result.fetchone() @@ -2983,17 +2855,13 @@ async def ensure_promo_groups_setup(): default_group_id = row[0] if not row[1]: await conn.execute( - text( - "UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id" - ), - {"is_default": True, "group_id": default_group_id}, + text('UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id'), + {'is_default': True, 'group_id': default_group_id}, ) else: result = await conn.execute( - text( - "SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1" - ), - {"is_default": True}, + text('SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1'), + {'is_default': True}, ) existing_default = result.fetchone() @@ -3001,12 +2869,12 @@ async def ensure_promo_groups_setup(): default_group_id = existing_default[0] else: insert_params = { - "name": default_group_name, - "is_default": True, + 'name': default_group_name, + 'is_default': True, } if priority_column_exists: - insert_params["priority"] = 0 + insert_params['priority'] = 0 if addon_discount_column_exists and priority_column_exists: insert_sql = """ @@ -3020,7 +2888,7 @@ async def ensure_promo_groups_setup(): is_default ) VALUES (:name, :priority, 0, 0, 0, :apply_discounts_to_addons, :is_default) """ - insert_params["apply_discounts_to_addons"] = True + insert_params['apply_discounts_to_addons'] = True elif addon_discount_column_exists: insert_sql = """ INSERT INTO promo_groups ( @@ -3032,7 +2900,7 @@ async def ensure_promo_groups_setup(): is_default ) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default) """ - insert_params["apply_discounts_to_addons"] = True + insert_params['apply_discounts_to_addons'] = True elif priority_column_exists: insert_sql = """ INSERT INTO promo_groups ( @@ -3058,16 +2926,14 @@ async def ensure_promo_groups_setup(): await conn.execute(text(insert_sql), insert_params) result = await conn.execute( - text( - "SELECT id FROM promo_groups WHERE name = :name LIMIT 1" - ), - {"name": default_group_name}, + text('SELECT id FROM promo_groups WHERE name = :name LIMIT 1'), + {'name': default_group_name}, ) row = result.fetchone() default_group_id = row[0] if row else None if default_group_id is None: - logger.error("Не удалось определить идентификатор базовой промо-группы") + logger.error('Не удалось определить идентификатор базовой промо-группы') return False await conn.execute( @@ -3078,13 +2944,11 @@ async def ensure_promo_groups_setup(): WHERE promo_group_id IS NULL """ ), - {"group_id": default_group_id}, + {'group_id': default_group_id}, ) - if db_type == "postgresql": - constraint_exists = await check_constraint_exists( - "users", "fk_users_promo_group_id_promo_groups" - ) + if db_type == 'postgresql': + constraint_exists = await check_constraint_exists('users', 'fk_users_promo_group_id_promo_groups') if not constraint_exists: try: await conn.execute( @@ -3098,27 +2962,17 @@ async def ensure_promo_groups_setup(): """ ) ) - logger.info("Добавлен внешний ключ users -> promo_groups") + logger.info('Добавлен внешний ключ users -> promo_groups') except Exception as e: - logger.warning( - f"Не удалось добавить внешний ключ users.promo_group_id: {e}" - ) + logger.warning(f'Не удалось добавить внешний ключ users.promo_group_id: {e}') try: - await conn.execute( - text( - "ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL" - ) - ) + await conn.execute(text('ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL')) except Exception as e: - logger.warning( - f"Не удалось сделать users.promo_group_id NOT NULL: {e}" - ) + logger.warning(f'Не удалось сделать users.promo_group_id NOT NULL: {e}') - elif db_type == "mysql": - constraint_exists = await check_constraint_exists( - "users", "fk_users_promo_group_id_promo_groups" - ) + elif db_type == 'mysql': + constraint_exists = await check_constraint_exists('users', 'fk_users_promo_group_id_promo_groups') if not constraint_exists: try: await conn.execute( @@ -3132,78 +2986,70 @@ async def ensure_promo_groups_setup(): """ ) ) - logger.info("Добавлен внешний ключ users -> promo_groups") + logger.info('Добавлен внешний ключ users -> promo_groups') except Exception as e: - logger.warning( - f"Не удалось добавить внешний ключ users.promo_group_id: {e}" - ) + logger.warning(f'Не удалось добавить внешний ключ users.promo_group_id: {e}') try: - await conn.execute( - text( - "ALTER TABLE users MODIFY promo_group_id INT NOT NULL" - ) - ) + await conn.execute(text('ALTER TABLE users MODIFY promo_group_id INT NOT NULL')) except Exception as e: - logger.warning( - f"Не удалось сделать users.promo_group_id NOT NULL: {e}" - ) + logger.warning(f'Не удалось сделать users.promo_group_id NOT NULL: {e}') - logger.info("✅ Промо группы настроены") + logger.info('✅ Промо группы настроены') return True except Exception as e: - logger.error(f"Ошибка настройки промо групп: {e}") + logger.error(f'Ошибка настройки промо групп: {e}') return False + async def add_welcome_text_is_enabled_column(): column_exists = await check_column_exists('welcome_texts', 'is_enabled') if column_exists: - logger.info("Колонка is_enabled уже существует в таблице welcome_texts") + logger.info('Колонка is_enabled уже существует в таблице welcome_texts') return True - + try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': - alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT 1 NOT NULL" - elif db_type == 'postgresql': - alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL" - elif db_type == 'mysql': - alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL" + alter_sql = 'ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT 1 NOT NULL' + elif db_type == 'postgresql' or db_type == 'mysql': + alter_sql = 'ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL' else: - logger.error(f"Неподдерживаемый тип БД для добавления колонки: {db_type}") + logger.error(f'Неподдерживаемый тип БД для добавления колонки: {db_type}') return False - + await conn.execute(text(alter_sql)) - logger.info("✅ Поле is_enabled добавлено в таблицу welcome_texts") - + logger.info('✅ Поле is_enabled добавлено в таблицу welcome_texts') + if db_type == 'sqlite': - update_sql = "UPDATE welcome_texts SET is_enabled = 1 WHERE is_enabled IS NULL" + update_sql = 'UPDATE welcome_texts SET is_enabled = 1 WHERE is_enabled IS NULL' else: - update_sql = "UPDATE welcome_texts SET is_enabled = TRUE WHERE is_enabled IS NULL" - + update_sql = 'UPDATE welcome_texts SET is_enabled = TRUE WHERE is_enabled IS NULL' + result = await conn.execute(text(update_sql)) updated_count = result.rowcount - logger.info(f"Обновлено {updated_count} существующих записей welcome_texts") - + logger.info(f'Обновлено {updated_count} существующих записей welcome_texts') + return True - + except Exception as e: - logger.error(f"Ошибка при добавлении поля is_enabled: {e}") + logger.error(f'Ошибка при добавлении поля is_enabled: {e}') return False + async def create_welcome_texts_table(): table_exists = await check_table_exists('welcome_texts') if table_exists: - logger.info("Таблица welcome_texts уже существует") + logger.info('Таблица welcome_texts уже существует') return await add_welcome_text_is_enabled_column() - + try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': create_sql = """ CREATE TABLE welcome_texts ( @@ -3216,12 +3062,12 @@ async def create_welcome_texts_table(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active); CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled); CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at); """ - + elif db_type == 'postgresql': create_sql = """ CREATE TABLE welcome_texts ( @@ -3234,12 +3080,12 @@ async def create_welcome_texts_table(): updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active); CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled); CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at); """ - + elif db_type == 'mysql': create_sql = """ CREATE TABLE welcome_texts ( @@ -3252,35 +3098,35 @@ async def create_welcome_texts_table(): updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); - + CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active); CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled); CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at); """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') return False - + await conn.execute(text(create_sql)) - logger.info("✅ Таблица welcome_texts успешно создана с полем is_enabled") + logger.info('✅ Таблица welcome_texts успешно создана с полем is_enabled') return True - + except Exception as e: - logger.error(f"Ошибка создания таблицы welcome_texts: {e}") + logger.error(f'Ошибка создания таблицы welcome_texts: {e}') return False async def create_pinned_messages_table(): - table_exists = await check_table_exists("pinned_messages") + table_exists = await check_table_exists('pinned_messages') if table_exists: - logger.info("Таблица pinned_messages уже существует") + logger.info('Таблица pinned_messages уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE pinned_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3299,7 +3145,7 @@ async def create_pinned_messages_table(): CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE pinned_messages ( id SERIAL PRIMARY KEY, @@ -3317,7 +3163,7 @@ async def create_pinned_messages_table(): CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active); """ - elif db_type == "mysql": + elif db_type == 'mysql': create_sql = """ CREATE TABLE pinned_messages ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -3337,130 +3183,118 @@ async def create_pinned_messages_table(): """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("✅ Таблица pinned_messages успешно создана") + logger.info('✅ Таблица pinned_messages успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы pinned_messages: {e}") + logger.error(f'Ошибка создания таблицы pinned_messages: {e}') return False async def ensure_pinned_message_media_columns(): - table_exists = await check_table_exists("pinned_messages") + table_exists = await check_table_exists('pinned_messages') if not table_exists: - logger.warning("⚠️ Таблица pinned_messages отсутствует — пропускаем обновление медиа полей") + logger.warning('⚠️ Таблица pinned_messages отсутствует — пропускаем обновление медиа полей') return False try: async with engine.begin() as conn: db_type = await get_database_type() - if not await check_column_exists("pinned_messages", "media_type"): - await conn.execute( - text("ALTER TABLE pinned_messages ADD COLUMN media_type VARCHAR(32)") - ) + if not await check_column_exists('pinned_messages', 'media_type'): + await conn.execute(text('ALTER TABLE pinned_messages ADD COLUMN media_type VARCHAR(32)')) - if not await check_column_exists("pinned_messages", "media_file_id"): - await conn.execute( - text("ALTER TABLE pinned_messages ADD COLUMN media_file_id VARCHAR(255)") - ) + if not await check_column_exists('pinned_messages', 'media_file_id'): + await conn.execute(text('ALTER TABLE pinned_messages ADD COLUMN media_file_id VARCHAR(255)')) - if not await check_column_exists("pinned_messages", "send_before_menu"): - default_value = "TRUE" if db_type != "sqlite" else "1" + if not await check_column_exists('pinned_messages', 'send_before_menu'): + default_value = 'TRUE' if db_type != 'sqlite' else '1' await conn.execute( text( - f"ALTER TABLE pinned_messages ADD COLUMN send_before_menu BOOLEAN NOT NULL DEFAULT {default_value}" + f'ALTER TABLE pinned_messages ADD COLUMN send_before_menu BOOLEAN NOT NULL DEFAULT {default_value}' ) ) - if not await check_column_exists("pinned_messages", "send_on_every_start"): - default_value = "TRUE" if db_type != "sqlite" else "1" + if not await check_column_exists('pinned_messages', 'send_on_every_start'): + default_value = 'TRUE' if db_type != 'sqlite' else '1' await conn.execute( text( - f"ALTER TABLE pinned_messages ADD COLUMN send_on_every_start BOOLEAN NOT NULL DEFAULT {default_value}" + f'ALTER TABLE pinned_messages ADD COLUMN send_on_every_start BOOLEAN NOT NULL DEFAULT {default_value}' ) ) await conn.execute(text("UPDATE pinned_messages SET content = '' WHERE content IS NULL")) - if db_type == "postgresql": - await conn.execute( - text("ALTER TABLE pinned_messages ALTER COLUMN content SET DEFAULT ''") - ) - elif db_type == "mysql": - await conn.execute( - text("ALTER TABLE pinned_messages MODIFY content TEXT NOT NULL DEFAULT ''") - ) + if db_type == 'postgresql': + await conn.execute(text("ALTER TABLE pinned_messages ALTER COLUMN content SET DEFAULT ''")) + elif db_type == 'mysql': + await conn.execute(text("ALTER TABLE pinned_messages MODIFY content TEXT NOT NULL DEFAULT ''")) else: - logger.info("ℹ️ Пропускаем установку DEFAULT для content в SQLite") + logger.info('ℹ️ Пропускаем установку DEFAULT для content в SQLite') - logger.info("✅ Медиа поля pinned_messages приведены в актуальное состояние") + logger.info('✅ Медиа поля pinned_messages приведены в актуальное состояние') return True except Exception as e: - logger.error(f"Ошибка обновления медиа полей pinned_messages: {e}") + logger.error(f'Ошибка обновления медиа полей pinned_messages: {e}') return False async def ensure_user_last_pinned_column(): try: async with engine.begin() as conn: - if not await check_column_exists("users", "last_pinned_message_id"): - await conn.execute( - text("ALTER TABLE users ADD COLUMN last_pinned_message_id INTEGER") - ) - logger.info("✅ Поле last_pinned_message_id у пользователей готово") + if not await check_column_exists('users', 'last_pinned_message_id'): + await conn.execute(text('ALTER TABLE users ADD COLUMN last_pinned_message_id INTEGER')) + logger.info('✅ Поле last_pinned_message_id у пользователей готово') return True except Exception as e: - logger.error(f"Ошибка добавления поля last_pinned_message_id: {e}") + logger.error(f'Ошибка добавления поля last_pinned_message_id: {e}') return False + async def add_media_fields_to_broadcast_history(): - logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===") - + logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===') + media_fields = { 'has_media': 'BOOLEAN DEFAULT FALSE', 'media_type': 'VARCHAR(20)', - 'media_file_id': 'VARCHAR(255)', - 'media_caption': 'TEXT' + 'media_file_id': 'VARCHAR(255)', + 'media_caption': 'TEXT', } - + try: async with engine.begin() as conn: db_type = await get_database_type() - + for field_name, field_type in media_fields.items(): field_exists = await check_column_exists('broadcast_history', field_name) - + if not field_exists: - logger.info(f"Добавление поля {field_name} в таблицу broadcast_history") - + logger.info(f'Добавление поля {field_name} в таблицу broadcast_history') + if db_type == 'sqlite': if 'BOOLEAN' in field_type: field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT 0') - elif db_type == 'postgresql': + elif db_type == 'postgresql' or db_type == 'mysql': if 'BOOLEAN' in field_type: field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE') - elif db_type == 'mysql': - if 'BOOLEAN' in field_type: - field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE') - - alter_sql = f"ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}" + + alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}' await conn.execute(text(alter_sql)) - logger.info(f"✅ Поле {field_name} успешно добавлено") + logger.info(f'✅ Поле {field_name} успешно добавлено') else: - logger.info(f"Поле {field_name} уже существует в broadcast_history") - - logger.info("✅ Все поля медиа в broadcast_history готовы") + logger.info(f'Поле {field_name} уже существует в broadcast_history') + + logger.info('✅ Все поля медиа в broadcast_history готовы') return True - + except Exception as e: - logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}") + logger.error(f'Ошибка при добавлении полей медиа в broadcast_history: {e}') return False @@ -3477,33 +3311,33 @@ async def add_ticket_reply_block_columns(): if not col_perm_exists: if db_type == 'sqlite': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT 0 NOT NULL" - elif db_type == 'postgresql': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL" - elif db_type == 'mysql': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT 0 NOT NULL' + elif db_type == 'postgresql' or db_type == 'mysql': + alter_sql = ( + 'ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL' + ) else: - logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}") + logger.error(f'Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}') return False await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка tickets.user_reply_block_permanent") + logger.info('✅ Добавлена колонка tickets.user_reply_block_permanent') if not col_until_exists: if db_type == 'sqlite': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL' elif db_type == 'postgresql': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until TIMESTAMP NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN user_reply_block_until TIMESTAMP NULL' elif db_type == 'mysql': - alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL' else: - logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}") + logger.error(f'Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}') return False await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка tickets.user_reply_block_until") + logger.info('✅ Добавлена колонка tickets.user_reply_block_until') return True except Exception as e: - logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}") + logger.error(f'Ошибка добавления колонок блокировок в tickets: {e}') return False @@ -3515,19 +3349,19 @@ async def add_ticket_sla_columns(): async with engine.begin() as conn: db_type = await get_database_type() if db_type == 'sqlite': - alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL' elif db_type == 'postgresql': - alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL' elif db_type == 'mysql': - alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL" + alter_sql = 'ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL' else: - logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}") + logger.error(f'Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}') return False await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at") + logger.info('✅ Добавлена колонка tickets.last_sla_reminder_at') return True except Exception as e: - logger.error(f"Ошибка добавления SLA колонки в tickets: {e}") + logger.error(f'Ошибка добавления SLA колонки в tickets: {e}') return False @@ -3539,7 +3373,7 @@ async def add_user_restriction_columns() -> bool: col_reason = await check_column_exists('users', 'restriction_reason') if col_topup and col_sub and col_reason: - logger.info("ℹ️ Колонки ограничений пользователей уже существуют") + logger.info('ℹ️ Колонки ограничений пользователей уже существуют') return True async with engine.begin() as conn: @@ -3547,77 +3381,59 @@ async def add_user_restriction_columns() -> bool: if not col_topup: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT 0 NOT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT 0 NOT NULL') + ) + elif db_type == 'postgresql' or db_type == 'mysql': + await conn.execute( + text('ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL') + ) else: - logger.error(f"Неподдерживаемый тип БД: {db_type}") + logger.error(f'Неподдерживаемый тип БД: {db_type}') return False - logger.info("✅ Добавлена колонка users.restriction_topup") + logger.info('✅ Добавлена колонка users.restriction_topup') if not col_sub: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT 0 NOT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT 0 NOT NULL') + ) + elif db_type == 'postgresql' or db_type == 'mysql': + await conn.execute( + text('ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL') + ) else: - logger.error(f"Неподдерживаемый тип БД: {db_type}") + logger.error(f'Неподдерживаемый тип БД: {db_type}') return False - logger.info("✅ Добавлена колонка users.restriction_subscription") + logger.info('✅ Добавлена колонка users.restriction_subscription') if not col_reason: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql' or db_type == 'mysql': + await conn.execute(text('ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL')) else: - logger.error(f"Неподдерживаемый тип БД: {db_type}") + logger.error(f'Неподдерживаемый тип БД: {db_type}') return False - logger.info("✅ Добавлена колонка users.restriction_reason") + logger.info('✅ Добавлена колонка users.restriction_reason') return True except Exception as e: - logger.error(f"Ошибка добавления колонок ограничений пользователей: {e}") + logger.error(f'Ошибка добавления колонок ограничений пользователей: {e}') return False async def add_user_cabinet_columns() -> bool: """Add cabinet (personal account) columns to users table.""" cabinet_columns = [ - ("email", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"), - ("email_verified", "BOOLEAN DEFAULT 0", "BOOLEAN DEFAULT FALSE", "TINYINT(1) DEFAULT 0"), - ("email_verified_at", "DATETIME", "TIMESTAMP", "DATETIME"), - ("password_hash", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"), - ("email_verification_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"), - ("email_verification_expires", "DATETIME", "TIMESTAMP", "DATETIME"), - ("password_reset_token", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)"), - ("password_reset_expires", "DATETIME", "TIMESTAMP", "DATETIME"), - ("cabinet_last_login", "DATETIME", "TIMESTAMP", "DATETIME"), + ('email', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'), + ('email_verified', 'BOOLEAN DEFAULT 0', 'BOOLEAN DEFAULT FALSE', 'TINYINT(1) DEFAULT 0'), + ('email_verified_at', 'DATETIME', 'TIMESTAMP', 'DATETIME'), + ('password_hash', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'), + ('email_verification_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'), + ('email_verification_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'), + ('password_reset_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'), + ('password_reset_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'), + ('cabinet_last_login', 'DATETIME', 'TIMESTAMP', 'DATETIME'), ] try: @@ -3636,28 +3452,26 @@ async def add_user_cabinet_columns() -> bool: else: col_type = mysql_type - await conn.execute( - text(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}") - ) + await conn.execute(text(f'ALTER TABLE users ADD COLUMN {col_name} {col_type}')) added_count += 1 - logger.info(f"✅ Добавлена колонка users.{col_name}") + logger.info(f'✅ Добавлена колонка users.{col_name}') if added_count == 0: - logger.info("ℹ️ Все колонки cabinet уже существуют в таблице users") + logger.info('ℹ️ Все колонки cabinet уже существуют в таблице users') else: - logger.info(f"✅ Добавлено {added_count} колонок cabinet в таблицу users") + logger.info(f'✅ Добавлено {added_count} колонок cabinet в таблицу users') return True except Exception as e: - logger.error(f"Ошибка добавления колонок cabinet: {e}") + logger.error(f'Ошибка добавления колонок cabinet: {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 уже существует") + logger.info('ℹ️ Колонка subscription_crypto_link уже существует') return True try: @@ -3665,24 +3479,26 @@ async def add_subscription_crypto_link_column() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link TEXT")) + 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")) + 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)")) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)')) else: - logger.error(f"Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}") + 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 = ''" - )) + 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") + logger.info('✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions') return True except Exception as e: - logger.error(f"Ошибка добавления колонки subscription_crypto_link: {e}") + logger.error(f'Ошибка добавления колонки subscription_crypto_link: {e}') return False @@ -3690,134 +3506,141 @@ async def fix_foreign_keys_for_user_deletion(): try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'postgresql': try: - await conn.execute(text(""" - ALTER TABLE user_messages + await conn.execute( + text(""" + ALTER TABLE user_messages DROP CONSTRAINT IF EXISTS user_messages_created_by_fkey; - """)) - - await conn.execute(text(""" - ALTER TABLE user_messages - ADD CONSTRAINT user_messages_created_by_fkey + """) + ) + + await conn.execute( + text(""" + ALTER TABLE user_messages + ADD CONSTRAINT user_messages_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; - """)) - logger.info("Обновлен внешний ключ user_messages.created_by") + """) + ) + logger.info('Обновлен внешний ключ user_messages.created_by') except Exception as e: - logger.warning(f"Ошибка обновления FK user_messages: {e}") - + logger.warning(f'Ошибка обновления FK user_messages: {e}') + try: - await conn.execute(text(""" - ALTER TABLE promocodes + await conn.execute( + text(""" + ALTER TABLE promocodes DROP CONSTRAINT IF EXISTS promocodes_created_by_fkey; - """)) - - await conn.execute(text(""" - ALTER TABLE promocodes - ADD CONSTRAINT promocodes_created_by_fkey + """) + ) + + await conn.execute( + text(""" + ALTER TABLE promocodes + ADD CONSTRAINT promocodes_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; - """)) - logger.info("Обновлен внешний ключ promocodes.created_by") + """) + ) + logger.info('Обновлен внешний ключ promocodes.created_by') except Exception as e: - logger.warning(f"Ошибка обновления FK promocodes: {e}") - - logger.info("Внешние ключи обновлены для безопасного удаления пользователей") + logger.warning(f'Ошибка обновления FK promocodes: {e}') + + logger.info('Внешние ключи обновлены для безопасного удаления пользователей') return True - + except Exception as e: - logger.error(f"Ошибка обновления внешних ключей: {e}") + logger.error(f'Ошибка обновления внешних ключей: {e}') return False + async def add_referral_commission_percent_column() -> bool: column_exists = await check_column_exists('users', 'referral_commission_percent') if column_exists: - logger.info("ℹ️ Колонка referral_commission_percent уже существует") + logger.info('ℹ️ Колонка referral_commission_percent уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == 'sqlite': - alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL" - elif db_type == 'postgresql': - alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL" + if db_type == 'sqlite' or db_type == 'postgresql': + alter_sql = 'ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL' elif db_type == 'mysql': - alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL" + alter_sql = 'ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL' else: - logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}") + logger.error(f'Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}') return False await conn.execute(text(alter_sql)) - logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users") + logger.info('✅ Добавлена колонка referral_commission_percent в таблицу users') return True except Exception as error: - logger.error(f"Ошибка добавления referral_commission_percent: {error}") + logger.error(f'Ошибка добавления referral_commission_percent: {error}') return False async def add_referral_system_columns(): - logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===") - + logger.info('=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===') + try: async with engine.begin() as conn: db_type = await get_database_type() - + column_exists = await check_column_exists('users', 'has_made_first_topup') - + if not column_exists: - logger.info("Добавление колонки has_made_first_topup в таблицу users") - + logger.info('Добавление колонки has_made_first_topup в таблицу users') + if db_type == 'sqlite': column_def = 'BOOLEAN DEFAULT 0' else: column_def = 'BOOLEAN DEFAULT FALSE' - - await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}")) - logger.info("Колонка has_made_first_topup успешно добавлена") - - logger.info("Обновление существующих пользователей...") - + + await conn.execute(text(f'ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}')) + logger.info('Колонка has_made_first_topup успешно добавлена') + + logger.info('Обновление существующих пользователей...') + if db_type == 'sqlite': update_sql = """ - UPDATE users - SET has_made_first_topup = 1 + UPDATE users + SET has_made_first_topup = 1 WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1 """ else: update_sql = """ - UPDATE users - SET has_made_first_topup = TRUE + UPDATE users + SET has_made_first_topup = TRUE WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE """ - + result = await conn.execute(text(update_sql)) updated_count = result.rowcount - - logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE") - logger.info("✅ Миграция реферальной системы завершена") - + + logger.info(f'Обновлено {updated_count} пользователей с has_made_first_topup = TRUE') + logger.info('✅ Миграция реферальной системы завершена') + return True - else: - logger.info("Колонка has_made_first_topup уже существует") - return True - + logger.info('Колонка has_made_first_topup уже существует') + return True + except Exception as e: - logger.error(f"Ошибка миграции реферальной системы: {e}") + logger.error(f'Ошибка миграции реферальной системы: {e}') return False + async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: - logger.info("Таблица subscription_conversions уже существует") + logger.info('Таблица subscription_conversions уже существует') return True - + try: async with engine.begin() as conn: db_type = await get_database_type() - + if db_type == 'sqlite': create_sql = """ CREATE TABLE subscription_conversions ( @@ -3831,11 +3654,11 @@ async def create_subscription_conversions_table(): created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); - + CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id); CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at); """ - + elif db_type == 'postgresql': create_sql = """ CREATE TABLE subscription_conversions ( @@ -3849,11 +3672,11 @@ async def create_subscription_conversions_table(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); - + CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id); CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at); """ - + elif db_type == 'mysql': create_sql = """ CREATE TABLE subscription_conversions ( @@ -3867,34 +3690,34 @@ async def create_subscription_conversions_table(): created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); - + CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id); CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at); """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы: {db_type}') return False - + await conn.execute(text(create_sql)) - logger.info("✅ Таблица subscription_conversions успешно создана") + logger.info('✅ Таблица subscription_conversions успешно создана') return True - + except Exception as e: - logger.error(f"Ошибка создания таблицы subscription_conversions: {e}") + logger.error(f'Ошибка создания таблицы subscription_conversions: {e}') return False async def create_subscription_events_table(): - table_exists = await check_table_exists("subscription_events") + table_exists = await check_table_exists('subscription_events') if table_exists: - logger.info("Таблица subscription_events уже существует") + logger.info('Таблица subscription_events уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE subscription_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3917,7 +3740,7 @@ async def create_subscription_events_table(): CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE subscription_events ( id SERIAL PRIMARY KEY, @@ -3937,7 +3760,7 @@ async def create_subscription_events_table(): CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id); """ - elif db_type == "mysql": + elif db_type == 'mysql': create_sql = """ CREATE TABLE subscription_events ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -3960,107 +3783,122 @@ async def create_subscription_events_table(): CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id); """ else: - logger.error(f"Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}") + logger.error(f'Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}') return False await conn.execute(text(create_sql)) - logger.info("✅ Таблица subscription_events успешно создана") + logger.info('✅ Таблица subscription_events успешно создана') return True except Exception as e: - logger.error(f"Ошибка создания таблицы subscription_events: {e}") + logger.error(f'Ошибка создания таблицы subscription_events: {e}') return False + async def fix_subscription_duplicates_universal(): async with engine.begin() as conn: db_type = await get_database_type() - logger.info(f"Обнаружен тип базы данных: {db_type}") - + logger.info(f'Обнаружен тип базы данных: {db_type}') + try: - result = await conn.execute(text(""" - SELECT user_id, COUNT(*) as count - FROM subscriptions - GROUP BY user_id + result = await conn.execute( + text(""" + SELECT user_id, COUNT(*) as count + FROM subscriptions + GROUP BY user_id HAVING COUNT(*) > 1 - """)) - + """) + ) + duplicates = result.fetchall() - + if not duplicates: - logger.info("Дублирующихся подписок не найдено") + logger.info('Дублирующихся подписок не найдено') return 0 - - logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками") - + + logger.info(f'Найдено {len(duplicates)} пользователей с дублирующимися подписками') + total_deleted = 0 - + for user_id_row, count in duplicates: user_id = user_id_row - + if db_type == 'sqlite': - delete_result = await conn.execute(text(""" - DELETE FROM subscriptions + delete_result = await conn.execute( + text(""" + DELETE FROM subscriptions WHERE user_id = :user_id AND id NOT IN ( - SELECT MAX(id) - FROM subscriptions + SELECT MAX(id) + FROM subscriptions WHERE user_id = :user_id ) - """), {"user_id": user_id}) - + """), + {'user_id': user_id}, + ) + elif db_type in ['postgresql', 'mysql']: - delete_result = await conn.execute(text(""" - DELETE FROM subscriptions + delete_result = await conn.execute( + text(""" + DELETE FROM subscriptions WHERE user_id = :user_id AND id NOT IN ( SELECT max_id FROM ( SELECT MAX(id) as max_id - FROM subscriptions + FROM subscriptions WHERE user_id = :user_id ) as subquery ) - """), {"user_id": user_id}) - + """), + {'user_id': user_id}, + ) + else: - subs_result = await conn.execute(text(""" - SELECT id FROM subscriptions - WHERE user_id = :user_id + subs_result = await conn.execute( + text(""" + SELECT id FROM subscriptions + WHERE user_id = :user_id ORDER BY created_at DESC, id DESC - """), {"user_id": user_id}) - + """), + {'user_id': user_id}, + ) + sub_ids = [row[0] for row in subs_result.fetchall()] - + if len(sub_ids) > 1: ids_to_delete = sub_ids[1:] for sub_id in ids_to_delete: - await conn.execute(text(""" + await conn.execute( + text(""" DELETE FROM subscriptions WHERE id = :id - """), {"id": sub_id}) + """), + {'id': sub_id}, + ) delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})() else: delete_result = type('Result', (), {'rowcount': 0})() - + deleted_count = delete_result.rowcount total_deleted += deleted_count - logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}") + logger.info(f'Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}') - logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}") + logger.info(f'Всего удалено дублирующихся подписок: {total_deleted}') return total_deleted except Exception as e: - logger.error(f"Ошибка при очистке дублирующихся подписок: {e}") + logger.error(f'Ошибка при очистке дублирующихся подписок: {e}') raise async def ensure_server_promo_groups_setup() -> bool: - logger.info("=== НАСТРОЙКА ДОСТУПА СЕРВЕРОВ К ПРОМОГРУППАМ ===") + logger.info('=== НАСТРОЙКА ДОСТУПА СЕРВЕРОВ К ПРОМОГРУППАМ ===') try: - table_exists = await check_table_exists("server_squad_promo_groups") + table_exists = await check_table_exists('server_squad_promo_groups') async with engine.begin() as conn: db_type = await get_database_type() if not table_exists: - if db_type == "sqlite": + if db_type == 'sqlite': create_table_sql = """ CREATE TABLE server_squad_promo_groups ( server_squad_id INTEGER NOT NULL, @@ -4073,7 +3911,7 @@ async def ensure_server_promo_groups_setup() -> bool: create_index_sql = """ CREATE INDEX IF NOT EXISTS idx_server_squad_promo_groups_promo ON server_squad_promo_groups(promo_group_id); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_table_sql = """ CREATE TABLE server_squad_promo_groups ( server_squad_id INTEGER NOT NULL REFERENCES server_squads(id) ON DELETE CASCADE, @@ -4100,67 +3938,58 @@ async def ensure_server_promo_groups_setup() -> bool: await conn.execute(text(create_table_sql)) await conn.execute(text(create_index_sql)) - logger.info("✅ Таблица server_squad_promo_groups создана") + logger.info('✅ Таблица server_squad_promo_groups создана') else: - logger.info("ℹ️ Таблица server_squad_promo_groups уже существует") + logger.info('ℹ️ Таблица server_squad_promo_groups уже существует') default_query = ( - "SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1" - if db_type == "postgresql" - else "SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1" + 'SELECT id FROM promo_groups WHERE is_default IS TRUE LIMIT 1' + if db_type == 'postgresql' + else 'SELECT id FROM promo_groups WHERE is_default = 1 LIMIT 1' ) default_result = await conn.execute(text(default_query)) default_row = default_result.fetchone() if not default_row: - logger.warning("⚠️ Не найдена базовая промогруппа для назначения серверам") + logger.warning('⚠️ Не найдена базовая промогруппа для назначения серверам') return True default_group_id = default_row[0] - servers_result = await conn.execute(text("SELECT id FROM server_squads")) + servers_result = await conn.execute(text('SELECT id FROM server_squads')) server_ids = [row[0] for row in servers_result.fetchall()] assigned_count = 0 for server_id in server_ids: existing = await conn.execute( - text( - "SELECT 1 FROM server_squad_promo_groups WHERE server_squad_id = :sid LIMIT 1" - ), - {"sid": server_id}, + text('SELECT 1 FROM server_squad_promo_groups WHERE server_squad_id = :sid LIMIT 1'), + {'sid': server_id}, ) if existing.fetchone(): continue await conn.execute( - text( - "INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) " - "VALUES (:sid, :gid)" - ), - {"sid": server_id, "gid": default_group_id}, + text('INSERT INTO server_squad_promo_groups (server_squad_id, promo_group_id) VALUES (:sid, :gid)'), + {'sid': server_id, 'gid': default_group_id}, ) assigned_count += 1 if assigned_count: - logger.info( - f"✅ Базовая промогруппа назначена {assigned_count} серверам" - ) + logger.info(f'✅ Базовая промогруппа назначена {assigned_count} серверам') else: - logger.info("ℹ️ Все серверы уже имеют назначенные промогруппы") + logger.info('ℹ️ Все серверы уже имеют назначенные промогруппы') return True except Exception as e: - logger.error( - f"Ошибка настройки таблицы server_squad_promo_groups: {e}" - ) + logger.error(f'Ошибка настройки таблицы server_squad_promo_groups: {e}') return False async def add_server_trial_flag_column() -> bool: column_exists = await check_column_exists('server_squads', 'is_trial_eligible') if column_exists: - logger.info("Колонка is_trial_eligible уже существует в server_squads") + logger.info('Колонка is_trial_eligible уже существует в server_squads') return True try: @@ -4174,34 +4003,30 @@ async def add_server_trial_flag_column() -> bool: else: column_def = 'BOOLEAN NOT NULL DEFAULT FALSE' - await conn.execute( - text(f"ALTER TABLE server_squads ADD COLUMN is_trial_eligible {column_def}") - ) + await conn.execute(text(f'ALTER TABLE server_squads ADD COLUMN is_trial_eligible {column_def}')) if db_type == 'postgresql': - await conn.execute( - text("ALTER TABLE server_squads ALTER COLUMN is_trial_eligible SET DEFAULT FALSE") - ) + await conn.execute(text('ALTER TABLE server_squads ALTER COLUMN is_trial_eligible SET DEFAULT FALSE')) - logger.info("✅ Добавлена колонка is_trial_eligible в server_squads") + logger.info('✅ Добавлена колонка is_trial_eligible в server_squads') return True except Exception as error: - logger.error(f"Ошибка добавления колонки is_trial_eligible: {error}") + logger.error(f'Ошибка добавления колонки is_trial_eligible: {error}') return False async def create_system_settings_table() -> bool: - table_exists = await check_table_exists("system_settings") + table_exists = await check_table_exists('system_settings') if table_exists: - logger.info("ℹ️ Таблица system_settings уже существует") + logger.info('ℹ️ Таблица system_settings уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE system_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4212,7 +4037,7 @@ async def create_system_settings_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE system_settings ( id SERIAL PRIMARY KEY, @@ -4236,26 +4061,26 @@ async def create_system_settings_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица system_settings создана") + logger.info('✅ Таблица system_settings создана') return True except Exception as error: - logger.error(f"Ошибка создания таблицы system_settings: {error}") + logger.error(f'Ошибка создания таблицы system_settings: {error}') return False async def create_menu_layout_history_table() -> bool: """Создаёт таблицу для хранения истории изменений конфигурации меню.""" - table_exists = await check_table_exists("menu_layout_history") + table_exists = await check_table_exists('menu_layout_history') if table_exists: - logger.info("ℹ️ Таблица menu_layout_history уже существует") + logger.info('ℹ️ Таблица menu_layout_history уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_table_sql = """ CREATE TABLE menu_layout_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4266,7 +4091,7 @@ async def create_menu_layout_history_table() -> bool: created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_table_sql = """ CREATE TABLE menu_layout_history ( id SERIAL PRIMARY KEY, @@ -4290,29 +4115,27 @@ async def create_menu_layout_history_table() -> bool: """ await conn.execute(text(create_table_sql)) - await conn.execute(text( - "CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)" - )) - logger.info("✅ Таблица menu_layout_history создана") + await conn.execute(text('CREATE INDEX ix_menu_layout_history_created ON menu_layout_history(created_at)')) + logger.info('✅ Таблица menu_layout_history создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы menu_layout_history: {error}") + logger.error(f'❌ Ошибка создания таблицы menu_layout_history: {error}') return False async def create_button_click_logs_table() -> bool: """Создаёт таблицу для логирования кликов по кнопкам меню.""" - table_exists = await check_table_exists("button_click_logs") + table_exists = await check_table_exists('button_click_logs') if table_exists: - logger.info("ℹ️ Таблица button_click_logs уже существует") + logger.info('ℹ️ Таблица button_click_logs уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_table_sql = """ CREATE TABLE button_click_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4324,7 +4147,7 @@ async def create_button_click_logs_table() -> bool: button_text VARCHAR(255) NULL ) """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_table_sql = """ CREATE TABLE button_click_logs ( id SERIAL PRIMARY KEY, @@ -4354,34 +4177,34 @@ async def create_button_click_logs_table() -> bool: # Создаём индексы отдельными запросами index_statements = [ - "CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)", - "CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)", - "CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)", - "CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)", - "CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)", + 'CREATE INDEX ix_button_click_logs_button_id ON button_click_logs(button_id)', + 'CREATE INDEX ix_button_click_logs_user_id ON button_click_logs(user_id)', + 'CREATE INDEX ix_button_click_logs_clicked_at ON button_click_logs(clicked_at)', + 'CREATE INDEX ix_button_click_logs_button_date ON button_click_logs(button_id, clicked_at)', + 'CREATE INDEX ix_button_click_logs_user_date ON button_click_logs(user_id, clicked_at)', ] for stmt in index_statements: await conn.execute(text(stmt)) - logger.info("✅ Таблица button_click_logs создана") + logger.info('✅ Таблица button_click_logs создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы button_click_logs: {error}") + logger.error(f'❌ Ошибка создания таблицы button_click_logs: {error}') return False async def create_web_api_tokens_table() -> bool: - table_exists = await check_table_exists("web_api_tokens") + table_exists = await check_table_exists('web_api_tokens') if table_exists: - logger.info("ℹ️ Таблица web_api_tokens уже существует") + logger.info('ℹ️ Таблица web_api_tokens уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE web_api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4401,7 +4224,7 @@ async def create_web_api_tokens_table() -> bool: 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": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE web_api_tokens ( id SERIAL PRIMARY KEY, @@ -4443,25 +4266,25 @@ async def create_web_api_tokens_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица web_api_tokens создана") + logger.info('✅ Таблица web_api_tokens создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы web_api_tokens: {error}") + logger.error(f'❌ Ошибка создания таблицы web_api_tokens: {error}') return False async def create_privacy_policies_table() -> bool: - table_exists = await check_table_exists("privacy_policies") + table_exists = await check_table_exists('privacy_policies') if table_exists: - logger.info("ℹ️ Таблица privacy_policies уже существует") + logger.info('ℹ️ Таблица privacy_policies уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE privacy_policies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4472,7 +4295,7 @@ async def create_privacy_policies_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE privacy_policies ( id SERIAL PRIMARY KEY, @@ -4496,25 +4319,25 @@ async def create_privacy_policies_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица privacy_policies создана") + logger.info('✅ Таблица privacy_policies создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы privacy_policies: {error}") + logger.error(f'❌ Ошибка создания таблицы privacy_policies: {error}') return False async def create_public_offers_table() -> bool: - table_exists = await check_table_exists("public_offers") + table_exists = await check_table_exists('public_offers') if table_exists: - logger.info("ℹ️ Таблица public_offers уже существует") + logger.info('ℹ️ Таблица public_offers уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE public_offers ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4525,7 +4348,7 @@ async def create_public_offers_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE public_offers ( id SERIAL PRIMARY KEY, @@ -4549,25 +4372,25 @@ async def create_public_offers_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица public_offers создана") + logger.info('✅ Таблица public_offers создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы public_offers: {error}") + logger.error(f'❌ Ошибка создания таблицы public_offers: {error}') return False async def create_faq_settings_table() -> bool: - table_exists = await check_table_exists("faq_settings") + table_exists = await check_table_exists('faq_settings') if table_exists: - logger.info("ℹ️ Таблица faq_settings уже существует") + logger.info('ℹ️ Таблица faq_settings уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE faq_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4577,7 +4400,7 @@ async def create_faq_settings_table() -> bool: updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE faq_settings ( id SERIAL PRIMARY KEY, @@ -4599,25 +4422,25 @@ async def create_faq_settings_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица faq_settings создана") + logger.info('✅ Таблица faq_settings создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы faq_settings: {error}") + logger.error(f'❌ Ошибка создания таблицы faq_settings: {error}') return False async def create_faq_pages_table() -> bool: - table_exists = await check_table_exists("faq_pages") + table_exists = await check_table_exists('faq_pages') if table_exists: - logger.info("ℹ️ Таблица faq_pages уже существует") + logger.info('ℹ️ Таблица faq_pages уже существует') return True try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': create_sql = """ CREATE TABLE faq_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -4631,7 +4454,7 @@ async def create_faq_pages_table() -> bool: ); CREATE INDEX idx_faq_pages_language ON faq_pages(language); """ - elif db_type == "postgresql": + elif db_type == 'postgresql': create_sql = """ CREATE TABLE faq_pages ( id SERIAL PRIMARY KEY, @@ -4663,27 +4486,25 @@ async def create_faq_pages_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица faq_pages создана") + logger.info('✅ Таблица faq_pages создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы faq_pages: {error}") + logger.error(f'❌ Ошибка создания таблицы faq_pages: {error}') return False async def ensure_default_web_api_token() -> bool: - default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip() + 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() + 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) - ) + result = await session.execute(select(WebApiToken).where(WebApiToken.token_hash == token_hash)) existing = result.scalar_one_or_none() if existing: @@ -4703,20 +4524,20 @@ async def ensure_default_web_api_token() -> bool: return True token = WebApiToken( - name=token_name or "Bootstrap Token", + name=token_name or 'Bootstrap Token', token_hash=token_hash, token_prefix=default_token[:12], - description="Автоматически создан при миграции", - created_by="migration", + description='Автоматически создан при миграции', + created_by='migration', is_active=True, ) session.add(token) await session.commit() - logger.info("✅ Создан дефолтный токен веб-API из конфигурации") + logger.info('✅ Создан дефолтный токен веб-API из конфигурации') return True except Exception as error: - logger.error(f"❌ Ошибка создания дефолтного веб-API токена: {error}") + logger.error(f'❌ Ошибка создания дефолтного веб-API токена: {error}') return False @@ -4724,7 +4545,41 @@ async def add_promo_group_priority_column() -> bool: """Добавляет колонку priority в таблицу promo_groups.""" column_exists = await check_column_exists('promo_groups', 'priority') if column_exists: - logger.info("Колонка priority уже существует в promo_groups") + logger.info('Колонка priority уже существует в promo_groups') + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite' or db_type == 'postgresql': + column_def = 'INTEGER NOT NULL DEFAULT 0' + else: + column_def = 'INT NOT NULL DEFAULT 0' + + await conn.execute(text(f'ALTER TABLE promo_groups ADD COLUMN priority {column_def}')) + + # Создаем индекс для оптимизации сортировки + if db_type == 'postgresql' or db_type == 'sqlite': + await conn.execute( + text('CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)') + ) + else: # MySQL + await conn.execute(text('CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)')) + + logger.info('✅ Добавлена колонка priority в promo_groups с индексом') + return True + + except Exception as error: + logger.error(f'Ошибка добавления колонки priority: {error}') + return False + + +async def create_user_promo_groups_table() -> bool: + """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" + table_exists = await check_table_exists('user_promo_groups') + if table_exists: + logger.info('ℹ️ Таблица user_promo_groups уже существует') return True try: @@ -4732,50 +4587,6 @@ async def add_promo_group_priority_column() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - column_def = 'INTEGER NOT NULL DEFAULT 0' - elif db_type == 'postgresql': - column_def = 'INTEGER NOT NULL DEFAULT 0' - else: - column_def = 'INT NOT NULL DEFAULT 0' - - await conn.execute( - text(f"ALTER TABLE promo_groups ADD COLUMN priority {column_def}") - ) - - # Создаем индекс для оптимизации сортировки - if db_type == 'postgresql': - await conn.execute( - text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)") - ) - elif db_type == 'sqlite': - await conn.execute( - text("CREATE INDEX IF NOT EXISTS idx_promo_groups_priority ON promo_groups(priority DESC)") - ) - else: # MySQL - await conn.execute( - text("CREATE INDEX idx_promo_groups_priority ON promo_groups(priority DESC)") - ) - - logger.info("✅ Добавлена колонка priority в promo_groups с индексом") - return True - - except Exception as error: - logger.error(f"Ошибка добавления колонки priority: {error}") - return False - - -async def create_user_promo_groups_table() -> bool: - """Создает таблицу user_promo_groups для связи Many-to-Many между users и promo_groups.""" - table_exists = await check_table_exists("user_promo_groups") - if table_exists: - logger.info("ℹ️ Таблица user_promo_groups уже существует") - return True - - try: - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == "sqlite": create_sql = """ CREATE TABLE user_promo_groups ( user_id INTEGER NOT NULL, @@ -4787,8 +4598,8 @@ async def create_user_promo_groups_table() -> bool: FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ); """ - index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" - elif db_type == "postgresql": + index_sql = 'CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);' + elif db_type == 'postgresql': create_sql = """ CREATE TABLE user_promo_groups ( user_id INTEGER NOT NULL, @@ -4800,7 +4611,7 @@ async def create_user_promo_groups_table() -> bool: FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ); """ - index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" + index_sql = 'CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);' else: # MySQL create_sql = """ CREATE TABLE user_promo_groups ( @@ -4813,44 +4624,44 @@ async def create_user_promo_groups_table() -> bool: FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ); """ - index_sql = "CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);" + index_sql = 'CREATE INDEX idx_user_promo_groups_user_id ON user_promo_groups(user_id);' await conn.execute(text(create_sql)) await conn.execute(text(index_sql)) - logger.info("✅ Таблица user_promo_groups создана с индексом") + logger.info('✅ Таблица user_promo_groups создана с индексом') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы user_promo_groups: {error}") + logger.error(f'❌ Ошибка создания таблицы user_promo_groups: {error}') return False async def migrate_existing_user_promo_groups_data() -> bool: """Переносит существующие связи users.promo_group_id в таблицу user_promo_groups.""" try: - table_exists = await check_table_exists("user_promo_groups") + table_exists = await check_table_exists('user_promo_groups') if not table_exists: - logger.warning("⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных") + logger.warning('⚠️ Таблица user_promo_groups не существует, пропускаем миграцию данных') return False column_exists = await check_column_exists('users', 'promo_group_id') if not column_exists: - logger.warning("⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных") + logger.warning('⚠️ Колонка users.promo_group_id не существует, пропускаем миграцию данных') return True async with engine.begin() as conn: # Проверяем есть ли уже данные в user_promo_groups - result = await conn.execute(text("SELECT COUNT(*) FROM user_promo_groups")) + result = await conn.execute(text('SELECT COUNT(*) FROM user_promo_groups')) count = result.scalar() if count > 0: - logger.info(f"ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию") + logger.info(f'ℹ️ В таблице user_promo_groups уже есть {count} записей, пропускаем миграцию') return True # Переносим данные из users.promo_group_id db_type = await get_database_type() - if db_type == "sqlite": + if db_type == 'sqlite': migrate_sql = """ INSERT INTO user_promo_groups (user_id, promo_group_id, assigned_at, assigned_by) SELECT id, promo_group_id, CURRENT_TIMESTAMP, 'system' @@ -4868,11 +4679,11 @@ async def migrate_existing_user_promo_groups_data() -> bool: result = await conn.execute(text(migrate_sql)) migrated_count = result.rowcount if hasattr(result, 'rowcount') else 0 - logger.info(f"✅ Перенесено {migrated_count} связей пользователей с промогруппами") + logger.info(f'✅ Перенесено {migrated_count} связей пользователей с промогруппами') return True except Exception as error: - logger.error(f"❌ Ошибка миграции данных user_promo_groups: {error}") + logger.error(f'❌ Ошибка миграции данных user_promo_groups: {error}') return False @@ -4880,7 +4691,7 @@ async def add_promocode_promo_group_column() -> bool: """Добавляет колонку promo_group_id в таблицу promocodes.""" column_exists = await check_column_exists('promocodes', 'promo_group_id') if column_exists: - logger.info("Колонка promo_group_id уже существует в promocodes") + logger.info('Колонка promo_group_id уже существует в promocodes') return True try: @@ -4889,13 +4700,9 @@ async def add_promocode_promo_group_column() -> bool: # Add column if db_type == 'sqlite': - await conn.execute( - text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER") - ) + await conn.execute(text('ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER')) elif db_type == 'postgresql': - await conn.execute( - text("ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER") - ) + await conn.execute(text('ALTER TABLE promocodes ADD COLUMN promo_group_id INTEGER')) # Add foreign key await conn.execute( text(""" @@ -4908,7 +4715,7 @@ async def add_promocode_promo_group_column() -> bool: ) # Add index await conn.execute( - text("CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)") + text('CREATE INDEX IF NOT EXISTS idx_promocodes_promo_group_id ON promocodes(promo_group_id)') ) elif db_type == 'mysql': await conn.execute( @@ -4921,15 +4728,13 @@ async def add_promocode_promo_group_column() -> bool: ON DELETE SET NULL """) ) - await conn.execute( - text("CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)") - ) + await conn.execute(text('CREATE INDEX idx_promocodes_promo_group_id ON promocodes(promo_group_id)')) - logger.info("✅ Добавлена колонка promo_group_id в promocodes") + logger.info('✅ Добавлена колонка promo_group_id в promocodes') return True except Exception as error: - logger.error(f"❌ Ошибка добавления promo_group_id в promocodes: {error}") + logger.error(f'❌ Ошибка добавления promo_group_id в promocodes: {error}') return False @@ -4937,7 +4742,7 @@ async def add_promocode_first_purchase_only_column() -> bool: """Добавляет колонку first_purchase_only в таблицу promocodes.""" column_exists = await check_column_exists('promocodes', 'first_purchase_only') if column_exists: - logger.info("Колонка first_purchase_only уже существует в promocodes") + logger.info('Колонка first_purchase_only уже существует в promocodes') return True try: @@ -4945,34 +4750,26 @@ async def add_promocode_first_purchase_only_column() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute( - text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT 0") - ) - elif db_type == 'postgresql': - await conn.execute( - text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE") - ) - elif db_type == 'mysql': - await conn.execute( - text("ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE") - ) + await conn.execute(text('ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT 0')) + elif db_type == 'postgresql' or db_type == 'mysql': + await conn.execute(text('ALTER TABLE promocodes ADD COLUMN first_purchase_only BOOLEAN DEFAULT FALSE')) - logger.info("✅ Добавлена колонка first_purchase_only в promocodes") + logger.info('✅ Добавлена колонка first_purchase_only в promocodes') return True except Exception as error: - logger.error(f"❌ Ошибка добавления first_purchase_only в promocodes: {error}") + logger.error(f'❌ Ошибка добавления first_purchase_only в promocodes: {error}') return False async def migrate_contest_templates_prize_columns() -> bool: """Миграция contest_templates: prize_days -> prize_type + prize_value.""" try: - prize_type_exists = await check_column_exists("contest_templates", "prize_type") - prize_value_exists = await check_column_exists("contest_templates", "prize_value") + prize_type_exists = await check_column_exists('contest_templates', 'prize_type') + prize_value_exists = await check_column_exists('contest_templates', 'prize_value') if prize_type_exists and prize_value_exists: - logger.info("Колонки prize_type и prize_value уже существуют в contest_templates") + logger.info('Колонки prize_type и prize_value уже существуют в contest_templates') return True async with engine.begin() as conn: @@ -4980,184 +4777,141 @@ async def migrate_contest_templates_prize_columns() -> bool: # Добавляем prize_type if not prize_type_exists: - if db_type == "sqlite": - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text("ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'") + ) else: - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'" - )) - logger.info("✅ Добавлена колонка prize_type в contest_templates") + await conn.execute( + text("ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'") + ) + logger.info('✅ Добавлена колонка prize_type в contest_templates') # Добавляем prize_value if not prize_value_exists: - if db_type == "sqlite": - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text("ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'") + ) else: - await conn.execute(text( - "ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'" - )) - logger.info("✅ Добавлена колонка prize_value в contest_templates") + await conn.execute( + text("ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'") + ) + logger.info('✅ Добавлена колонка prize_value в contest_templates') # Мигрируем данные из prize_days в prize_value (если prize_days существует) - prize_days_exists = await check_column_exists("contest_templates", "prize_days") + prize_days_exists = await check_column_exists('contest_templates', 'prize_days') if prize_days_exists: - await conn.execute(text( - "UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'" - )) - logger.info("✅ Данные из prize_days перенесены в prize_value") + await conn.execute( + text( + "UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'" + ) + ) + logger.info('✅ Данные из prize_days перенесены в prize_value') return True except Exception as error: - logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}") + logger.error(f'❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}') return False async def add_subscription_modem_enabled_column() -> bool: """Добавить колонку modem_enabled в subscriptions.""" try: - column_exists = await check_column_exists("subscriptions", "modem_enabled") + column_exists = await check_column_exists('subscriptions', 'modem_enabled') if column_exists: - logger.info("Колонка modem_enabled уже существует в subscriptions") + logger.info('Колонка modem_enabled уже существует в subscriptions') return True 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 modem_enabled BOOLEAN DEFAULT 0" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE" - )) + if db_type == 'sqlite': + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0')) + elif db_type == 'postgresql': + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE')) else: - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0')) - logger.info("✅ Добавлена колонка modem_enabled в subscriptions") + logger.info('✅ Добавлена колонка modem_enabled в subscriptions') return True except Exception as error: - logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}") + logger.error(f'❌ Ошибка добавления modem_enabled в subscriptions: {error}') return False async def add_subscription_purchased_traffic_column() -> bool: """Добавить колонку purchased_traffic_gb в subscriptions.""" try: - column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb") + column_exists = await check_column_exists('subscriptions', 'purchased_traffic_gb') if column_exists: - logger.info("Колонка purchased_traffic_gb уже существует в subscriptions") + logger.info('Колонка purchased_traffic_gb уже существует в subscriptions') return True 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 purchased_traffic_gb INTEGER DEFAULT 0" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0')) else: - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0')) - logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions") + logger.info('✅ Добавлена колонка purchased_traffic_gb в subscriptions') return True except Exception as error: - logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}") + logger.error(f'❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}') return False async def add_transaction_receipt_columns() -> bool: """Добавить колонки receipt_uuid и receipt_created_at в transactions.""" try: - receipt_uuid_exists = await check_column_exists("transactions", "receipt_uuid") - receipt_created_at_exists = await check_column_exists("transactions", "receipt_created_at") + receipt_uuid_exists = await check_column_exists('transactions', 'receipt_uuid') + receipt_created_at_exists = await check_column_exists('transactions', 'receipt_created_at') if receipt_uuid_exists and receipt_created_at_exists: - logger.info("Колонки receipt_uuid и receipt_created_at уже существуют в transactions") + logger.info('Колонки receipt_uuid и receipt_created_at уже существуют в transactions') return True async with engine.begin() as conn: db_type = await get_database_type() if not receipt_uuid_exists: - if db_type == "sqlite": - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute(text('ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)')) else: - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)" - )) - logger.info("✅ Добавлена колонка receipt_uuid в transactions") + await conn.execute(text('ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)')) + logger.info('✅ Добавлена колонка receipt_uuid в transactions') if not receipt_created_at_exists: - if db_type == "sqlite": - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME" - )) - elif db_type == "postgresql": - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_created_at TIMESTAMP" - )) + if db_type == 'sqlite': + await conn.execute(text('ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME')) + elif db_type == 'postgresql': + await conn.execute(text('ALTER TABLE transactions ADD COLUMN receipt_created_at TIMESTAMP')) else: - await conn.execute(text( - "ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME" - )) - logger.info("✅ Добавлена колонка receipt_created_at в transactions") + await conn.execute(text('ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME')) + logger.info('✅ Добавлена колонка receipt_created_at в transactions') # Создаём индекс на receipt_uuid try: async with engine.begin() as conn: db_type = await get_database_type() - if db_type == "postgresql": - await conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid " - "ON transactions (receipt_uuid)" - )) - elif db_type == "sqlite": - await conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid " - "ON transactions (receipt_uuid)" - )) + if db_type == 'postgresql' or db_type == 'sqlite': + await conn.execute( + text('CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid ON transactions (receipt_uuid)') + ) else: - await conn.execute(text( - "CREATE INDEX ix_transactions_receipt_uuid " - "ON transactions (receipt_uuid)" - )) + await conn.execute(text('CREATE INDEX ix_transactions_receipt_uuid ON transactions (receipt_uuid)')) except Exception as idx_error: - logger.warning(f"Индекс на receipt_uuid возможно уже существует: {idx_error}") + logger.warning(f'Индекс на receipt_uuid возможно уже существует: {idx_error}') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок чеков в transactions: {error}") + logger.error(f'❌ Ошибка добавления колонок чеков в transactions: {error}') return False @@ -5165,7 +4919,7 @@ async def create_withdrawal_requests_table() -> bool: """Создаёт таблицу для заявок на вывод реферального баланса.""" try: if await check_table_exists('withdrawal_requests'): - logger.debug("Таблица withdrawal_requests уже существует") + logger.debug('Таблица withdrawal_requests уже существует') return True async with engine.begin() as conn: @@ -5228,22 +4982,18 @@ async def create_withdrawal_requests_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица withdrawal_requests создана") + logger.info('✅ Таблица withdrawal_requests создана') # Создаём индексы try: - await conn.execute(text( - "CREATE INDEX idx_withdrawal_requests_user_id ON withdrawal_requests(user_id)" - )) - await conn.execute(text( - "CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status)" - )) + await conn.execute(text('CREATE INDEX idx_withdrawal_requests_user_id ON withdrawal_requests(user_id)')) + await conn.execute(text('CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status)')) except Exception: pass # Индексы могут уже существовать return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы withdrawal_requests: {error}") + logger.error(f'❌ Ошибка создания таблицы withdrawal_requests: {error}') return False @@ -5251,11 +5001,12 @@ async def create_withdrawal_requests_table() -> bool: # МИГРАЦИЯ ДЛЯ ИНДИВИДУАЛЬНЫХ ДОКУПОК ТРАФИКА # ============================================================================= + async def create_traffic_purchases_table() -> bool: """Создаёт таблицу для индивидуальных докупок трафика с отдельными датами истечения.""" try: if await check_table_exists('traffic_purchases'): - logger.info("ℹ️ Таблица traffic_purchases уже существует") + logger.info('ℹ️ Таблица traffic_purchases уже существует') return True async with engine.begin() as conn: @@ -5301,11 +5052,11 @@ async def create_traffic_purchases_table() -> bool: """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица traffic_purchases создана") + logger.info('✅ Таблица traffic_purchases создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы traffic_purchases: {error}") + logger.error(f'❌ Ошибка создания таблицы traffic_purchases: {error}') return False @@ -5313,18 +5064,20 @@ async def create_traffic_purchases_table() -> bool: # МИГРАЦИИ ДЛЯ РЕЖИМА ТАРИФОВ # ============================================================================= + async def create_tariffs_table() -> bool: """Создаёт таблицу тарифов для режима продаж 'Тарифы'.""" try: if await check_table_exists('tariffs'): - logger.info("ℹ️ Таблица tariffs уже существует") + logger.info('ℹ️ Таблица tariffs уже существует') return True async with engine.begin() as conn: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, @@ -5340,9 +5093,11 @@ async def create_tariffs_table() -> bool: created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - """)) + """) + ) elif db_type == 'postgresql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariffs ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -5358,9 +5113,11 @@ async def create_tariffs_table() -> bool: created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ) - """)) + """) + ) else: # MySQL - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariffs ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, @@ -5376,13 +5133,14 @@ async def create_tariffs_table() -> bool: created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) - """)) + """) + ) - logger.info("✅ Таблица tariffs создана") + logger.info('✅ Таблица tariffs создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы tariffs: {error}") + logger.error(f'❌ Ошибка создания таблицы tariffs: {error}') return False @@ -5390,14 +5148,15 @@ async def create_tariff_promo_groups_table() -> bool: """Создаёт связующую таблицу tariff_promo_groups для M2M связи тарифов и промогрупп.""" try: if await check_table_exists('tariff_promo_groups'): - logger.info("ℹ️ Таблица tariff_promo_groups уже существует") + logger.info('ℹ️ Таблица tariff_promo_groups уже существует') return True async with engine.begin() as conn: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariff_promo_groups ( tariff_id INTEGER NOT NULL, promo_group_id INTEGER NOT NULL, @@ -5405,17 +5164,21 @@ async def create_tariff_promo_groups_table() -> bool: FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE, FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ) - """)) + """) + ) elif db_type == 'postgresql': - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariff_promo_groups ( tariff_id INTEGER NOT NULL REFERENCES tariffs(id) ON DELETE CASCADE, promo_group_id INTEGER NOT NULL REFERENCES promo_groups(id) ON DELETE CASCADE, PRIMARY KEY (tariff_id, promo_group_id) ) - """)) + """) + ) else: # MySQL - await conn.execute(text(""" + await conn.execute( + text(""" CREATE TABLE tariff_promo_groups ( tariff_id INT NOT NULL, promo_group_id INT NOT NULL, @@ -5423,13 +5186,14 @@ async def create_tariff_promo_groups_table() -> bool: FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE CASCADE, FOREIGN KEY (promo_group_id) REFERENCES promo_groups(id) ON DELETE CASCADE ) - """)) + """) + ) - logger.info("✅ Таблица tariff_promo_groups создана") + logger.info('✅ Таблица tariff_promo_groups создана') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблицы tariff_promo_groups: {error}") + logger.error(f'❌ Ошибка создания таблицы tariff_promo_groups: {error}') return False @@ -5438,30 +5202,22 @@ async def ensure_tariff_max_device_limit_column() -> bool: try: column_exists = await check_column_exists('tariffs', 'max_device_limit') if column_exists: - logger.info("ℹ️ Колонка max_device_limit в tariffs уже существует") + logger.info('ℹ️ Колонка max_device_limit в tariffs уже существует') return True async with engine.begin() as conn: db_type = await get_database_type() - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN max_device_limit INTEGER NULL')) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_device_limit INT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN max_device_limit INT NULL')) - logger.info("✅ Колонка max_device_limit добавлена в tariffs") + logger.info('✅ Колонка max_device_limit добавлена в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки max_device_limit: {error}") + logger.error(f'❌ Ошибка добавления колонки max_device_limit: {error}') return False @@ -5469,41 +5225,41 @@ async def add_subscription_tariff_id_column() -> bool: """Добавляет колонку tariff_id в таблицу subscriptions.""" try: if await check_column_exists('subscriptions', 'tariff_id'): - logger.info("ℹ️ Колонка tariff_id уже существует в subscriptions") + logger.info('ℹ️ Колонка tariff_id уже существует в subscriptions') return True 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 tariff_id INTEGER REFERENCES tariffs(id)" - )) + await conn.execute( + text('ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL" - )) + await conn.execute( + text( + 'ALTER TABLE subscriptions ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL' + ) + ) # Создаём индекс - await conn.execute(text( - "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)" - )) + await conn.execute( + text('CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_id ON subscriptions(tariff_id)') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL" - )) - await conn.execute(text( - "ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff " - "FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL" - )) - await conn.execute(text( - "CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN tariff_id INT NULL')) + await conn.execute( + text( + 'ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_tariff ' + 'FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL' + ) + ) + await conn.execute(text('CREATE INDEX ix_subscriptions_tariff_id ON subscriptions(tariff_id)')) - logger.info("✅ Колонка tariff_id добавлена в subscriptions") + logger.info('✅ Колонка tariff_id добавлена в subscriptions') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки tariff_id: {error}") + logger.error(f'❌ Ошибка добавления колонки tariff_id: {error}') return False @@ -5513,10 +5269,17 @@ async def add_campaign_tariff_columns() -> bool: campaigns_tariff_id_exists = await check_column_exists('advertising_campaigns', 'tariff_id') campaigns_duration_exists = await check_column_exists('advertising_campaigns', 'tariff_duration_days') registrations_tariff_id_exists = await check_column_exists('advertising_campaign_registrations', 'tariff_id') - registrations_duration_exists = await check_column_exists('advertising_campaign_registrations', 'tariff_duration_days') + registrations_duration_exists = await check_column_exists( + 'advertising_campaign_registrations', 'tariff_duration_days' + ) - if campaigns_tariff_id_exists and campaigns_duration_exists and registrations_tariff_id_exists and registrations_duration_exists: - logger.info("ℹ️ Колонки tariff в рекламных кампаниях уже существуют") + if ( + campaigns_tariff_id_exists + and campaigns_duration_exists + and registrations_tariff_id_exists + and registrations_duration_exists + ): + logger.info('ℹ️ Колонки tariff в рекламных кампаниях уже существуют') return True async with engine.begin() as conn: @@ -5525,69 +5288,67 @@ async def add_campaign_tariff_columns() -> bool: # Добавляем колонки в advertising_campaigns if not campaigns_tariff_id_exists: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)" - )) + await conn.execute( + text('ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL" - )) + await conn.execute( + text( + 'ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL' + ) + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INT NULL" - )) - logger.info("✅ Колонка tariff_id добавлена в advertising_campaigns") + await conn.execute(text('ALTER TABLE advertising_campaigns ADD COLUMN tariff_id INT NULL')) + logger.info('✅ Колонка tariff_id добавлена в advertising_campaigns') if not campaigns_duration_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_duration_days INTEGER NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_duration_days INTEGER NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text('ALTER TABLE advertising_campaigns ADD COLUMN tariff_duration_days INTEGER NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE advertising_campaigns ADD COLUMN tariff_duration_days INT NULL" - )) - logger.info("✅ Колонка tariff_duration_days добавлена в advertising_campaigns") + await conn.execute( + text('ALTER TABLE advertising_campaigns ADD COLUMN tariff_duration_days INT NULL') + ) + logger.info('✅ Колонка tariff_duration_days добавлена в advertising_campaigns') # Добавляем колонки в advertising_campaign_registrations if not registrations_tariff_id_exists: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)" - )) + await conn.execute( + text( + 'ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id)' + ) + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL" - )) + await conn.execute( + text( + 'ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INTEGER REFERENCES tariffs(id) ON DELETE SET NULL' + ) + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INT NULL" - )) - logger.info("✅ Колонка tariff_id добавлена в advertising_campaign_registrations") + await conn.execute( + text('ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_id INT NULL') + ) + logger.info('✅ Колонка tariff_id добавлена в advertising_campaign_registrations') if not registrations_duration_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_duration_days INTEGER NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_duration_days INTEGER NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text( + 'ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_duration_days INTEGER NULL' + ) + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_duration_days INT NULL" - )) - logger.info("✅ Колонка tariff_duration_days добавлена в advertising_campaign_registrations") + await conn.execute( + text('ALTER TABLE advertising_campaign_registrations ADD COLUMN tariff_duration_days INT NULL') + ) + logger.info('✅ Колонка tariff_duration_days добавлена в advertising_campaign_registrations') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок tariff в рекламные кампании: {error}") + logger.error(f'❌ Ошибка добавления колонок tariff в рекламные кампании: {error}') return False @@ -5595,30 +5356,22 @@ async def add_tariff_device_price_column() -> bool: """Добавляет колонку device_price_kopeks в таблицу tariffs.""" try: if await check_column_exists('tariffs', 'device_price_kopeks'): - logger.info("ℹ️ Колонка device_price_kopeks уже существует в tariffs") + logger.info('ℹ️ Колонка device_price_kopeks уже существует в tariffs') return True async with engine.begin() as conn: db_type = await get_database_type() - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN device_price_kopeks INTEGER DEFAULT NULL')) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN device_price_kopeks INT DEFAULT NULL')) - logger.info("✅ Колонка device_price_kopeks добавлена в tariffs") + logger.info('✅ Колонка device_price_kopeks добавлена в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки device_price_kopeks: {error}") + logger.error(f'❌ Ошибка добавления колонки device_price_kopeks: {error}') return False @@ -5626,30 +5379,24 @@ async def add_tariff_server_traffic_limits_column() -> bool: """Добавляет колонку server_traffic_limits в таблицу tariffs.""" try: if await check_column_exists('tariffs', 'server_traffic_limits'): - logger.info("ℹ️ Колонка server_traffic_limits уже существует в tariffs") + logger.info('ℹ️ Колонка server_traffic_limits уже существует в tariffs') return True async with engine.begin() as conn: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN server_traffic_limits TEXT DEFAULT '{}'" - )) + await conn.execute(text("ALTER TABLE tariffs ADD COLUMN server_traffic_limits TEXT DEFAULT '{}'")) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSONB DEFAULT '{}'" - )) + await conn.execute(text("ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSONB DEFAULT '{}'")) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSON DEFAULT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSON DEFAULT NULL')) - logger.info("✅ Колонка server_traffic_limits добавлена в tariffs") + logger.info('✅ Колонка server_traffic_limits добавлена в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки server_traffic_limits: {error}") + logger.error(f'❌ Ошибка добавления колонки server_traffic_limits: {error}') return False @@ -5657,30 +5404,30 @@ async def add_tariff_allow_traffic_topup_column() -> bool: """Добавляет колонку allow_traffic_topup в таблицу tariffs.""" try: if await check_column_exists('tariffs', 'allow_traffic_topup'): - logger.info("ℹ️ Колонка allow_traffic_topup уже существует в tariffs") + logger.info('ℹ️ Колонка allow_traffic_topup уже существует в tariffs') return True async with engine.begin() as conn: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup INTEGER NOT NULL DEFAULT 1" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN allow_traffic_topup INTEGER NOT NULL DEFAULT 1') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE') + ) - logger.info("✅ Колонка allow_traffic_topup добавлена в tariffs") + logger.info('✅ Колонка allow_traffic_topup добавлена в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки allow_traffic_topup: {error}") + logger.error(f'❌ Ошибка добавления колонки allow_traffic_topup: {error}') return False @@ -5750,9 +5497,9 @@ async def create_wheel_tables() -> bool: ) """ await conn.execute(text(create_config_sql)) - logger.info("✅ Таблица wheel_config создана") + logger.info('✅ Таблица wheel_config создана') else: - logger.debug("ℹ️ Таблица wheel_config уже существует") + logger.debug('ℹ️ Таблица wheel_config уже существует') # Создание wheel_prizes if not await check_table_exists('wheel_prizes'): @@ -5825,14 +5572,12 @@ async def create_wheel_tables() -> bool: await conn.execute(text(create_prizes_sql)) # Индексы try: - await conn.execute(text( - "CREATE INDEX idx_wheel_prizes_config_id ON wheel_prizes(config_id)" - )) + await conn.execute(text('CREATE INDEX idx_wheel_prizes_config_id ON wheel_prizes(config_id)')) except Exception: pass - logger.info("✅ Таблица wheel_prizes создана") + logger.info('✅ Таблица wheel_prizes создана') else: - logger.debug("ℹ️ Таблица wheel_prizes уже существует") + logger.debug('ℹ️ Таблица wheel_prizes уже существует') # Создание wheel_spins if not await check_table_exists('wheel_spins'): @@ -5902,22 +5647,18 @@ async def create_wheel_tables() -> bool: await conn.execute(text(create_spins_sql)) # Индексы try: - await conn.execute(text( - "CREATE INDEX idx_wheel_spins_user_id ON wheel_spins(user_id)" - )) - await conn.execute(text( - "CREATE INDEX idx_wheel_spins_created_at ON wheel_spins(created_at)" - )) + await conn.execute(text('CREATE INDEX idx_wheel_spins_user_id ON wheel_spins(user_id)')) + await conn.execute(text('CREATE INDEX idx_wheel_spins_created_at ON wheel_spins(created_at)')) except Exception: pass - logger.info("✅ Таблица wheel_spins создана") + logger.info('✅ Таблица wheel_spins создана') else: - logger.debug("ℹ️ Таблица wheel_spins уже существует") + logger.debug('ℹ️ Таблица wheel_spins уже существует') return True except Exception as error: - logger.error(f"❌ Ошибка создания таблиц для колеса удачи: {error}") + logger.error(f'❌ Ошибка создания таблиц для колеса удачи: {error}') return False @@ -5932,22 +5673,22 @@ async def add_tariff_traffic_topup_columns() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled INTEGER DEFAULT 0 NOT NULL') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled BOOLEAN DEFAULT FALSE NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN traffic_topup_enabled TINYINT(1) DEFAULT 0 NOT NULL') + ) - logger.info("✅ Колонка traffic_topup_enabled добавлена в tariffs") + logger.info('✅ Колонка traffic_topup_enabled добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка traffic_topup_enabled уже существует в tariffs") + logger.info('ℹ️ Колонка traffic_topup_enabled уже существует в tariffs') # Колонка traffic_topup_packages (JSON) if not await check_column_exists('tariffs', 'traffic_topup_packages'): @@ -5955,50 +5696,40 @@ async def add_tariff_traffic_topup_columns() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'" - )) + await conn.execute(text("ALTER TABLE tariffs ADD COLUMN traffic_topup_packages TEXT DEFAULT '{}'")) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'" - )) + await conn.execute(text("ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSONB DEFAULT '{}'")) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN traffic_topup_packages JSON DEFAULT NULL')) - logger.info("✅ Колонка traffic_topup_packages добавлена в tariffs") + logger.info('✅ Колонка traffic_topup_packages добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка traffic_topup_packages уже существует в tariffs") + logger.info('ℹ️ Колонка traffic_topup_packages уже существует в tariffs') # Колонка max_topup_traffic_gb (максимальный лимит трафика после докупок) if not await check_column_exists('tariffs', 'max_topup_traffic_gb'): async with engine.begin() as conn: db_type = await get_database_type() - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INTEGER DEFAULT 0 NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN max_topup_traffic_gb INT DEFAULT 0 NOT NULL') + ) - logger.info("✅ Колонка max_topup_traffic_gb добавлена в tariffs") + logger.info('✅ Колонка max_topup_traffic_gb добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка max_topup_traffic_gb уже существует в tariffs") + logger.info('ℹ️ Колонка max_topup_traffic_gb уже существует в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок для докупки трафика: {error}") + logger.error(f'❌ Ошибка добавления колонок для докупки трафика: {error}') return False @@ -6013,50 +5744,38 @@ async def add_tariff_daily_columns() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN is_daily INTEGER DEFAULT 0 NOT NULL')) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN is_daily BOOLEAN DEFAULT FALSE NOT NULL')) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN is_daily TINYINT(1) DEFAULT 0 NOT NULL')) - logger.info("✅ Колонка is_daily добавлена в tariffs") + logger.info('✅ Колонка is_daily добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка is_daily уже существует в tariffs") + logger.info('ℹ️ Колонка is_daily уже существует в tariffs') # Колонка daily_price_kopeks if not await check_column_exists('tariffs', 'daily_price_kopeks'): async with engine.begin() as conn: db_type = await get_database_type() - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql': + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INTEGER DEFAULT 0 NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL" - )) + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN daily_price_kopeks INT DEFAULT 0 NOT NULL')) - logger.info("✅ Колонка daily_price_kopeks добавлена в tariffs") + logger.info('✅ Колонка daily_price_kopeks добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка daily_price_kopeks уже существует в tariffs") + logger.info('ℹ️ Колонка daily_price_kopeks уже существует в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок суточного тарифа: {error}") + logger.error(f'❌ Ошибка добавления колонок суточного тарифа: {error}') return False @@ -6071,116 +5790,108 @@ async def add_tariff_custom_days_traffic_columns() -> bool: if not await check_column_exists('tariffs', 'custom_days_enabled'): async with engine.begin() as conn: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_days_enabled INTEGER DEFAULT 0 NOT NULL') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_days_enabled BOOLEAN DEFAULT FALSE NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL" - )) - logger.info("✅ Колонка custom_days_enabled добавлена в tariffs") + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_days_enabled TINYINT(1) DEFAULT 0 NOT NULL') + ) + logger.info('✅ Колонка custom_days_enabled добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка custom_days_enabled уже существует в tariffs") + logger.info('ℹ️ Колонка custom_days_enabled уже существует в tariffs') # price_per_day_kopeks if not await check_column_exists('tariffs', 'price_per_day_kopeks'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL" - )) - logger.info("✅ Колонка price_per_day_kopeks добавлена в tariffs") + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN price_per_day_kopeks INTEGER DEFAULT 0 NOT NULL') + ) + logger.info('✅ Колонка price_per_day_kopeks добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка price_per_day_kopeks уже существует в tariffs") + logger.info('ℹ️ Колонка price_per_day_kopeks уже существует в tariffs') # min_days if not await check_column_exists('tariffs', 'min_days'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL" - )) - logger.info("✅ Колонка min_days добавлена в tariffs") + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN min_days INTEGER DEFAULT 1 NOT NULL')) + logger.info('✅ Колонка min_days добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка min_days уже существует в tariffs") + logger.info('ℹ️ Колонка min_days уже существует в tariffs') # max_days if not await check_column_exists('tariffs', 'max_days'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL" - )) - logger.info("✅ Колонка max_days добавлена в tariffs") + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN max_days INTEGER DEFAULT 365 NOT NULL')) + logger.info('✅ Колонка max_days добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка max_days уже существует в tariffs") + logger.info('ℹ️ Колонка max_days уже существует в tariffs') # === ПРОИЗВОЛЬНЫЙ ТРАФИК ПРИ ПОКУПКЕ === # custom_traffic_enabled if not await check_column_exists('tariffs', 'custom_traffic_enabled'): async with engine.begin() as conn: if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled INTEGER DEFAULT 0 NOT NULL') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled BOOLEAN DEFAULT FALSE NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL" - )) - logger.info("✅ Колонка custom_traffic_enabled добавлена в tariffs") + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN custom_traffic_enabled TINYINT(1) DEFAULT 0 NOT NULL') + ) + logger.info('✅ Колонка custom_traffic_enabled добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка custom_traffic_enabled уже существует в tariffs") + logger.info('ℹ️ Колонка custom_traffic_enabled уже существует в tariffs') # traffic_price_per_gb_kopeks if not await check_column_exists('tariffs', 'traffic_price_per_gb_kopeks'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL" - )) - logger.info("✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs") + await conn.execute( + text('ALTER TABLE tariffs ADD COLUMN traffic_price_per_gb_kopeks INTEGER DEFAULT 0 NOT NULL') + ) + logger.info('✅ Колонка traffic_price_per_gb_kopeks добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка traffic_price_per_gb_kopeks уже существует в tariffs") + logger.info('ℹ️ Колонка traffic_price_per_gb_kopeks уже существует в tariffs') # min_traffic_gb if not await check_column_exists('tariffs', 'min_traffic_gb'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL" - )) - logger.info("✅ Колонка min_traffic_gb добавлена в tariffs") + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN min_traffic_gb INTEGER DEFAULT 1 NOT NULL')) + logger.info('✅ Колонка min_traffic_gb добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка min_traffic_gb уже существует в tariffs") + logger.info('ℹ️ Колонка min_traffic_gb уже существует в tariffs') # max_traffic_gb if not await check_column_exists('tariffs', 'max_traffic_gb'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL" - )) - logger.info("✅ Колонка max_traffic_gb добавлена в tariffs") + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN max_traffic_gb INTEGER DEFAULT 1000 NOT NULL')) + logger.info('✅ Колонка max_traffic_gb добавлена в tariffs') columns_added += 1 else: - logger.info("ℹ️ Колонка max_traffic_gb уже существует в tariffs") + logger.info('ℹ️ Колонка max_traffic_gb уже существует в tariffs') if columns_added > 0: - logger.info(f"✅ Добавлено {columns_added} колонок для произвольных дней/трафика") + logger.info(f'✅ Добавлено {columns_added} колонок для произвольных дней/трафика') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок произвольных дней/трафика: {error}") + logger.error(f'❌ Ошибка добавления колонок произвольных дней/трафика: {error}') return False @@ -6192,17 +5903,15 @@ async def add_tariff_traffic_reset_mode_column() -> bool: try: if not await check_column_exists('tariffs', 'traffic_reset_mode'): async with engine.begin() as conn: - await conn.execute(text( - "ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL" - )) - logger.info("✅ Колонка traffic_reset_mode добавлена в tariffs") + await conn.execute(text('ALTER TABLE tariffs ADD COLUMN traffic_reset_mode VARCHAR(20) NULL')) + logger.info('✅ Колонка traffic_reset_mode добавлена в tariffs') return True else: - logger.info("ℹ️ Колонка traffic_reset_mode уже существует в tariffs") + logger.info('ℹ️ Колонка traffic_reset_mode уже существует в tariffs') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки traffic_reset_mode: {error}") + logger.error(f'❌ Ошибка добавления колонки traffic_reset_mode: {error}') return False @@ -6217,22 +5926,22 @@ async def add_subscription_daily_columns() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN is_daily_paused INTEGER DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE subscriptions ADD COLUMN is_daily_paused INTEGER DEFAULT 0 NOT NULL') + ) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL" - )) + await conn.execute( + text('ALTER TABLE subscriptions ADD COLUMN is_daily_paused BOOLEAN DEFAULT FALSE NOT NULL') + ) else: # MySQL - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL" - )) + await conn.execute( + text('ALTER TABLE subscriptions ADD COLUMN is_daily_paused TINYINT(1) DEFAULT 0 NOT NULL') + ) - logger.info("✅ Колонка is_daily_paused добавлена в subscriptions") + logger.info('✅ Колонка is_daily_paused добавлена в subscriptions') columns_added += 1 else: - logger.info("ℹ️ Колонка is_daily_paused уже существует в subscriptions") + logger.info('ℹ️ Колонка is_daily_paused уже существует в subscriptions') # Колонка last_daily_charge_at if not await check_column_exists('subscriptions', 'last_daily_charge_at'): @@ -6240,27 +5949,21 @@ async def add_subscription_daily_columns() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL')) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at TIMESTAMP NULL')) else: # MySQL - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_daily_charge_at DATETIME NULL')) - logger.info("✅ Колонка last_daily_charge_at добавлена в subscriptions") + logger.info('✅ Колонка last_daily_charge_at добавлена в subscriptions') columns_added += 1 else: - logger.info("ℹ️ Колонка last_daily_charge_at уже существует в subscriptions") + logger.info('ℹ️ Колонка last_daily_charge_at уже существует в subscriptions') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонок суточной подписки: {error}") + logger.error(f'❌ Ошибка добавления колонок суточной подписки: {error}') return False @@ -6272,26 +5975,20 @@ async def add_subscription_traffic_reset_at_column() -> bool: db_type = await get_database_type() if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL')) elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN traffic_reset_at TIMESTAMP NULL')) else: # MySQL - await conn.execute(text( - "ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL" - )) + await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN traffic_reset_at DATETIME NULL')) - logger.info("✅ Колонка traffic_reset_at добавлена в subscriptions") + logger.info('✅ Колонка traffic_reset_at добавлена в subscriptions') return True else: - logger.info("ℹ️ Колонка traffic_reset_at уже существует в subscriptions") + logger.info('ℹ️ Колонка traffic_reset_at уже существует в subscriptions') return True except Exception as error: - logger.error(f"❌ Ошибка добавления колонки traffic_reset_at: {error}") + logger.error(f'❌ Ошибка добавления колонки traffic_reset_at: {error}') return False @@ -6311,24 +6008,16 @@ async def add_user_email_auth_columns() -> bool: async with engine.begin() as conn: # 1. Добавляем колонку auth_type если её нет if not auth_type_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL" - )) + if db_type == 'sqlite' or db_type == 'postgresql' or db_type == 'mysql': + await conn.execute( + text("ALTER TABLE users ADD COLUMN auth_type VARCHAR(20) DEFAULT 'telegram' NOT NULL") + ) else: - logger.error(f"Неподдерживаемый тип БД: {db_type}") + logger.error(f'Неподдерживаемый тип БД: {db_type}') return False - logger.info("✅ Добавлена колонка users.auth_type") + logger.info('✅ Добавлена колонка users.auth_type') else: - logger.info("ℹ️ Колонка auth_type уже существует") + logger.info('ℹ️ Колонка auth_type уже существует') # 2. Делаем telegram_id nullable (только PostgreSQL и MySQL поддерживают ALTER COLUMN) # SQLite не поддерживает ALTER COLUMN, но мы можем просто не делать это - @@ -6336,458 +6025,458 @@ async def add_user_email_auth_columns() -> bool: if db_type == 'postgresql': # Проверяем является ли telegram_id nullable - result = await conn.execute(text(""" + result = await conn.execute( + text(""" SELECT is_nullable FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'telegram_id' - """)) + """) + ) row = result.fetchone() if row and row[0] == 'NO': # telegram_id NOT NULL - нужно сделать nullable - await conn.execute(text( - "ALTER TABLE users ALTER COLUMN telegram_id DROP NOT NULL" - )) - logger.info("✅ Колонка users.telegram_id теперь nullable") + await conn.execute(text('ALTER TABLE users ALTER COLUMN telegram_id DROP NOT NULL')) + logger.info('✅ Колонка users.telegram_id теперь nullable') else: - logger.info("ℹ️ Колонка telegram_id уже nullable") + logger.info('ℹ️ Колонка telegram_id уже nullable') elif db_type == 'mysql': # MySQL требует полное определение колонки при ALTER - result = await conn.execute(text(""" + result = await conn.execute( + text(""" SELECT IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'telegram_id' - """)) + """) + ) row = result.fetchone() if row and row[0] == 'NO': - await conn.execute(text( - "ALTER TABLE users MODIFY COLUMN telegram_id BIGINT NULL" - )) - logger.info("✅ Колонка users.telegram_id теперь nullable") + await conn.execute(text('ALTER TABLE users MODIFY COLUMN telegram_id BIGINT NULL')) + logger.info('✅ Колонка users.telegram_id теперь nullable') else: - logger.info("ℹ️ Колонка telegram_id уже nullable") + logger.info('ℹ️ Колонка telegram_id уже nullable') elif db_type == 'sqlite': # SQLite не поддерживает ALTER COLUMN # Для SQLite нужна пересоздание таблицы, но это сложно # Оставляем как есть - при необходимости нужна ручная миграция - logger.info("ℹ️ SQLite: изменение nullable требует ручной миграции") + logger.info('ℹ️ SQLite: изменение nullable требует ручной миграции') return True except Exception as error: - logger.error(f"❌ Ошибка миграции email auth: {error}") + logger.error(f'❌ Ошибка миграции email auth: {error}') return False async def run_universal_migration(): - logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===") + logger.info('=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===') try: db_type = await get_database_type() - logger.info(f"Тип базы данных: {db_type}") + logger.info(f'Тип базы данных: {db_type}') if db_type == 'postgresql': - logger.info("=== СИНХРОНИЗАЦИЯ ПОСЛЕДОВАТЕЛЬНОСТЕЙ PostgreSQL ===") + logger.info('=== СИНХРОНИЗАЦИЯ ПОСЛЕДОВАТЕЛЬНОСТЕЙ PostgreSQL ===') sequences_synced = await sync_postgres_sequences() if sequences_synced: - logger.info("✅ Последовательности PostgreSQL синхронизированы") + logger.info('✅ Последовательности PostgreSQL синхронизированы') else: - logger.warning("⚠️ Не удалось синхронизировать последовательности PostgreSQL") + logger.warning('⚠️ Не удалось синхронизировать последовательности PostgreSQL') referral_migration_success = await add_referral_system_columns() if not referral_migration_success: - logger.warning("⚠️ Проблемы с миграцией реферальной системы") + logger.warning('⚠️ Проблемы с миграцией реферальной системы') commission_column_ready = await add_referral_commission_percent_column() if commission_column_ready: - logger.info("✅ Колонка referral_commission_percent готова") + logger.info('✅ Колонка referral_commission_percent готова') else: - logger.warning("⚠️ Проблемы с колонкой referral_commission_percent") + logger.warning('⚠️ Проблемы с колонкой referral_commission_percent') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===') system_settings_ready = await create_system_settings_table() if system_settings_ready: - logger.info("✅ Таблица system_settings готова") + logger.info('✅ Таблица system_settings готова') else: - logger.warning("⚠️ Проблемы с таблицей system_settings") + logger.warning('⚠️ Проблемы с таблицей system_settings') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WEB_API_TOKENS ===") + 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 готова") + logger.info('✅ Таблица web_api_tokens готова') else: - logger.warning("⚠️ Проблемы с таблицей web_api_tokens") + logger.warning('⚠️ Проблемы с таблицей web_api_tokens') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ MENU_LAYOUT_HISTORY ===') menu_layout_history_ready = await create_menu_layout_history_table() if menu_layout_history_ready: - logger.info("✅ Таблица menu_layout_history готова") + logger.info('✅ Таблица menu_layout_history готова') else: - logger.warning("⚠️ Проблемы с таблицей menu_layout_history") + logger.warning('⚠️ Проблемы с таблицей menu_layout_history') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ BUTTON_CLICK_LOGS ===') button_click_logs_ready = await create_button_click_logs_table() if button_click_logs_ready: - logger.info("✅ Таблица button_click_logs готова") + logger.info('✅ Таблица button_click_logs готова') else: - logger.warning("⚠️ Проблемы с таблицей button_click_logs") + logger.warning('⚠️ Проблемы с таблицей button_click_logs') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===') trial_column_ready = await add_server_trial_flag_column() if trial_column_ready: - logger.info("✅ Колонка is_trial_eligible готова") + logger.info('✅ Колонка is_trial_eligible готова') else: - logger.warning("⚠️ Проблемы с колонкой is_trial_eligible") + logger.warning('⚠️ Проблемы с колонкой is_trial_eligible') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===') privacy_policies_ready = await create_privacy_policies_table() if privacy_policies_ready: - logger.info("✅ Таблица privacy_policies готова") + logger.info('✅ Таблица privacy_policies готова') else: - logger.warning("⚠️ Проблемы с таблицей privacy_policies") + logger.warning('⚠️ Проблемы с таблицей privacy_policies') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PUBLIC_OFFERS ===') public_offers_ready = await create_public_offers_table() if public_offers_ready: - logger.info("✅ Таблица public_offers готова") + logger.info('✅ Таблица public_offers готова') else: - logger.warning("⚠️ Проблемы с таблицей public_offers") + logger.warning('⚠️ Проблемы с таблицей public_offers') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_SETTINGS ===') faq_settings_ready = await create_faq_settings_table() if faq_settings_ready: - logger.info("✅ Таблица faq_settings готова") + logger.info('✅ Таблица faq_settings готова') else: - logger.warning("⚠️ Проблемы с таблицей faq_settings") + logger.warning('⚠️ Проблемы с таблицей faq_settings') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ FAQ_PAGES ===') faq_pages_ready = await create_faq_pages_table() if faq_pages_ready: - logger.info("✅ Таблица faq_pages готова") + logger.info('✅ Таблица faq_pages готова') else: - logger.warning("⚠️ Проблемы с таблицей faq_pages") + logger.warning('⚠️ Проблемы с таблицей faq_pages') - logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===") + logger.info('=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===') default_token_ready = await ensure_default_web_api_token() if default_token_ready: - logger.info("✅ Бутстрап токен веб-API готов") + logger.info('✅ Бутстрап токен веб-API готов') else: - logger.warning("⚠️ Не удалось создать бутстрап токен веб-API") + logger.warning('⚠️ Не удалось создать бутстрап токен веб-API') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===') cryptobot_created = await create_cryptobot_payments_table() if cryptobot_created: - logger.info("✅ Таблица CryptoBot payments готова") + logger.info('✅ Таблица CryptoBot payments готова') else: - logger.warning("⚠️ Проблемы с таблицей CryptoBot payments") + logger.warning('⚠️ Проблемы с таблицей CryptoBot payments') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ HELEKET ===') heleket_created = await create_heleket_payments_table() if heleket_created: - logger.info("✅ Таблица Heleket payments готова") + logger.info('✅ Таблица Heleket payments готова') else: - logger.warning("⚠️ Проблемы с таблицей Heleket payments") + logger.warning('⚠️ Проблемы с таблицей Heleket payments') mulenpay_name = settings.get_mulenpay_display_name() - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ %s ===", mulenpay_name) + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ %s ===', mulenpay_name) mulenpay_created = await create_mulenpay_payments_table() if mulenpay_created: - logger.info("✅ Таблица %s payments готова", mulenpay_name) + logger.info('✅ Таблица %s payments готова', mulenpay_name) else: - logger.warning("⚠️ Проблемы с таблицей %s payments", mulenpay_name) + logger.warning('⚠️ Проблемы с таблицей %s payments', mulenpay_name) mulenpay_schema_ok = await ensure_mulenpay_payment_schema() if mulenpay_schema_ok: - logger.info("✅ Схема %s payments актуальна", mulenpay_name) + logger.info('✅ Схема %s payments актуальна', mulenpay_name) else: - logger.warning("⚠️ Не удалось обновить схему %s payments", mulenpay_name) + logger.warning('⚠️ Не удалось обновить схему %s payments', mulenpay_name) - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PAL24 ===') pal24_created = await create_pal24_payments_table() if pal24_created: - logger.info("✅ Таблица Pal24 payments готова") + logger.info('✅ Таблица Pal24 payments готова') else: - logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.warning('⚠️ Проблемы с таблицей Pal24 payments') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ WATA ===') wata_created = await create_wata_payments_table() if wata_created: - logger.info("✅ Таблица Wata payments готова") + logger.info('✅ Таблица Wata payments готова') else: - logger.warning("⚠️ Проблемы с таблицей Wata payments") + logger.warning('⚠️ Проблемы с таблицей Wata payments') wata_schema_ok = await ensure_wata_payment_schema() if wata_schema_ok: - logger.info("✅ Схема Wata payments актуальна") + logger.info('✅ Схема Wata payments актуальна') else: - logger.warning("⚠️ Не удалось обновить схему Wata payments") + logger.warning('⚠️ Не удалось обновить схему Wata payments') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FREEKASSA ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ FREEKASSA ===') freekassa_created = await create_freekassa_payments_table() if freekassa_created: - logger.info("✅ Таблица Freekassa payments готова") + logger.info('✅ Таблица Freekassa payments готова') else: - logger.warning("⚠️ Проблемы с таблицей Freekassa payments") + logger.warning('⚠️ Проблемы с таблицей Freekassa payments') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ KASSA_AI ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ KASSA_AI ===') kassa_ai_created = await create_kassa_ai_payments_table() if kassa_ai_created: - logger.info("✅ Таблица KassaAI payments готова") + logger.info('✅ Таблица KassaAI payments готова') else: - logger.warning("⚠️ Проблемы с таблицей KassaAI payments") + logger.warning('⚠️ Проблемы с таблицей KassaAI payments') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===') discount_created = await create_discount_offers_table() if discount_created: - logger.info("✅ Таблица discount_offers готова") + logger.info('✅ Таблица discount_offers готова') else: - logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.warning('⚠️ Проблемы с таблицей discount_offers') discount_columns_ready = await ensure_discount_offer_columns() if discount_columns_ready: - logger.info("✅ Колонки discount_offers в актуальном состоянии") + logger.info('✅ Колонки discount_offers в актуальном состоянии') else: - logger.warning("⚠️ Не удалось обновить колонки discount_offers") + logger.warning('⚠️ Не удалось обновить колонки discount_offers') - logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===') contests_table_ready = await create_referral_contests_table() if contests_table_ready: - logger.info("✅ Таблица referral_contests готова") + logger.info('✅ Таблица referral_contests готова') else: - logger.warning("⚠️ Проблемы с таблицей referral_contests") + logger.warning('⚠️ Проблемы с таблицей referral_contests') contest_events_ready = await create_referral_contest_events_table() if contest_events_ready: - logger.info("✅ Таблица referral_contest_events готова") + logger.info('✅ Таблица referral_contest_events готова') else: - logger.warning("⚠️ Проблемы с таблицей referral_contest_events") + logger.warning('⚠️ Проблемы с таблицей referral_contest_events') contest_type_ready = await ensure_referral_contest_type_column() if contest_type_ready: - logger.info("✅ Колонка contest_type для referral_contests готова") + logger.info('✅ Колонка contest_type для referral_contests готова') else: - logger.warning("⚠️ Не удалось добавить contest_type в referral_contests") + logger.warning('⚠️ Не удалось добавить contest_type в referral_contests') contest_summary_ready = await ensure_referral_contest_summary_columns() if contest_summary_ready: - logger.info("✅ Колонки daily_summary_times/last_daily_summary_at готовы") + logger.info('✅ Колонки daily_summary_times/last_daily_summary_at готовы') else: - logger.warning("⚠️ Не удалось обновить колонки сводок для referral_contests") + logger.warning('⚠️ Не удалось обновить колонки сводок для referral_contests') contest_templates_ready = await create_contest_templates_table() if contest_templates_ready: - logger.info("✅ Таблица contest_templates готова") + logger.info('✅ Таблица contest_templates готова') else: - logger.warning("⚠️ Проблемы с таблицей contest_templates") + logger.warning('⚠️ Проблемы с таблицей contest_templates') - logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===") + logger.info('=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===') prize_columns_ready = await migrate_contest_templates_prize_columns() if prize_columns_ready: - logger.info("✅ Колонки prize_type и prize_value готовы") + logger.info('✅ Колонки prize_type и prize_value готовы') else: - logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value") + logger.warning('⚠️ Проблемы с миграцией prize_type/prize_value') contest_rounds_ready = await create_contest_rounds_table() if contest_rounds_ready: - logger.info("✅ Таблица contest_rounds готова") + logger.info('✅ Таблица contest_rounds готова') else: - logger.warning("⚠️ Проблемы с таблицей contest_rounds") + logger.warning('⚠️ Проблемы с таблицей contest_rounds') contest_attempts_ready = await create_contest_attempts_table() if contest_attempts_ready: - logger.info("✅ Таблица contest_attempts готова") + logger.info('✅ Таблица contest_attempts готова') else: - logger.warning("⚠️ Проблемы с таблицей contest_attempts") + logger.warning('⚠️ Проблемы с таблицей contest_attempts') user_discount_columns_ready = await ensure_user_promo_offer_discount_columns() if user_discount_columns_ready: - logger.info("✅ Колонки пользовательских промо-скидок готовы") + logger.info('✅ Колонки пользовательских промо-скидок готовы') else: - logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки") + logger.warning('⚠️ Не удалось обновить пользовательские промо-скидки') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ NOTIFICATION_SETTINGS ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ NOTIFICATION_SETTINGS ===') notification_settings_ready = await ensure_user_notification_settings_column() if notification_settings_ready: - logger.info("✅ Колонка notification_settings готова") + logger.info('✅ Колонка notification_settings готова') else: - logger.warning("⚠️ Не удалось добавить колонку notification_settings") + logger.warning('⚠️ Не удалось добавить колонку notification_settings') effect_types_updated = await migrate_discount_offer_effect_types() if effect_types_updated: - logger.info("✅ Типы эффектов промо-предложений обновлены") + logger.info('✅ Типы эффектов промо-предложений обновлены') else: - logger.warning("⚠️ Не удалось обновить типы эффектов промо-предложений") + logger.warning('⚠️ Не удалось обновить типы эффектов промо-предложений') bonuses_reset = await reset_discount_offer_bonuses() if bonuses_reset: - logger.info("✅ Бонусные начисления промо-предложений отключены") + logger.info('✅ Бонусные начисления промо-предложений отключены') else: - logger.warning("⚠️ Не удалось обнулить бонусы промо-предложений") + logger.warning('⚠️ Не удалось обнулить бонусы промо-предложений') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===') promo_templates_created = await create_promo_offer_templates_table() if promo_templates_created: - logger.info("✅ Таблица promo_offer_templates готова") + logger.info('✅ Таблица promo_offer_templates готова') else: - logger.warning("⚠️ Проблемы с таблицей promo_offer_templates") + logger.warning('⚠️ Проблемы с таблицей promo_offer_templates') - logger.info("=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===") + logger.info('=== ДОБАВЛЕНИЕ ПРИОРИТЕТА В ПРОМОГРУППЫ ===') priority_column_ready = await add_promo_group_priority_column() if priority_column_ready: - logger.info("✅ Колонка priority в promo_groups готова") + logger.info('✅ Колонка priority в promo_groups готова') else: - logger.warning("⚠️ Проблемы с добавлением priority в promo_groups") + logger.warning('⚠️ Проблемы с добавлением priority в promo_groups') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ USER_PROMO_GROUPS ===') user_promo_groups_ready = await create_user_promo_groups_table() if user_promo_groups_ready: - logger.info("✅ Таблица user_promo_groups готова") + logger.info('✅ Таблица user_promo_groups готова') else: - logger.warning("⚠️ Проблемы с таблицей user_promo_groups") + logger.warning('⚠️ Проблемы с таблицей user_promo_groups') - logger.info("=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===") + logger.info('=== МИГРАЦИЯ ДАННЫХ В USER_PROMO_GROUPS ===') data_migrated = await migrate_existing_user_promo_groups_data() if data_migrated: - logger.info("✅ Данные перенесены в user_promo_groups") + logger.info('✅ Данные перенесены в user_promo_groups') else: - logger.warning("⚠️ Проблемы с миграцией данных в user_promo_groups") + logger.warning('⚠️ Проблемы с миграцией данных в user_promo_groups') - logger.info("=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===") + logger.info('=== ДОБАВЛЕНИЕ PROMO_GROUP_ID В PROMOCODES ===') promocode_column_ready = await add_promocode_promo_group_column() if promocode_column_ready: - logger.info("✅ Колонка promo_group_id в promocodes готова") + logger.info('✅ Колонка promo_group_id в promocodes готова') else: - logger.warning("⚠️ Проблемы с добавлением promo_group_id в promocodes") + logger.warning('⚠️ Проблемы с добавлением promo_group_id в promocodes') - logger.info("=== ДОБАВЛЕНИЕ FIRST_PURCHASE_ONLY В PROMOCODES ===") + logger.info('=== ДОБАВЛЕНИЕ FIRST_PURCHASE_ONLY В PROMOCODES ===') first_purchase_ready = await add_promocode_first_purchase_only_column() if first_purchase_ready: - logger.info("✅ Колонка first_purchase_only в promocodes готова") + logger.info('✅ Колонка first_purchase_only в promocodes готова') else: - logger.warning("⚠️ Проблемы с добавлением first_purchase_only в promocodes") + logger.warning('⚠️ Проблемы с добавлением first_purchase_only в promocodes') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===') main_menu_buttons_created = await create_main_menu_buttons_table() if main_menu_buttons_created: - logger.info("✅ Таблица main_menu_buttons готова") + logger.info('✅ Таблица main_menu_buttons готова') else: - logger.warning("⚠️ Проблемы с таблицей main_menu_buttons") + logger.warning('⚠️ Проблемы с таблицей main_menu_buttons') template_columns_ready = await ensure_promo_offer_template_active_duration_column() if template_columns_ready: - logger.info("✅ Колонка active_discount_hours промо-предложений готова") + logger.info('✅ Колонка active_discount_hours промо-предложений готова') else: - logger.warning("⚠️ Не удалось обновить колонку active_discount_hours промо-предложений") + logger.warning('⚠️ Не удалось обновить колонку active_discount_hours промо-предложений') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_LOGS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_LOGS ===') promo_logs_created = await create_promo_offer_logs_table() if promo_logs_created: - logger.info("✅ Таблица promo_offer_logs готова") + logger.info('✅ Таблица promo_offer_logs готова') else: - logger.warning("⚠️ Проблемы с таблицей promo_offer_logs") + logger.warning('⚠️ Проблемы с таблицей promo_offer_logs') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_TEMPORARY_ACCESS ===') temp_access_created = await create_subscription_temporary_access_table() if temp_access_created: - logger.info("✅ Таблица subscription_temporary_access готова") + logger.info('✅ Таблица subscription_temporary_access готова') else: - logger.warning("⚠️ Проблемы с таблицей subscription_temporary_access") + logger.warning('⚠️ Проблемы с таблицей subscription_temporary_access') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===') user_messages_created = await create_user_messages_table() if user_messages_created: - logger.info("✅ Таблица user_messages готова") + logger.info('✅ Таблица user_messages готова') else: - logger.warning("⚠️ Проблемы с таблицей user_messages") + logger.warning('⚠️ Проблемы с таблицей user_messages') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PINNED_MESSAGES ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PINNED_MESSAGES ===') pinned_messages_created = await create_pinned_messages_table() if pinned_messages_created: - logger.info("✅ Таблица pinned_messages готова") + logger.info('✅ Таблица pinned_messages готова') else: - logger.warning("⚠️ Проблемы с таблицей pinned_messages") + logger.warning('⚠️ Проблемы с таблицей pinned_messages') - logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===") + logger.info('=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===') welcome_texts_created = await create_welcome_texts_table() if welcome_texts_created: - logger.info("✅ Таблица welcome_texts готова с полем is_enabled") + logger.info('✅ Таблица welcome_texts готова с полем is_enabled') else: - logger.warning("⚠️ Проблемы с таблицей welcome_texts") + logger.warning('⚠️ Проблемы с таблицей welcome_texts') - logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ PINNED_MESSAGES ===") + logger.info('=== ОБНОВЛЕНИЕ СХЕМЫ PINNED_MESSAGES ===') pinned_media_ready = await ensure_pinned_message_media_columns() if pinned_media_ready: - logger.info("✅ Медиа поля для pinned_messages готовы") + logger.info('✅ Медиа поля для pinned_messages готовы') else: - logger.warning("⚠️ Проблемы с медиа полями pinned_messages") + logger.warning('⚠️ Проблемы с медиа полями pinned_messages') - logger.info("=== ДОБАВЛЕНИЕ СЛЕДА ОТПРАВКИ ЗАКРЕПА ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===") + logger.info('=== ДОБАВЛЕНИЕ СЛЕДА ОТПРАВКИ ЗАКРЕПА ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===') last_pinned_ready = await ensure_user_last_pinned_column() if last_pinned_ready: - logger.info("✅ Колонка last_pinned_message_id добавлена") + logger.info('✅ Колонка last_pinned_message_id добавлена') else: - logger.warning("⚠️ Не удалось обновить колонку last_pinned_message_id") - - logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===") + logger.warning('⚠️ Не удалось обновить колонку last_pinned_message_id') + + logger.info('=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===') media_fields_added = await add_media_fields_to_broadcast_history() if media_fields_added: - logger.info("✅ Медиа поля в broadcast_history готовы") + logger.info('✅ Медиа поля в broadcast_history готовы') else: - logger.warning("⚠️ Проблемы с добавлением медиа полей") + logger.warning('⚠️ Проблемы с добавлением медиа полей') - logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===") + logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===') tickets_block_cols_added = await add_ticket_reply_block_columns() if tickets_block_cols_added: - logger.info("✅ Поля блокировок в tickets готовы") + logger.info('✅ Поля блокировок в tickets готовы') else: - logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets") + logger.warning('⚠️ Проблемы с добавлением полей блокировок в tickets') - logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===") + logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===') sla_cols_added = await add_ticket_sla_columns() if sla_cols_added: - logger.info("✅ Поля SLA в tickets готовы") + logger.info('✅ Поля SLA в tickets готовы') else: - logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets") + logger.warning('⚠️ Проблемы с добавлением полей SLA в tickets') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===') crypto_link_added = await add_subscription_crypto_link_column() if crypto_link_added: - logger.info("✅ Колонка subscription_crypto_link готова") + logger.info('✅ Колонка subscription_crypto_link готова') else: - logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link") + logger.warning('⚠️ Проблемы с добавлением колонки subscription_crypto_link') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===') modem_enabled_added = await add_subscription_modem_enabled_column() if modem_enabled_added: - logger.info("✅ Колонка modem_enabled готова") + logger.info('✅ Колонка modem_enabled готова') else: - logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled") + logger.warning('⚠️ Проблемы с добавлением колонки modem_enabled') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===') purchased_traffic_added = await add_subscription_purchased_traffic_column() if purchased_traffic_added: - logger.info("✅ Колонка purchased_traffic_gb готова") + logger.info('✅ Колонка purchased_traffic_gb готова') else: - logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb") + logger.warning('⚠️ Проблемы с добавлением колонки purchased_traffic_gb') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ОГРАНИЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК ОГРАНИЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ ===') restrictions_added = await add_user_restriction_columns() if restrictions_added: - logger.info("✅ Колонки ограничений пользователей готовы") + logger.info('✅ Колонки ограничений пользователей готовы') else: - logger.warning("⚠️ Проблемы с добавлением колонок ограничений пользователей") + logger.warning('⚠️ Проблемы с добавлением колонок ограничений пользователей') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЛИЧНОГО КАБИНЕТА ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК ЛИЧНОГО КАБИНЕТА ===') cabinet_added = await add_user_cabinet_columns() if cabinet_added: - logger.info("✅ Колонки личного кабинета готовы") + logger.info('✅ Колонки личного кабинета готовы') else: - logger.warning("⚠️ Проблемы с добавлением колонок личного кабинета") + logger.warning('⚠️ Проблемы с добавлением колонок личного кабинета') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===') try: async with engine.begin() as conn: db_type = await get_database_type() @@ -6847,474 +6536,512 @@ async def run_universal_migration(): CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action); """ await conn.execute(text(create_sql)) - logger.info("✅ Таблица support_audit_logs создана") + logger.info('✅ Таблица support_audit_logs создана') else: - logger.info("ℹ️ Таблица support_audit_logs уже существует") + logger.info('ℹ️ Таблица support_audit_logs уже существует') except Exception as e: - logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}") + logger.warning(f'⚠️ Проблемы с созданием таблицы support_audit_logs: {e}') - logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===") + logger.info('=== НАСТРОЙКА ПРОМО ГРУПП ===') promo_groups_ready = await ensure_promo_groups_setup() if promo_groups_ready: - logger.info("✅ Промо группы готовы") + logger.info('✅ Промо группы готовы') else: - logger.warning("⚠️ Проблемы с настройкой промо групп") + logger.warning('⚠️ Проблемы с настройкой промо групп') server_promo_groups_ready = await ensure_server_promo_groups_setup() if server_promo_groups_ready: - logger.info("✅ Доступ серверов по промогруппам настроен") + logger.info('✅ Доступ серверов по промогруппам настроен') else: - logger.warning("⚠️ Проблемы с настройкой доступа серверов к промогруппам") + logger.warning('⚠️ Проблемы с настройкой доступа серверов к промогруппам') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ ДОКУПОК ТРАФИКА ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ ДОКУПОК ТРАФИКА ===') traffic_purchases_ready = await create_traffic_purchases_table() if traffic_purchases_ready: - logger.info("✅ Таблица traffic_purchases готова") + logger.info('✅ Таблица traffic_purchases готова') else: - logger.warning("⚠️ Проблемы с таблицей traffic_purchases") + logger.warning('⚠️ Проблемы с таблицей traffic_purchases') - logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕЖИМА ТАРИФОВ ===') tariffs_table_ready = await create_tariffs_table() if tariffs_table_ready: - logger.info("✅ Таблица tariffs готова") + logger.info('✅ Таблица tariffs готова') else: - logger.warning("⚠️ Проблемы с таблицей tariffs") + logger.warning('⚠️ Проблемы с таблицей tariffs') tariff_promo_groups_ready = await create_tariff_promo_groups_table() if tariff_promo_groups_ready: - logger.info("✅ Таблица tariff_promo_groups готова") + logger.info('✅ Таблица tariff_promo_groups готова') else: - logger.warning("⚠️ Проблемы с таблицей tariff_promo_groups") + logger.warning('⚠️ Проблемы с таблицей tariff_promo_groups') tariff_id_column_ready = await add_subscription_tariff_id_column() if tariff_id_column_ready: - logger.info("✅ Колонка tariff_id в subscriptions готова") + logger.info('✅ Колонка tariff_id в subscriptions готова') else: - logger.warning("⚠️ Проблемы с колонкой tariff_id в subscriptions") + logger.warning('⚠️ Проблемы с колонкой tariff_id в subscriptions') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ТАРИФОВ В РЕКЛАМНЫЕ КАМПАНИИ ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК ТАРИФОВ В РЕКЛАМНЫЕ КАМПАНИИ ===') campaign_tariff_columns_ready = await add_campaign_tariff_columns() if campaign_tariff_columns_ready: - logger.info("✅ Колонки tariff в рекламных кампаниях готовы") + logger.info('✅ Колонки tariff в рекламных кампаниях готовы') else: - logger.warning("⚠️ Проблемы с колонками tariff в рекламных кампаниях") + logger.warning('⚠️ Проблемы с колонками tariff в рекламных кампаниях') device_price_column_ready = await add_tariff_device_price_column() if device_price_column_ready: - logger.info("✅ Колонка device_price_kopeks в tariffs готова") + logger.info('✅ Колонка device_price_kopeks в tariffs готова') else: - logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs") + logger.warning('⚠️ Проблемы с колонкой device_price_kopeks в tariffs') max_device_limit_ready = await ensure_tariff_max_device_limit_column() if max_device_limit_ready: - logger.info("✅ Колонка max_device_limit в tariffs готова") + logger.info('✅ Колонка max_device_limit в tariffs готова') else: - logger.warning("⚠️ Проблемы с колонкой max_device_limit в tariffs") + logger.warning('⚠️ Проблемы с колонкой max_device_limit в tariffs') server_traffic_limits_ready = await add_tariff_server_traffic_limits_column() if server_traffic_limits_ready: - logger.info("✅ Колонка server_traffic_limits в tariffs готова") + logger.info('✅ Колонка server_traffic_limits в tariffs готова') else: - logger.warning("⚠️ Проблемы с колонкой server_traffic_limits в tariffs") + logger.warning('⚠️ Проблемы с колонкой server_traffic_limits в tariffs') allow_traffic_topup_ready = await add_tariff_allow_traffic_topup_column() if allow_traffic_topup_ready: - logger.info("✅ Колонка allow_traffic_topup в tariffs готова") + logger.info('✅ Колонка allow_traffic_topup в tariffs готова') else: - logger.warning("⚠️ Проблемы с колонкой allow_traffic_topup в tariffs") + logger.warning('⚠️ Проблемы с колонкой allow_traffic_topup в tariffs') traffic_topup_columns_ready = await add_tariff_traffic_topup_columns() if traffic_topup_columns_ready: - logger.info("✅ Колонки докупки трафика в tariffs готовы") + logger.info('✅ Колонки докупки трафика в tariffs готовы') else: - logger.warning("⚠️ Проблемы с колонками докупки трафика в tariffs") + logger.warning('⚠️ Проблемы с колонками докупки трафика в tariffs') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ТАРИФОВ ===') daily_tariff_columns_ready = await add_tariff_daily_columns() if daily_tariff_columns_ready: - logger.info("✅ Колонки суточных тарифов в tariffs готовы") + logger.info('✅ Колонки суточных тарифов в tariffs готовы') else: - logger.warning("⚠️ Проблемы с колонками суточных тарифов в tariffs") + logger.warning('⚠️ Проблемы с колонками суточных тарифов в tariffs') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК ПРОИЗВОЛЬНЫХ ДНЕЙ/ТРАФИКА ===') custom_days_traffic_ready = await add_tariff_custom_days_traffic_columns() if custom_days_traffic_ready: - logger.info("✅ Колонки произвольных дней/трафика в tariffs готовы") + logger.info('✅ Колонки произвольных дней/трафика в tariffs готовы') else: - logger.warning("⚠️ Проблемы с колонками произвольных дней/трафика в tariffs") + logger.warning('⚠️ Проблемы с колонками произвольных дней/трафика в tariffs') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ РЕЖИМА СБРОСА ТРАФИКА В ТАРИФАХ ===') traffic_reset_mode_ready = await add_tariff_traffic_reset_mode_column() if traffic_reset_mode_ready: - logger.info("✅ Колонка traffic_reset_mode в tariffs готова") + logger.info('✅ Колонка traffic_reset_mode в tariffs готова') else: - logger.warning("⚠️ Проблемы с колонкой traffic_reset_mode в tariffs") + logger.warning('⚠️ Проблемы с колонкой traffic_reset_mode в tariffs') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК СУТОЧНЫХ ПОДПИСОК ===') daily_subscription_columns_ready = await add_subscription_daily_columns() if daily_subscription_columns_ready: - logger.info("✅ Колонки суточных подписок в subscriptions готовы") + logger.info('✅ Колонки суточных подписок в subscriptions готовы') else: - logger.warning("⚠️ Проблемы с колонками суточных подписок в subscriptions") + logger.warning('⚠️ Проблемы с колонками суточных подписок в subscriptions') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ СБРОСА ТРАФИКА ===') traffic_reset_column_ready = await add_subscription_traffic_reset_at_column() if traffic_reset_column_ready: - logger.info("✅ Колонка traffic_reset_at в subscriptions готова") + logger.info('✅ Колонка traffic_reset_at в subscriptions готова') else: - logger.warning("⚠️ Проблемы с колонкой traffic_reset_at в subscriptions") + logger.warning('⚠️ Проблемы с колонкой traffic_reset_at в subscriptions') - logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===") + logger.info('=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===') fk_updated = await fix_foreign_keys_for_user_deletion() if fk_updated: - logger.info("✅ Внешние ключи обновлены") + logger.info('✅ Внешние ключи обновлены') else: - logger.warning("⚠️ Проблемы с обновлением внешних ключей") - - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===") + logger.warning('⚠️ Проблемы с обновлением внешних ключей') + + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===') conversions_created = await create_subscription_conversions_table() if conversions_created: - logger.info("✅ Таблица subscription_conversions готова") + logger.info('✅ Таблица subscription_conversions готова') else: - logger.warning("⚠️ Проблемы с таблицей subscription_conversions") + logger.warning('⚠️ Проблемы с таблицей subscription_conversions') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===') events_created = await create_subscription_events_table() if events_created: - logger.info("✅ Таблица subscription_events готова") + logger.info('✅ Таблица subscription_events готова') else: - logger.warning("⚠️ Проблемы с таблицей subscription_events") + logger.warning('⚠️ Проблемы с таблицей subscription_events') - logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЧЕКОВ В TRANSACTIONS ===") + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК ЧЕКОВ В TRANSACTIONS ===') receipt_columns_ready = await add_transaction_receipt_columns() if receipt_columns_ready: - logger.info("✅ Колонки receipt_uuid и receipt_created_at готовы") + logger.info('✅ Колонки receipt_uuid и receipt_created_at готовы') else: - logger.warning("⚠️ Проблемы с колонками чеков в transactions") + logger.warning('⚠️ Проблемы с колонками чеков в transactions') - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WITHDRAWAL_REQUESTS ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ WITHDRAWAL_REQUESTS ===') withdrawal_requests_ready = await create_withdrawal_requests_table() if withdrawal_requests_ready: - logger.info("✅ Таблица withdrawal_requests готова") + logger.info('✅ Таблица withdrawal_requests готова') else: - logger.warning("⚠️ Проблемы с таблицей withdrawal_requests") + logger.warning('⚠️ Проблемы с таблицей withdrawal_requests') - logger.info("=== НАСТРОЙКА EMAIL АУТЕНТИФИКАЦИИ ===") + logger.info('=== НАСТРОЙКА EMAIL АУТЕНТИФИКАЦИИ ===') email_auth_ready = await add_user_email_auth_columns() if email_auth_ready: - logger.info("✅ Колонки для email-аутентификации готовы") + logger.info('✅ Колонки для email-аутентификации готовы') else: - logger.warning("⚠️ Проблемы с настройкой email-аутентификации") + logger.warning('⚠️ Проблемы с настройкой email-аутентификации') - logger.info("=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===") + logger.info('=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===') wheel_tables_ready = await create_wheel_tables() if wheel_tables_ready: - logger.info("✅ Таблицы колеса удачи готовы") + logger.info('✅ Таблицы колеса удачи готовы') else: - logger.warning("⚠️ Проблемы с таблицами колеса удачи") + logger.warning('⚠️ Проблемы с таблицами колеса удачи') async with engine.begin() as conn: - total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions")) - unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions")) - + total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions')) + unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions')) + total_count = total_subs.fetchone()[0] unique_count = unique_users.fetchone()[0] - - logger.info(f"Всего подписок: {total_count}") - logger.info(f"Уникальных пользователей: {unique_count}") - + + logger.info(f'Всего подписок: {total_count}') + logger.info(f'Уникальных пользователей: {unique_count}') + if total_count == unique_count: - logger.info("База данных уже в корректном состоянии") - logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===") + logger.info('База данных уже в корректном состоянии') + logger.info('=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===') return True - - deleted_count = await fix_subscription_duplicates_universal() - + + await fix_subscription_duplicates_universal() + async with engine.begin() as conn: - final_check = await conn.execute(text(""" - SELECT user_id, COUNT(*) as count - FROM subscriptions - GROUP BY user_id + final_check = await conn.execute( + text(""" + SELECT user_id, COUNT(*) as count + FROM subscriptions + GROUP BY user_id HAVING COUNT(*) > 1 - """)) - + """) + ) + remaining_duplicates = final_check.fetchall() - + if remaining_duplicates: - logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей") + logger.warning(f'Остались дубликаты у {len(remaining_duplicates)} пользователей') return False - else: - logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===") - logger.info("✅ Реферальная система обновлена") - logger.info("✅ CryptoBot таблица готова") - logger.info("✅ Heleket таблица готова") - logger.info("✅ Таблица конверсий подписок создана") - logger.info("✅ Таблица событий подписок создана") - logger.info("✅ Таблица welcome_texts с полем is_enabled готова") - logger.info("✅ Медиа поля в broadcast_history добавлены") - logger.info("✅ Дубликаты подписок исправлены") - return True - + logger.info('=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===') + logger.info('✅ Реферальная система обновлена') + logger.info('✅ CryptoBot таблица готова') + logger.info('✅ Heleket таблица готова') + logger.info('✅ Таблица конверсий подписок создана') + logger.info('✅ Таблица событий подписок создана') + logger.info('✅ Таблица welcome_texts с полем is_enabled готова') + logger.info('✅ Медиа поля в broadcast_history добавлены') + logger.info('✅ Дубликаты подписок исправлены') + return True + except Exception as e: - logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===") + logger.error(f'=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===') return False + async def check_migration_status(): - logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===") - + logger.info('=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===') + try: status = { - "has_made_first_topup_column": False, - "cryptobot_table": False, - "heleket_table": False, - "user_messages_table": False, - "pinned_messages_table": False, - "welcome_texts_table": False, - "welcome_texts_is_enabled_column": False, - "pinned_messages_media_columns": False, - "pinned_messages_position_column": False, - "pinned_messages_start_mode_column": False, - "users_last_pinned_column": False, - "broadcast_history_media_fields": False, - "subscription_duplicates": False, - "subscription_conversions_table": False, - "subscription_events_table": False, - "promo_groups_table": False, - "server_promo_groups_table": False, - "server_squads_trial_column": False, - "privacy_policies_table": False, - "public_offers_table": False, - "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, - "users_promo_offer_discount_percent_column": False, - "users_promo_offer_discount_source_column": False, - "users_promo_offer_discount_expires_column": False, - "users_referral_commission_percent_column": False, - "users_notification_settings_column": False, - "subscription_crypto_link_column": False, - "subscription_modem_enabled_column": False, - "subscription_purchased_traffic_column": False, - "users_restriction_topup_column": False, - "users_restriction_subscription_column": False, - "users_restriction_reason_column": False, - "contest_templates_prize_type_column": False, - "contest_templates_prize_value_column": False, - "discount_offers_table": False, - "discount_offers_effect_column": False, - "discount_offers_extra_column": False, - "referral_contests_table": False, - "referral_contest_events_table": False, - "referral_contest_type_column": False, - "referral_contest_summary_times_column": False, - "referral_contest_last_summary_at_column": False, - "contest_templates_table": False, - "contest_rounds_table": False, - "contest_attempts_table": False, - "promo_offer_templates_table": False, - "promo_offer_templates_active_discount_column": False, - "promo_offer_logs_table": False, - "subscription_temporary_access_table": False, - "campaign_tariff_id_column": False, - "campaign_tariff_duration_days_column": False, - "campaign_registration_tariff_id_column": False, - "campaign_registration_tariff_duration_days_column": False, + 'has_made_first_topup_column': False, + 'cryptobot_table': False, + 'heleket_table': False, + 'user_messages_table': False, + 'pinned_messages_table': False, + 'welcome_texts_table': False, + 'welcome_texts_is_enabled_column': False, + 'pinned_messages_media_columns': False, + 'pinned_messages_position_column': False, + 'pinned_messages_start_mode_column': False, + 'users_last_pinned_column': False, + 'broadcast_history_media_fields': False, + 'subscription_duplicates': False, + 'subscription_conversions_table': False, + 'subscription_events_table': False, + 'promo_groups_table': False, + 'server_promo_groups_table': False, + 'server_squads_trial_column': False, + 'privacy_policies_table': False, + 'public_offers_table': False, + '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, + 'users_promo_offer_discount_percent_column': False, + 'users_promo_offer_discount_source_column': False, + 'users_promo_offer_discount_expires_column': False, + 'users_referral_commission_percent_column': False, + 'users_notification_settings_column': False, + 'subscription_crypto_link_column': False, + 'subscription_modem_enabled_column': False, + 'subscription_purchased_traffic_column': False, + 'users_restriction_topup_column': False, + 'users_restriction_subscription_column': False, + 'users_restriction_reason_column': False, + 'contest_templates_prize_type_column': False, + 'contest_templates_prize_value_column': False, + 'discount_offers_table': False, + 'discount_offers_effect_column': False, + 'discount_offers_extra_column': False, + 'referral_contests_table': False, + 'referral_contest_events_table': False, + 'referral_contest_type_column': False, + 'referral_contest_summary_times_column': False, + 'referral_contest_last_summary_at_column': False, + 'contest_templates_table': False, + 'contest_rounds_table': False, + 'contest_attempts_table': False, + 'promo_offer_templates_table': False, + 'promo_offer_templates_active_discount_column': False, + 'promo_offer_logs_table': False, + 'subscription_temporary_access_table': False, + 'campaign_tariff_id_column': False, + 'campaign_tariff_duration_days_column': False, + 'campaign_registration_tariff_id_column': False, + 'campaign_registration_tariff_duration_days_column': False, } - - status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') - - status["cryptobot_table"] = await check_table_exists('cryptobot_payments') - status["heleket_table"] = await check_table_exists('heleket_payments') - status["user_messages_table"] = await check_table_exists('user_messages') - status["pinned_messages_table"] = await check_table_exists('pinned_messages') - status["welcome_texts_table"] = await check_table_exists('welcome_texts') - status["privacy_policies_table"] = await check_table_exists('privacy_policies') - status["public_offers_table"] = await check_table_exists('public_offers') - status["subscription_conversions_table"] = await check_table_exists('subscription_conversions') - status["subscription_events_table"] = await check_table_exists('subscription_events') - status["promo_groups_table"] = await check_table_exists('promo_groups') - status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups') - status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible') - status["discount_offers_table"] = await check_table_exists('discount_offers') - status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type') - status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data') - status["referral_contests_table"] = await check_table_exists('referral_contests') - status["referral_contest_events_table"] = await check_table_exists('referral_contest_events') - status["referral_contest_type_column"] = await check_column_exists('referral_contests', 'contest_type') - status["referral_contest_summary_times_column"] = await check_column_exists('referral_contests', 'daily_summary_times') - status["referral_contest_last_summary_at_column"] = await check_column_exists('referral_contests', 'last_daily_summary_at') - status["contest_templates_table"] = await check_table_exists('contest_templates') - status["contest_rounds_table"] = await check_table_exists('contest_rounds') - status["contest_attempts_table"] = await check_table_exists('contest_attempts') - status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates') - status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours') - status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs') - status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access') + status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup') + + status['cryptobot_table'] = await check_table_exists('cryptobot_payments') + status['heleket_table'] = await check_table_exists('heleket_payments') + status['user_messages_table'] = await check_table_exists('user_messages') + status['pinned_messages_table'] = await check_table_exists('pinned_messages') + status['welcome_texts_table'] = await check_table_exists('welcome_texts') + status['privacy_policies_table'] = await check_table_exists('privacy_policies') + status['public_offers_table'] = await check_table_exists('public_offers') + status['subscription_conversions_table'] = await check_table_exists('subscription_conversions') + status['subscription_events_table'] = await check_table_exists('subscription_events') + status['promo_groups_table'] = await check_table_exists('promo_groups') + status['server_promo_groups_table'] = await check_table_exists('server_squad_promo_groups') + status['server_squads_trial_column'] = await check_column_exists('server_squads', 'is_trial_eligible') + + status['discount_offers_table'] = await check_table_exists('discount_offers') + status['discount_offers_effect_column'] = await check_column_exists('discount_offers', 'effect_type') + status['discount_offers_extra_column'] = await check_column_exists('discount_offers', 'extra_data') + status['referral_contests_table'] = await check_table_exists('referral_contests') + status['referral_contest_events_table'] = await check_table_exists('referral_contest_events') + status['referral_contest_type_column'] = await check_column_exists('referral_contests', 'contest_type') + status['referral_contest_summary_times_column'] = await check_column_exists( + 'referral_contests', 'daily_summary_times' + ) + status['referral_contest_last_summary_at_column'] = await check_column_exists( + 'referral_contests', 'last_daily_summary_at' + ) + status['contest_templates_table'] = await check_table_exists('contest_templates') + status['contest_rounds_table'] = await check_table_exists('contest_rounds') + status['contest_attempts_table'] = await check_table_exists('contest_attempts') + status['promo_offer_templates_table'] = await check_table_exists('promo_offer_templates') + status['promo_offer_templates_active_discount_column'] = await check_column_exists( + 'promo_offer_templates', 'active_discount_hours' + ) + status['promo_offer_logs_table'] = await check_table_exists('promo_offer_logs') + status['subscription_temporary_access_table'] = await check_table_exists('subscription_temporary_access') # Проверяем колонки tariff в рекламных кампаниях - status["campaign_tariff_id_column"] = await check_column_exists('advertising_campaigns', 'tariff_id') - status["campaign_tariff_duration_days_column"] = await check_column_exists('advertising_campaigns', 'tariff_duration_days') - status["campaign_registration_tariff_id_column"] = await check_column_exists('advertising_campaign_registrations', 'tariff_id') - status["campaign_registration_tariff_duration_days_column"] = await check_column_exists('advertising_campaign_registrations', 'tariff_duration_days') + status['campaign_tariff_id_column'] = await check_column_exists('advertising_campaigns', 'tariff_id') + status['campaign_tariff_duration_days_column'] = await check_column_exists( + 'advertising_campaigns', 'tariff_duration_days' + ) + status['campaign_registration_tariff_id_column'] = await check_column_exists( + 'advertising_campaign_registrations', 'tariff_id' + ) + status['campaign_registration_tariff_duration_days_column'] = await check_column_exists( + 'advertising_campaign_registrations', 'tariff_duration_days' + ) - status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') - 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["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent') - status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source') - status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at') - status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent') - status["users_notification_settings_column"] = await check_column_exists('users', 'notification_settings') - status["users_auth_type_column"] = await check_column_exists('users', 'auth_type') - status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') - status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled') - status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb') - status["users_restriction_topup_column"] = await check_column_exists('users', 'restriction_topup') - status["users_restriction_subscription_column"] = await check_column_exists('users', 'restriction_subscription') - status["users_restriction_reason_column"] = await check_column_exists('users', 'restriction_reason') - status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type') - status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value') + status['welcome_texts_is_enabled_column'] = await check_column_exists('welcome_texts', 'is_enabled') + 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['users_promo_offer_discount_percent_column'] = await check_column_exists( + 'users', 'promo_offer_discount_percent' + ) + status['users_promo_offer_discount_source_column'] = await check_column_exists( + 'users', 'promo_offer_discount_source' + ) + status['users_promo_offer_discount_expires_column'] = await check_column_exists( + 'users', 'promo_offer_discount_expires_at' + ) + status['users_referral_commission_percent_column'] = await check_column_exists( + 'users', 'referral_commission_percent' + ) + status['users_notification_settings_column'] = await check_column_exists('users', 'notification_settings') + status['users_auth_type_column'] = await check_column_exists('users', 'auth_type') + status['subscription_crypto_link_column'] = await check_column_exists( + 'subscriptions', 'subscription_crypto_link' + ) + status['subscription_modem_enabled_column'] = await check_column_exists('subscriptions', 'modem_enabled') + status['subscription_purchased_traffic_column'] = await check_column_exists( + 'subscriptions', 'purchased_traffic_gb' + ) + status['users_restriction_topup_column'] = await check_column_exists('users', 'restriction_topup') + status['users_restriction_subscription_column'] = await check_column_exists('users', 'restriction_subscription') + status['users_restriction_reason_column'] = await check_column_exists('users', 'restriction_reason') + status['contest_templates_prize_type_column'] = await check_column_exists('contest_templates', 'prize_type') + status['contest_templates_prize_value_column'] = await check_column_exists('contest_templates', 'prize_value') media_fields_exist = ( - await check_column_exists('broadcast_history', 'has_media') and - await check_column_exists('broadcast_history', 'media_type') and - await check_column_exists('broadcast_history', 'media_file_id') and - await check_column_exists('broadcast_history', 'media_caption') + await check_column_exists('broadcast_history', 'has_media') + and await check_column_exists('broadcast_history', 'media_type') + and await check_column_exists('broadcast_history', 'media_file_id') + and await check_column_exists('broadcast_history', 'media_caption') ) - status["broadcast_history_media_fields"] = media_fields_exist + status['broadcast_history_media_fields'] = media_fields_exist pinned_media_columns_exist = ( - status["pinned_messages_table"] + status['pinned_messages_table'] and await check_column_exists('pinned_messages', 'media_type') and await check_column_exists('pinned_messages', 'media_file_id') ) - status["pinned_messages_media_columns"] = pinned_media_columns_exist + status['pinned_messages_media_columns'] = pinned_media_columns_exist - status["pinned_messages_position_column"] = ( - status["pinned_messages_table"] - and await check_column_exists('pinned_messages', 'send_before_menu') + status['pinned_messages_position_column'] = status['pinned_messages_table'] and await check_column_exists( + 'pinned_messages', 'send_before_menu' ) - status["pinned_messages_start_mode_column"] = ( - status["pinned_messages_table"] - and await check_column_exists('pinned_messages', 'send_on_every_start') + status['pinned_messages_start_mode_column'] = status['pinned_messages_table'] and await check_column_exists( + 'pinned_messages', 'send_on_every_start' ) - status["users_last_pinned_column"] = await check_column_exists('users', 'last_pinned_message_id') + status['users_last_pinned_column'] = await check_column_exists('users', 'last_pinned_message_id') # Колонки чеков в transactions - status["transactions_receipt_uuid_column"] = await check_column_exists('transactions', 'receipt_uuid') - status["transactions_receipt_created_at_column"] = await check_column_exists('transactions', 'receipt_created_at') + status['transactions_receipt_uuid_column'] = await check_column_exists('transactions', 'receipt_uuid') + status['transactions_receipt_created_at_column'] = await check_column_exists( + 'transactions', 'receipt_created_at' + ) async with engine.begin() as conn: - duplicates_check = await conn.execute(text(""" + duplicates_check = await conn.execute( + text(""" SELECT COUNT(*) FROM ( - SELECT user_id, COUNT(*) as count - FROM subscriptions - GROUP BY user_id + SELECT user_id, COUNT(*) as count + FROM subscriptions + GROUP BY user_id HAVING COUNT(*) > 1 ) as dups - """)) + """) + ) duplicates_count = duplicates_check.fetchone()[0] - status["subscription_duplicates"] = (duplicates_count == 0) - + status['subscription_duplicates'] = duplicates_count == 0 + check_names = { - "has_made_first_topup_column": "Колонка реферальной системы", - "cryptobot_table": "Таблица CryptoBot payments", - "heleket_table": "Таблица Heleket payments", - "user_messages_table": "Таблица пользовательских сообщений", - "pinned_messages_table": "Таблица закреплённых сообщений", - "welcome_texts_table": "Таблица приветственных текстов", - "privacy_policies_table": "Таблица политик конфиденциальности", - "public_offers_table": "Таблица публичных оферт", - "welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts", - "pinned_messages_media_columns": "Медиа поля в pinned_messages", - "pinned_messages_position_column": "Позиция закрепа (до/после меню)", - "pinned_messages_start_mode_column": "Режим отправки закрепа при /start", - "users_last_pinned_column": "Колонка last_pinned_message_id у пользователей", - "broadcast_history_media_fields": "Медиа поля в broadcast_history", - "subscription_conversions_table": "Таблица конверсий подписок", - "subscription_events_table": "Таблица событий подписок", - "subscription_duplicates": "Отсутствие дубликатов подписок", - "promo_groups_table": "Таблица промо-групп", - "server_promo_groups_table": "Связи серверов и промогрупп", - "server_squads_trial_column": "Колонка триального назначения у серверов", - "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": "Порог последней авто-промогруппы у пользователей", - "users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей", - "users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей", - "users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей", - "users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей", - "users_notification_settings_column": "Колонка notification_settings у пользователей", - "users_auth_type_column": "Колонка auth_type у пользователей (email-регистрация)", - "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", - "subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions", - "subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions", - "contest_templates_prize_type_column": "Колонка prize_type в contest_templates", - "contest_templates_prize_value_column": "Колонка prize_value в contest_templates", - "discount_offers_table": "Таблица discount_offers", - "discount_offers_effect_column": "Колонка effect_type в discount_offers", - "discount_offers_extra_column": "Колонка extra_data в discount_offers", - "referral_contests_table": "Таблица referral_contests", - "referral_contest_events_table": "Таблица referral_contest_events", - "referral_contest_type_column": "Колонка contest_type в referral_contests", - "referral_contest_summary_times_column": "Колонка daily_summary_times в referral_contests", - "referral_contest_last_summary_at_column": "Колонка last_daily_summary_at в referral_contests", - "contest_templates_table": "Таблица contest_templates", - "contest_rounds_table": "Таблица contest_rounds", - "contest_attempts_table": "Таблица contest_attempts", - "promo_offer_templates_table": "Таблица promo_offer_templates", - "promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates", - "promo_offer_logs_table": "Таблица promo_offer_logs", - "subscription_temporary_access_table": "Таблица subscription_temporary_access", - "transactions_receipt_uuid_column": "Колонка receipt_uuid в transactions", - "transactions_receipt_created_at_column": "Колонка receipt_created_at в transactions", + 'has_made_first_topup_column': 'Колонка реферальной системы', + 'cryptobot_table': 'Таблица CryptoBot payments', + 'heleket_table': 'Таблица Heleket payments', + 'user_messages_table': 'Таблица пользовательских сообщений', + 'pinned_messages_table': 'Таблица закреплённых сообщений', + 'welcome_texts_table': 'Таблица приветственных текстов', + 'privacy_policies_table': 'Таблица политик конфиденциальности', + 'public_offers_table': 'Таблица публичных оферт', + 'welcome_texts_is_enabled_column': 'Поле is_enabled в welcome_texts', + 'pinned_messages_media_columns': 'Медиа поля в pinned_messages', + 'pinned_messages_position_column': 'Позиция закрепа (до/после меню)', + 'pinned_messages_start_mode_column': 'Режим отправки закрепа при /start', + 'users_last_pinned_column': 'Колонка last_pinned_message_id у пользователей', + 'broadcast_history_media_fields': 'Медиа поля в broadcast_history', + 'subscription_conversions_table': 'Таблица конверсий подписок', + 'subscription_events_table': 'Таблица событий подписок', + 'subscription_duplicates': 'Отсутствие дубликатов подписок', + 'promo_groups_table': 'Таблица промо-групп', + 'server_promo_groups_table': 'Связи серверов и промогрупп', + 'server_squads_trial_column': 'Колонка триального назначения у серверов', + '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': 'Порог последней авто-промогруппы у пользователей', + 'users_promo_offer_discount_percent_column': 'Колонка процента промо-скидки у пользователей', + 'users_promo_offer_discount_source_column': 'Колонка источника промо-скидки у пользователей', + 'users_promo_offer_discount_expires_column': 'Колонка срока действия промо-скидки у пользователей', + 'users_referral_commission_percent_column': 'Колонка процента реферальной комиссии у пользователей', + 'users_notification_settings_column': 'Колонка notification_settings у пользователей', + 'users_auth_type_column': 'Колонка auth_type у пользователей (email-регистрация)', + 'subscription_crypto_link_column': 'Колонка subscription_crypto_link в subscriptions', + 'subscription_modem_enabled_column': 'Колонка modem_enabled в subscriptions', + 'subscription_purchased_traffic_column': 'Колонка purchased_traffic_gb в subscriptions', + 'contest_templates_prize_type_column': 'Колонка prize_type в contest_templates', + 'contest_templates_prize_value_column': 'Колонка prize_value в contest_templates', + 'discount_offers_table': 'Таблица discount_offers', + 'discount_offers_effect_column': 'Колонка effect_type в discount_offers', + 'discount_offers_extra_column': 'Колонка extra_data в discount_offers', + 'referral_contests_table': 'Таблица referral_contests', + 'referral_contest_events_table': 'Таблица referral_contest_events', + 'referral_contest_type_column': 'Колонка contest_type в referral_contests', + 'referral_contest_summary_times_column': 'Колонка daily_summary_times в referral_contests', + 'referral_contest_last_summary_at_column': 'Колонка last_daily_summary_at в referral_contests', + 'contest_templates_table': 'Таблица contest_templates', + 'contest_rounds_table': 'Таблица contest_rounds', + 'contest_attempts_table': 'Таблица contest_attempts', + 'promo_offer_templates_table': 'Таблица promo_offer_templates', + 'promo_offer_templates_active_discount_column': 'Колонка active_discount_hours в promo_offer_templates', + 'promo_offer_logs_table': 'Таблица promo_offer_logs', + 'subscription_temporary_access_table': 'Таблица subscription_temporary_access', + 'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions', + 'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions', } - + for check_key, check_status in status.items(): check_name = check_names.get(check_key, check_key) - icon = "✅" if check_status else "❌" - logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}") - + icon = '✅' if check_status else '❌' + logger.info(f'{icon} {check_name}: {"OK" if check_status else "ТРЕБУЕТ ВНИМАНИЯ"}') + all_good = all(status.values()) if all_good: - logger.info("🎉 Все миграции выполнены успешно!") - + logger.info('🎉 Все миграции выполнены успешно!') + try: async with engine.begin() as conn: - conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions")) - users_count = await conn.execute(text("SELECT COUNT(*) FROM users")) - welcome_texts_count = await conn.execute(text("SELECT COUNT(*) FROM welcome_texts")) - broadcasts_count = await conn.execute(text("SELECT COUNT(*) FROM broadcast_history")) - + conversions_count = await conn.execute(text('SELECT COUNT(*) FROM subscription_conversions')) + users_count = await conn.execute(text('SELECT COUNT(*) FROM users')) + welcome_texts_count = await conn.execute(text('SELECT COUNT(*) FROM welcome_texts')) + broadcasts_count = await conn.execute(text('SELECT COUNT(*) FROM broadcast_history')) + conv_count = conversions_count.fetchone()[0] usr_count = users_count.fetchone()[0] welcome_count = welcome_texts_count.fetchone()[0] broadcast_count = broadcasts_count.fetchone()[0] - - logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок") + + logger.info( + f'📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок' + ) except Exception as stats_error: - logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}") - + logger.debug(f'Не удалось получить дополнительную статистику: {stats_error}') + else: - logger.warning("⚠️ Некоторые миграции требуют внимания") + logger.warning('⚠️ Некоторые миграции требуют внимания') missing_migrations = [check_names[k] for k, v in status.items() if not v] - logger.warning(f"Требуют выполнения: {', '.join(missing_migrations)}") - + logger.warning(f'Требуют выполнения: {", ".join(missing_migrations)}') + return status - + except Exception as e: - logger.error(f"Ошибка проверки статуса миграций: {e}") + logger.error(f'Ошибка проверки статуса миграций: {e}') return None diff --git a/app/external/ban_system_api.py b/app/external/ban_system_api.py index c8789ab6..7ad39479 100644 --- a/app/external/ban_system_api.py +++ b/app/external/ban_system_api.py @@ -3,20 +3,20 @@ Ban System API Client. Client for interacting with the BedolagaBan monitoring system. """ -import asyncio + import logging -from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any import aiohttp + logger = logging.getLogger(__name__) class BanSystemAPIError(Exception): """Ban System API error.""" - def __init__(self, message: str, status_code: Optional[int] = None, response_data: Optional[dict] = None): + def __init__(self, message: str, status_code: int | None = None, response_data: dict | None = None): self.message = message self.status_code = status_code self.response_data = response_data @@ -30,22 +30,19 @@ class BanSystemAPI: self.base_url = base_url.rstrip('/') self.api_token = api_token self.timeout = aiohttp.ClientTimeout(total=timeout) - self.session: Optional[aiohttp.ClientSession] = None + self.session: aiohttp.ClientSession | None = None - def _get_headers(self) -> Dict[str, str]: + def _get_headers(self) -> dict[str, str]: """Get request headers with authorization.""" return { - "Authorization": f"Bearer {self.api_token}", - "Content-Type": "application/json", - "Accept": "application/json", + 'Authorization': f'Bearer {self.api_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', } async def __aenter__(self): """Async context manager entry.""" - self.session = aiohttp.ClientSession( - timeout=self.timeout, - headers=self._get_headers() - ) + self.session = aiohttp.ClientSession(timeout=self.timeout, headers=self._get_headers()) return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -57,22 +54,19 @@ class BanSystemAPI: async def _ensure_session(self): """Ensure session is created.""" if self.session is None: - self.session = aiohttp.ClientSession( - timeout=self.timeout, - headers=self._get_headers() - ) + self.session = aiohttp.ClientSession(timeout=self.timeout, headers=self._get_headers()) async def _request( self, method: str, endpoint: str, - params: Optional[Dict] = None, - json_data: Optional[Dict] = None, + params: dict | None = None, + json_data: dict | None = None, ) -> Any: """Execute HTTP request.""" await self._ensure_session() - url = f"{self.base_url}{endpoint}" + url = f'{self.base_url}{endpoint}' try: async with self.session.request( @@ -84,34 +78,26 @@ class BanSystemAPI: response_text = await response.text() if response.status >= 400: - logger.error(f"Ban System API error: {response.status} - {response_text}") + logger.error(f'Ban System API error: {response.status} - {response_text}') raise BanSystemAPIError( - message=f"API error {response.status}: {response_text}", + message=f'API error {response.status}: {response_text}', status_code=response.status, - response_data={"error": response_text} + response_data={'error': response_text}, ) if response_text: try: return await response.json() except Exception: - return {"raw": response_text} + return {'raw': response_text} return {} except aiohttp.ClientError as e: - logger.error(f"Ban System API connection error: {e}") - raise BanSystemAPIError( - message=f"Connection error: {str(e)}", - status_code=None, - response_data=None - ) - except asyncio.TimeoutError: - logger.error("Ban System API request timeout") - raise BanSystemAPIError( - message="Request timeout", - status_code=None, - response_data=None - ) + logger.error(f'Ban System API connection error: {e}') + raise BanSystemAPIError(message=f'Connection error: {e!s}', status_code=None, response_data=None) + except TimeoutError: + logger.error('Ban System API request timeout') + raise BanSystemAPIError(message='Request timeout', status_code=None, response_data=None) async def close(self): """Close the session.""" @@ -121,21 +107,21 @@ class BanSystemAPI: # === Stats === - async def get_stats(self) -> Dict[str, Any]: + async def get_stats(self) -> dict[str, Any]: """ Get overall system statistics. GET /api/stats """ - return await self._request("GET", "/api/stats") + return await self._request('GET', '/api/stats') - async def get_stats_period(self, hours: int = 24) -> Dict[str, Any]: + async def get_stats_period(self, hours: int = 24) -> dict[str, Any]: """ Get statistics for a specific period. GET /api/stats/period?hours={hours} """ - return await self._request("GET", "/api/stats/period", params={"hours": hours}) + return await self._request('GET', '/api/stats/period', params={'hours': hours}) # === Users === @@ -143,8 +129,8 @@ class BanSystemAPI: self, offset: int = 0, limit: int = 50, - status: Optional[str] = None, - ) -> Dict[str, Any]: + status: str | None = None, + ) -> dict[str, Any]: """ Get list of users with pagination. @@ -155,117 +141,109 @@ class BanSystemAPI: limit: Number of users per page (max 100) status: Filter by status (over_limit, with_limit, unlimited) """ - params = {"offset": offset, "limit": min(limit, 100)} + params = {'offset': offset, 'limit': min(limit, 100)} if status: - params["status"] = status - return await self._request("GET", "/api/users", params=params) + params['status'] = status + return await self._request('GET', '/api/users', params=params) - async def get_users_over_limit(self, limit: int = 50, window: bool = True) -> Dict[str, Any]: + async def get_users_over_limit(self, limit: int = 50, window: bool = True) -> dict[str, Any]: """ Get users who exceeded their device limit. GET /api/users/over-limit """ return await self._request( - "GET", - "/api/users/over-limit", - params={"limit": limit, "window": str(window).lower()} + 'GET', '/api/users/over-limit', params={'limit': limit, 'window': str(window).lower()} ) - async def search_users(self, query: str) -> Dict[str, Any]: + async def search_users(self, query: str) -> dict[str, Any]: """ Search for a user. GET /api/users/search/{query} """ - return await self._request("GET", f"/api/users/search/{query}") + return await self._request('GET', f'/api/users/search/{query}') - async def get_user(self, email: str) -> Dict[str, Any]: + async def get_user(self, email: str) -> dict[str, Any]: """ Get detailed user information. GET /api/users/{email} """ - return await self._request("GET", f"/api/users/{email}") + return await self._request('GET', f'/api/users/{email}') - async def get_user_network(self, email: str) -> Dict[str, Any]: + async def get_user_network(self, email: str) -> dict[str, Any]: """ Get user network information (WiFi/Mobile detection). GET /api/users/{email}/network """ - return await self._request("GET", f"/api/users/{email}/network") + return await self._request('GET', f'/api/users/{email}/network') # === Punishments (Bans) === - async def get_punishments(self) -> List[Dict[str, Any]]: + async def get_punishments(self) -> list[dict[str, Any]]: """ Get list of active punishments (bans). GET /api/punishments """ - return await self._request("GET", "/api/punishments") + return await self._request('GET', '/api/punishments') - async def enable_user(self, user_id: str) -> Dict[str, Any]: + async def enable_user(self, user_id: str) -> dict[str, Any]: """ Enable (unban) a user. POST /api/punishments/{user_id}/enable """ - return await self._request("POST", f"/api/punishments/{user_id}/enable") + return await self._request('POST', f'/api/punishments/{user_id}/enable') async def ban_user( self, username: str, minutes: int = 30, - reason: Optional[str] = None, - ) -> Dict[str, Any]: + reason: str | None = None, + ) -> dict[str, Any]: """ Manually ban a user. POST /api/ban """ - params = {"username": username, "minutes": minutes} + params = {'username': username, 'minutes': minutes} if reason: - params["reason"] = reason - return await self._request("POST", "/api/ban", params=params) + params['reason'] = reason + return await self._request('POST', '/api/ban', params=params) - async def get_punishment_history(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + async def get_punishment_history(self, query: str, limit: int = 20) -> list[dict[str, Any]]: """ Get punishment history for a user. GET /api/history/{query} """ - return await self._request( - "GET", - f"/api/history/{query}", - params={"limit": limit} - ) + return await self._request('GET', f'/api/history/{query}', params={'limit': limit}) # === Nodes === - async def get_nodes(self, include_agent_stats: bool = True) -> List[Dict[str, Any]]: + async def get_nodes(self, include_agent_stats: bool = True) -> list[dict[str, Any]]: """ Get list of connected nodes. GET /api/nodes """ return await self._request( - "GET", - "/api/nodes", - params={"include_agent_stats": str(include_agent_stats).lower()} + 'GET', '/api/nodes', params={'include_agent_stats': str(include_agent_stats).lower()} ) # === Agents === async def get_agents( self, - search: Optional[str] = None, - health: Optional[str] = None, - status: Optional[str] = None, - sort_by: str = "name", - sort_order: str = "asc", - ) -> Dict[str, Any]: + search: str | None = None, + health: str | None = None, + status: str | None = None, + sort_by: str = 'name', + sort_order: str = 'asc', + ) -> dict[str, Any]: """ Get list of monitoring agents. @@ -278,138 +256,134 @@ class BanSystemAPI: sort_by: Sort by field (name, sent, dropped, health) sort_order: Sort order (asc, desc) """ - params = {"sort_by": sort_by, "sort_order": sort_order} + params = {'sort_by': sort_by, 'sort_order': sort_order} if search: - params["search"] = search + params['search'] = search if health: - params["health"] = health + params['health'] = health if status: - params["status"] = status - return await self._request("GET", "/api/agents", params=params) + params['status'] = status + return await self._request('GET', '/api/agents', params=params) - async def get_agents_summary(self) -> Dict[str, Any]: + async def get_agents_summary(self) -> dict[str, Any]: """ Get summary statistics for all agents. GET /api/agents/summary """ - return await self._request("GET", "/api/agents/summary") + return await self._request('GET', '/api/agents/summary') async def get_agent_history( self, node_name: str, hours: int = 24, limit: int = 50, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """ Get agent statistics history. GET /api/agents/{node_name}/history """ - return await self._request( - "GET", - f"/api/agents/{node_name}/history", - params={"hours": hours, "limit": limit} - ) + return await self._request('GET', f'/api/agents/{node_name}/history', params={'hours': hours, 'limit': limit}) # === Traffic === - async def get_traffic(self) -> Dict[str, Any]: + async def get_traffic(self) -> dict[str, Any]: """ Get overall traffic statistics. GET /api/traffic """ - return await self._request("GET", "/api/traffic") + return await self._request('GET', '/api/traffic') - async def get_traffic_top(self, limit: int = 20) -> List[Dict[str, Any]]: + async def get_traffic_top(self, limit: int = 20) -> list[dict[str, Any]]: """ Get top users by traffic. GET /api/traffic/top """ - return await self._request("GET", "/api/traffic/top", params={"limit": limit}) + return await self._request('GET', '/api/traffic/top', params={'limit': limit}) - async def get_user_traffic(self, username: str) -> Dict[str, Any]: + async def get_user_traffic(self, username: str) -> dict[str, Any]: """ Get traffic information for a specific user. GET /api/traffic/user/{username} """ - return await self._request("GET", f"/api/traffic/user/{username}") + return await self._request('GET', f'/api/traffic/user/{username}') - async def get_traffic_violations(self, limit: int = 50) -> List[Dict[str, Any]]: + async def get_traffic_violations(self, limit: int = 50) -> list[dict[str, Any]]: """ Get list of traffic limit violations. GET /api/traffic/violations """ - return await self._request("GET", "/api/traffic/violations", params={"limit": limit}) + return await self._request('GET', '/api/traffic/violations', params={'limit': limit}) # === Health === - async def health_check(self) -> Dict[str, Any]: + async def health_check(self) -> dict[str, Any]: """ Check API health. GET /health """ - return await self._request("GET", "/health") + return await self._request('GET', '/health') - async def health_detailed(self) -> Dict[str, Any]: + async def health_detailed(self) -> dict[str, Any]: """ Get detailed health information. GET /health/detailed """ - return await self._request("GET", "/health/detailed") + return await self._request('GET', '/health/detailed') # === Settings === - async def get_settings(self) -> Dict[str, Any]: + async def get_settings(self) -> dict[str, Any]: """ Get all settings with their definitions. GET /api/settings """ - return await self._request("GET", "/api/settings") + return await self._request('GET', '/api/settings') - async def get_setting(self, key: str) -> Dict[str, Any]: + async def get_setting(self, key: str) -> dict[str, Any]: """ Get a specific setting value. GET /api/settings/{key} """ - return await self._request("GET", f"/api/settings/{key}") + return await self._request('GET', f'/api/settings/{key}') - async def set_setting(self, key: str, value: Any) -> Dict[str, Any]: + async def set_setting(self, key: str, value: Any) -> dict[str, Any]: """ Set a setting value. POST /api/settings/{key}?value={value} """ - return await self._request("POST", f"/api/settings/{key}", params={"value": value}) + return await self._request('POST', f'/api/settings/{key}', params={'value': value}) - async def toggle_setting(self, key: str) -> Dict[str, Any]: + async def toggle_setting(self, key: str) -> dict[str, Any]: """ Toggle a boolean setting. POST /api/settings/{key}/toggle """ - return await self._request("POST", f"/api/settings/{key}/toggle") + return await self._request('POST', f'/api/settings/{key}/toggle') - async def whitelist_add(self, username: str) -> Dict[str, Any]: + async def whitelist_add(self, username: str) -> dict[str, Any]: """ Add user to whitelist. POST /api/settings/whitelist/add?username={username} """ - return await self._request("POST", "/api/settings/whitelist/add", params={"username": username}) + return await self._request('POST', '/api/settings/whitelist/add', params={'username': username}) - async def whitelist_remove(self, username: str) -> Dict[str, Any]: + async def whitelist_remove(self, username: str) -> dict[str, Any]: """ Remove user from whitelist. POST /api/settings/whitelist/remove?username={username} """ - return await self._request("POST", "/api/settings/whitelist/remove", params={"username": username}) + return await self._request('POST', '/api/settings/whitelist/remove', params={'username': username}) diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py index 7088dc21..d91d4849 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -1,115 +1,99 @@ -import logging import hashlib import hmac -import json +import logging +from typing import Any + import aiohttp -from typing import Optional, Dict, Any -from datetime import datetime from app.config import settings + logger = logging.getLogger(__name__) class CryptoBotService: - def __init__(self): self.api_token = settings.CRYPTOBOT_API_TOKEN self.base_url = settings.get_cryptobot_base_url() self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET - + async def _make_request( self, method: str, endpoint: str, - data: Optional[Dict] = None, - ) -> Optional[Dict[str, Any]]: - + data: dict | None = None, + ) -> dict[str, Any] | None: if not self.api_token: - logger.error("CryptoBot API token не настроен") + logger.error('CryptoBot API token не настроен') return None - - url = f"{self.base_url}/api/{endpoint}" - headers = { - 'Crypto-Pay-API-Token': self.api_token, - 'Content-Type': 'application/json' - } - + + url = f'{self.base_url}/api/{endpoint}' + headers = {'Crypto-Pay-API-Token': self.api_token, 'Content-Type': 'application/json'} + try: async with aiohttp.ClientSession() as session: - request_kwargs: Dict[str, Any] = {"headers": headers} + request_kwargs: dict[str, Any] = {'headers': headers} - if method.upper() == "GET": + if method.upper() == 'GET': if data: - request_kwargs["params"] = data + request_kwargs['params'] = data elif data: - request_kwargs["json"] = data + request_kwargs['json'] = data async with session.request( method, url, **request_kwargs, ) as response: - response_data = await response.json() - + if response.status == 200 and response_data.get('ok'): return response_data.get('result') - else: - logger.error(f"CryptoBot API ошибка: {response_data}") - return None - + logger.error(f'CryptoBot API ошибка: {response_data}') + return None + except Exception as e: - logger.error(f"Ошибка запроса к CryptoBot API: {e}") + logger.error(f'Ошибка запроса к CryptoBot API: {e}') return None - - async def get_me(self) -> Optional[Dict[str, Any]]: + + async def get_me(self) -> dict[str, Any] | None: return await self._make_request('GET', 'getMe') - + async def create_invoice( self, amount: str, - asset: str = "USDT", - description: Optional[str] = None, - payload: Optional[str] = None, - expires_in: Optional[int] = None - ) -> Optional[Dict[str, Any]]: - - data = { - 'currency_type': 'crypto', - 'asset': asset, - 'amount': amount - } - + asset: str = 'USDT', + description: str | None = None, + payload: str | None = None, + expires_in: int | None = None, + ) -> dict[str, Any] | None: + data = {'currency_type': 'crypto', 'asset': asset, 'amount': amount} + if description: data['description'] = description - + if payload: data['payload'] = payload - + if expires_in: data['expires_in'] = expires_in - + result = await self._make_request('POST', 'createInvoice', data) - + if result: - logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}") - + logger.info(f'Создан CryptoBot invoice {result.get("invoice_id")} на {amount} {asset}') + return result - + async def get_invoices( self, - asset: Optional[str] = None, - status: Optional[str] = None, + asset: str | None = None, + status: str | None = None, offset: int = 0, count: int = 100, - invoice_ids: Optional[list] = None, - ) -> Optional[list]: - - data = { - 'offset': offset, - 'count': count - } + invoice_ids: list | None = None, + ) -> list | None: + data = {'offset': offset, 'count': count} if asset: data['asset'] = asset @@ -130,44 +114,42 @@ class CryptoBotService: return result return [] - - async def get_balance(self) -> Optional[list]: + + async def get_balance(self) -> list | None: return await self._make_request('GET', 'getBalance') - - async def get_exchange_rates(self) -> Optional[list]: + + async def get_exchange_rates(self) -> list | None: return await self._make_request('GET', 'getExchangeRates') - + def verify_webhook_signature(self, body: str, signature: str) -> bool: - if not self.webhook_secret: - logger.warning("CryptoBot webhook secret не настроен") + logger.warning('CryptoBot webhook secret не настроен') return True - + try: secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest() expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest() - + is_valid = hmac.compare_digest(signature, expected_signature) - + if is_valid: - logger.info("✅ CryptoBot webhook подпись валидна") + logger.info('✅ CryptoBot webhook подпись валидна') else: - logger.error("❌ Неверная подпись CryptoBot webhook") - + logger.error('❌ Неверная подпись CryptoBot webhook') + return is_valid - + except Exception as e: - logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}") + logger.error(f'Ошибка проверки подписи CryptoBot webhook: {e}') return False - - async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - + + async def process_webhook(self, webhook_data: dict[str, Any]) -> dict[str, Any] | None: try: update_type = webhook_data.get('update_type') - + if update_type == 'invoice_paid': invoice_data = webhook_data.get('payload', {}) - + return { 'event_type': 'payment', 'payment_id': str(invoice_data.get('invoice_id')), @@ -176,12 +158,12 @@ class CryptoBotService: 'status': 'paid', 'user_payload': invoice_data.get('payload'), 'paid_at': invoice_data.get('paid_at'), - 'payment_system': 'cryptobot' + 'payment_system': 'cryptobot', } - - logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}") + + logger.warning(f'Неизвестный тип CryptoBot webhook: {update_type}') return None - + except Exception as e: - logger.error(f"Ошибка обработки CryptoBot webhook: {e}") + logger.error(f'Ошибка обработки CryptoBot webhook: {e}') return None diff --git a/app/external/heleket.py b/app/external/heleket.py index ff0052e8..b4ed0163 100644 --- a/app/external/heleket.py +++ b/app/external/heleket.py @@ -7,12 +7,13 @@ import hashlib import hmac import json import logging -from typing import Any, Dict, Optional +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ class HeleketService: """Minimal wrapper around Heleket API endpoints.""" def __init__(self) -> None: - self.base_url = settings.HELEKET_BASE_URL.rstrip("/") + self.base_url = settings.HELEKET_BASE_URL.rstrip('/') self.merchant_id = settings.HELEKET_MERCHANT_ID self.api_key = settings.HELEKET_API_KEY @@ -30,7 +31,7 @@ class HeleketService: def _prepare_body( self, - payload: Dict[str, Any], + payload: dict[str, Any], *, ignore_none: bool, sort_keys: bool, @@ -43,132 +44,134 @@ class HeleketService: serialized = json.dumps( cleaned, ensure_ascii=False, - separators=(",", ":"), + separators=(',', ':'), sort_keys=sort_keys, ) - if "/" in serialized: - serialized = serialized.replace("/", "\\/") + if '/' in serialized: + serialized = serialized.replace('/', '\\/') return serialized def _generate_signature(self, body: str) -> str: - api_key = self.api_key or "" - encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8") - raw = f"{encoded}{api_key}" - return hashlib.md5(raw.encode("utf-8")).hexdigest() + api_key = self.api_key or '' + encoded = base64.b64encode(body.encode('utf-8')).decode('utf-8') + raw = f'{encoded}{api_key}' + return hashlib.md5(raw.encode('utf-8')).hexdigest() async def _request( self, endpoint: str, - payload: Dict[str, Any], + payload: dict[str, Any], *, - params: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: if not self.is_configured: - logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют") + logger.error('Heleket сервис не настроен: merchant или api_key отсутствуют') return None body = self._prepare_body(payload, ignore_none=True, sort_keys=True) signature = self._generate_signature(body) - url = f"{self.base_url}/{endpoint.lstrip('/')}" + url = f'{self.base_url}/{endpoint.lstrip("/")}' headers = { - "merchant": self.merchant_id or "", - "sign": signature, - "Content-Type": "application/json", + 'merchant': self.merchant_id or '', + 'sign': signature, + 'Content-Type': 'application/json', } try: timeout = aiohttp.ClientTimeout(total=30) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post( + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.post( url, - data=body.encode("utf-8"), + data=body.encode('utf-8'), headers=headers, params=params, - ) as response: - text = await response.text() - if response.content_type != "application/json": - logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text) - return None - - try: - data = json.loads(text) - except json.JSONDecodeError: - logger.error("Ошибка парсинга Heleket JSON: %s", text) - return None - - if response.status >= 400: - logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data) - return None - - if isinstance(data, dict) and data.get("state") == 0: - return data - - logger.error("Heleket API вернул ошибку: %s", data) + ) as response, + ): + text = await response.text() + if response.content_type != 'application/json': + logger.error('Ответ Heleket не JSON (%s): %s', response.content_type, text) return None + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.error('Ошибка парсинга Heleket JSON: %s', text) + return None + + if response.status >= 400: + logger.error('Heleket API %s вернул статус %s: %s', endpoint, response.status, data) + return None + + if isinstance(data, dict) and data.get('state') == 0: + return data + + logger.error('Heleket API вернул ошибку: %s', data) + return None except Exception as error: # pragma: no cover - defensive - logger.error("Ошибка запроса к Heleket API: %s", error) + logger.error('Ошибка запроса к Heleket API: %s', error) return None - async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: - return await self._request("payment", payload) + async def create_payment(self, payload: dict[str, Any]) -> dict[str, Any] | None: + return await self._request('payment', payload) async def get_payment_info( self, *, - uuid: Optional[str] = None, - order_id: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + uuid: str | None = None, + order_id: str | None = None, + ) -> dict[str, Any] | None: if not uuid and not order_id: - raise ValueError("Нужно указать uuid или order_id для Heleket payment/info") + raise ValueError('Нужно указать uuid или order_id для Heleket payment/info') - payload: Dict[str, Any] = {} + payload: dict[str, Any] = {} if uuid: - payload["uuid"] = uuid + payload['uuid'] = uuid if order_id: - payload["order_id"] = order_id + payload['order_id'] = order_id - return await self._request("payment/info", payload) + return await self._request('payment/info', payload) async def list_payments( self, *, - date_from: Optional[str] = None, - date_to: Optional[str] = None, - cursor: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - payload: Dict[str, Any] = {} + date_from: str | None = None, + date_to: str | None = None, + cursor: str | None = None, + ) -> dict[str, Any] | None: + payload: dict[str, Any] = {} if date_from: - payload["date_from"] = date_from + payload['date_from'] = date_from if date_to: - payload["date_to"] = date_to + payload['date_to'] = date_to - params = {"cursor": cursor} if cursor else None - return await self._request("payment/list", payload, params=params) + params = {'cursor': cursor} if cursor else None + return await self._request('payment/list', payload, params=params) - def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool: + def verify_webhook_signature(self, payload: dict[str, Any]) -> bool: if not self.is_configured: - logger.warning("Heleket сервис не настроен, подпись пропускается") + logger.warning('Heleket сервис не настроен, подпись пропускается') return True if not isinstance(payload, dict): - logger.error("Heleket webhook payload не dict: %s", payload) + logger.error('Heleket webhook payload не dict: %s', payload) return False - signature = payload.get("sign") + signature = payload.get('sign') if not signature: - logger.error("Heleket webhook без подписи") + logger.error('Heleket webhook без подписи') return False data = dict(payload) - data.pop("sign", None) + data.pop('sign', None) body = self._prepare_body(data, ignore_none=False, sort_keys=False) expected = self._generate_signature(body) is_valid = hmac.compare_digest(expected, str(signature)) if not is_valid: - logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature) + logger.error('Неверная подпись Heleket webhook: ожидается %s, получено %s', expected, signature) return is_valid diff --git a/app/external/heleket_webhook.py b/app/external/heleket_webhook.py index bb26b201..7bea2ad6 100644 --- a/app/external/heleket_webhook.py +++ b/app/external/heleket_webhook.py @@ -1,7 +1,7 @@ import asyncio import json import logging -from typing import Any, Dict, Optional +from typing import Any from aiohttp import web @@ -10,6 +10,7 @@ from app.database.database import AsyncSessionLocal from app.external.heleket import HeleketService from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) @@ -20,40 +21,40 @@ class HeleketWebhookHandler: async def handle(self, request: web.Request) -> web.Response: if not settings.is_heleket_enabled(): - logger.warning("Получен Heleket webhook, но сервис отключен") - return web.json_response({"status": "error", "reason": "disabled"}, status=503) + logger.warning('Получен Heleket webhook, но сервис отключен') + return web.json_response({'status': 'error', 'reason': 'disabled'}, status=503) try: - payload: Dict[str, Any] = await request.json() + payload: dict[str, Any] = await request.json() except json.JSONDecodeError: - logger.error("Некорректный JSON Heleket webhook") - return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + logger.error('Некорректный JSON Heleket webhook') + return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) if not self.service.verify_webhook_signature(payload): - return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) + return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401) - processed: Optional[bool] = None + processed: bool | None = None async with AsyncSessionLocal() as db: try: processed = await self.payment_service.process_heleket_webhook(db, payload) await db.commit() except Exception as e: - logger.error(f"Ошибка обработки Heleket webhook: {e}") + logger.error(f'Ошибка обработки Heleket webhook: {e}') await db.rollback() - return web.json_response({"status": "error", "reason": "internal_error"}, status=500) + return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) if processed: - return web.json_response({"status": "ok"}, status=200) + return web.json_response({'status': 'ok'}, status=200) - return web.json_response({"status": "error", "reason": "not_processed"}, status=400) + return web.json_response({'status': 'error', 'reason': 'not_processed'}, status=400) async def health_check(self, _: web.Request) -> web.Response: return web.json_response( { - "status": "ok", - "service": "heleket_webhook", - "enabled": settings.is_heleket_enabled(), - "path": settings.HELEKET_WEBHOOK_PATH, + 'status': 'ok', + 'service': 'heleket_webhook', + 'enabled': settings.is_heleket_enabled(), + 'path': settings.HELEKET_WEBHOOK_PATH, } ) @@ -61,9 +62,9 @@ class HeleketWebhookHandler: return web.Response( status=200, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, ) @@ -72,15 +73,15 @@ def create_heleket_app(payment_service: PaymentService) -> web.Application: handler = HeleketWebhookHandler(payment_service) app = web.Application() app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle) - app.router.add_get("/heleket/health", handler.health_check) - app.router.add_get("/health", handler.health_check) + app.router.add_get('/heleket/health', handler.health_check) + app.router.add_get('/health', handler.health_check) app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler) return app async def start_heleket_webhook_server(payment_service: PaymentService) -> None: if not settings.is_heleket_enabled(): - logger.info("Heleket отключен, webhook сервер не запускается") + logger.info('Heleket отключен, webhook сервер не запускается') return app = create_heleket_app(payment_service) @@ -96,12 +97,12 @@ async def start_heleket_webhook_server(payment_service: PaymentService) -> None: try: await site.start() logger.info( - "Heleket webhook сервер запущен на %s:%s", + 'Heleket webhook сервер запущен на %s:%s', settings.HELEKET_WEBHOOK_HOST, settings.HELEKET_WEBHOOK_PORT, ) logger.info( - "Heleket webhook URL: http://%s:%s%s", + 'Heleket webhook URL: http://%s:%s%s', settings.HELEKET_WEBHOOK_HOST, settings.HELEKET_WEBHOOK_PORT, settings.HELEKET_WEBHOOK_PATH, @@ -110,8 +111,8 @@ async def start_heleket_webhook_server(payment_service: PaymentService) -> None: while True: await asyncio.sleep(1) except asyncio.CancelledError: - logger.info("Heleket webhook сервер остановлен по запросу") + logger.info('Heleket webhook сервер остановлен по запросу') finally: await site.stop() await runner.cleanup() - logger.info("Heleket webhook сервер корректно остановлен") + logger.info('Heleket webhook сервер корректно остановлен') diff --git a/app/external/pal24_client.py b/app/external/pal24_client.py index 4c77a250..999e4404 100644 --- a/app/external/pal24_client.py +++ b/app/external/pal24_client.py @@ -2,17 +2,17 @@ from __future__ import annotations -import asyncio import hashlib import logging from dataclasses import dataclass from decimal import Decimal, InvalidOperation -from typing import Any, Dict, Optional +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @@ -25,20 +25,18 @@ class Pal24Response: """Wrapper for Pal24 API responses.""" success: bool - data: Dict[str, Any] + data: dict[str, Any] status: int @classmethod - def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response": - success = bool(payload.get("success", status < 400)) + def from_payload(cls, payload: dict[str, Any], status: int) -> Pal24Response: + success = bool(payload.get('success', status < 400)) return cls(success=success, data=payload, status=status) def raise_for_status(self, endpoint: str) -> None: if not self.success: - detail = self.data.get("message") or self.data.get("error") - raise Pal24APIError( - f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}" - ) + detail = self.data.get('message') or self.data.get('error') + raise Pal24APIError(f'Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}') class Pal24Client: @@ -47,16 +45,16 @@ class Pal24Client: def __init__( self, *, - api_token: Optional[str] = None, - base_url: Optional[str] = None, - timeout: Optional[int] = None, + api_token: str | None = None, + base_url: str | None = None, + timeout: int | None = None, ) -> None: self.api_token = api_token or settings.PAL24_API_TOKEN - self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/" + self.base_url = (base_url or settings.PAL24_BASE_URL or '').rstrip('/') + '/' self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT if not self.api_token: - logger.warning("Pal24Client initialized without API token") + logger.warning('Pal24Client initialized without API token') @property def is_configured(self) -> bool: @@ -67,61 +65,61 @@ class Pal24Client: method: str, endpoint: str, *, - json_payload: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, + json_payload: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, ) -> Pal24Response: if not self.is_configured: - raise Pal24APIError("Pal24 client is not configured") + raise Pal24APIError('Pal24 client is not configured') - url = f"{self.base_url}{endpoint.lstrip('/')}" + url = f'{self.base_url}{endpoint.lstrip("/")}' headers = { - "Authorization": f"Bearer {self.api_token}", - "Content-Type": "application/json", - "Accept": "application/json", + 'Authorization': f'Bearer {self.api_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', } timeout = aiohttp.ClientTimeout(total=self.timeout) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.request( + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.request( method, url, headers=headers, json=json_payload, params=params, - ) as response: - status = response.status - try: - payload = await response.json(content_type=None) - except aiohttp.ContentTypeError: - text_body = await response.text() - logger.error( - "Pal24 API returned non-JSON response for %s: %s", - endpoint, - text_body, - ) - raise Pal24APIError( - f"Pal24 API returned non-JSON response: {text_body}" - ) from None + ) as response, + ): + status = response.status + try: + payload = await response.json(content_type=None) + except aiohttp.ContentTypeError: + text_body = await response.text() + logger.error( + 'Pal24 API returned non-JSON response for %s: %s', + endpoint, + text_body, + ) + raise Pal24APIError(f'Pal24 API returned non-JSON response: {text_body}') from None - result = Pal24Response.from_payload(payload, status) - if status >= 400 or not result.success: - logger.error( - "Pal24 API error %s %s: %s", - status, - endpoint, - payload, - ) - result.raise_for_status(endpoint) + result = Pal24Response.from_payload(payload, status) + if status >= 400 or not result.success: + logger.error( + 'Pal24 API error %s %s: %s', + status, + endpoint, + payload, + ) + result.raise_for_status(endpoint) - return result + return result - except asyncio.TimeoutError as error: - logger.error("Pal24 API request timeout for %s: %s", endpoint, error) - raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error + except TimeoutError as error: + logger.error('Pal24 API request timeout for %s: %s', endpoint, error) + raise Pal24APIError(f'Pal24 API request timeout for {endpoint}') from error except aiohttp.ClientError as error: - logger.error("Pal24 API client error for %s: %s", endpoint, error) + logger.error('Pal24 API client error for %s: %s', endpoint, error) raise Pal24APIError(str(error)) from error # API methods ----------------------------------------------------------------- @@ -131,66 +129,66 @@ class Pal24Client: *, amount: Decimal, shop_id: str, - order_id: Optional[str] = None, - description: Optional[str] = None, - currency_in: str = "RUB", - type_: str = "normal", + order_id: str | None = None, + description: str | None = None, + currency_in: str = 'RUB', + type_: str = 'normal', **kwargs: Any, - ) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "amount": str(amount), - "shop_id": shop_id, - "currency_in": currency_in, - "type": type_, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + 'amount': str(amount), + 'shop_id': shop_id, + 'currency_in': currency_in, + 'type': type_, } if order_id: - payload["order_id"] = order_id + payload['order_id'] = order_id if description: - payload["description"] = description + payload['description'] = description payload.update({k: v for k, v in kwargs.items() if v is not None}) - response = await self._request("POST", "bill/create", json_payload=payload) + response = await self._request('POST', 'bill/create', json_payload=payload) return response.data - async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: - response = await self._request("GET", "bill/status", params={"id": bill_id}) + async def get_bill_status(self, bill_id: str) -> dict[str, Any]: + response = await self._request('GET', 'bill/status', params={'id': bill_id}) return response.data - async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]: - payload = {"id": bill_id, "active": 1 if active else 0} - response = await self._request("POST", "bill/toggle_activity", json_payload=payload) + async def toggle_bill_activity(self, bill_id: str, active: bool) -> dict[str, Any]: + payload = {'id': bill_id, 'active': 1 if active else 0} + response = await self._request('POST', 'bill/toggle_activity', json_payload=payload) return response.data - async def search_payments(self, **params: Any) -> Dict[str, Any]: - response = await self._request("GET", "payment/search", params=params) + async def search_payments(self, **params: Any) -> dict[str, Any]: + response = await self._request('GET', 'payment/search', params=params) return response.data - async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: - response = await self._request("GET", "payment/status", params={"id": payment_id}) + async def get_payment_status(self, payment_id: str) -> dict[str, Any]: + response = await self._request('GET', 'payment/status', params={'id': payment_id}) return response.data - async def get_balance(self) -> Dict[str, Any]: - response = await self._request("GET", "merchant/balance") + async def get_balance(self) -> dict[str, Any]: + response = await self._request('GET', 'merchant/balance') return response.data - async def search_bills(self, **params: Any) -> Dict[str, Any]: - response = await self._request("GET", "bill/search", params=params) + async def search_bills(self, **params: Any) -> dict[str, Any]: + response = await self._request('GET', 'bill/search', params=params) return response.data - async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]: - response = await self._request("GET", "bill/payments", params={"id": bill_id}) + async def get_bill_payments(self, bill_id: str) -> dict[str, Any]: + response = await self._request('GET', 'bill/payments', params={'id': bill_id}) return response.data # Helpers --------------------------------------------------------------------- @staticmethod - def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str: + def calculate_signature(out_sum: str, inv_id: str, api_token: str | None = None) -> str: token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN if not token: - raise Pal24APIError("Pal24 signature token is not configured") - raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8") + raise Pal24APIError('Pal24 signature token is not configured') + raw = f'{out_sum}:{inv_id}:{token}'.encode() return hashlib.md5(raw).hexdigest().upper() @staticmethod @@ -198,19 +196,18 @@ class Pal24Client: out_sum: str, inv_id: str, signature: str, - api_token: Optional[str] = None, + api_token: str | None = None, ) -> bool: try: expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token) except Pal24APIError: - logger.error("Pal24 signature verification failed: missing token") + logger.error('Pal24 signature verification failed: missing token') return False return expected == signature.upper() @staticmethod def normalize_amount(amount_kopeks: int) -> Decimal: try: - return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01")) + return (Decimal(amount_kopeks) / Decimal(100)).quantize(Decimal('0.01')) except (InvalidOperation, TypeError) as error: - raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error - + raise Pal24APIError(f'Invalid amount: {amount_kopeks}') from error diff --git a/app/external/pal24_webhook.py b/app/external/pal24_webhook.py index 749937ba..a808d182 100644 --- a/app/external/pal24_webhook.py +++ b/app/external/pal24_webhook.py @@ -3,43 +3,44 @@ from __future__ import annotations import asyncio -from concurrent.futures import TimeoutError as FuturesTimeoutError import json import logging import threading from asyncio import AbstractEventLoop -from typing import Any, Dict, Optional +from concurrent.futures import TimeoutError as FuturesTimeoutError +from typing import Any from flask import Flask, jsonify, request from werkzeug.serving import make_server from app.config import settings from app.database.database import AsyncSessionLocal -from app.services.pal24_service import Pal24Service, Pal24APIError +from app.services.pal24_service import Pal24APIError, Pal24Service from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) -def _normalize_payload() -> Dict[str, str]: +def _normalize_payload() -> dict[str, str]: if request.is_json: payload = request.get_json(silent=True) or {} if isinstance(payload, dict): return {k: str(v) for k, v in payload.items()} - logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload) + logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload) return {} if request.form: return {k: v for k, v in request.form.items()} try: - raw_body = request.data.decode("utf-8") + raw_body = request.data.decode('utf-8') if raw_body: payload = json.loads(raw_body) if isinstance(payload, dict): return {k: str(v) for k, v in payload.items()} except json.JSONDecodeError: - logger.debug("Pal24 webhook body не удалось распарсить как JSON") + logger.debug('Pal24 webhook body не удалось распарсить как JSON') return {} @@ -51,24 +52,24 @@ def create_pal24_flask_app( pal24_service = Pal24Service() app = Flask(__name__) - @app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"]) + @app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST']) def pal24_webhook() -> tuple: if not pal24_service.is_configured: - logger.error("Pal24 webhook получен, но сервис не настроен") - return jsonify({"status": "error", "reason": "service_not_configured"}), 503 + logger.error('Pal24 webhook получен, но сервис не настроен') + return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503 - logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers)) + logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers)) payload = _normalize_payload() if not payload: - logger.warning("Пустой Pal24 webhook") - return jsonify({"status": "error", "reason": "empty_payload"}), 400 + logger.warning('Пустой Pal24 webhook') + return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400 try: parsed_payload = pal24_service.parse_callback(payload) except Pal24APIError as error: - logger.error("Ошибка валидации Pal24 webhook: %s", error) - return jsonify({"status": "error", "reason": str(error)}), 400 + logger.error('Ошибка валидации Pal24 webhook: %s', error) + return jsonify({'status': 'error', 'reason': str(error)}), 400 async def process() -> bool: async with AsyncSessionLocal() as db: @@ -82,31 +83,35 @@ def create_pal24_flask_app( future = asyncio.run_coroutine_threadsafe(process(), loop) processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT) except FuturesTimeoutError: - logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT) - return jsonify({"status": "error", "reason": "timeout"}), 504 + logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT) + return jsonify({'status': 'error', 'reason': 'timeout'}), 504 except Exception as error: # pragma: no cover - defensive - logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error) - return jsonify({"status": "error", "reason": "internal_error"}), 500 + logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error) + return jsonify({'status': 'error', 'reason': 'internal_error'}), 500 if processed: - return jsonify({"status": "ok"}), 200 - return jsonify({"status": "error", "reason": "not_processed"}), 400 + return jsonify({'status': 'ok'}), 200 + return jsonify({'status': 'error', 'reason': 'not_processed'}), 400 - @app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"]) + @app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET']) def pal24_health() -> tuple: - return jsonify({ - "status": "ok", - "service": "pal24_webhook", - "enabled": settings.is_pal24_enabled(), - }), 200 + return jsonify( + { + 'status': 'ok', + 'service': 'pal24_webhook', + 'enabled': settings.is_pal24_enabled(), + } + ), 200 - @app.route("/pal24/health", methods=["GET"]) + @app.route('/pal24/health', methods=['GET']) def pal24_additional_health() -> tuple: - return jsonify({ - "status": "ok", - "service": "pal24_webhook", - "path": settings.PAL24_WEBHOOK_PATH, - }), 200 + return jsonify( + { + 'status': 'ok', + 'service': 'pal24_webhook', + 'path': settings.PAL24_WEBHOOK_PATH, + } + ), 200 return app @@ -116,16 +121,16 @@ class Pal24WebhookServer: def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None: self.app = create_pal24_flask_app(payment_service, loop) - self._server: Optional[Any] = None - self._thread: Optional[threading.Thread] = None + self._server: Any | None = None + self._thread: threading.Thread | None = None def start(self) -> None: if self._server: - logger.warning("Pal24 webhook server уже запущен") + logger.warning('Pal24 webhook server уже запущен') return self._server = make_server( - host="0.0.0.0", + host='0.0.0.0', port=settings.PAL24_WEBHOOK_PORT, app=self.app, threaded=True, @@ -133,8 +138,8 @@ class Pal24WebhookServer: def _serve() -> None: logger.info( - "Pal24 webhook сервер запущен на %s:%s%s", - "0.0.0.0", + 'Pal24 webhook сервер запущен на %s:%s%s', + '0.0.0.0', settings.PAL24_WEBHOOK_PORT, settings.PAL24_WEBHOOK_PATH, ) @@ -145,7 +150,7 @@ class Pal24WebhookServer: def stop(self) -> None: if self._server: - logger.info("Останавливаем Pal24 webhook сервер") + logger.info('Останавливаем Pal24 webhook сервер') self._server.shutdown() self._server = None @@ -159,4 +164,3 @@ async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24We server = Pal24WebhookServer(payment_service, loop) await loop.run_in_executor(None, server.start) return server - diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 8b8f3b58..01609d8d 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -1,40 +1,42 @@ -import asyncio +import base64 import json -import ssl -import base64 -from datetime import datetime, timedelta -from typing import Dict, List, Optional, Union, Any -import aiohttp import logging +import ssl from dataclasses import dataclass +from datetime import datetime from enum import Enum -from urllib.parse import urlparse, urljoin +from typing import Any +from urllib.parse import urlparse + +import aiohttp + logger = logging.getLogger(__name__) class UserStatus(Enum): - ACTIVE = "ACTIVE" - DISABLED = "DISABLED" - LIMITED = "LIMITED" - EXPIRED = "EXPIRED" + ACTIVE = 'ACTIVE' + DISABLED = 'DISABLED' + LIMITED = 'LIMITED' + EXPIRED = 'EXPIRED' class TrafficLimitStrategy(Enum): - NO_RESET = "NO_RESET" - DAY = "DAY" - WEEK = "WEEK" - MONTH = "MONTH" + NO_RESET = 'NO_RESET' + DAY = 'DAY' + WEEK = 'WEEK' + MONTH = 'MONTH' @dataclass class UserTraffic: """Данные о трафике пользователя (новая структура API)""" + used_traffic_bytes: int lifetime_used_traffic_bytes: int - online_at: Optional[datetime] = None - first_connected_at: Optional[datetime] = None - last_connected_node_uuid: Optional[str] = None + online_at: datetime | None = None + first_connected_at: datetime | None = None + last_connected_node_uuid: str | None = None @dataclass @@ -46,28 +48,28 @@ class RemnaWaveUser: traffic_limit_bytes: int traffic_limit_strategy: TrafficLimitStrategy expire_at: datetime - telegram_id: Optional[int] - email: Optional[str] - hwid_device_limit: Optional[int] - description: Optional[str] - tag: Optional[str] + telegram_id: int | None + email: str | None + hwid_device_limit: int | None + description: str | None + tag: str | None subscription_url: str - active_internal_squads: List[Dict[str, str]] + active_internal_squads: list[dict[str, str]] created_at: datetime updated_at: datetime - user_traffic: Optional[UserTraffic] = None - sub_last_user_agent: Optional[str] = None - sub_last_opened_at: Optional[datetime] = None - sub_revoked_at: Optional[datetime] = None - last_traffic_reset_at: Optional[datetime] = None - trojan_password: Optional[str] = None - vless_uuid: Optional[str] = None - ss_password: Optional[str] = None + user_traffic: UserTraffic | None = None + sub_last_user_agent: str | None = None + sub_last_opened_at: datetime | None = None + sub_revoked_at: datetime | None = None + last_traffic_reset_at: datetime | None = None + trojan_password: str | None = None + vless_uuid: str | None = None + ss_password: str | None = None last_triggered_threshold: int = 0 - happ_link: Optional[str] = None - happ_crypto_link: Optional[str] = None - external_squad_uuid: Optional[str] = None - id: Optional[int] = None + happ_link: str | None = None + happ_crypto_link: str | None = None + external_squad_uuid: str | None = None + id: int | None = None @property def used_traffic_bytes(self) -> int: @@ -84,14 +86,14 @@ class RemnaWaveUser: return 0 @property - def online_at(self) -> Optional[datetime]: + def online_at(self) -> datetime | None: """Обратная совместимость: получение online_at из user_traffic""" if self.user_traffic: return self.user_traffic.online_at return None @property - def first_connected_at(self) -> Optional[datetime]: + def first_connected_at(self) -> datetime | None: """Обратная совместимость: получение first_connected_at из user_traffic""" if self.user_traffic: return self.user_traffic.first_connected_at @@ -101,14 +103,15 @@ class RemnaWaveUser: @dataclass class RemnaWaveInbound: """Структура inbound для Internal Squad""" + uuid: str profile_uuid: str tag: str type: str - network: Optional[str] = None - security: Optional[str] = None - port: Optional[int] = None - raw_inbound: Optional[Any] = None + network: str | None = None + security: str | None = None + port: int | None = None + raw_inbound: Any | None = None @dataclass @@ -117,21 +120,22 @@ class RemnaWaveInternalSquad: name: str members_count: int inbounds_count: int - inbounds: List[RemnaWaveInbound] + inbounds: list[RemnaWaveInbound] view_position: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None @dataclass class RemnaWaveAccessibleNode: """Доступная нода для Internal Squad""" + uuid: str node_name: str country_code: str config_profile_uuid: str config_profile_name: str - active_inbounds: List[str] + active_inbounds: list[str] @dataclass @@ -142,29 +146,29 @@ class RemnaWaveNode: country_code: str is_connected: bool is_disabled: bool - users_online: Optional[int] - traffic_used_bytes: Optional[int] - traffic_limit_bytes: Optional[int] - port: Optional[int] = None + users_online: int | None + traffic_used_bytes: int | None + traffic_limit_bytes: int | None + port: int | None = None is_connecting: bool = False - xray_version: Optional[str] = None - node_version: Optional[str] = None + xray_version: str | None = None + node_version: str | None = None view_position: int = 0 - tags: Optional[List[str]] = None + tags: list[str] | None = None # Новые поля API - last_status_change: Optional[datetime] = None - last_status_message: Optional[str] = None - xray_uptime: Optional[str] = None + last_status_change: datetime | None = None + last_status_message: str | None = None + xray_uptime: str | None = None is_traffic_tracking_active: bool = False - traffic_reset_day: Optional[int] = None - notify_percent: Optional[int] = None + traffic_reset_day: int | None = None + notify_percent: int | None = None consumption_multiplier: float = 1.0 - cpu_count: Optional[int] = None - cpu_model: Optional[str] = None - total_ram: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - provider_uuid: Optional[str] = None + cpu_count: int | None = None + cpu_model: str | None = None + total_ram: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + provider_uuid: str | None = None @property def is_node_online(self) -> bool: @@ -180,40 +184,42 @@ class RemnaWaveNode: @dataclass class SubscriptionInfo: is_found: bool - user: Optional[Dict[str, Any]] - links: List[str] - ss_conf_links: Dict[str, str] + user: dict[str, Any] | None + links: list[str] + 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 + happ: dict[str, str] | None + happ_link: str | None = None + happ_crypto_link: str | None = None @dataclass class SubscriptionPageConfig: """Конфигурация страницы подписки""" + uuid: str name: str view_position: int - config: Optional[Dict[str, Any]] = None + config: dict[str, Any] | None = None @dataclass class RemnaWaveExternalSquad: """Структура External Squad""" + uuid: str name: str view_position: int members_count: int - templates: List[Dict[str, str]] - subscription_settings: Optional[Dict[str, Any]] = None - host_overrides: Optional[Dict[str, Any]] = None - response_headers: Optional[Dict[str, str]] = None - hwid_settings: Optional[Dict[str, Any]] = None - custom_remarks: Optional[Dict[str, Any]] = None - subpage_config_uuid: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + templates: list[dict[str, str]] + subscription_settings: dict[str, Any] | None = None + host_overrides: dict[str, Any] | None = None + response_headers: dict[str, str] | None = None + hwid_settings: dict[str, Any] | None = None + custom_remarks: dict[str, Any] | None = None + subpage_config_uuid: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None class RemnaWaveAPIError(Exception): @@ -225,16 +231,15 @@ class RemnaWaveAPIError(Exception): class RemnaWaveAPI: - def __init__( self, base_url: str, api_key: str, - secret_key: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - caddy_token: Optional[str] = None, - auth_type: str = "api_key", + secret_key: str | None = None, + username: str | None = None, + password: str | None = None, + caddy_token: str | None = None, + auth_type: str = 'api_key', ): self.base_url = base_url.rstrip('/') self.api_key = api_key @@ -242,169 +247,148 @@ class RemnaWaveAPI: self.username = username self.password = password self.caddy_token = caddy_token - self.auth_type = auth_type.lower() if auth_type else "api_key" - self.session: Optional[aiohttp.ClientSession] = None + self.auth_type = auth_type.lower() if auth_type else 'api_key' + self.session: aiohttp.ClientSession | None = None self.authenticated = False - + def _detect_connection_type(self) -> str: parsed = urlparse(self.base_url) - - local_hosts = [ - 'localhost', '127.0.0.1', 'remnawave', - 'remnawave-backend', 'app', 'api' - ] - - if parsed.hostname in local_hosts: - return "local" - - if parsed.hostname: - if (parsed.hostname.startswith('192.168.') or - parsed.hostname.startswith('10.') or - parsed.hostname.startswith('172.') or - parsed.hostname.endswith('.local')): - return "local" - - return "external" - def _prepare_auth_headers(self) -> Dict[str, str]: + local_hosts = ['localhost', '127.0.0.1', 'remnawave', 'remnawave-backend', 'app', 'api'] + + if parsed.hostname in local_hosts: + return 'local' + + if parsed.hostname: + if ( + parsed.hostname.startswith('192.168.') + or parsed.hostname.startswith('10.') + or parsed.hostname.startswith('172.') + or parsed.hostname.endswith('.local') + ): + return 'local' + + return 'external' + + def _prepare_auth_headers(self) -> dict[str, str]: headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Forwarded-Proto': 'https', 'X-Forwarded-For': '127.0.0.1', - 'X-Real-IP': '127.0.0.1' + 'X-Real-IP': '127.0.0.1', } # Caddy авторизация — добавляется поверх основной if self.caddy_token: # Caddy Security: готовый base64 токен используется как есть headers['Authorization'] = f'Basic {self.caddy_token}' - logger.debug("Используем Caddy Basic Auth") + logger.debug('Используем Caddy Basic Auth') # Основная авторизация RemnaWave API - if self.auth_type == "basic" and self.username and self.password: - credentials = f"{self.username}:{self.password}" + if self.auth_type == 'basic' and self.username and self.password: + credentials = f'{self.username}:{self.password}' encoded_credentials = base64.b64encode(credentials.encode()).decode() - headers['X-Api-Key'] = f"Basic {encoded_credentials}" - logger.debug("Используем Basic Auth в X-Api-Key заголовке") - elif self.auth_type == "caddy": + headers['X-Api-Key'] = f'Basic {encoded_credentials}' + logger.debug('Используем Basic Auth в X-Api-Key заголовке') + elif self.auth_type == 'caddy': # Для caddy auth_type основная авторизация уже в Authorization header # Но API ключ всё равно нужен для RemnaWave if self.api_key: headers['X-Api-Key'] = self.api_key - logger.debug("Используем API ключ для RemnaWave + Caddy авторизацию") + logger.debug('Используем API ключ для RemnaWave + Caddy авторизацию') else: # api_key или bearer — стандартный режим headers['X-Api-Key'] = self.api_key if not self.caddy_token: headers['Authorization'] = f'Bearer {self.api_key}' - logger.debug("Используем API ключ в X-Api-Key заголовке") + logger.debug('Используем API ключ в X-Api-Key заголовке') return headers - + async def __aenter__(self): conn_type = self._detect_connection_type() - - logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})") - - headers = self._prepare_auth_headers() - + + logger.debug(f'Подключение к Remnawave: {self.base_url} (тип: {conn_type})') + + headers = self._prepare_auth_headers() + cookies = None if self.secret_key: if ':' in self.secret_key: key_name, key_value = self.secret_key.split(':', 1) cookies = {key_name: key_value} - logger.debug(f"Используем куки: {key_name}=***") + logger.debug(f'Используем куки: {key_name}=***') else: cookies = {self.secret_key: self.secret_key} - logger.debug(f"Используем куки: {self.secret_key}=***") - + logger.debug(f'Используем куки: {self.secret_key}=***') + connector_kwargs = {} - - if conn_type == "local": - logger.debug("Используют локальные заголовки proxy") - headers.update({ - 'X-Forwarded-Host': 'localhost', - 'Host': 'localhost' - }) - + + if conn_type == 'local': + logger.debug('Используют локальные заголовки proxy') + headers.update({'X-Forwarded-Host': 'localhost', 'Host': 'localhost'}) + if self.base_url.startswith('https://'): ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE connector_kwargs['ssl'] = ssl_context - logger.debug("SSL проверка отключена для локального HTTPS") - - elif conn_type == "external": - logger.debug("Используют внешнее подключение с полной SSL проверкой") - pass - + logger.debug('SSL проверка отключена для локального HTTPS') + + elif conn_type == 'external': + logger.debug('Используют внешнее подключение с полной SSL проверкой') + connector = aiohttp.TCPConnector(**connector_kwargs) - - session_kwargs = { - 'timeout': aiohttp.ClientTimeout(total=30), - 'headers': headers, - 'connector': connector - } - + + session_kwargs = {'timeout': aiohttp.ClientTimeout(total=30), 'headers': headers, 'connector': connector} + if cookies: session_kwargs['cookies'] = cookies - + self.session = aiohttp.ClientSession(**session_kwargs) - self.authenticated = True - + self.authenticated = True + return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): if self.session: await self.session.close() - + async def _make_request( - self, - method: str, - endpoint: str, - data: Optional[Dict] = None, - params: Optional[Dict] = None - ) -> Dict: + self, method: str, endpoint: str, data: dict | None = None, params: dict | None = None + ) -> dict: if not self.session: - raise RemnaWaveAPIError("Session not initialized. Use async context manager.") - - url = f"{self.base_url}{endpoint}" - + raise RemnaWaveAPIError('Session not initialized. Use async context manager.') + + url = f'{self.base_url}{endpoint}' + try: - kwargs = { - 'url': url, - 'params': params - } - + kwargs = {'url': url, 'params': params} + if data: kwargs['json'] = data - + async with self.session.request(method, **kwargs) as response: response_text = await response.text() - + try: response_data = json.loads(response_text) if response_text else {} except json.JSONDecodeError: response_data = {'raw_response': response_text} - + if response.status >= 400: error_message = response_data.get('message', f'HTTP {response.status}') - logger.error(f"API Error {response.status}: {error_message}") - logger.error(f"Response: {response_text[:500]}") - raise RemnaWaveAPIError( - error_message, - response.status, - response_data - ) - + logger.error(f'API Error {response.status}: {error_message}') + logger.error(f'Response: {response_text[:500]}') + raise RemnaWaveAPIError(error_message, response.status, response_data) + return response_data - + except aiohttp.ClientError as e: - logger.error(f"Request failed: {e}") - raise RemnaWaveAPIError(f"Request failed: {str(e)}") - - + logger.error(f'Request failed: {e}') + raise RemnaWaveAPIError(f'Request failed: {e!s}') + async def create_user( self, username: str, @@ -412,21 +396,21 @@ class RemnaWaveAPI: status: UserStatus = UserStatus.ACTIVE, traffic_limit_bytes: int = 0, traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET, - telegram_id: Optional[int] = None, - email: Optional[str] = None, - hwid_device_limit: Optional[int] = None, - description: Optional[str] = None, - tag: Optional[str] = None, - active_internal_squads: Optional[List[str]] = None + telegram_id: int | None = None, + email: str | None = None, + hwid_device_limit: int | None = None, + description: str | None = None, + tag: str | None = None, + active_internal_squads: list[str] | None = None, ) -> RemnaWaveUser: data = { 'username': username, 'status': status.value, 'expireAt': expire_at.isoformat(), 'trafficLimitBytes': traffic_limit_bytes, - 'trafficLimitStrategy': traffic_limit_strategy.value + 'trafficLimitStrategy': traffic_limit_strategy.value, } - + if telegram_id: data['telegramId'] = telegram_id if email: @@ -440,12 +424,12 @@ class RemnaWaveAPI: if active_internal_squads: data['activeInternalSquads'] = active_internal_squads - logger.debug("Создание пользователя в панели: %s", data) + logger.debug('Создание пользователя в панели: %s', data) response = await self._make_request('POST', '/api/users', data) user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) - - async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]: + + async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None: try: response = await self._make_request('GET', f'/api/users/{uuid}') user = self._parse_user(response['response']) @@ -454,8 +438,8 @@ class RemnaWaveAPI: if e.status_code == 404: return None raise - - async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]: + + async def get_user_by_telegram_id(self, telegram_id: int) -> list[RemnaWaveUser]: try: response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}') users_data = response.get('response', []) @@ -467,8 +451,8 @@ class RemnaWaveAPI: if e.status_code == 404: return [] raise - - async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]: + + async def get_user_by_username(self, username: str) -> RemnaWaveUser | None: try: response = await self._make_request('GET', f'/api/users/by-username/{username}') user = self._parse_user(response['response']) @@ -477,23 +461,23 @@ class RemnaWaveAPI: if e.status_code == 404: return None raise - + async def update_user( self, uuid: str, - status: Optional[UserStatus] = None, - traffic_limit_bytes: Optional[int] = None, - traffic_limit_strategy: Optional[TrafficLimitStrategy] = None, - expire_at: Optional[datetime] = None, - telegram_id: Optional[int] = None, - email: Optional[str] = None, - hwid_device_limit: Optional[int] = None, - description: Optional[str] = None, - tag: Optional[str] = None, - active_internal_squads: Optional[List[str]] = None + status: UserStatus | None = None, + traffic_limit_bytes: int | None = None, + traffic_limit_strategy: TrafficLimitStrategy | None = None, + expire_at: datetime | None = None, + telegram_id: int | None = None, + email: str | None = None, + hwid_device_limit: int | None = None, + description: str | None = None, + tag: str | None = None, + active_internal_squads: list[str] | None = None, ) -> RemnaWaveUser: data = {'uuid': uuid} - + if status: data['status'] = status.value if traffic_limit_bytes is not None: @@ -514,7 +498,7 @@ class RemnaWaveAPI: data['tag'] = tag if active_internal_squads is not None: data['activeInternalSquads'] = active_internal_squads - + response = await self._make_request('PATCH', '/api/users', data) user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) @@ -522,7 +506,7 @@ class RemnaWaveAPI: async def delete_user(self, uuid: str) -> bool: response = await self._make_request('DELETE', f'/api/users/{uuid}') return response['response']['isDeleted'] - + async def enable_user(self, uuid: str) -> RemnaWaveUser: response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable') user = self._parse_user(response['response']) @@ -539,10 +523,7 @@ class RemnaWaveAPI: return await self.enrich_user_with_happ_link(user) async def revoke_user_subscription( - self, - uuid: str, - new_short_uuid: Optional[str] = None, - revoke_only_passwords: bool = False + self, uuid: str, new_short_uuid: str | None = None, revoke_only_passwords: bool = False ) -> RemnaWaveUser: """ Отзывает подписку пользователя (меняет ссылку/пароли). @@ -561,8 +542,8 @@ class RemnaWaveAPI: response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data) user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) - - async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]: + + async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]: params = {'start': start, 'size': size} response = await self._make_request('GET', '/api/users', params=params) @@ -571,17 +552,13 @@ class RemnaWaveAPI: if enrich_happ_links: users = [await self.enrich_user_with_happ_link(u) for u in users] - return { - 'users': users, - 'total': response['response']['total'] - } - - - async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]: + return {'users': users, 'total': response['response']['total']} + + async def get_internal_squads(self) -> list[RemnaWaveInternalSquad]: response = await self._make_request('GET', '/api/internal-squads') return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']] - - async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]: + + async def get_internal_squad_by_uuid(self, uuid: str) -> RemnaWaveInternalSquad | None: try: response = await self._make_request('GET', f'/api/internal-squads/{uuid}') return self._parse_internal_squad(response['response']) @@ -589,35 +566,29 @@ class RemnaWaveAPI: if e.status_code == 404: return None raise - - async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad: - data = { - 'name': name, - 'inbounds': inbounds - } + + async def create_internal_squad(self, name: str, inbounds: list[str]) -> RemnaWaveInternalSquad: + data = {'name': name, 'inbounds': inbounds} response = await self._make_request('POST', '/api/internal-squads', data) return self._parse_internal_squad(response['response']) - + async def update_internal_squad( - self, - uuid: str, - name: Optional[str] = None, - inbounds: Optional[List[str]] = None + self, uuid: str, name: str | None = None, inbounds: list[str] | None = None ) -> RemnaWaveInternalSquad: data = {'uuid': uuid} if name: data['name'] = name if inbounds is not None: data['inbounds'] = inbounds - + response = await self._make_request('PATCH', '/api/internal-squads', data) return self._parse_internal_squad(response['response']) - + async def delete_internal_squad(self, uuid: str) -> bool: response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}') return response['response']['isDeleted'] - async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]: + async def get_internal_squad_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]: """Получает список доступных нод для Internal Squad""" try: response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes') @@ -637,7 +608,7 @@ class RemnaWaveAPI: response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users') return response['response']['eventSent'] - async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]: + async def reorder_internal_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveInternalSquad]: """ Изменяет порядок Internal Squads items: список словарей с uuid и viewPosition @@ -649,12 +620,12 @@ class RemnaWaveAPI: # ============== External Squads API ============== - async def get_external_squads(self) -> List[RemnaWaveExternalSquad]: + async def get_external_squads(self) -> list[RemnaWaveExternalSquad]: """Получает список всех External Squads""" response = await self._make_request('GET', '/api/external-squads') return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']] - async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]: + async def get_external_squad_by_uuid(self, uuid: str) -> RemnaWaveExternalSquad | None: """Получает External Squad по UUID""" try: response = await self._make_request('GET', f'/api/external-squads/{uuid}') @@ -672,14 +643,14 @@ class RemnaWaveAPI: async def update_external_squad( self, uuid: str, - name: Optional[str] = None, - templates: Optional[List[Dict[str, str]]] = None, - subscription_settings: Optional[Dict[str, Any]] = None, - host_overrides: Optional[Dict[str, Any]] = None, - response_headers: Optional[Dict[str, str]] = None, - hwid_settings: Optional[Dict[str, Any]] = None, - custom_remarks: Optional[Dict[str, Any]] = None, - subpage_config_uuid: Optional[str] = None + name: str | None = None, + templates: list[dict[str, str]] | None = None, + subscription_settings: dict[str, Any] | None = None, + host_overrides: dict[str, Any] | None = None, + response_headers: dict[str, str] | None = None, + hwid_settings: dict[str, Any] | None = None, + custom_remarks: dict[str, Any] | None = None, + subpage_config_uuid: str | None = None, ) -> RemnaWaveExternalSquad: data = {'uuid': uuid} if name is not None: @@ -717,12 +688,12 @@ class RemnaWaveAPI: response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users') return response['response']['eventSent'] - async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]: + async def reorder_external_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveExternalSquad]: data = {'items': items} response = await self._make_request('POST', '/api/external-squads/actions/reorder', data) return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']] - def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad: + def _parse_external_squad(self, squad_data: dict) -> RemnaWaveExternalSquad: """Парсит данные External Squad""" info = squad_data.get('info', {}) return RemnaWaveExternalSquad( @@ -738,15 +709,14 @@ class RemnaWaveAPI: custom_remarks=squad_data.get('customRemarks'), subpage_config_uuid=squad_data.get('subpageConfigUuid'), created_at=self._parse_optional_datetime(squad_data.get('createdAt')), - updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')) + updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')), ) - - async def get_all_nodes(self) -> List[RemnaWaveNode]: + async def get_all_nodes(self) -> list[RemnaWaveNode]: response = await self._make_request('GET', '/api/nodes') return [self._parse_node(node) for node in response['response']] - - async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]: + + async def get_node_by_uuid(self, uuid: str) -> RemnaWaveNode | None: try: response = await self._make_request('GET', f'/api/nodes/{uuid}') return self._parse_node(response['response']) @@ -754,24 +724,23 @@ class RemnaWaveAPI: if e.status_code == 404: return None raise - + async def enable_node(self, uuid: str) -> RemnaWaveNode: response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable') return self._parse_node(response['response']) - + async def disable_node(self, uuid: str) -> RemnaWaveNode: response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable') return self._parse_node(response['response']) - + async def restart_node(self, uuid: str) -> bool: response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart') return response['response']['eventSent'] - + async def restart_all_nodes(self) -> bool: response = await self._make_request('POST', '/api/nodes/actions/restart-all') return response['response']['eventSent'] - - + async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo: response = await self._make_request('GET', f'/api/sub/{short_uuid}/info') info = self._parse_subscription_info(response['response']) @@ -781,51 +750,50 @@ class RemnaWaveAPI: if encrypted: info.happ_crypto_link = encrypted return info - + async def get_subscription_by_short_uuid(self, short_uuid: str) -> str: - async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response: + async with self.session.get(f'{self.base_url}/api/sub/{short_uuid}') as response: if response.status >= 400: - raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}") + raise RemnaWaveAPIError(f'Failed to get subscription: {response.status}') return await response.text() - + async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str: - valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"] + valid_types = ['stash', 'singbox', 'singbox-legacy', 'mihomo', 'json', 'v2ray-json', 'clash'] if client_type not in valid_types: - raise ValueError(f"Invalid client type. Must be one of: {valid_types}") - - async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response: + raise ValueError(f'Invalid client type. Must be one of: {valid_types}') + + async with self.session.get(f'{self.base_url}/api/sub/{short_uuid}/{client_type}') as response: if response.status >= 400: - raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}") + raise RemnaWaveAPIError(f'Failed to get subscription: {response.status}') return await response.text() - - async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]: - base_url = f"{self.base_url}/api/sub/{short_uuid}" - + + async def get_subscription_links(self, short_uuid: str) -> dict[str, str]: + base_url = f'{self.base_url}/api/sub/{short_uuid}' + links = { - "base": base_url, - "stash": f"{base_url}/stash", - "singbox": f"{base_url}/singbox", - "singbox_legacy": f"{base_url}/singbox-legacy", - "mihomo": f"{base_url}/mihomo", - "json": f"{base_url}/json", - "v2ray_json": f"{base_url}/v2ray-json", - "clash": f"{base_url}/clash" + 'base': base_url, + 'stash': f'{base_url}/stash', + 'singbox': f'{base_url}/singbox', + 'singbox_legacy': f'{base_url}/singbox-legacy', + 'mihomo': f'{base_url}/mihomo', + 'json': f'{base_url}/json', + 'v2ray_json': f'{base_url}/v2ray-json', + 'clash': f'{base_url}/clash', } - + return links - + async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str: - async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response: + async with self.session.get(f'{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}') as response: if response.status >= 400: - raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}") + raise RemnaWaveAPIError(f'Failed to get outline subscription: {response.status}') return await response.text() - - - async def get_system_stats(self) -> Dict[str, Any]: + + async def get_system_stats(self) -> dict[str, Any]: response = await self._make_request('GET', '/api/system/stats') return response['response'] - async def get_system_metadata(self) -> Dict[str, Any]: + async def get_system_metadata(self) -> dict[str, Any]: """ Получает метаданные системы Remnawave. @@ -837,97 +805,66 @@ class RemnaWaveAPI: """ response = await self._make_request('GET', '/api/system/metadata') return response['response'] - - async def get_bandwidth_stats(self) -> Dict[str, Any]: + + async def get_bandwidth_stats(self) -> dict[str, Any]: response = await self._make_request('GET', '/api/system/stats/bandwidth') return response['response'] - - async def get_nodes_statistics(self) -> Dict[str, Any]: + + async def get_nodes_statistics(self) -> dict[str, Any]: response = await self._make_request('GET', '/api/system/stats/nodes') return response['response'] - - async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: + + async def get_nodes_realtime_usage(self) -> list[dict[str, Any]]: return await self.get_bandwidth_stats_nodes_realtime() - async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]: + async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> dict[str, Any]: return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date) # ============== Bandwidth Stats API ============== - async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]: - params = { - 'start': start_date, - 'end': end_date - } + async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> dict[str, Any]: + params = {'start': start_date, 'end': end_date} response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params) return response['response'] - async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]: + async def get_bandwidth_stats_nodes_realtime(self) -> list[dict[str, Any]]: response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime') return response['response'] async def get_bandwidth_stats_node_users( - self, - node_uuid: str, - start_date: str, - end_date: str, - top_users_limit: int = 10 - ) -> Dict[str, Any]: - params = { - 'start': start_date, - 'end': end_date, - 'topUsersLimit': top_users_limit - } + self, node_uuid: str, start_date: str, end_date: str, top_users_limit: int = 10 + ) -> dict[str, Any]: + params = {'start': start_date, 'end': end_date, 'topUsersLimit': top_users_limit} response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params) return response['response'] async def get_bandwidth_stats_node_users_legacy( - self, - node_uuid: str, - start_date: str, - end_date: str - ) -> Dict[str, Any]: - params = { - 'start': start_date, - 'end': end_date - } - response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params) + self, node_uuid: str, start_date: str, end_date: str + ) -> dict[str, Any]: + params = {'start': start_date, 'end': end_date} + response = await self._make_request( + 'GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params + ) return response['response'] - async def get_bandwidth_stats_user( - self, - user_uuid: str, - start_date: str, - end_date: str - ) -> Dict[str, Any]: - params = { - 'start': start_date, - 'end': end_date - } + async def get_bandwidth_stats_user(self, user_uuid: str, start_date: str, end_date: str) -> dict[str, Any]: + params = {'start': start_date, 'end': end_date} response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params) return response['response'] - async def get_bandwidth_stats_user_legacy( - self, - user_uuid: str, - start_date: str, - end_date: str - ) -> Dict[str, Any]: - params = { - 'start': start_date, - 'end': end_date - } + async def get_bandwidth_stats_user_legacy(self, user_uuid: str, start_date: str, end_date: str) -> dict[str, Any]: + params = {'start': start_date, 'end': end_date} response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params) return response # ============== Subscription Page Configs API ============== - async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]: + async def get_subscription_page_configs(self) -> list[SubscriptionPageConfig]: response = await self._make_request('GET', '/api/subscription-page-configs') configs_data = response['response'].get('configs', []) return [self._parse_subscription_page_config(c) for c in configs_data] - async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]: + async def get_subscription_page_config(self, uuid: str) -> SubscriptionPageConfig | None: try: response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}') return self._parse_subscription_page_config(response['response']) @@ -942,10 +879,7 @@ class RemnaWaveAPI: return self._parse_subscription_page_config(response['response']) async def update_subscription_page_config( - self, - uuid: str, - name: Optional[str] = None, - config: Optional[Dict[str, Any]] = None + self, uuid: str, name: str | None = None, config: dict[str, Any] | None = None ) -> SubscriptionPageConfig: data = {'uuid': uuid} if name is not None: @@ -959,7 +893,7 @@ class RemnaWaveAPI: response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}') return response['response']['isDeleted'] - async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]: + async def reorder_subscription_page_configs(self, items: list[dict[str, Any]]) -> list[SubscriptionPageConfig]: data = {'items': items} response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data) configs_data = response['response'].get('configs', []) @@ -970,7 +904,7 @@ class RemnaWaveAPI: response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data) return self._parse_subscription_page_config(response['response']) - async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]: + async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> dict[str, Any] | None: try: response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}') return response.get('response') @@ -979,17 +913,13 @@ class RemnaWaveAPI: return None raise - def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig: + def _parse_subscription_page_config(self, data: dict) -> SubscriptionPageConfig: """Парсит данные конфигурации страницы подписки""" return SubscriptionPageConfig( - uuid=data['uuid'], - name=data['name'], - view_position=data['viewPosition'], - config=data.get('config') + uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config') ) - - async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]: + async def get_user_devices(self, user_uuid: str) -> dict[str, Any]: try: response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}') return response['response'] @@ -1002,52 +932,46 @@ class RemnaWaveAPI: try: devices_info = await self.get_user_devices(user_uuid) devices = devices_info.get('devices', []) - + if not devices: return True - + failed_count = 0 for device in devices: device_hwid = device.get('hwid') if device_hwid: try: - delete_data = { - "userUuid": user_uuid, - "hwid": device_hwid - } + delete_data = {'userUuid': user_uuid, 'hwid': device_hwid} await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) except Exception as device_error: - logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}") + logger.error(f'Ошибка удаления устройства {device_hwid}: {device_error}') failed_count += 1 - + return failed_count < len(devices) / 2 - + except Exception as e: - logger.error(f"Ошибка при сбросе устройств: {e}") + logger.error(f'Ошибка при сбросе устройств: {e}') return False async def remove_device(self, user_uuid: str, device_hwid: str) -> bool: try: - delete_data = { - "userUuid": user_uuid, - "hwid": device_hwid - } + delete_data = {'userUuid': user_uuid, 'hwid': device_hwid} await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data) return True except Exception as e: - logger.error(f"Ошибка удаления устройства {device_hwid}: {e}") + logger.error(f'Ошибка удаления устройства {device_hwid}: {e}') return False - async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]: + async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> str | None: try: - data = {"linkToEncrypt": link_to_encrypt} + data = {'linkToEncrypt': link_to_encrypt} response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data) return response.get('response', {}).get('encryptedLink') except RemnaWaveAPIError as e: - logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}") + logger.warning(f'Не удалось зашифровать happ ссылку: {e.message}') return None except Exception as e: - logger.warning(f"Ошибка при шифровании happ ссылки: {e}") + logger.warning(f'Ошибка при шифровании happ ссылки: {e}') return None async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser: @@ -1057,8 +981,7 @@ class RemnaWaveAPI: user.happ_crypto_link = encrypted return user - - def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]: + def _parse_user_traffic(self, traffic_data: dict | None) -> UserTraffic | None: """Парсит данные трафика из нового формата API""" if not traffic_data: return None @@ -1068,10 +991,10 @@ class RemnaWaveAPI: lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)), online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')), first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')), - last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid') + last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid'), ) - def _parse_user(self, user_data: Dict) -> RemnaWaveUser: + 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') @@ -1084,7 +1007,7 @@ class RemnaWaveAPI: try: status = UserStatus(status_str) except ValueError: - logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE") + logger.warning(f'Неизвестный статус пользователя: {status_str}, используем ACTIVE') status = UserStatus.ACTIVE # Получаем trafficLimitStrategy с fallback @@ -1092,7 +1015,7 @@ class RemnaWaveAPI: try: traffic_strategy = TrafficLimitStrategy(strategy_str) except ValueError: - logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET") + logger.warning(f'Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET') traffic_strategy = TrafficLimitStrategy.NO_RESET return RemnaWaveUser( @@ -1124,15 +1047,15 @@ class RemnaWaveAPI: happ_link=happ_link, happ_crypto_link=happ_crypto_link, external_squad_uuid=user_data.get('externalSquadUuid'), - id=user_data.get('id') + id=user_data.get('id'), ) - def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]: + def _parse_optional_datetime(self, date_str: str | None) -> datetime | None: if date_str: return datetime.fromisoformat(date_str.replace('Z', '+00:00')) return None - - def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound: + + def _parse_inbound(self, inbound_data: dict) -> RemnaWaveInbound: """Парсит данные inbound""" return RemnaWaveInbound( uuid=inbound_data['uuid'], @@ -1142,10 +1065,10 @@ class RemnaWaveAPI: network=inbound_data.get('network'), security=inbound_data.get('security'), port=inbound_data.get('port'), - raw_inbound=inbound_data.get('rawInbound') + raw_inbound=inbound_data.get('rawInbound'), ) - def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad: + def _parse_internal_squad(self, squad_data: dict) -> RemnaWaveInternalSquad: info = squad_data.get('info', {}) inbounds_raw = squad_data.get('inbounds', []) inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else [] @@ -1157,10 +1080,10 @@ class RemnaWaveAPI: inbounds=inbounds, view_position=squad_data.get('viewPosition', 0), created_at=self._parse_optional_datetime(squad_data.get('createdAt')), - updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')) + updated_at=self._parse_optional_datetime(squad_data.get('updatedAt')), ) - def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode: + def _parse_accessible_node(self, node_data: dict) -> RemnaWaveAccessibleNode: """Парсит данные доступной ноды для Internal Squad""" return RemnaWaveAccessibleNode( uuid=node_data['uuid'], @@ -1168,10 +1091,10 @@ class RemnaWaveAPI: country_code=node_data['countryCode'], config_profile_uuid=node_data['configProfileUuid'], config_profile_name=node_data['configProfileName'], - active_inbounds=node_data.get('activeInbounds', []) + active_inbounds=node_data.get('activeInbounds', []), ) - def _parse_node(self, node_data: Dict) -> RemnaWaveNode: + def _parse_node(self, node_data: dict) -> RemnaWaveNode: return RemnaWaveNode( uuid=node_data['uuid'], name=node_data['name'], @@ -1201,10 +1124,10 @@ class RemnaWaveAPI: total_ram=node_data.get('totalRam'), created_at=self._parse_optional_datetime(node_data.get('createdAt')), updated_at=self._parse_optional_datetime(node_data.get('updatedAt')), - provider_uuid=node_data.get('providerUuid') + provider_uuid=node_data.get('providerUuid'), ) - - def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo: + + 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') @@ -1217,44 +1140,38 @@ class RemnaWaveAPI: subscription_url=data.get('subscriptionUrl', ''), happ=data.get('happ'), happ_link=happ_link, - happ_crypto_link=happ_crypto_link + happ_crypto_link=happ_crypto_link, ) def format_bytes(bytes_value: int) -> str: if bytes_value == 0: - return "0 B" - - units = ["B", "KB", "MB", "GB", "TB"] + return '0 B' + + units = ['B', 'KB', 'MB', 'GB', 'TB'] size = bytes_value unit_index = 0 - + while size >= 1024 and unit_index < len(units) - 1: size /= 1024 unit_index += 1 - - return f"{size:.1f} {units[unit_index]}" + + return f'{size:.1f} {units[unit_index]}' def parse_bytes(size_str: str) -> int: size_str = size_str.upper().strip() - - units = { - 'B': 1, - 'KB': 1024, - 'MB': 1024 ** 2, - 'GB': 1024 ** 3, - 'TB': 1024 ** 4 - } - + + units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4} + for unit, multiplier in units.items(): if size_str.endswith(unit): try: - value = float(size_str[:-len(unit)].strip()) + value = float(size_str[: -len(unit)].strip()) return int(value * multiplier) except ValueError: break - + return 0 @@ -1263,5 +1180,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool: await api.get_system_stats() return True except Exception as e: - logger.error(f"API connection test failed: {e}") + logger.error(f'API connection test failed: {e}') return False diff --git a/app/external/telegram_stars.py b/app/external/telegram_stars.py index de81cfbb..bb1f3e49 100644 --- a/app/external/telegram_stars.py +++ b/app/external/telegram_stars.py @@ -1,28 +1,29 @@ import logging -from decimal import Decimal, ROUND_HALF_UP -from typing import Optional, Dict, Any +from decimal import ROUND_HALF_UP, Decimal +from typing import Any + from aiogram import Bot -from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardMarkup, LabeledPrice from app.config import settings + logger = logging.getLogger(__name__) class TelegramStarsService: - def __init__(self, bot: Bot): self.bot = bot - + @staticmethod def calculate_stars_from_rubles(rubles: float) -> int: return settings.rubles_to_stars(rubles) - + @staticmethod def calculate_rubles_from_stars(stars: int) -> Decimal: rate = Decimal(str(settings.get_stars_rate())) - return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - + return (Decimal(stars) * rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + async def create_invoice( self, chat_id: int, @@ -30,8 +31,8 @@ class TelegramStarsService: description: str, amount_kopeks: int, payload: str, - start_parameter: Optional[str] = None - ) -> Optional[str]: + start_parameter: str | None = None, + ) -> str | None: try: amount_rubles = Decimal(amount_kopeks) / Decimal(100) stars_amount = self.calculate_stars_from_rubles(float(amount_rubles)) @@ -41,22 +42,22 @@ class TelegramStarsService: title=title, description=description, payload=payload, - provider_token="", - currency="XTR", + provider_token='', + currency='XTR', prices=[LabeledPrice(label=title, amount=stars_amount)], - start_parameter=start_parameter + start_parameter=start_parameter, ) - + logger.info( - f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) " - f"для {chat_id}, курс: {stars_rate}₽/⭐" + f'Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) ' + f'для {chat_id}, курс: {stars_rate}₽/⭐' ) return invoice_link - + except Exception as e: - logger.error(f"Ошибка создания Stars invoice: {e}") + logger.error(f'Ошибка создания Stars invoice: {e}') return None - + async def send_invoice( self, chat_id: int, @@ -64,53 +65,48 @@ class TelegramStarsService: description: str, amount_kopeks: int, payload: str, - keyboard: Optional[InlineKeyboardMarkup] = None - ) -> Optional[Dict[str, Any]]: + keyboard: InlineKeyboardMarkup | None = None, + ) -> dict[str, Any] | None: try: amount_rubles = Decimal(amount_kopeks) / Decimal(100) stars_amount = self.calculate_stars_from_rubles(float(amount_rubles)) stars_rate = settings.get_stars_rate() - + message = await self.bot.send_invoice( chat_id=chat_id, title=title, description=description, payload=payload, - provider_token="", - currency="XTR", + provider_token='', + currency='XTR', prices=[LabeledPrice(label=title, amount=stars_amount)], - reply_markup=keyboard + reply_markup=keyboard, ) - + logger.info( - f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд " - f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐" + f'Отправлен Stars invoice {message.message_id} на {stars_amount} звезд ' + f'(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐' ) return { - "message_id": message.message_id, - "stars_amount": stars_amount, - "rubles_amount": float(amount_rubles), - "payload": payload + 'message_id': message.message_id, + 'stars_amount': stars_amount, + 'rubles_amount': float(amount_rubles), + 'payload': payload, } - + except Exception as e: - logger.error(f"Ошибка отправки Stars invoice: {e}") + logger.error(f'Ошибка отправки Stars invoice: {e}') return None - + async def answer_pre_checkout_query( - self, - pre_checkout_query_id: str, - ok: bool = True, - error_message: Optional[str] = None + self, pre_checkout_query_id: str, ok: bool = True, error_message: str | None = None ) -> bool: try: await self.bot.answer_pre_checkout_query( - pre_checkout_query_id=pre_checkout_query_id, - ok=ok, - error_message=error_message + pre_checkout_query_id=pre_checkout_query_id, ok=ok, error_message=error_message ) - logger.info(f"Ответ на pre_checkout_query: ok={ok}") + logger.info(f'Ответ на pre_checkout_query: ok={ok}') return True except Exception as e: - logger.error(f"Ошибка ответа на pre_checkout_query: {e}") + logger.error(f'Ошибка ответа на pre_checkout_query: {e}') return False diff --git a/app/external/tribute.py b/app/external/tribute.py index 95c90a8d..bcecd5a2 100644 --- a/app/external/tribute.py +++ b/app/external/tribute.py @@ -1,161 +1,150 @@ -import logging import hashlib import hmac import json -from typing import Optional, Dict, Any +import logging +from typing import Any from app.config import settings + logger = logging.getLogger(__name__) class TributeService: - def __init__(self): self.api_key = settings.TRIBUTE_API_KEY self.donate_link = settings.TRIBUTE_DONATE_LINK - - async def create_payment_link( - self, - user_id: int, - amount_kopeks: int = 0, - description: str = "Пополнение баланса" - ) -> Optional[str]: - - if not settings.TRIBUTE_ENABLED: - logger.warning("Tribute платежи отключены") - return None - - try: - payment_url = f"{self.donate_link}&user_id={user_id}" - - logger.info(f"Создана ссылка Tribute для пользователя {user_id}") - return payment_url - - except Exception as e: - logger.error(f"Ошибка создания Tribute ссылки: {e}") - return None - - def verify_webhook_signature(self, payload: str, signature: str) -> bool: + async def create_payment_link( + self, user_id: int, amount_kopeks: int = 0, description: str = 'Пополнение баланса' + ) -> str | None: + if not settings.TRIBUTE_ENABLED: + logger.warning('Tribute платежи отключены') + return None + + try: + payment_url = f'{self.donate_link}&user_id={user_id}' + + logger.info(f'Создана ссылка Tribute для пользователя {user_id}') + return payment_url + + except Exception as e: + logger.error(f'Ошибка создания Tribute ссылки: {e}') + return None + + def verify_webhook_signature(self, payload: str, signature: str) -> bool: if not self.api_key: - logger.warning("API key не настроен, пропускаем проверку") + logger.warning('API key не настроен, пропускаем проверку') return True try: - expected_signature = hmac.new( - self.api_key.encode(), - payload.encode(), - hashlib.sha256 - ).hexdigest() + expected_signature = hmac.new(self.api_key.encode(), payload.encode(), hashlib.sha256).hexdigest() is_valid = hmac.compare_digest(signature, expected_signature) if is_valid: - logger.info("✅ Подпись Tribute webhook проверена успешно") + logger.info('✅ Подпись Tribute webhook проверена успешно') else: - logger.error("❌ Неверная подпись Tribute webhook") + logger.error('❌ Неверная подпись Tribute webhook') return is_valid except Exception as e: - logger.error(f"Ошибка проверки подписи webhook: {e}") + logger.error(f'Ошибка проверки подписи webhook: {e}') return False - - async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]: - + + async def process_webhook(self, payload_or_data) -> dict[str, Any] | None: try: - logger.info(f"🔄 Начинаем обработку Tribute webhook") - + logger.info('🔄 Начинаем обработку Tribute webhook') + if isinstance(payload_or_data, str): try: webhook_data = json.loads(payload_or_data) - logger.info(f"📊 Распарсенные данные: {webhook_data}") + logger.info(f'📊 Распарсенные данные: {webhook_data}') except json.JSONDecodeError as e: - logger.error(f"❌ Ошибка парсинга JSON: {e}") + logger.error(f'❌ Ошибка парсинга JSON: {e}') return None else: webhook_data = payload_or_data - + payment_id = None status = None amount_kopeks = 0 telegram_user_id = None - - payment_id = webhook_data.get("id") or webhook_data.get("payment_id") - status = webhook_data.get("status") - amount_kopeks = webhook_data.get("amount", 0) - telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id") - - if not payment_id and "payload" in webhook_data: - data = webhook_data["payload"] - payment_id = data.get("id") or data.get("payment_id") - status = data.get("status") - amount_kopeks = data.get("amount", 0) - telegram_user_id = data.get("telegram_user_id") or data.get("user_id") - - if not payment_id and "name" in webhook_data: - event_name = webhook_data.get("name") - data = webhook_data.get("payload", {}) - payment_id = str(data.get("donation_request_id")) - amount_kopeks = data.get("amount", 0) - telegram_user_id = data.get("telegram_user_id") - - if event_name == "new_donation": - status = "paid" - elif event_name == "cancelled_subscription": - status = "cancelled" + + payment_id = webhook_data.get('id') or webhook_data.get('payment_id') + status = webhook_data.get('status') + amount_kopeks = webhook_data.get('amount', 0) + telegram_user_id = webhook_data.get('telegram_user_id') or webhook_data.get('user_id') + + if not payment_id and 'payload' in webhook_data: + data = webhook_data['payload'] + payment_id = data.get('id') or data.get('payment_id') + status = data.get('status') + amount_kopeks = data.get('amount', 0) + telegram_user_id = data.get('telegram_user_id') or data.get('user_id') + + if not payment_id and 'name' in webhook_data: + event_name = webhook_data.get('name') + data = webhook_data.get('payload', {}) + payment_id = str(data.get('donation_request_id')) + amount_kopeks = data.get('amount', 0) + telegram_user_id = data.get('telegram_user_id') + + if event_name == 'new_donation': + status = 'paid' + elif event_name == 'cancelled_subscription': + status = 'cancelled' else: - status = "unknown" - - logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}") - + status = 'unknown' + + logger.info( + f'📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}' + ) + if not telegram_user_id: - logger.error("❌ Не найден telegram_user_id в webhook данных") - logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}") + logger.error('❌ Не найден telegram_user_id в webhook данных') + logger.error(f'🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}') return None - + try: telegram_user_id = int(telegram_user_id) except (ValueError, TypeError): - logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}") + logger.error(f'❌ Некорректный telegram_user_id: {telegram_user_id}') return None - + result = { - "event_type": "payment", - "payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}", - "user_id": telegram_user_id, - "amount_kopeks": int(amount_kopeks) if amount_kopeks else 0, - "status": status or "paid", - "external_id": f"donation_{payment_id or 'unknown'}", - "payment_system": "tribute" + 'event_type': 'payment', + 'payment_id': payment_id or f'tribute_{telegram_user_id}_{amount_kopeks}', + 'user_id': telegram_user_id, + 'amount_kopeks': int(amount_kopeks) if amount_kopeks else 0, + 'status': status or 'paid', + 'external_id': f'donation_{payment_id or "unknown"}', + 'payment_system': 'tribute', } - - logger.info(f"✅ Tribute webhook обработан успешно: {result}") + + logger.info(f'✅ Tribute webhook обработан успешно: {result}') return result - + except Exception as e: - logger.error(f"❌ Ошибка обработки Tribute webhook: {e}", exc_info=True) - logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}") + logger.error(f'❌ Ошибка обработки Tribute webhook: {e}', exc_info=True) + logger.error(f'🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}') return None - - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: + + async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: try: - logger.info(f"Запрос статуса платежа {payment_id}") - return {"status": "unknown", "payment_id": payment_id} + logger.info(f'Запрос статуса платежа {payment_id}') + return {'status': 'unknown', 'payment_id': payment_id} except Exception as e: - logger.error(f"Ошибка получения статуса платежа: {e}") + logger.error(f'Ошибка получения статуса платежа: {e}') return None - + async def refund_payment( - self, - payment_id: str, - amount_kopeks: Optional[int] = None, - reason: str = "Возврат по запросу" - ) -> Optional[Dict[str, Any]]: + self, payment_id: str, amount_kopeks: int | None = None, reason: str = 'Возврат по запросу' + ) -> dict[str, Any] | None: try: - logger.info(f"Создание возврата для платежа {payment_id}") - return {"refund_id": f"refund_{payment_id}", "status": "pending"} + logger.info(f'Создание возврата для платежа {payment_id}') + return {'refund_id': f'refund_{payment_id}', 'status': 'pending'} except Exception as e: - logger.error(f"Ошибка создания возврата: {e}") + logger.error(f'Ошибка создания возврата: {e}') return None diff --git a/app/external/wata_webhook.py b/app/external/wata_webhook.py index 9e3de0d7..eea4f22e 100644 --- a/app/external/wata_webhook.py +++ b/app/external/wata_webhook.py @@ -5,7 +5,7 @@ import base64 import json import logging from datetime import datetime, timedelta -from typing import Any, Dict, Optional +from typing import Any import aiohttp from aiohttp import web @@ -17,19 +17,20 @@ from app.config import settings from app.database.database import AsyncSessionLocal from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) class WataPublicKeyProvider: """Loads and caches the WATA public key used for webhook signature validation.""" - def __init__(self, *, cache_seconds: Optional[int] = None) -> None: + def __init__(self, *, cache_seconds: int | None = None) -> None: self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS) - self._cached_key: Optional[str] = None - self._expires_at: Optional[datetime] = None + self._cached_key: str | None = None + self._expires_at: datetime | None = None self._lock = asyncio.Lock() - async def get_public_key(self) -> Optional[str]: + async def get_public_key(self) -> str | None: """Returns a cached public key or fetches a new one from WATA.""" now = datetime.utcnow() @@ -48,47 +49,46 @@ class WataPublicKeyProvider: self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds) else: self._expires_at = None - logger.debug("Получен и закеширован публичный ключ WATA") + logger.debug('Получен и закеширован публичный ключ WATA') return self._cached_key if self._cached_key: - logger.warning("Используем ранее закешированный публичный ключ WATA") + logger.warning('Используем ранее закешированный публичный ключ WATA') return self._cached_key - logger.error("Публичный ключ WATA недоступен") + logger.error('Публичный ключ WATA недоступен') return None - async def _fetch_public_key(self) -> Optional[str]: - url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key" + async def _fetch_public_key(self) -> str | None: + url = settings.WATA_PUBLIC_KEY_URL or f'{settings.WATA_BASE_URL.rstrip("/")}/public-key' timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url) as response: - text = await response.text() - if response.status >= 400: - logger.error( - "Ошибка получения публичного ключа WATA %s: %s", - response.status, - text, - ) - return None + async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response: + text = await response.text() + if response.status >= 400: + logger.error( + 'Ошибка получения публичного ключа WATA %s: %s', + response.status, + text, + ) + return None - try: - payload = await response.json() - except aiohttp.ContentTypeError: - logger.error("Ответ WATA public-key не является JSON: %s", text) - return None + try: + payload = await response.json() + except aiohttp.ContentTypeError: + logger.error('Ответ WATA public-key не является JSON: %s', text) + return None if isinstance(payload, dict): - value = payload.get("value") + value = payload.get('value') if value: return value - logger.error("Ответ WATA public-key не содержит ключ: %s", payload) + logger.error('Ответ WATA public-key не содержит ключ: %s', payload) else: - logger.error("Неожиданный формат ответа WATA public-key: %s", payload) + logger.error('Неожиданный формат ответа WATA public-key: %s', payload) except Exception as error: - logger.error("Ошибка запроса публичного ключа WATA: %s", error) + logger.error('Ошибка запроса публичного ключа WATA: %s', error) return None @@ -100,101 +100,101 @@ class WataWebhookHandler: self, payment_service: PaymentService, *, - public_key_provider: Optional[WataPublicKeyProvider] = None, + public_key_provider: WataPublicKeyProvider | None = None, ) -> None: self.payment_service = payment_service self.public_key_provider = public_key_provider or WataPublicKeyProvider() async def _verify_signature(self, raw_body: str, signature: str) -> bool: - signature = (signature or "").strip() + signature = (signature or '').strip() if not signature: - logger.error("WATA webhook без подписи") + logger.error('WATA webhook без подписи') return False public_key_pem = await self.public_key_provider.get_public_key() if not public_key_pem: - logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна") + logger.error('Публичный ключ WATA отсутствует, проверка подписи невозможна') return False try: signature_bytes = base64.b64decode(signature) except (ValueError, TypeError): - logger.error("Некорректная подпись WATA (не Base64)") + logger.error('Некорректная подпись WATA (не Base64)') return False try: - public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + public_key = serialization.load_pem_public_key(public_key_pem.encode('utf-8')) except ValueError as error: - logger.error("Ошибка загрузки публичного ключа WATA: %s", error) + logger.error('Ошибка загрузки публичного ключа WATA: %s', error) return False try: public_key.verify( signature_bytes, - raw_body.encode("utf-8"), + raw_body.encode('utf-8'), padding.PKCS1v15(), hashes.SHA512(), ) return True except InvalidSignature: - logger.warning("Подпись WATA webhook не прошла проверку") + logger.warning('Подпись WATA webhook не прошла проверку') return False except Exception as error: - logger.error("Ошибка проверки подписи WATA: %s", error) + logger.error('Ошибка проверки подписи WATA: %s', error) return False async def handle_webhook(self, request: web.Request) -> web.Response: if not settings.is_wata_enabled(): - logger.warning("Получен WATA webhook, но сервис отключен") - return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503) + logger.warning('Получен WATA webhook, но сервис отключен') + return web.json_response({'status': 'error', 'reason': 'wata_disabled'}, status=503) raw_body = await request.text() if not raw_body: - logger.warning("Получен пустой WATA webhook") - return web.json_response({"status": "error", "reason": "empty_body"}, status=400) + logger.warning('Получен пустой WATA webhook') + return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) - signature = request.headers.get("X-Signature") - if not await self._verify_signature(raw_body, signature or ""): - return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) + signature = request.headers.get('X-Signature') + if not await self._verify_signature(raw_body, signature or ''): + return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401) try: - payload: Dict[str, Any] = json.loads(raw_body) + payload: dict[str, Any] = json.loads(raw_body) except json.JSONDecodeError: - logger.error("Некорректный JSON WATA webhook") - return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + logger.error('Некорректный JSON WATA webhook') + return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) logger.info( - "Получен WATA webhook: order_id=%s, status=%s", - payload.get("orderId"), - payload.get("transactionStatus"), + 'Получен WATA webhook: order_id=%s, status=%s', + payload.get('orderId'), + payload.get('transactionStatus'), ) - processed: Optional[bool] = None + processed: bool | None = None async with AsyncSessionLocal() as db: try: processed = await self.payment_service.process_wata_webhook(db, payload) await db.commit() except Exception as e: - logger.error(f"Ошибка обработки WATA webhook: {e}") + logger.error(f'Ошибка обработки WATA webhook: {e}') await db.rollback() - return web.json_response({"status": "error", "reason": "internal_error"}, status=500) + return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) if processed is None: - logger.error("Не удалось обработать WATA webhook: нет сессии БД") - return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500) + logger.error('Не удалось обработать WATA webhook: нет сессии БД') + return web.json_response({'status': 'error', 'reason': 'db_session_unavailable'}, status=500) if processed: - return web.json_response({"status": "ok"}, status=200) + return web.json_response({'status': 'ok'}, status=200) - return web.json_response({"status": "error", "reason": "not_processed"}, status=400) + return web.json_response({'status': 'error', 'reason': 'not_processed'}, status=400) async def health_check(self, request: web.Request) -> web.Response: return web.json_response( { - "status": "ok", - "service": "wata_webhook", - "enabled": settings.is_wata_enabled(), - "path": settings.WATA_WEBHOOK_PATH, + 'status': 'ok', + 'service': 'wata_webhook', + 'enabled': settings.is_wata_enabled(), + 'path': settings.WATA_WEBHOOK_PATH, } ) @@ -202,9 +202,9 @@ class WataWebhookHandler: return web.Response( status=200, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-Signature", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Signature', }, ) @@ -216,10 +216,10 @@ def create_wata_webhook_app(payment_service: PaymentService) -> web.Application: app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook) app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check) app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler) - app.router.add_get("/health", handler.health_check) + app.router.add_get('/health', handler.health_check) logger.info( - "Настроен WATA webhook endpoint на %s", + 'Настроен WATA webhook endpoint на %s', settings.WATA_WEBHOOK_PATH, ) @@ -228,7 +228,7 @@ def create_wata_webhook_app(payment_service: PaymentService) -> web.Application: async def start_wata_webhook_server(payment_service: PaymentService) -> None: if not settings.is_wata_enabled(): - logger.info("WATA отключен, webhook сервер не запускается") + logger.info('WATA отключен, webhook сервер не запускается') return app = create_wata_webhook_app(payment_service) @@ -244,12 +244,12 @@ async def start_wata_webhook_server(payment_service: PaymentService) -> None: try: await site.start() logger.info( - "WATA webhook сервер запущен на %s:%s", + 'WATA webhook сервер запущен на %s:%s', settings.WATA_WEBHOOK_HOST, settings.WATA_WEBHOOK_PORT, ) logger.info( - "WATA webhook URL: http://%s:%s%s", + 'WATA webhook URL: http://%s:%s%s', settings.WATA_WEBHOOK_HOST, settings.WATA_WEBHOOK_PORT, settings.WATA_WEBHOOK_PATH, @@ -258,8 +258,8 @@ async def start_wata_webhook_server(payment_service: PaymentService) -> None: while True: await asyncio.sleep(1) except asyncio.CancelledError: - logger.info("WATA webhook сервер остановлен по запросу") + logger.info('WATA webhook сервер остановлен по запросу') finally: await site.stop() await runner.cleanup() - logger.info("WATA webhook сервер корректно остановлен") + logger.info('WATA webhook сервер корректно остановлен') diff --git a/app/external/webhook_server.py b/app/external/webhook_server.py index c8ad3210..483327f5 100644 --- a/app/external/webhook_server.py +++ b/app/external/webhook_server.py @@ -1,34 +1,33 @@ import base64 import hashlib import hmac -import logging import json -from typing import Optional, Iterable +import logging +from collections.abc import Iterable -from aiohttp import web from aiogram import Bot +from aiohttp import web from app.config import settings -from app.services.tribute_service import TributeService -from app.services.payment_service import PaymentService from app.database.database import get_db +from app.services.payment_service import PaymentService +from app.services.tribute_service import TributeService + logger = logging.getLogger(__name__) class WebhookServer: - def __init__(self, bot: Bot): self.bot = bot self.app = None self.runner = None self.site = None self.tribute_service = TributeService(bot) - + async def create_app(self) -> web.Application: - self.app = web.Application() - + self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler) if settings.is_mulenpay_enabled(): @@ -39,26 +38,25 @@ class WebhookServer: if settings.is_freekassa_enabled(): self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler) - else: - # Диагностика почему Freekassa не включена - if settings.FREEKASSA_ENABLED: - missing = [] - if settings.FREEKASSA_SHOP_ID is None: - missing.append("FREEKASSA_SHOP_ID") - if settings.FREEKASSA_API_KEY is None: - missing.append("FREEKASSA_API_KEY") - if settings.FREEKASSA_SECRET_WORD_1 is None: - missing.append("FREEKASSA_SECRET_WORD_1") - if settings.FREEKASSA_SECRET_WORD_2 is None: - missing.append("FREEKASSA_SECRET_WORD_2") - if missing: - logger.warning( - f"Freekassa ENABLED=true, но webhook не зарегистрирован. " - f"Отсутствуют параметры: {', '.join(missing)}" - ) + # Диагностика почему Freekassa не включена + elif settings.FREEKASSA_ENABLED: + missing = [] + if settings.FREEKASSA_SHOP_ID is None: + missing.append('FREEKASSA_SHOP_ID') + if settings.FREEKASSA_API_KEY is None: + missing.append('FREEKASSA_API_KEY') + if settings.FREEKASSA_SECRET_WORD_1 is None: + missing.append('FREEKASSA_SECRET_WORD_1') + if settings.FREEKASSA_SECRET_WORD_2 is None: + missing.append('FREEKASSA_SECRET_WORD_2') + if missing: + logger.warning( + f'Freekassa ENABLED=true, но webhook не зарегистрирован. ' + f'Отсутствуют параметры: {", ".join(missing)}' + ) self.app.router.add_get('/health', self._health_check) - + self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler) if settings.is_mulenpay_enabled(): self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler) @@ -67,47 +65,42 @@ class WebhookServer: if settings.is_freekassa_enabled(): self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler) - logger.info(f"Webhook сервер настроен:") - logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}") + logger.info('Webhook сервер настроен:') + logger.info(f' - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}') if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() logger.info( - " - %s webhook: POST %s", + ' - %s webhook: POST %s', mulenpay_name, settings.MULENPAY_WEBHOOK_PATH, ) if settings.is_cryptobot_enabled(): - logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}") + logger.info(f' - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}') if settings.is_freekassa_enabled(): - logger.info(f" - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}") - logger.info(f" - Health check: GET /health") - + logger.info(f' - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}') + logger.info(' - Health check: GET /health') + return self.app - + async def start(self): - try: if not self.app: await self.create_app() - + self.runner = web.AppRunner(self.app) await self.runner.setup() - - self.site = web.TCPSite( - self.runner, - host=settings.TRIBUTE_WEBHOOK_HOST, - port=settings.TRIBUTE_WEBHOOK_PORT - ) + + self.site = web.TCPSite(self.runner, host=settings.TRIBUTE_WEBHOOK_HOST, port=settings.TRIBUTE_WEBHOOK_PORT) await self.site.start() logger.info( - "Webhook сервер запущен на %s:%s", + 'Webhook сервер запущен на %s:%s', settings.TRIBUTE_WEBHOOK_HOST, settings.TRIBUTE_WEBHOOK_PORT, ) logger.info( - "Tribute webhook URL: http://%s:%s%s", + 'Tribute webhook URL: http://%s:%s%s', settings.TRIBUTE_WEBHOOK_HOST, settings.TRIBUTE_WEBHOOK_PORT, settings.TRIBUTE_WEBHOOK_PATH, @@ -115,7 +108,7 @@ class WebhookServer: if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() logger.info( - "%s webhook URL: http://%s:%s%s", + '%s webhook URL: http://%s:%s%s', mulenpay_name, settings.TRIBUTE_WEBHOOK_HOST, settings.TRIBUTE_WEBHOOK_PORT, @@ -123,30 +116,29 @@ class WebhookServer: ) if settings.is_cryptobot_enabled(): logger.info( - "CryptoBot webhook URL: http://%s:%s%s", + 'CryptoBot webhook URL: http://%s:%s%s', settings.TRIBUTE_WEBHOOK_HOST, settings.TRIBUTE_WEBHOOK_PORT, settings.CRYPTOBOT_WEBHOOK_PATH, ) - + except Exception as e: - logger.error(f"Ошибка запуска webhook сервера: {e}") + logger.error(f'Ошибка запуска webhook сервера: {e}') raise - + async def stop(self): - try: if self.site: await self.site.stop() - logger.info("Webhook сайт остановлен") - + logger.info('Webhook сайт остановлен') + if self.runner: await self.runner.cleanup() - logger.info("Webhook runner очищен") - + logger.info('Webhook runner очищен') + except Exception as e: - logger.error(f"Ошибка остановки webhook сервера: {e}") - + logger.error(f'Ошибка остановки webhook сервера: {e}') + async def _options_handler(self, request: web.Request) -> web.Response: return web.Response( status=200, @@ -154,34 +146,34 @@ class WebhookServer: 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization', - } + }, ) async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response: try: mulenpay_name = settings.get_mulenpay_display_name() logger.info( - "%s webhook: %s %s", + '%s webhook: %s %s', mulenpay_name, request.method, request.path, ) logger.info( - "%s webhook headers: %s", + '%s webhook headers: %s', mulenpay_name, dict(request.headers), ) raw_body = await request.read() if not raw_body: - logger.warning("Пустой %s webhook", mulenpay_name) - return web.json_response({"status": "error", "reason": "empty_body"}, status=400) + logger.warning('Пустой %s webhook', mulenpay_name) + return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) # Временно отключаем проверку подписи для отладки # TODO: Включить обратно после настройки MulenPay if not self._verify_mulenpay_signature(request, raw_body): logger.warning( - "%s webhook signature verification failed, but processing anyway for debugging", + '%s webhook signature verification failed, but processing anyway for debugging', mulenpay_name, ) # return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401) @@ -189,26 +181,26 @@ class WebhookServer: try: payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError as error: - logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}") - return web.json_response({"status": "error", "reason": "invalid_json"}, status=400) + logger.error(f'Ошибка парсинга {mulenpay_name} webhook: {error}') + return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) payment_service = PaymentService(self.bot) # Получаем соединение с БД db_generator = get_db() db = await db_generator.__anext__() - + try: success = await payment_service.process_mulenpay_callback(db, payload) if success: - return web.json_response({"status": "ok"}, status=200) - return web.json_response({"status": "error", "reason": "processing_failed"}, status=400) + return web.json_response({'status': 'ok'}, status=200) + return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) except Exception as error: logger.error( - f"Ошибка обработки {mulenpay_name} webhook: {error}", + f'Ошибка обработки {mulenpay_name} webhook: {error}', exc_info=True, ) - return web.json_response({"status": "error", "reason": "internal_error"}, status=500) + return web.json_response({'status': 'error', 'reason': 'internal_error'}, status=500) finally: try: await db_generator.__anext__() @@ -218,13 +210,13 @@ class WebhookServer: except Exception as error: mulenpay_name = settings.get_mulenpay_display_name() logger.error( - f"Критическая ошибка {mulenpay_name} webhook: {error}", + f'Критическая ошибка {mulenpay_name} webhook: {error}', exc_info=True, ) - return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500) + return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(error)}, status=500) @staticmethod - def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]: + def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> str | None: for header_name in header_names: value = request.headers.get(header_name) if value: @@ -236,14 +228,14 @@ class WebhookServer: secret_key = settings.MULENPAY_SECRET_KEY display_name = settings.get_mulenpay_display_name() if not secret_key: - logger.error("%s secret key is not configured", display_name) + logger.error('%s secret key is not configured', display_name) return False # Логируем все заголовки для отладки - logger.info("%s webhook headers for signature verification:", display_name) + logger.info('%s webhook headers for signature verification:', display_name) for header_name, header_value in request.headers.items(): if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']): - logger.info(f" {header_name}: {header_value}") + logger.info(f' {header_name}: {header_value}') signature = WebhookServer._extract_mulenpay_header( request, @@ -264,7 +256,7 @@ class WebhookServer: 'MULENPAY-SIGNATURE', 'signature', 'sign', - ) + ), ) if signature: normalized_signature = signature @@ -291,7 +283,7 @@ class WebhookServer: if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')): return True - logger.error("Неверная подпись %s webhook", display_name) + logger.error('Неверная подпись %s webhook', display_name) return False authorization_header = request.headers.get('Authorization') @@ -304,7 +296,7 @@ class WebhookServer: if hmac.compare_digest(token, secret_key): return True - logger.error("Неверный %s токен %s webhook", scheme, display_name) + logger.error('Неверный %s токен %s webhook', scheme, display_name) return False if not value and hmac.compare_digest(token, secret_key): @@ -316,166 +308,132 @@ class WebhookServer: 'X-MulenPay-Token', 'X-Mulenpay-Token', 'X-Webhook-Token', - ) + ), ) if fallback_token and hmac.compare_digest(fallback_token, secret_key): return True logger.info( - "%s webhook headers received: %s", + '%s webhook headers received: %s', display_name, - {key: value for key, value in request.headers.items() if 'authorization' not in key.lower()} + {key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}, ) - logger.error("Отсутствует подпись %s webhook", display_name) + logger.error('Отсутствует подпись %s webhook', display_name) return False async def _tribute_webhook_handler(self, request: web.Request) -> web.Response: - try: - logger.info(f"Получен Tribute webhook: {request.method} {request.path}") - logger.info(f"Headers: {dict(request.headers)}") - + logger.info(f'Получен Tribute webhook: {request.method} {request.path}') + logger.info(f'Headers: {dict(request.headers)}') + raw_body = await request.read() - + if not raw_body: - logger.warning("Получен пустой webhook от Tribute") - return web.json_response( - {"status": "error", "reason": "empty_body"}, - status=400 - ) - + logger.warning('Получен пустой webhook от Tribute') + return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) + payload = raw_body.decode('utf-8') - logger.info(f"Payload: {payload}") - + logger.info(f'Payload: {payload}') + try: webhook_data = json.loads(payload) - logger.info(f"Распарсенные данные: {webhook_data}") + logger.info(f'Распарсенные данные: {webhook_data}') except json.JSONDecodeError as e: - logger.error(f"Ошибка парсинга JSON: {e}") - return web.json_response( - {"status": "error", "reason": "invalid_json"}, - status=400 - ) - + logger.error(f'Ошибка парсинга JSON: {e}') + return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) + signature = request.headers.get('trbt-signature') - logger.info(f"Signature: {signature}") + logger.info(f'Signature: {signature}') if not signature: - logger.error("Отсутствует заголовок подписи Tribute webhook") - return web.json_response( - {"status": "error", "reason": "missing_signature"}, - status=401 - ) + logger.error('Отсутствует заголовок подписи Tribute webhook') + return web.json_response({'status': 'error', 'reason': 'missing_signature'}, status=401) if settings.TRIBUTE_API_KEY: from app.external.tribute import TributeService as TributeAPI + tribute_api = TributeAPI() if not tribute_api.verify_webhook_signature(payload, signature): - logger.error("Неверная подпись Tribute webhook") - return web.json_response( - {"status": "error", "reason": "invalid_signature"}, - status=401 - ) + logger.error('Неверная подпись Tribute webhook') + return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401) result = await self.tribute_service.process_webhook(payload) - + if result: - logger.info(f"Tribute webhook обработан успешно: {result}") - return web.json_response({"status": "ok", "result": result}, status=200) - else: - logger.error("Ошибка обработки Tribute webhook") - return web.json_response( - {"status": "error", "reason": "processing_failed"}, - status=400 - ) - + logger.info(f'Tribute webhook обработан успешно: {result}') + return web.json_response({'status': 'ok', 'result': result}, status=200) + logger.error('Ошибка обработки Tribute webhook') + return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) + except Exception as e: - logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True) - return web.json_response( - {"status": "error", "reason": "internal_error", "message": str(e)}, - status=500 - ) - + logger.error(f'Критическая ошибка обработки Tribute webhook: {e}', exc_info=True) + return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500) + async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response: - try: - logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}") - logger.info(f"Headers: {dict(request.headers)}") - + logger.info(f'Получен CryptoBot webhook: {request.method} {request.path}') + logger.info(f'Headers: {dict(request.headers)}') + raw_body = await request.read() - + if not raw_body: - logger.warning("Получен пустой CryptoBot webhook") - return web.json_response( - {"status": "error", "reason": "empty_body"}, - status=400 - ) - + logger.warning('Получен пустой CryptoBot webhook') + return web.json_response({'status': 'error', 'reason': 'empty_body'}, status=400) + payload = raw_body.decode('utf-8') - logger.info(f"CryptoBot Payload: {payload}") - + logger.info(f'CryptoBot Payload: {payload}') + try: webhook_data = json.loads(payload) - logger.info(f"CryptoBot данные: {webhook_data}") + logger.info(f'CryptoBot данные: {webhook_data}') except json.JSONDecodeError as e: - logger.error(f"Ошибка парсинга CryptoBot JSON: {e}") - return web.json_response( - {"status": "error", "reason": "invalid_json"}, - status=400 - ) - + logger.error(f'Ошибка парсинга CryptoBot JSON: {e}') + return web.json_response({'status': 'error', 'reason': 'invalid_json'}, status=400) + signature = request.headers.get('Crypto-Pay-API-Signature') - logger.info(f"CryptoBot Signature: {signature}") + logger.info(f'CryptoBot Signature: {signature}') if signature and settings.CRYPTOBOT_WEBHOOK_SECRET: from app.external.cryptobot import CryptoBotService + cryptobot_service = CryptoBotService() if not cryptobot_service.verify_webhook_signature(payload, signature): - logger.error("Неверная подпись CryptoBot webhook") - return web.json_response( - {"status": "error", "reason": "invalid_signature"}, - status=401 - ) + logger.error('Неверная подпись CryptoBot webhook') + return web.json_response({'status': 'error', 'reason': 'invalid_signature'}, status=401) - from app.services.payment_service import PaymentService from app.database.database import AsyncSessionLocal - + from app.services.payment_service import PaymentService + payment_service = PaymentService(self.bot) - + async with AsyncSessionLocal() as db: result = await payment_service.process_cryptobot_webhook(db, webhook_data) - + if result: - logger.info(f"CryptoBot webhook обработан успешно") - return web.json_response({"status": "ok"}, status=200) - else: - logger.error("Ошибка обработки CryptoBot webhook") - return web.json_response( - {"status": "error", "reason": "processing_failed"}, - status=400 - ) - + logger.info('CryptoBot webhook обработан успешно') + return web.json_response({'status': 'ok'}, status=200) + logger.error('Ошибка обработки CryptoBot webhook') + return web.json_response({'status': 'error', 'reason': 'processing_failed'}, status=400) + except Exception as e: - logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True) - return web.json_response( - {"status": "error", "reason": "internal_error", "message": str(e)}, - status=500 - ) - + logger.error(f'Критическая ошибка обработки CryptoBot webhook: {e}', exc_info=True) + return web.json_response({'status': 'error', 'reason': 'internal_error', 'message': str(e)}, status=500) + async def _health_check(self, request: web.Request) -> web.Response: - - return web.json_response({ - "status": "ok", - "service": "payment-webhooks", - "tribute_enabled": settings.TRIBUTE_ENABLED, - "cryptobot_enabled": settings.is_cryptobot_enabled(), - "freekassa_enabled": settings.is_freekassa_enabled(), - "port": settings.TRIBUTE_WEBHOOK_PORT, - "tribute_path": settings.TRIBUTE_WEBHOOK_PATH, - "cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None, - "freekassa_path": settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None, - }) + return web.json_response( + { + 'status': 'ok', + 'service': 'payment-webhooks', + 'tribute_enabled': settings.TRIBUTE_ENABLED, + 'cryptobot_enabled': settings.is_cryptobot_enabled(), + 'freekassa_enabled': settings.is_freekassa_enabled(), + 'port': settings.TRIBUTE_WEBHOOK_PORT, + 'tribute_path': settings.TRIBUTE_WEBHOOK_PATH, + 'cryptobot_path': settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None, + 'freekassa_path': settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None, + } + ) async def _freekassa_webhook_handler(self, request: web.Request) -> web.Response: """ @@ -490,38 +448,38 @@ class WebhookServer: - CUR_ID: ID валюты/платежной системы """ try: - logger.info(f"Получен Freekassa webhook: {request.method} {request.path}") + logger.info(f'Получен Freekassa webhook: {request.method} {request.path}') # Получаем IP клиента - client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() if not client_ip: - client_ip = request.remote or "unknown" - logger.info(f"Freekassa webhook IP: {client_ip}") + client_ip = request.remote or 'unknown' + logger.info(f'Freekassa webhook IP: {client_ip}') # Freekassa отправляет form-data try: form_data = await request.post() except Exception as e: - logger.error(f"Ошибка парсинга Freekassa form-data: {e}") - return web.Response(text="NO", status=400) + logger.error(f'Ошибка парсинга Freekassa form-data: {e}') + return web.Response(text='NO', status=400) - logger.info(f"Freekassa webhook data: {dict(form_data)}") + logger.info(f'Freekassa webhook data: {dict(form_data)}') # Извлекаем параметры - merchant_id = int(form_data.get("MERCHANT_ID", 0)) - amount = float(form_data.get("AMOUNT", 0)) - order_id = form_data.get("MERCHANT_ORDER_ID", "") - sign = form_data.get("SIGN", "") - intid = form_data.get("intid", "") - cur_id = form_data.get("CUR_ID") + merchant_id = int(form_data.get('MERCHANT_ID', 0)) + amount = float(form_data.get('AMOUNT', 0)) + order_id = form_data.get('MERCHANT_ORDER_ID', '') + sign = form_data.get('SIGN', '') + intid = form_data.get('intid', '') + cur_id = form_data.get('CUR_ID') if not order_id or not sign: - logger.warning("Freekassa webhook: отсутствуют обязательные параметры") - return web.Response(text="NO", status=400) + logger.warning('Freekassa webhook: отсутствуют обязательные параметры') + return web.Response(text='NO', status=400) # Обрабатываем платеж через PaymentService - from app.services.payment_service import PaymentService from app.database.database import AsyncSessionLocal + from app.services.payment_service import PaymentService payment_service = PaymentService(self.bot) @@ -538,13 +496,12 @@ class WebhookServer: ) if success: - logger.info(f"Freekassa webhook обработан успешно: order_id={order_id}") + logger.info(f'Freekassa webhook обработан успешно: order_id={order_id}') # Freekassa ожидает YES в ответе - return web.Response(text="YES", status=200) - else: - logger.error(f"Ошибка обработки Freekassa webhook: order_id={order_id}") - return web.Response(text="NO", status=400) + return web.Response(text='YES', status=200) + logger.error(f'Ошибка обработки Freekassa webhook: order_id={order_id}') + return web.Response(text='NO', status=400) except Exception as e: - logger.error(f"Критическая ошибка обработки Freekassa webhook: {e}", exc_info=True) - return web.Response(text="NO", status=500) + logger.error(f'Критическая ошибка обработки Freekassa webhook: {e}', exc_info=True) + return web.Response(text='NO', status=500) diff --git a/app/external/yookassa_webhook.py b/app/external/yookassa_webhook.py index b9bb3a4e..df2b8e8c 100644 --- a/app/external/yookassa_webhook.py +++ b/app/external/yookassa_webhook.py @@ -1,8 +1,9 @@ from __future__ import annotations import asyncio -import logging import json +import logging +from collections.abc import Iterable from ipaddress import ( IPv4Address, IPv4Network, @@ -11,12 +12,14 @@ from ipaddress import ( ip_address, ip_network, ) -from typing import Iterable, Optional, Dict, Any, List, Union, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING, Union + from aiohttp import web from app.config import settings from app.database.database import AsyncSessionLocal + if TYPE_CHECKING: from app.services.payment_service import PaymentService @@ -27,74 +30,74 @@ IPAddress = Union[IPv4Address, IPv6Address] IPNetwork = Union[IPv4Network, IPv6Network] YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = ( - ip_network("185.71.76.0/27"), - ip_network("185.71.77.0/27"), - ip_network("77.75.153.0/25"), - ip_network("77.75.154.128/25"), - ip_network("77.75.156.11/32"), - ip_network("77.75.156.35/32"), - ip_network("2a02:5180::/32"), + ip_network('185.71.76.0/27'), + ip_network('185.71.77.0/27'), + ip_network('77.75.153.0/25'), + ip_network('77.75.154.128/25'), + ip_network('77.75.156.11/32'), + ip_network('77.75.156.35/32'), + ip_network('2a02:5180::/32'), ) CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = ( - ip_network("173.245.48.0/20"), - ip_network("103.21.244.0/22"), - ip_network("103.22.200.0/22"), - ip_network("103.31.4.0/22"), - ip_network("141.101.64.0/18"), - ip_network("108.162.192.0/18"), - ip_network("190.93.240.0/20"), - ip_network("188.114.96.0/20"), - ip_network("197.234.240.0/22"), - ip_network("198.41.128.0/17"), - ip_network("162.158.0.0/15"), - ip_network("104.16.0.0/13"), - ip_network("104.24.0.0/14"), - ip_network("172.64.0.0/13"), - ip_network("131.0.72.0/22"), - ip_network("2400:cb00::/32"), - ip_network("2606:4700::/32"), - ip_network("2803:f800::/32"), - ip_network("2405:b500::/32"), - ip_network("2405:8100::/32"), - ip_network("2a06:98c0::/29"), - ip_network("2c0f:f248::/32"), + ip_network('173.245.48.0/20'), + ip_network('103.21.244.0/22'), + ip_network('103.22.200.0/22'), + ip_network('103.31.4.0/22'), + ip_network('141.101.64.0/18'), + ip_network('108.162.192.0/18'), + ip_network('190.93.240.0/20'), + ip_network('188.114.96.0/20'), + ip_network('197.234.240.0/22'), + ip_network('198.41.128.0/17'), + ip_network('162.158.0.0/15'), + ip_network('104.16.0.0/13'), + ip_network('104.24.0.0/14'), + ip_network('172.64.0.0/13'), + ip_network('131.0.72.0/22'), + ip_network('2400:cb00::/32'), + ip_network('2606:4700::/32'), + ip_network('2803:f800::/32'), + ip_network('2405:b500::/32'), + ip_network('2405:8100::/32'), + ip_network('2a06:98c0::/29'), + ip_network('2c0f:f248::/32'), ) YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = ( - "payment.succeeded", - "payment.waiting_for_capture", - "payment.canceled", + 'payment.succeeded', + 'payment.waiting_for_capture', + 'payment.canceled', ) -def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]: - candidates: List[str] = [] +def collect_yookassa_ip_candidates(*values: str | None) -> list[str]: + candidates: list[str] = [] for value in values: if not value: continue - for part in value.split(","): + for part in value.split(','): normalized = part.strip() if normalized: candidates.append(normalized) return candidates -def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]: +def _parse_candidate_ip(candidate: str) -> IPAddress | None: value = candidate.strip() if not value: return None - if value.startswith("[") and "]" in value: - value = value[1:value.index("]")] + if value.startswith('[') and ']' in value: + value = value[1 : value.index(']')] - if "%" in value: - value = value.split("%", 1)[0] + if '%' in value: + value = value.split('%', 1)[0] - if value.count(":") == 1 and "." in value: - host, _, port = value.rpartition(":") + if value.count(':') == 1 and '.' in value: + host, _, port = value.rpartition(':') if port.isdigit(): value = host @@ -104,7 +107,7 @@ def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]: return None -def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool: +def _should_trust_forwarded_headers(remote_ip: IPAddress | None) -> bool: if remote_ip is None: return True @@ -112,25 +115,24 @@ def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool: return True return any( - getattr(remote_ip, attribute) - for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved") + getattr(remote_ip, attribute) for attribute in ('is_private', 'is_loopback', 'is_link_local', 'is_reserved') ) -_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ()) +_TRUSTED_PROXY_NETWORKS_CACHE: tuple[str, tuple[IPNetwork, ...]] = ('', ()) -def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: +def _get_trusted_proxy_networks() -> tuple[IPNetwork, ...]: global _TRUSTED_PROXY_NETWORKS_CACHE - raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or "" + raw_value = getattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '') or '' cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE if raw_value == cached_raw: return cached_networks - networks: List[IPNetwork] = [] - for part in raw_value.split(","): + networks: list[IPNetwork] = [] + for part in raw_value.split(','): candidate = part.strip() if not candidate: continue @@ -138,7 +140,7 @@ def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: try: networks.append(ip_network(candidate, strict=False)) except ValueError: - logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate) + logger.warning('Неверная сеть доверенного прокси YooKassa: %s', candidate) cached_networks = tuple(networks) _TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks) @@ -147,8 +149,7 @@ def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]: def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool: if any( - getattr(ip_object, attribute) - for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved") + getattr(ip_object, attribute) for attribute in ('is_private', 'is_loopback', 'is_link_local', 'is_reserved') ): return True @@ -161,15 +162,11 @@ def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool: def resolve_yookassa_ip( candidates: Iterable[str], *, - remote: Optional[str] = None, -) -> Optional[IPAddress]: + remote: str | None = None, +) -> IPAddress | None: remote_ip = _parse_candidate_ip(remote) if remote else None - if ( - remote_ip is not None - and remote_ip.is_global - and not _is_trusted_proxy_ip(remote_ip) - ): + if remote_ip is not None and remote_ip.is_global and not _is_trusted_proxy_ip(remote_ip): return remote_ip candidate_list = list(candidates) @@ -189,9 +186,13 @@ def resolve_yookassa_ip( if last_hop is not None and not _is_trusted_proxy_ip(last_hop): return last_hop - return remote_ip if remote_ip is not None else next( - (ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None), - None, + return ( + remote_ip + if remote_ip is not None + else next( + (ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None), + None, + ) ) @@ -200,20 +201,18 @@ def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool: class YooKassaWebhookHandler: - def __init__(self, payment_service: PaymentService): self.payment_service = payment_service async def handle_webhook(self, request: web.Request) -> web.Response: - try: - logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}") - logger.info(f"📋 Headers: {dict(request.headers)}") + logger.info(f'📥 Получен YooKassa webhook: {request.method} {request.path}') + logger.info(f'📋 Headers: {dict(request.headers)}') header_ip_candidates = collect_yookassa_ip_candidates( - request.headers.get("X-Forwarded-For"), - request.headers.get("X-Real-IP"), - request.headers.get("Cf-Connecting-Ip"), + request.headers.get('X-Forwarded-For'), + request.headers.get('X-Real-IP'), + request.headers.get('Cf-Connecting-Ip'), ) client_ip = resolve_yookassa_ip( header_ip_candidates, @@ -222,107 +221,116 @@ class YooKassaWebhookHandler: if client_ip is None: logger.warning( - "🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s", + '🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s', header_ip_candidates + ([request.remote] if request.remote else []), ) - return web.Response(status=403, text="Forbidden") + return web.Response(status=403, text='Forbidden') if not is_yookassa_ip_allowed(client_ip): logger.warning( - "🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)", + '🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)', client_ip, - ", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS), + ', '.join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS), ) - return web.Response(status=403, text="Forbidden") + return web.Response(status=403, text='Forbidden') - logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip) + logger.info('🌐 IP-адрес YooKassa подтверждён: %s', client_ip) body = await request.text() if not body: - logger.warning("⚠️ Получен пустой webhook от YooKassa") - return web.Response(status=400, text="Empty body") + logger.warning('⚠️ Получен пустой webhook от YooKassa') + return web.Response(status=400, text='Empty body') - logger.info(f"📄 Body: {body}") + logger.info(f'📄 Body: {body}') signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature') if signature: - logger.info("ℹ️ Получена подпись YooKassa: %s", signature) + logger.info('ℹ️ Получена подпись YooKassa: %s', signature) try: webhook_data = json.loads(body) except json.JSONDecodeError as e: - logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}") - return web.Response(status=400, text="Invalid JSON") + logger.error(f'❌ Ошибка парсинга JSON webhook YooKassa: {e}') + return web.Response(status=400, text='Invalid JSON') - logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}") - logger.debug(f"🔍 Полные данные webhook: {webhook_data}") + logger.info(f'📊 Обработка webhook YooKassa: {webhook_data.get("event", "unknown_event")}') + logger.debug(f'🔍 Полные данные webhook: {webhook_data}') - event_type = webhook_data.get("event") + event_type = webhook_data.get('event') if not event_type: - logger.warning("⚠️ Webhook YooKassa без типа события") - return web.Response(status=400, text="No event type") + logger.warning('⚠️ Webhook YooKassa без типа события') + return web.Response(status=400, text='No event type') # Извлекаем ID платежа из вебхука для предотвращения дублирования - yookassa_payment_id = webhook_data.get("object", {}).get("id") + yookassa_payment_id = webhook_data.get('object', {}).get('id') if not yookassa_payment_id: - logger.warning("⚠️ Webhook YooKassa без ID платежа") - return web.Response(status=400, text="No payment id") + logger.warning('⚠️ Webhook YooKassa без ID платежа') + return web.Response(status=400, text='No payment id') if event_type not in YOOKASSA_ALLOWED_EVENTS: - logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}") - return web.Response(status=200, text="OK") + logger.info(f'ℹ️ Игнорируем событие YooKassa: {event_type}') + return web.Response(status=200, text='OK') async with AsyncSessionLocal() as db: try: # Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования) - from app.database.models import PaymentMethod from app.database.crud.transaction import get_transaction_by_external_id - existing_transaction = None - if yookassa_payment_id and hasattr(db, "execute"): - existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA) + from app.database.models import PaymentMethod - if existing_transaction and event_type == "payment.succeeded": - logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.") - return web.Response(status=200, text="OK") + existing_transaction = None + if yookassa_payment_id and hasattr(db, 'execute'): + existing_transaction = await get_transaction_by_external_id( + db, yookassa_payment_id, PaymentMethod.YOOKASSA + ) + + if existing_transaction and event_type == 'payment.succeeded': + logger.info( + f'ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.' + ) + return web.Response(status=200, text='OK') success = await self.payment_service.process_yookassa_webhook(db, webhook_data) if success: await db.commit() - logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}") - return web.Response(status=200, text="OK") - else: - await db.rollback() - logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}") - return web.Response(status=500, text="Processing error") + logger.info( + f'✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}' + ) + return web.Response(status=200, text='OK') + await db.rollback() + logger.error( + f'❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}' + ) + return web.Response(status=500, text='Processing error') except Exception as e: await db.rollback() - logger.error(f"❌ Ошибка обработки webhook YooKassa: {e}", exc_info=True) - return web.Response(status=500, text="Processing error") + logger.error(f'❌ Ошибка обработки webhook YooKassa: {e}', exc_info=True) + return web.Response(status=500, text='Processing error') except Exception as e: - logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True) - return web.Response(status=500, text="Internal server error") + logger.error(f'❌ Критическая ошибка обработки webhook YooKassa: {e}', exc_info=True) + return web.Response(status=500, text='Internal server error') def setup_routes(self, app: web.Application) -> None: - webhook_path = settings.YOOKASSA_WEBHOOK_PATH app.router.add_post(webhook_path, self.handle_webhook) app.router.add_get(webhook_path, self._get_handler) app.router.add_options(webhook_path, self._options_handler) - logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}") + logger.info(f'✅ Настроен YooKassa webhook на пути: POST {webhook_path}') async def _get_handler(self, request: web.Request) -> web.Response: - return web.json_response({ - "status": "ok", - "message": "YooKassa webhook endpoint is working", - "method": "GET", - "path": request.path, - "note": "Use POST method for actual webhooks" - }) + return web.json_response( + { + 'status': 'ok', + 'message': 'YooKassa webhook endpoint is working', + 'method': 'GET', + 'path': request.path, + 'note': 'Use POST method for actual webhooks', + } + ) async def _options_handler(self, request: web.Request) -> web.Response: return web.Response( @@ -331,35 +339,35 @@ class YooKassaWebhookHandler: 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature', - } + }, ) def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application: - app = web.Application() webhook_handler = YooKassaWebhookHandler(payment_service) webhook_handler.setup_routes(app) async def health_check(request): - return web.json_response({ - "status": "ok", - "service": "yookassa_webhook", - "port": settings.YOOKASSA_WEBHOOK_PORT, - "path": settings.YOOKASSA_WEBHOOK_PATH, - "enabled": settings.is_yookassa_enabled() - }) + return web.json_response( + { + 'status': 'ok', + 'service': 'yookassa_webhook', + 'port': settings.YOOKASSA_WEBHOOK_PORT, + 'path': settings.YOOKASSA_WEBHOOK_PATH, + 'enabled': settings.is_yookassa_enabled(), + } + ) - app.router.add_get("/health", health_check) + app.router.add_get('/health', health_check) return app async def start_yookassa_webhook_server(payment_service: PaymentService) -> None: - if not settings.is_yookassa_enabled(): - logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается") + logger.info('ℹ️ YooKassa отключена, webhook сервер не запускается') return try: @@ -368,21 +376,17 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None runner = web.AppRunner(app) await runner.setup() - site = web.TCPSite( - runner, - host=settings.YOOKASSA_WEBHOOK_HOST, - port=settings.YOOKASSA_WEBHOOK_PORT - ) + site = web.TCPSite(runner, host=settings.YOOKASSA_WEBHOOK_HOST, port=settings.YOOKASSA_WEBHOOK_PORT) await site.start() logger.info( - "✅ YooKassa webhook сервер запущен на %s:%s", + '✅ YooKassa webhook сервер запущен на %s:%s', settings.YOOKASSA_WEBHOOK_HOST, settings.YOOKASSA_WEBHOOK_PORT, ) logger.info( - "🎯 YooKassa webhook URL: http://%s:%s%s", + '🎯 YooKassa webhook URL: http://%s:%s%s', settings.YOOKASSA_WEBHOOK_HOST, settings.YOOKASSA_WEBHOOK_PORT, settings.YOOKASSA_WEBHOOK_PATH, @@ -392,12 +396,12 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None while True: await asyncio.sleep(1) except asyncio.CancelledError: - logger.info("🛑 YooKassa webhook сервер получил сигнал остановки") + logger.info('🛑 YooKassa webhook сервер получил сигнал остановки') finally: await site.stop() await runner.cleanup() - logger.info("✅ YooKassa webhook сервер остановлен") + logger.info('✅ YooKassa webhook сервер остановлен') except Exception as e: - logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True) - raise \ No newline at end of file + logger.error(f'❌ Ошибка запуска YooKassa webhook сервера: {e}', exc_info=True) + raise diff --git a/app/handlers/__init__.py b/app/handlers/__init__.py index 84d30575..3d475150 100644 --- a/app/handlers/__init__.py +++ b/app/handlers/__init__.py @@ -1,3 +1,4 @@ from . import balance -__all__ = ["balance"] \ No newline at end of file + +__all__ = ['balance'] diff --git a/app/handlers/admin/__init__.py b/app/handlers/admin/__init__.py index 0e6e421c..e5e454f1 100644 --- a/app/handlers/admin/__init__.py +++ b/app/handlers/admin/__init__.py @@ -5,8 +5,8 @@ from . import ( bot_configuration, bulk_ban, campaigns, - daily_contests, contests, + daily_contests, faq, main, maintenance, diff --git a/app/handlers/admin/backup.py b/app/handlers/admin/backup.py index 6d7c0965..7953486d 100644 --- a/app/handlers/admin/backup.py +++ b/app/handlers/admin/backup.py @@ -1,18 +1,17 @@ import logging -import os from datetime import datetime -from pathlib import Path -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.models import User from app.services.backup_service import backup_service from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @@ -21,123 +20,98 @@ class BackupStates(StatesGroup): waiting_settings_update = State() -def get_backup_main_keyboard(language: str = "ru"): - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"), - InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore") - ], - [ - InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"), - InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings") - ], - [ - InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel") +def get_backup_main_keyboard(language: str = 'ru'): + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text='🚀 Создать бекап', callback_data='backup_create'), + InlineKeyboardButton(text='📥 Восстановить', callback_data='backup_restore'), + ], + [ + InlineKeyboardButton(text='📋 Список бекапов', callback_data='backup_list'), + InlineKeyboardButton(text='⚙️ Настройки', callback_data='backup_settings'), + ], + [InlineKeyboardButton(text='◀️ Назад', callback_data='admin_panel')], ] - ]) + ) def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5): keyboard = [] - + start_idx = (page - 1) * per_page end_idx = start_idx + per_page page_backups = backups[start_idx:end_idx] - + for backup in page_backups: try: - if backup.get("timestamp"): - dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00')) - date_str = dt.strftime("%d.%m %H:%M") + if backup.get('timestamp'): + dt = datetime.fromisoformat(backup['timestamp'].replace('Z', '+00:00')) + date_str = dt.strftime('%d.%m %H:%M') else: - date_str = "?" + date_str = '?' except: - date_str = "?" - - size_str = f"{backup.get('file_size_mb', 0):.1f}MB" + date_str = '?' + + size_str = f'{backup.get("file_size_mb", 0):.1f}MB' records_str = backup.get('total_records', '?') - - button_text = f"📦 {date_str} • {size_str} • {records_str} записей" - callback_data = f"backup_manage_{backup['filename']}" - + + button_text = f'📦 {date_str} • {size_str} • {records_str} записей' + callback_data = f'backup_manage_{backup["filename"]}' + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)]) - + if len(backups) > per_page: total_pages = (len(backups) + per_page - 1) // per_page nav_row = [] - + if page > 1: - nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}")) - - nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop")) - + nav_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'backup_list_page_{page - 1}')) + + nav_row.append(InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='noop')) + if page < total_pages: - nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}")) - + nav_row.append(InlineKeyboardButton(text='➡️', callback_data=f'backup_list_page_{page + 1}')) + keyboard.append(nav_row) - - keyboard.extend([ - [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] - ]) - + + keyboard.extend([[InlineKeyboardButton(text='◀️ Назад', callback_data='backup_panel')]]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_backup_manage_keyboard(backup_filename: str): - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}") - ], - [ - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}") - ], - [ - InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list") + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📥 Восстановить', callback_data=f'backup_restore_file_{backup_filename}')], + [InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'backup_delete_{backup_filename}')], + [InlineKeyboardButton(text='◀️ К списку', callback_data='backup_list')], ] - ]) + ) def get_backup_settings_keyboard(settings_obj): - auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены" - compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено" - logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены" - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=f"🔄 Автобекапы: {auto_status}", - callback_data="backup_toggle_auto" - ) - ], - [ - InlineKeyboardButton( - text=f"🗜️ Сжатие: {compression_status}", - callback_data="backup_toggle_compression" - ) - ], - [ - InlineKeyboardButton( - text=f"📋 Логи в бекапе: {logs_status}", - callback_data="backup_toggle_logs" - ) - ], - [ - InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel") + auto_status = '✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены' + compression_status = '✅ Включено' if settings_obj.compression_enabled else '❌ Отключено' + logs_status = '✅ Включены' if settings_obj.include_logs else '❌ Отключены' + + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=f'🔄 Автобекапы: {auto_status}', callback_data='backup_toggle_auto')], + [InlineKeyboardButton(text=f'🗜️ Сжатие: {compression_status}', callback_data='backup_toggle_compression')], + [InlineKeyboardButton(text=f'📋 Логи в бекапе: {logs_status}', callback_data='backup_toggle_logs')], + [InlineKeyboardButton(text='◀️ Назад', callback_data='backup_panel')], ] - ]) + ) @admin_required @error_handler -async def show_backup_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_backup_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): settings_obj = await backup_service.get_backup_settings() - - status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены" - + + status_auto = '✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены' + text = f"""🗄️ СИСТЕМА БЕКАПОВ 📊 Статус: @@ -153,117 +127,94 @@ async def show_backup_panel( • Восстановление из файла бекапа • Управление автоматическими бекапами """ - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=get_backup_main_keyboard(db_user.language) - ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=get_backup_main_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler -async def create_backup_handler( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - await callback.answer("🔄 Создание бекапа запущено...") - +async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.answer('🔄 Создание бекапа запущено...') + progress_msg = await callback.message.edit_text( - "🔄 Создание бекапа...\n\n" - "⏳ Экспортируем данные из базы...\n" - "Это может занять несколько минут.", - parse_mode="HTML" + '🔄 Создание бекапа...\n\n⏳ Экспортируем данные из базы...\nЭто может занять несколько минут.', + parse_mode='HTML', ) - + # Создаем бекап - created_by_id = db_user.telegram_id or db_user.email or f"#{db_user.id}" - success, message, file_path = await backup_service.create_backup( - created_by=created_by_id, - compress=True - ) - + created_by_id = db_user.telegram_id or db_user.email or f'#{db_user.id}' + success, message, file_path = await backup_service.create_backup(created_by=created_by_id, compress=True) + if success: await progress_msg.edit_text( - f"✅ Бекап создан успешно!\n\n{message}", - parse_mode="HTML", - reply_markup=get_backup_main_keyboard(db_user.language) + f'✅ Бекап создан успешно!\n\n{message}', + parse_mode='HTML', + reply_markup=get_backup_main_keyboard(db_user.language), ) else: await progress_msg.edit_text( - f"❌ Ошибка создания бекапа\n\n{message}", - parse_mode="HTML", - reply_markup=get_backup_main_keyboard(db_user.language) + f'❌ Ошибка создания бекапа\n\n{message}', + parse_mode='HTML', + reply_markup=get_backup_main_keyboard(db_user.language), ) @admin_required @error_handler -async def show_backup_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_backup_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = 1 - if callback.data.startswith("backup_list_page_"): + if callback.data.startswith('backup_list_page_'): try: - page = int(callback.data.split("_")[-1]) + page = int(callback.data.split('_')[-1]) except: page = 1 - + backups = await backup_service.get_backup_list() - + if not backups: - text = "📦 Список бекапов пуст\n\nБекапы еще не создавались." - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")], - [InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")] - ]) + text = '📦 Список бекапов пуст\n\nБекапы еще не создавались.' + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🚀 Создать первый бекап', callback_data='backup_create')], + [InlineKeyboardButton(text='◀️ Назад', callback_data='backup_panel')], + ] + ) else: - text = f"📦 Список бекапов (всего: {len(backups)})\n\n" - text += "Выберите бекап для управления:" + text = f'📦 Список бекапов (всего: {len(backups)})\n\n' + text += 'Выберите бекап для управления:' keyboard = get_backup_list_keyboard(backups, page) - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=keyboard - ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) await callback.answer() @admin_required @error_handler -async def manage_backup_file( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - filename = callback.data.replace("backup_manage_", "") - +async def manage_backup_file(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + filename = callback.data.replace('backup_manage_', '') + backups = await backup_service.get_backup_list() backup_info = None - + for backup in backups: - if backup["filename"] == filename: + if backup['filename'] == filename: backup_info = backup break - + if not backup_info: - await callback.answer("❌ Файл бекапа не найден", show_alert=True) + await callback.answer('❌ Файл бекапа не найден', show_alert=True) return - + try: - if backup_info.get("timestamp"): - dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00')) - date_str = dt.strftime("%d.%m.%Y %H:%M:%S") + if backup_info.get('timestamp'): + dt = datetime.fromisoformat(backup_info['timestamp'].replace('Z', '+00:00')) + date_str = dt.strftime('%d.%m.%Y %H:%M:%S') else: - date_str = "Неизвестно" + date_str = 'Неизвестно' except: - date_str = "Ошибка формата даты" - + date_str = 'Ошибка формата даты' + text = f"""📦 Информация о бекапе 📄 Файл: {filename} @@ -274,105 +225,90 @@ async def manage_backup_file( 🗜️ Сжатие: {'Да' if backup_info.get('compressed') else 'Нет'} 🗄️ БД: {backup_info.get('database_type', 'unknown')} """ - - if backup_info.get("error"): - text += f"\n⚠️ Ошибка: {backup_info['error']}" - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=get_backup_manage_keyboard(filename) - ) + + if backup_info.get('error'): + text += f'\n⚠️ Ошибка: {backup_info["error"]}' + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=get_backup_manage_keyboard(filename)) await callback.answer() @admin_required @error_handler -async def delete_backup_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - filename = callback.data.replace("backup_delete_", "") - - text = f"🗑️ Удаление бекапа\n\n" - text += f"Вы уверены, что хотите удалить бекап?\n\n" - text += f"📄 {filename}\n\n" - text += "⚠️ Это действие нельзя отменить!" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"), - InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}") +async def delete_backup_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + filename = callback.data.replace('backup_delete_', '') + + text = '🗑️ Удаление бекапа\n\n' + text += 'Вы уверены, что хотите удалить бекап?\n\n' + text += f'📄 {filename}\n\n' + text += '⚠️ Это действие нельзя отменить!' + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text='✅ Да, удалить', callback_data=f'backup_delete_confirm_{filename}'), + InlineKeyboardButton(text='❌ Отмена', callback_data=f'backup_manage_{filename}'), + ] ] - ]) - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=keyboard ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) await callback.answer() @admin_required @error_handler -async def delete_backup_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - filename = callback.data.replace("backup_delete_confirm_", "") - +async def delete_backup_execute(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + filename = callback.data.replace('backup_delete_confirm_', '') + success, message = await backup_service.delete_backup(filename) - + if success: await callback.message.edit_text( - f"✅ Бекап удален\n\n{message}", - parse_mode="HTML", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")] - ]) + f'✅ Бекап удален\n\n{message}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='📋 К списку бекапов', callback_data='backup_list')]] + ), ) else: await callback.message.edit_text( - f"❌ Ошибка удаления\n\n{message}", - parse_mode="HTML", - reply_markup=get_backup_manage_keyboard(filename) + f'❌ Ошибка удаления\n\n{message}', + parse_mode='HTML', + reply_markup=get_backup_manage_keyboard(filename), ) - + await callback.answer() @admin_required @error_handler -async def restore_backup_start( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - if callback.data.startswith("backup_restore_file_"): +async def restore_backup_start(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + if callback.data.startswith('backup_restore_file_'): # Восстановление из конкретного файла - filename = callback.data.replace("backup_restore_file_", "") - - text = f"📥 Восстановление из бекапа\n\n" - text += f"📄 Файл: {filename}\n\n" - text += "⚠️ ВНИМАНИЕ!\n" - text += "• Процесс может занять несколько минут\n" - text += "• Рекомендуется создать бекап перед восстановлением\n" - text += "• Существующие данные будут дополнены\n\n" - text += "Продолжить восстановление?" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"), - InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}") - ], - [ - InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}") + filename = callback.data.replace('backup_restore_file_', '') + + text = '📥 Восстановление из бекапа\n\n' + text += f'📄 Файл: {filename}\n\n' + text += '⚠️ ВНИМАНИЕ!\n' + text += '• Процесс может занять несколько минут\n' + text += '• Рекомендуется создать бекап перед восстановлением\n' + text += '• Существующие данные будут дополнены\n\n' + text += 'Продолжить восстановление?' + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Да, восстановить', callback_data=f'backup_restore_execute_{filename}' + ), + InlineKeyboardButton( + text='🗑️ Очистить и восстановить', callback_data=f'backup_restore_clear_{filename}' + ), + ], + [InlineKeyboardButton(text='❌ Отмена', callback_data=f'backup_manage_{filename}')], ] - ]) + ) else: text = """📥 Восстановление из бекапа @@ -384,116 +320,102 @@ async def restore_backup_start( • Рекомендуется создать бекап перед восстановлением 💡 Или выберите из существующих бекапов ниже.""" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")], - [InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📋 Выбрать из списка', callback_data='backup_list')], + [InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')], + ] + ) + await state.set_state(BackupStates.waiting_backup_file) - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=keyboard - ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) await callback.answer() @admin_required @error_handler -async def restore_backup_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - if callback.data.startswith("backup_restore_execute_"): - filename = callback.data.replace("backup_restore_execute_", "") +async def restore_backup_execute(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + if callback.data.startswith('backup_restore_execute_'): + filename = callback.data.replace('backup_restore_execute_', '') clear_existing = False - elif callback.data.startswith("backup_restore_clear_"): - filename = callback.data.replace("backup_restore_clear_", "") + elif callback.data.startswith('backup_restore_clear_'): + filename = callback.data.replace('backup_restore_clear_', '') clear_existing = True else: - await callback.answer("❌ Неверный формат команды", show_alert=True) + await callback.answer('❌ Неверный формат команды', show_alert=True) return - - await callback.answer("🔄 Восстановление запущено...") - + + await callback.answer('🔄 Восстановление запущено...') + # Показываем прогресс - action_text = "очисткой и восстановлением" if clear_existing else "восстановлением" + action_text = 'очисткой и восстановлением' if clear_existing else 'восстановлением' progress_msg = await callback.message.edit_text( - f"📥 Восстановление из бекапа...\n\n" - f"⏳ Работаем с {action_text} данных...\n" - f"📄 Файл: {filename}\n\n" - f"Это может занять несколько минут.", - parse_mode="HTML" + f'📥 Восстановление из бекапа...\n\n' + f'⏳ Работаем с {action_text} данных...\n' + f'📄 Файл: {filename}\n\n' + f'Это может занять несколько минут.', + parse_mode='HTML', ) - + backup_path = backup_service.backup_dir / filename - - success, message = await backup_service.restore_backup( - str(backup_path), - clear_existing=clear_existing - ) - + + success, message = await backup_service.restore_backup(str(backup_path), clear_existing=clear_existing) + if success: await progress_msg.edit_text( - f"✅ Восстановление завершено!\n\n{message}", - parse_mode="HTML", - reply_markup=get_backup_main_keyboard(db_user.language) + f'✅ Восстановление завершено!\n\n{message}', + parse_mode='HTML', + reply_markup=get_backup_main_keyboard(db_user.language), ) else: await progress_msg.edit_text( - f"❌ Ошибка восстановления\n\n{message}", - parse_mode="HTML", - reply_markup=get_backup_manage_keyboard(filename) + f'❌ Ошибка восстановления\n\n{message}', + parse_mode='HTML', + reply_markup=get_backup_manage_keyboard(filename), ) @admin_required @error_handler -async def handle_backup_file_upload( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def handle_backup_file_upload(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): if not message.document: await message.answer( - "❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")] - ]) + '❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] + ), ) return - + document = message.document - + if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')): await message.answer( - "❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")] - ]) + '❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] + ), ) return - + if document.file_size > 50 * 1024 * 1024: await message.answer( - "❌ Файл слишком большой (максимум 50MB)", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")] - ]) + '❌ Файл слишком большой (максимум 50MB)', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] + ), ) return - + try: file = await message.bot.get_file(document.file_id) - - temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}" - + + temp_path = backup_service.backup_dir / f'uploaded_{document.file_name}' + await message.bot.download_file(file.file_path, temp_path) - + text = f"""📥 Файл загружен 📄 Имя: {document.file_name} @@ -504,39 +426,40 @@ async def handle_backup_file_upload( Рекомендуется создать бекап перед восстановлением. Продолжить?""" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"), - InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}") - ], - [ - InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel") + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}' + ), + InlineKeyboardButton( + text='🗑️ Очистить и восстановить', + callback_data=f'backup_restore_uploaded_clear_{temp_path.name}', + ), + ], + [InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')], ] - ]) - - await message.answer(text, parse_mode="HTML", reply_markup=keyboard) + ) + + await message.answer(text, parse_mode='HTML', reply_markup=keyboard) await state.clear() - + except Exception as e: - logger.error(f"Ошибка загрузки файла бекапа: {e}") + logger.error(f'Ошибка загрузки файла бекапа: {e}') await message.answer( - f"❌ Ошибка загрузки файла: {str(e)}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")] - ]) + f'❌ Ошибка загрузки файла: {e!s}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='◀️ Отмена', callback_data='backup_panel')]] + ), ) @admin_required @error_handler -async def show_backup_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_backup_settings(callback: types.CallbackQuery, db_user: User, db: AsyncSession): settings_obj = await backup_service.get_backup_settings() - + text = f"""⚙️ Настройки системы бекапов 🔄 Автоматические бекапы: @@ -551,98 +474,65 @@ async def show_backup_settings( 📁 Расположение: {settings_obj.backup_location} """ - - await callback.message.edit_text( - text, - parse_mode="HTML", - reply_markup=get_backup_settings_keyboard(settings_obj) - ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=get_backup_settings_keyboard(settings_obj)) await callback.answer() @admin_required @error_handler -async def toggle_backup_setting( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_backup_setting(callback: types.CallbackQuery, db_user: User, db: AsyncSession): settings_obj = await backup_service.get_backup_settings() - - if callback.data == "backup_toggle_auto": + + if callback.data == 'backup_toggle_auto': new_value = not settings_obj.auto_backup_enabled await backup_service.update_backup_settings(auto_backup_enabled=new_value) - status = "включены" if new_value else "отключены" - await callback.answer(f"Автобекапы {status}") - - elif callback.data == "backup_toggle_compression": + status = 'включены' if new_value else 'отключены' + await callback.answer(f'Автобекапы {status}') + + elif callback.data == 'backup_toggle_compression': new_value = not settings_obj.compression_enabled await backup_service.update_backup_settings(compression_enabled=new_value) - status = "включено" if new_value else "отключено" - await callback.answer(f"Сжатие {status}") - - elif callback.data == "backup_toggle_logs": + status = 'включено' if new_value else 'отключено' + await callback.answer(f'Сжатие {status}') + + elif callback.data == 'backup_toggle_logs': new_value = not settings_obj.include_logs await backup_service.update_backup_settings(include_logs=new_value) - status = "включены" if new_value else "отключены" - await callback.answer(f"Логи в бекапе {status}") - + status = 'включены' if new_value else 'отключены' + await callback.answer(f'Логи в бекапе {status}') + await show_backup_settings(callback, db_user, db) def register_handlers(dp: Dispatcher): - + dp.callback_query.register(show_backup_panel, F.data == 'backup_panel') + + dp.callback_query.register(create_backup_handler, F.data == 'backup_create') + + dp.callback_query.register(show_backup_list, F.data.startswith('backup_list')) + + dp.callback_query.register(manage_backup_file, F.data.startswith('backup_manage_')) + dp.callback_query.register( - show_backup_panel, - F.data == "backup_panel" + delete_backup_confirm, F.data.startswith('backup_delete_') & ~F.data.startswith('backup_delete_confirm_') ) - + + dp.callback_query.register(delete_backup_execute, F.data.startswith('backup_delete_confirm_')) + dp.callback_query.register( - create_backup_handler, - F.data == "backup_create" + restore_backup_start, F.data.in_(['backup_restore']) | F.data.startswith('backup_restore_file_') ) - - dp.callback_query.register( - show_backup_list, - F.data.startswith("backup_list") - ) - - dp.callback_query.register( - manage_backup_file, - F.data.startswith("backup_manage_") - ) - - dp.callback_query.register( - delete_backup_confirm, - F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_") - ) - - dp.callback_query.register( - delete_backup_execute, - F.data.startswith("backup_delete_confirm_") - ) - - dp.callback_query.register( - restore_backup_start, - F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_") - ) - + dp.callback_query.register( restore_backup_execute, - F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_") + F.data.startswith('backup_restore_execute_') | F.data.startswith('backup_restore_clear_'), ) - + + dp.callback_query.register(show_backup_settings, F.data == 'backup_settings') + dp.callback_query.register( - show_backup_settings, - F.data == "backup_settings" - ) - - dp.callback_query.register( - toggle_backup_setting, - F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"]) - ) - - dp.message.register( - handle_backup_file_upload, - BackupStates.waiting_backup_file + toggle_backup_setting, F.data.in_(['backup_toggle_auto', 'backup_toggle_compression', 'backup_toggle_logs']) ) + + dp.message.register(handle_backup_file_upload, BackupStates.waiting_backup_file) diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py index 3700dcec..13121e15 100644 --- a/app/handlers/admin/blacklist.py +++ b/app/handlers/admin/blacklist.py @@ -1,41 +1,37 @@ """ Обработчики админ-панели для управления черным списком """ + import logging + from aiogram import types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.models import User from app.services.blacklist_service import blacklist_service from app.states import BlacklistStates from app.utils.decorators import admin_required, error_handler -from app.keyboards.admin import get_admin_users_keyboard + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_blacklist_settings( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Показывает настройки черного списка """ - logger.info(f"Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}") + logger.info(f'Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}') is_enabled = blacklist_service.is_blacklist_check_enabled() github_url = blacklist_service.get_blacklist_github_url() blacklist_count = len(await blacklist_service.get_all_blacklisted_users()) - - status_text = "✅ Включена" if is_enabled else "❌ Отключена" - url_text = github_url if github_url else "Не задан" - + + status_text = '✅ Включена' if is_enabled else '❌ Отключена' + url_text = github_url if github_url else 'Не задан' + text = f""" 🔐 Настройки черного списка @@ -45,95 +41,69 @@ URL к черному списку: {url_text} Действия: """ - + keyboard = [ [ types.InlineKeyboardButton( - text="🔄 Обновить список" if is_enabled else "🔄 Обновить (откл.)", - callback_data="admin_blacklist_update" + text='🔄 Обновить список' if is_enabled else '🔄 Обновить (откл.)', + callback_data='admin_blacklist_update', ) ], [ types.InlineKeyboardButton( - text="📋 Просмотреть список" if is_enabled else "📋 Просмотр (откл.)", - callback_data="admin_blacklist_view" + text='📋 Просмотреть список' if is_enabled else '📋 Просмотр (откл.)', + callback_data='admin_blacklist_view', ) ], [ types.InlineKeyboardButton( - text="✏️ URL к GitHub" if not github_url else "✏️ Изменить URL", - callback_data="admin_blacklist_set_url" + text='✏️ URL к GitHub' if not github_url else '✏️ Изменить URL', callback_data='admin_blacklist_set_url' ) ], [ types.InlineKeyboardButton( - text="✅ Включить" if not is_enabled else "❌ Отключить", - callback_data="admin_blacklist_toggle" + text='✅ Включить' if not is_enabled else '❌ Отключить', callback_data='admin_blacklist_toggle' ) ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад к пользователям", - callback_data="admin_users" - ) - ] + [types.InlineKeyboardButton(text='⬅️ Назад к пользователям', callback_data='admin_users')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def toggle_blacklist( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def toggle_blacklist(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Переключает статус проверки черного списка """ # Текущая реализация использует настройки из .env # Для полной реализации нужно будет создать сервис настроек is_enabled = blacklist_service.is_blacklist_check_enabled() - + # В реальной реализации нужно будет изменить настройку в базе данных # или в системе настроек, но сейчас просто покажем статус new_status = not is_enabled - status_text = "включена" if new_status else "отключена" - + status_text = 'включена' if new_status else 'отключена' + await callback.message.edit_text( - f"Статус проверки черного списка: {status_text}\n\n" - f"Для изменения статуса проверки черного списка измените значение\n" - f"BLACKLIST_CHECK_ENABLED в файле .env", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔄 Обновить статус", - callback_data="admin_blacklist_settings" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_blacklist_settings" - ) + f'Статус проверки черного списка: {status_text}\n\n' + f'Для изменения статуса проверки черного списка измените значение\n' + f'BLACKLIST_CHECK_ENABLED в файле .env', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить статус', callback_data='admin_blacklist_settings')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_blacklist_settings')], ] - ]) + ), ) await callback.answer() @admin_required @error_handler -async def update_blacklist( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def update_blacklist(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Обновляет черный список из GitHub """ @@ -141,131 +111,85 @@ async def update_blacklist( if success: await callback.message.edit_text( - f"✅ {message}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="📋 Просмотреть список", - callback_data="admin_blacklist_view" - ) - ], - [ - types.InlineKeyboardButton( - text="🔄 Ручное обновление", - callback_data="admin_blacklist_update" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_blacklist_settings" - ) + f'✅ {message}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📋 Просмотреть список', callback_data='admin_blacklist_view')], + [types.InlineKeyboardButton(text='🔄 Ручное обновление', callback_data='admin_blacklist_update')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_blacklist_settings')], ] - ]) + ), ) else: await callback.message.edit_text( - f"❌ Ошибка обновления: {message}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔄 Повторить", - callback_data="admin_blacklist_update" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_blacklist_settings" - ) + f'❌ Ошибка обновления: {message}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Повторить', callback_data='admin_blacklist_update')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_blacklist_settings')], ] - ]) + ), ) await callback.answer() @admin_required @error_handler -async def show_blacklist_users( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Показывает список пользователей в черном списке """ blacklist_users = await blacklist_service.get_all_blacklisted_users() - + if not blacklist_users: - text = "Черный список пуст" + text = 'Черный список пуст' else: - text = f"🔐 Черный список ({len(blacklist_users)} записей)\n\n" - + text = f'🔐 Черный список ({len(blacklist_users)} записей)\n\n' + # Показываем первые 20 записей for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1): - text += f"{i}. {tg_id} {username or ''} — {reason}\n" - + text += f'{i}. {tg_id} {username or ""} — {reason}\n' + if len(blacklist_users) > 20: - text += f"\n... и еще {len(blacklist_users) - 20} записей" - + text += f'\n... и еще {len(blacklist_users) - 20} записей' + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔄 Обновить", - callback_data="admin_blacklist_view" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_blacklist_settings" - ) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_blacklist_view')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_blacklist_settings')], ] - ]) + ), ) await callback.answer() @admin_required @error_handler -async def start_set_blacklist_url( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_set_blacklist_url(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Начинает процесс установки URL к черному списку """ - current_url = blacklist_service.get_blacklist_github_url() or "не задан" - + current_url = blacklist_service.get_blacklist_github_url() or 'не задан' + await callback.message.edit_text( - f"Введите новый URL к файлу черного списка на GitHub\n\n" - f"Текущий URL: {current_url}\n\n" - f"Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n" - f"Для отмены используйте команду /cancel", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_blacklist_settings" - ) - ] - ]) + f'Введите новый URL к файлу черного списка на GitHub\n\n' + f'Текущий URL: {current_url}\n\n' + f'Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n' + f'Для отмены используйте команду /cancel', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_blacklist_settings')]] + ), ) - + await state.set_state(BlacklistStates.waiting_for_blacklist_url) await callback.answer() @admin_required @error_handler -async def process_blacklist_url( - message: types.Message, - db_user: User, - state: FSMContext -): +async def process_blacklist_url(message: types.Message, db_user: User, state: FSMContext): """ Обрабатывает введенный URL к черному списку """ @@ -274,60 +198,58 @@ async def process_blacklist_url( return url = message.text.strip() - + # В реальной реализации нужно сохранить URL в систему настроек # В текущей реализации просто выводим сообщение if url.lower() in ['/cancel', 'отмена', 'cancel']: await message.answer( - "Настройка URL отменена", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔐 Настройки черного списка", - callback_data="admin_blacklist_settings" - ) + 'Настройка URL отменена', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='🔐 Настройки черного списка', callback_data='admin_blacklist_settings' + ) + ] ] - ]) + ), ) await state.clear() return - + # Проверяем, что URL выглядит корректно if not url.startswith(('http://', 'https://')): await message.answer( - "❌ Некорректный URL. URL должен начинаться с http:// или https://", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔐 Настройки черного списка", - callback_data="admin_blacklist_settings" - ) + '❌ Некорректный URL. URL должен начинаться с http:// или https://', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='🔐 Настройки черного списка', callback_data='admin_blacklist_settings' + ) + ] ] - ]) + ), ) return - + # В реальной системе здесь нужно сохранить URL в базу данных настроек # или в систему конфигурации - + await message.answer( - f"✅ URL к черному списку установлен:\n{url}\n\n" - f"Для применения изменений перезапустите бота или измените значение\n" - f"BLACKLIST_GITHUB_URL в файле .env", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🔄 Обновить список", - callback_data="admin_blacklist_update" - ) - ], - [ - types.InlineKeyboardButton( - text="🔐 Настройки черного списка", - callback_data="admin_blacklist_settings" - ) + f'✅ URL к черному списку установлен:\n{url}\n\n' + f'Для применения изменений перезапустите бота или измените значение\n' + f'BLACKLIST_GITHUB_URL в файле .env', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить список', callback_data='admin_blacklist_update')], + [ + types.InlineKeyboardButton( + text='🔐 Настройки черного списка', callback_data='admin_blacklist_settings' + ) + ], ] - ]) + ), ) await state.clear() @@ -338,34 +260,16 @@ def register_blacklist_handlers(dp): """ # Обработчик показа настроек черного списка # Этот обработчик нужно будет вызывать из меню пользователей или отдельно - dp.callback_query.register( - show_blacklist_settings, - lambda c: c.data == "admin_blacklist_settings" - ) + dp.callback_query.register(show_blacklist_settings, lambda c: c.data == 'admin_blacklist_settings') # Обработчики для взаимодействия с черным списком - dp.callback_query.register( - toggle_blacklist, - lambda c: c.data == "admin_blacklist_toggle" - ) + dp.callback_query.register(toggle_blacklist, lambda c: c.data == 'admin_blacklist_toggle') - dp.callback_query.register( - update_blacklist, - lambda c: c.data == "admin_blacklist_update" - ) + dp.callback_query.register(update_blacklist, lambda c: c.data == 'admin_blacklist_update') - dp.callback_query.register( - show_blacklist_users, - lambda c: c.data == "admin_blacklist_view" - ) + dp.callback_query.register(show_blacklist_users, lambda c: c.data == 'admin_blacklist_view') - dp.callback_query.register( - start_set_blacklist_url, - lambda c: c.data == "admin_blacklist_set_url" - ) + dp.callback_query.register(start_set_blacklist_url, lambda c: c.data == 'admin_blacklist_set_url') # Обработчик сообщений для установки URL (работает только в нужном состоянии) - dp.message.register( - process_blacklist_url, - StateFilter(BlacklistStates.waiting_for_blacklist_url) - ) + dp.message.register(process_blacklist_url, StateFilter(BlacklistStates.waiting_for_blacklist_url)) diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index cb9de4e9..c115642c 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -3,8 +3,8 @@ import io import logging import math import time +from collections.abc import Iterable from datetime import datetime -from typing import Dict, Iterable, List, Optional, Tuple from aiogram import Dispatcher, F, types from aiogram.filters import BaseFilter, StateFilter @@ -12,366 +12,363 @@ from aiogram.fsm.context import FSMContext from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import SystemSetting, User +from app.config import settings from app.database.crud.server_squad import ( get_all_server_squads, get_server_squad_by_id, get_server_squad_by_uuid, ) +from app.database.models import SystemSetting, User +from app.external.telegram_stars import TelegramStarsService from app.localization.texts import get_texts -from app.config import settings -from app.services.remnawave_service import RemnaWaveService from app.services.payment_service import PaymentService -from app.services.tribute_service import TributeService +from app.services.remnawave_service import RemnaWaveService from app.services.system_settings_service import ( ReadOnlySettingError, bot_configuration_service, ) +from app.services.tribute_service import TributeService from app.states import BotConfigStates -from app.utils.decorators import admin_required, error_handler from app.utils.currency_converter import currency_converter -from app.external.telegram_stars import TelegramStarsService +from app.utils.decorators import admin_required, error_handler CATEGORY_PAGE_SIZE = 10 SETTINGS_PAGE_SIZE = 8 SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6 -CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { - "core": { - "title": "🤖 Основные", - "description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.", - "icon": "🤖", - "categories": ( - "CORE", - "CHANNEL", - "TIMEZONE", - "DATABASE", - "POSTGRES", - "SQLITE", - "REDIS", - "REMNAWAVE", +CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = { + 'core': { + 'title': '🤖 Основные', + 'description': 'Базовые настройки бота, обязательные каналы и ключевые сервисы.', + 'icon': '🤖', + 'categories': ( + 'CORE', + 'CHANNEL', + 'TIMEZONE', + 'DATABASE', + 'POSTGRES', + 'SQLITE', + 'REDIS', + 'REMNAWAVE', ), }, - "support": { - "title": "💬 Поддержка", - "description": "Контакты, режимы тикетов, SLA и уведомления модераторов.", - "icon": "💬", - "categories": ("SUPPORT",), + 'support': { + 'title': '💬 Поддержка', + 'description': 'Контакты, режимы тикетов, SLA и уведомления модераторов.', + 'icon': '💬', + 'categories': ('SUPPORT',), }, - "payments": { - "title": "💳 Платежные системы", - "description": "YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.", - "icon": "💳", - "categories": ( - "PAYMENT", - "PAYMENT_VERIFICATION", - "YOOKASSA", - "CRYPTOBOT", - "HELEKET", - "CLOUDPAYMENTS", - "FREEKASSA", - "MULENPAY", - "PAL24", - "WATA", - "PLATEGA", - "TRIBUTE", - "TELEGRAM", + 'payments': { + 'title': '💳 Платежные системы', + 'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.', + 'icon': '💳', + 'categories': ( + 'PAYMENT', + 'PAYMENT_VERIFICATION', + 'YOOKASSA', + 'CRYPTOBOT', + 'HELEKET', + 'CLOUDPAYMENTS', + 'FREEKASSA', + 'MULENPAY', + 'PAL24', + 'WATA', + 'PLATEGA', + 'TRIBUTE', + 'TELEGRAM', ), }, - "subscriptions": { - "title": "📅 Подписки и цены", - "description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.", - "icon": "📅", - "categories": ( - "SUBSCRIPTIONS_CORE", - "SIMPLE_SUBSCRIPTION", - "PERIODS", - "SUBSCRIPTION_PRICES", - "TRAFFIC", - "TRAFFIC_PACKAGES", - "AUTOPAY", + 'subscriptions': { + 'title': '📅 Подписки и цены', + 'description': 'Тарифы, простая покупка, периоды, лимиты трафика и автопродление.', + 'icon': '📅', + 'categories': ( + 'SUBSCRIPTIONS_CORE', + 'SIMPLE_SUBSCRIPTION', + 'PERIODS', + 'SUBSCRIPTION_PRICES', + 'TRAFFIC', + 'TRAFFIC_PACKAGES', + 'AUTOPAY', ), }, - "trial": { - "title": "🎁 Пробный период", - "description": "Длительность и ограничения бесплатного доступа.", - "icon": "🎁", - "categories": ("TRIAL",), + 'trial': { + 'title': '🎁 Пробный период', + 'description': 'Длительность и ограничения бесплатного доступа.', + 'icon': '🎁', + 'categories': ('TRIAL',), }, - "referral": { - "title": "👥 Реферальная программа", - "description": "Бонусы, пороги и уведомления для партнеров.", - "icon": "👥", - "categories": ("REFERRAL",), + 'referral': { + 'title': '👥 Реферальная программа', + 'description': 'Бонусы, пороги и уведомления для партнеров.', + 'icon': '👥', + 'categories': ('REFERRAL',), }, - "notifications": { - "title": "🔔 Уведомления", - "description": "Пользовательские, админские оповещения и отчеты.", - "icon": "🔔", - "categories": ("NOTIFICATIONS", "ADMIN_NOTIFICATIONS", "ADMIN_REPORTS"), + 'notifications': { + 'title': '🔔 Уведомления', + 'description': 'Пользовательские, админские оповещения и отчеты.', + 'icon': '🔔', + 'categories': ('NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'), }, - "interface": { - "title": "🎨 Интерфейс и брендинг", - "description": "Логотип, тексты, языки, главное меню, miniapp и deep links.", - "icon": "🎨", - "categories": ( - "INTERFACE", - "INTERFACE_BRANDING", - "INTERFACE_SUBSCRIPTION", - "CONNECT_BUTTON", - "MINIAPP", - "HAPP", - "SKIP", - "LOCALIZATION", - "ADDITIONAL", + 'interface': { + 'title': '🎨 Интерфейс и брендинг', + 'description': 'Логотип, тексты, языки, главное меню, miniapp и deep links.', + 'icon': '🎨', + 'categories': ( + 'INTERFACE', + 'INTERFACE_BRANDING', + 'INTERFACE_SUBSCRIPTION', + 'CONNECT_BUTTON', + 'MINIAPP', + 'HAPP', + 'SKIP', + 'LOCALIZATION', + 'ADDITIONAL', ), }, - "server": { - "title": "📊 Статус серверов", - "description": "Мониторинг серверов, SLA и внешние метрики.", - "icon": "📊", - "categories": ("SERVER_STATUS", "MONITORING"), + 'server': { + 'title': '📊 Статус серверов', + 'description': 'Мониторинг серверов, SLA и внешние метрики.', + 'icon': '📊', + 'categories': ('SERVER_STATUS', 'MONITORING'), }, - "maintenance": { - "title": "🔧 Обслуживание", - "description": "Режим техработ, бэкапы и проверка обновлений.", - "icon": "🔧", - "categories": ("MAINTENANCE", "BACKUP", "VERSION"), + 'maintenance': { + 'title': '🔧 Обслуживание', + 'description': 'Режим техработ, бэкапы и проверка обновлений.', + 'icon': '🔧', + 'categories': ('MAINTENANCE', 'BACKUP', 'VERSION'), }, - "advanced": { - "title": "⚡ Расширенные", - "description": "Web API, webhook, логирование, модерация и режим отладки.", - "icon": "⚡", - "categories": ( - "WEB_API", - "WEBHOOK", - "LOG", - "MODERATION", - "DEBUG", - "EXTERNAL_ADMIN", + 'advanced': { + 'title': '⚡ Расширенные', + 'description': 'Web API, webhook, логирование, модерация и режим отладки.', + 'icon': '⚡', + 'categories': ( + 'WEB_API', + 'WEBHOOK', + 'LOG', + 'MODERATION', + 'DEBUG', + 'EXTERNAL_ADMIN', ), }, } -CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( - "core", - "support", - "payments", - "subscriptions", - "trial", - "referral", - "notifications", - "interface", - "server", - "maintenance", - "advanced", +CATEGORY_GROUP_ORDER: tuple[str, ...] = ( + 'core', + 'support', + 'payments', + 'subscriptions', + 'trial', + 'referral', + 'notifications', + 'interface', + 'server', + 'maintenance', + 'advanced', ) -CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple( +CATEGORY_GROUP_DEFINITIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = tuple( ( group_key, - str(CATEGORY_GROUP_METADATA[group_key]["title"]), - tuple(CATEGORY_GROUP_METADATA[group_key]["categories"]), + str(CATEGORY_GROUP_METADATA[group_key]['title']), + tuple(CATEGORY_GROUP_METADATA[group_key]['categories']), ) for group_key in CATEGORY_GROUP_ORDER ) -CATEGORY_TO_GROUP: Dict[str, str] = {} +CATEGORY_TO_GROUP: dict[str, str] = {} for _group_key, _title, _category_keys in CATEGORY_GROUP_DEFINITIONS: for _category_key in _category_keys: CATEGORY_TO_GROUP[_category_key] = _group_key -CATEGORY_FALLBACK_KEY = "other" -CATEGORY_FALLBACK_TITLE = "📦 Прочие настройки" +CATEGORY_FALLBACK_KEY = 'other' +CATEGORY_FALLBACK_TITLE = '📦 Прочие настройки' -PRESET_CONFIGS: Dict[str, Dict[str, object]] = { - "recommended": { - "ENABLE_NOTIFICATIONS": True, - "ADMIN_NOTIFICATIONS_ENABLED": True, - "ADMIN_REPORTS_ENABLED": True, - "MONITORING_INTERVAL": 60, - "TRIAL_DURATION_DAYS": 3, +PRESET_CONFIGS: dict[str, dict[str, object]] = { + 'recommended': { + 'ENABLE_NOTIFICATIONS': True, + 'ADMIN_NOTIFICATIONS_ENABLED': True, + 'ADMIN_REPORTS_ENABLED': True, + 'MONITORING_INTERVAL': 60, + 'TRIAL_DURATION_DAYS': 3, }, - "minimal": { - "ENABLE_NOTIFICATIONS": False, - "ADMIN_NOTIFICATIONS_ENABLED": False, - "ADMIN_REPORTS_ENABLED": False, - "TRIAL_DURATION_DAYS": 0, - "REFERRAL_NOTIFICATIONS_ENABLED": False, + 'minimal': { + 'ENABLE_NOTIFICATIONS': False, + 'ADMIN_NOTIFICATIONS_ENABLED': False, + 'ADMIN_REPORTS_ENABLED': False, + 'TRIAL_DURATION_DAYS': 0, + 'REFERRAL_NOTIFICATIONS_ENABLED': False, }, - "secure": { - "MAINTENANCE_AUTO_ENABLE": True, - "ADMIN_NOTIFICATIONS_ENABLED": True, - "ADMIN_REPORTS_ENABLED": True, - "REFERRAL_MINIMUM_TOPUP_KOPEKS": 100000, - "SERVER_STATUS_MODE": "disabled", + 'secure': { + 'MAINTENANCE_AUTO_ENABLE': True, + 'ADMIN_NOTIFICATIONS_ENABLED': True, + 'ADMIN_REPORTS_ENABLED': True, + 'REFERRAL_MINIMUM_TOPUP_KOPEKS': 100000, + 'SERVER_STATUS_MODE': 'disabled', }, - "testing": { - "DEBUG": True, - "ENABLE_NOTIFICATIONS": False, - "TRIAL_DURATION_DAYS": 7, - "SERVER_STATUS_MODE": "disabled", - "ADMIN_NOTIFICATIONS_ENABLED": False, + 'testing': { + 'DEBUG': True, + 'ENABLE_NOTIFICATIONS': False, + 'TRIAL_DURATION_DAYS': 7, + 'SERVER_STATUS_MODE': 'disabled', + 'ADMIN_NOTIFICATIONS_ENABLED': False, }, } -PRESET_METADATA: Dict[str, Dict[str, str]] = { - "recommended": { - "title": "Рекомендуемые настройки", - "description": "Баланс между стабильностью и информированием команды.", +PRESET_METADATA: dict[str, dict[str, str]] = { + 'recommended': { + 'title': 'Рекомендуемые настройки', + 'description': 'Баланс между стабильностью и информированием команды.', }, - "minimal": { - "title": "Минимальная конфигурация", - "description": "Подходит для тестового запуска без уведомлений.", + 'minimal': { + 'title': 'Минимальная конфигурация', + 'description': 'Подходит для тестового запуска без уведомлений.', }, - "secure": { - "title": "Максимальная безопасность", - "description": "Усиленный контроль доступа и отключение лишних интеграций.", + 'secure': { + 'title': 'Максимальная безопасность', + 'description': 'Усиленный контроль доступа и отключение лишних интеграций.', }, - "testing": { - "title": "Для тестирования", - "description": "Включает режим отладки и отключает внешние уведомления.", + 'testing': { + 'title': 'Для тестирования', + 'description': 'Включает режим отладки и отключает внешние уведомления.', }, } -def _get_group_meta(group_key: str) -> Dict[str, object]: +def _get_group_meta(group_key: str) -> dict[str, object]: return CATEGORY_GROUP_METADATA.get(group_key, {}) def _get_group_description(group_key: str) -> str: meta = _get_group_meta(group_key) - return str(meta.get("description", "")) + return str(meta.get('description', '')) def _get_group_icon(group_key: str) -> str: meta = _get_group_meta(group_key) - return str(meta.get("icon", "⚙️")) + return str(meta.get('icon', '⚙️')) -def _get_group_status(group_key: str) -> Tuple[str, str]: +def _get_group_status(group_key: str) -> tuple[str, str]: key = group_key - if key == "payments": + if key == 'payments': payment_statuses = { - "YooKassa": settings.is_yookassa_enabled(), - "CryptoBot": settings.is_cryptobot_enabled(), - "Platega": settings.is_platega_enabled(), - "CloudPayments": settings.is_cloudpayments_enabled(), - "Freekassa": settings.is_freekassa_enabled(), - "MulenPay": settings.is_mulenpay_enabled(), - "PAL24": settings.is_pal24_enabled(), - "Tribute": settings.TRIBUTE_ENABLED, - "Stars": settings.TELEGRAM_STARS_ENABLED, + 'YooKassa': settings.is_yookassa_enabled(), + 'CryptoBot': settings.is_cryptobot_enabled(), + 'Platega': settings.is_platega_enabled(), + 'CloudPayments': settings.is_cloudpayments_enabled(), + 'Freekassa': settings.is_freekassa_enabled(), + 'MulenPay': settings.is_mulenpay_enabled(), + 'PAL24': settings.is_pal24_enabled(), + 'Tribute': settings.TRIBUTE_ENABLED, + 'Stars': settings.TELEGRAM_STARS_ENABLED, } active = sum(1 for value in payment_statuses.values() if value) total = len(payment_statuses) if active == 0: - return "🔴", "Нет активных платежей" + return '🔴', 'Нет активных платежей' if active < total: - return "🟡", f"Активно {active} из {total}" - return "🟢", "Все системы активны" + return '🟡', f'Активно {active} из {total}' + return '🟢', 'Все системы активны' - if key == "remnawave": + if key == 'remnawave': api_ready = bool( settings.REMNAWAVE_API_URL - and ( - settings.REMNAWAVE_API_KEY - or (settings.REMNAWAVE_USERNAME and settings.REMNAWAVE_PASSWORD) - ) + and (settings.REMNAWAVE_API_KEY or (settings.REMNAWAVE_USERNAME and settings.REMNAWAVE_PASSWORD)) ) - return ("🟢", "API подключено") if api_ready else ("🟡", "Нужно указать URL и ключи") + return ('🟢', 'API подключено') if api_ready else ('🟡', 'Нужно указать URL и ключи') - if key == "server": - mode = (settings.SERVER_STATUS_MODE or "").lower() - monitoring_active = mode not in {"", "disabled"} + if key == 'server': + mode = (settings.SERVER_STATUS_MODE or '').lower() + monitoring_active = mode not in {'', 'disabled'} if monitoring_active: - return "🟢", "Мониторинг активен" + return '🟢', 'Мониторинг активен' if settings.MONITORING_INTERVAL: - return "🟡", "Доступны только отчеты" - return "⚪", "Мониторинг выключен" + return '🟡', 'Доступны только отчеты' + return '⚪', 'Мониторинг выключен' - if key == "maintenance": + if key == 'maintenance': if settings.MAINTENANCE_MODE: - return "🟡", "Режим ТО включен" - return "🟢", "Рабочий режим" + return '🟡', 'Режим ТО включен' + return '🟢', 'Рабочий режим' - if key == "notifications": + if key == 'notifications': user_on = settings.is_notifications_enabled() admin_on = settings.is_admin_notifications_enabled() if user_on and admin_on: - return "🟢", "Все уведомления включены" + return '🟢', 'Все уведомления включены' if user_on or admin_on: - return "🟡", "Часть уведомлений включена" - return "⚪", "Уведомления отключены" + return '🟡', 'Часть уведомлений включена' + return '⚪', 'Уведомления отключены' - if key == "trial": + if key == 'trial': if settings.TRIAL_DURATION_DAYS > 0: - return "🟢", f"{settings.TRIAL_DURATION_DAYS} дней пробного периода" - return "⚪", "Триал отключен" + return '🟢', f'{settings.TRIAL_DURATION_DAYS} дней пробного периода' + return '⚪', 'Триал отключен' - if key == "referral": + if key == 'referral': active = ( settings.REFERRAL_COMMISSION_PERCENT or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS or settings.REFERRAL_INVITER_BONUS_KOPEKS ) - return ("🟢", "Программа активна") if active else ("⚪", "Бонусы не заданы") + return ('🟢', 'Программа активна') if active else ('⚪', 'Бонусы не заданы') - if key == "core": - token_ok = bool(getattr(settings, "BOT_TOKEN", "")) + if key == 'core': + token_ok = bool(getattr(settings, 'BOT_TOKEN', '')) channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB) if token_ok and channel_ok: - return "🟢", "Бот готов к работе" - return "🟡", "Проверьте токен и обязательную подписку" + return '🟢', 'Бот готов к работе' + return '🟡', 'Проверьте токен и обязательную подписку' - if key == "subscriptions": + if key == 'subscriptions': price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS - return ("🟢", "Тарифы настроены") if price_ready else ("⚪", "Нужно задать цены") + return ('🟢', 'Тарифы настроены') if price_ready else ('⚪', 'Нужно задать цены') - if key == "database": - mode = (settings.DATABASE_MODE or "auto").lower() - if mode == "postgresql": - return "🟢", "PostgreSQL" - if mode == "sqlite": - return "🟡", "SQLite режим" - return "🟢", "Авто режим" + if key == 'database': + mode = (settings.DATABASE_MODE or 'auto').lower() + if mode == 'postgresql': + return '🟢', 'PostgreSQL' + if mode == 'sqlite': + return '🟡', 'SQLite режим' + return '🟢', 'Авто режим' - if key == "interface": + if key == 'interface': branding = bool(settings.ENABLE_LOGO_MODE or settings.MINIAPP_CUSTOM_URL) - return ("🟢", "Брендинг настроен") if branding else ("⚪", "Настройки по умолчанию") + return ('🟢', 'Брендинг настроен') if branding else ('⚪', 'Настройки по умолчанию') - return "🟢", "Готово к работе" + return '🟢', 'Готово к работе' def _get_setting_icon(definition, current_value: object) -> str: key_upper = definition.key.upper() if definition.python_type is bool: - return "✅" if bool(current_value) else "❌" + return '✅' if bool(current_value) else '❌' if bot_configuration_service.has_choices(definition.key): - return "📋" + return '📋' if isinstance(current_value, (int, float)): - return "🔢" + return '🔢' if isinstance(current_value, str): if not current_value.strip(): - return "⚪" - if "URL" in key_upper: - return "🔗" - if any(keyword in key_upper for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")): - return "🔒" + return '⚪' + if 'URL' in key_upper: + return '🔗' + if any(keyword in key_upper for keyword in ('TOKEN', 'SECRET', 'PASSWORD', 'KEY')): + return '🔒' - if any(keyword in key_upper for keyword in ("TIME", "HOUR", "MINUTE")): - return "⏱" - if "DAYS" in key_upper: - return "📆" - if "GB" in key_upper or "TRAFFIC" in key_upper: - return "📊" + if any(keyword in key_upper for keyword in ('TIME', 'HOUR', 'MINUTE')): + return '⏱' + if 'DAYS' in key_upper: + return '📆' + if 'GB' in key_upper or 'TRAFFIC' in key_upper: + return '📊' - return "⚙️" + return '⚙️' def _render_dashboard_overview() -> str: @@ -387,41 +384,41 @@ def _render_dashboard_overview() -> str: 1 for definition in definitions if bot_configuration_service.has_override(definition.key) ) - lines: List[str] = [ - "⚙️ ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ", - "", - f"Всего параметров: {total_settings} • Переопределено: {total_overrides}", - "", - "Группы настроек", - "", + lines: list[str] = [ + '⚙️ ПАНЕЛЬ УПРАВЛЕНИЯ БОТОМ', + '', + f'Всего параметров: {total_settings} • Переопределено: {total_overrides}', + '', + 'Группы настроек', + '', ] for group_key, title, items in grouped: status_icon, status_text = _get_group_status(group_key) total = sum(count for _, _, count in items) - lines.append(f"{status_icon} {title} — {status_text}") - lines.append(f"└ Настроек: {total}") - lines.append("") + lines.append(f'{status_icon} {title} — {status_text}') + lines.append(f'└ Настроек: {total}') + lines.append('') - lines.append("🔍 Используйте поиск, чтобы быстро найти нужный параметр по ключу или названию.") - return "\n".join(lines).strip() + lines.append('🔍 Используйте поиск, чтобы быстро найти нужный параметр по ключу или названию.') + return '\n'.join(lines).strip() -def _build_group_category_index() -> Dict[str, List[str]]: - mapping: Dict[str, List[str]] = {} +def _build_group_category_index() -> dict[str, list[str]]: + mapping: dict[str, list[str]] = {} for group_key, _title, items in _get_grouped_categories(): mapping[group_key] = [category_key for category_key, _label, _count in items] return mapping -def _perform_settings_search(query: str) -> List[Dict[str, object]]: +def _perform_settings_search(query: str) -> list[dict[str, object]]: normalized = query.strip().lower() if not normalized: return [] categories = bot_configuration_service.get_categories() group_category_index = _build_group_category_index() - results: List[Dict[str, object]] = [] + results: list[dict[str, object]] = [] for category_key, _label, _count in categories: definitions = bot_configuration_service.get_settings_for_category(category_key) @@ -438,9 +435,9 @@ def _perform_settings_search(query: str) -> List[Dict[str, object]]: guidance = bot_configuration_service.get_setting_guidance(definition.key) fields.extend( [ - guidance.get("description", "").lower(), - guidance.get("format", "").lower(), - str(guidance.get("dependencies", "")).lower(), + guidance.get('description', '').lower(), + guidance.get('format', '').lower(), + str(guidance.get('dependencies', '')).lower(), ] ) @@ -450,42 +447,40 @@ def _perform_settings_search(query: str) -> List[Dict[str, object]]: settings_page = definition_index // SETTINGS_PAGE_SIZE + 1 results.append( { - "key": definition.key, - "name": definition.display_name, - "category_key": category_key, - "category_label": definition.category_label, - "group_key": group_key, - "category_page": category_page, - "settings_page": settings_page, - "token": bot_configuration_service.get_callback_token(definition.key), - "value": bot_configuration_service.format_value_human( + 'key': definition.key, + 'name': definition.display_name, + 'category_key': category_key, + 'category_label': definition.category_label, + 'group_key': group_key, + 'category_page': category_page, + 'settings_page': settings_page, + 'token': bot_configuration_service.get_callback_token(definition.key), + 'value': bot_configuration_service.format_value_human( definition.key, bot_configuration_service.get_current_value(definition.key), ), } ) - results.sort(key=lambda item: item["name"].lower()) + results.sort(key=lambda item: item['name'].lower()) return results[:20] -def _build_search_results_keyboard(results: List[Dict[str, object]]) -> types.InlineKeyboardMarkup: - rows: List[List[types.InlineKeyboardButton]] = [] +def _build_search_results_keyboard(results: list[dict[str, object]]) -> types.InlineKeyboardMarkup: + rows: list[list[types.InlineKeyboardButton]] = [] for result in results: - group_key = str(result["group_key"]) - category_page = int(result["category_page"]) - settings_page = int(result["settings_page"]) - token = str(result["token"]) - text = f"{result['name']}" + group_key = str(result['group_key']) + category_page = int(result['category_page']) + settings_page = int(result['settings_page']) + token = str(result['token']) + text = f'{result["name"]}' if len(text) > 60: - text = text[:59] + "…" + text = text[:59] + '…' rows.append( [ types.InlineKeyboardButton( text=text, - callback_data=( - f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}" - ), + callback_data=(f'botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}'), ) ] ) @@ -493,23 +488,23 @@ def _build_search_results_keyboard(results: List[Dict[str, object]]) -> types.In rows.append( [ types.InlineKeyboardButton( - text="⬅️ В главное меню", - callback_data="admin_bot_config", + text='⬅️ В главное меню', + callback_data='admin_bot_config', ) ] ) return types.InlineKeyboardMarkup(inline_keyboard=rows) -def _parse_env_content(content: str) -> Dict[str, Optional[str]]: - parsed: Dict[str, Optional[str]] = {} +def _parse_env_content(content: str) -> dict[str, str | None]: + parsed: dict[str, str | None] = {} for raw_line in content.splitlines(): line = raw_line.strip() - if not line or line.startswith("#"): + if not line or line.startswith('#'): continue - if "=" not in line: + if '=' not in line: continue - key, value = line.split("=", 1) + key, value = line.split('=', 1) parsed[key.strip()] = value.strip() return parsed @@ -523,26 +518,20 @@ async def start_settings_search( state: FSMContext, ): await state.set_state(BotConfigStates.waiting_for_search_query) - await state.update_data(botcfg_origin="bot_config") + await state.update_data(botcfg_origin='bot_config') keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ В главное меню", callback_data="admin_bot_config" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ В главное меню', callback_data='admin_bot_config')]] ) await callback.message.edit_text( - "🔍 Поиск по настройкам\n\n" - "Отправьте часть ключа или названия настройки. \n" - "Например: yookassa или уведомления.", + '🔍 Поиск по настройкам\n\n' + 'Отправьте часть ключа или названия настройки. \n' + 'Например: yookassa или уведомления.', reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) - await callback.answer("Введите запрос", show_alert=False) + await callback.answer('Введите запрос', show_alert=False) @admin_required @@ -553,51 +542,45 @@ async def handle_search_query( db: AsyncSession, state: FSMContext, ): - if message.chat.type != "private": + if message.chat.type != 'private': return data = await state.get_data() - if data.get("botcfg_origin") != "bot_config": + if data.get('botcfg_origin') != 'bot_config': return - query = (message.text or "").strip() + query = (message.text or '').strip() results = _perform_settings_search(query) if results: keyboard = _build_search_results_keyboard(results) lines = [ - "🔍 Результаты поиска", - f"Запрос: {html.escape(query)}", - "", + '🔍 Результаты поиска', + f'Запрос: {html.escape(query)}', + '', ] for index, item in enumerate(results, start=1): - lines.append( - f"{index}. {item['name']} — {item['value']} ({item['category_label']})" - ) - text = "\n".join(lines) + lines.append(f'{index}. {item["name"]} — {item["value"]} ({item["category_label"]})') + text = '\n'.join(lines) else: keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="⬅️ Попробовать снова", - callback_data="botcfg_action:search", - ) - ], - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", callback_data="admin_bot_config" + text='⬅️ Попробовать снова', + callback_data='botcfg_action:search', ) ], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='admin_bot_config')], ] ) text = ( - "🔍 Результаты поиска\n\n" - f"Запрос: {html.escape(query)}\n\n" - "Ничего не найдено. Попробуйте изменить формулировку." + '🔍 Результаты поиска\n\n' + f'Запрос: {html.escape(query)}\n\n' + 'Ничего не найдено. Попробуйте изменить формулировку.' ) - await message.answer(text, parse_mode="HTML", reply_markup=keyboard) + await message.answer(text, parse_mode='HTML', reply_markup=keyboard) await state.clear() @@ -610,63 +593,49 @@ async def show_presets( state: FSMContext, ): lines = [ - "🎯 Готовые пресеты", - "", - "Выберите набор параметров, чтобы быстро применить его к боту.", - "", + '🎯 Готовые пресеты', + '', + 'Выберите набор параметров, чтобы быстро применить его к боту.', + '', ] for key, meta in PRESET_METADATA.items(): - lines.append(f"• {meta['title']} — {meta['description']}") - text = "\n".join(lines) + lines.append(f'• {meta["title"]} — {meta["description"]}') + text = '\n'.join(lines) - buttons: List[types.InlineKeyboardButton] = [] + buttons: list[types.InlineKeyboardButton] = [] for key, meta in PRESET_METADATA.items(): - buttons.append( - types.InlineKeyboardButton( - text=meta["title"], callback_data=f"botcfg_preset:{key}" - ) - ) + buttons.append(types.InlineKeyboardButton(text=meta['title'], callback_data=f'botcfg_preset:{key}')) - rows: List[List[types.InlineKeyboardButton]] = [] + rows: list[list[types.InlineKeyboardButton]] = [] for chunk in _chunk(buttons, 2): rows.append(list(chunk)) - rows.append( - [ - types.InlineKeyboardButton( - text="⬅️ Главное меню", callback_data="admin_bot_config" - ) - ] - ) + rows.append([types.InlineKeyboardButton(text='⬅️ Главное меню', callback_data='admin_bot_config')]) await callback.message.edit_text( text, - parse_mode="HTML", + parse_mode='HTML', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows), ) await callback.answer() -def _format_preset_preview(preset_key: str) -> Tuple[str, List[str]]: +def _format_preset_preview(preset_key: str) -> tuple[str, list[str]]: config = PRESET_CONFIGS.get(preset_key, {}) - meta = PRESET_METADATA.get(preset_key, {"title": preset_key, "description": ""}) - title = meta["title"] - description = meta.get("description", "") + meta = PRESET_METADATA.get(preset_key, {'title': preset_key, 'description': ''}) + title = meta['title'] + description = meta.get('description', '') - lines = [f"🎯 {title}"] + lines = [f'🎯 {title}'] if description: lines.append(description) - lines.append("") - lines.append("Будут установлены следующие значения:") + lines.append('') + lines.append('Будут установлены следующие значения:') for index, (setting_key, new_value) in enumerate(config.items(), start=1): current_value = bot_configuration_service.get_current_value(setting_key) current_pretty = bot_configuration_service.format_value_human(setting_key, current_value) new_pretty = bot_configuration_service.format_value_human(setting_key, new_value) - lines.append( - f"{index}. {setting_key}\n" - f" Текущее: {current_pretty}\n" - f" Новое: {new_pretty}" - ) + lines.append(f'{index}. {setting_key}\n Текущее: {current_pretty}\n Новое: {new_pretty}') return title, lines @@ -679,31 +648,23 @@ async def preview_preset( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 1) - preset_key = parts[1] if len(parts) > 1 else "" + parts = callback.data.split(':', 1) + preset_key = parts[1] if len(parts) > 1 else '' if preset_key not in PRESET_CONFIGS: - await callback.answer("Этот пресет недоступен", show_alert=True) + await callback.answer('Этот пресет недоступен', show_alert=True) return title, lines = _format_preset_preview(preset_key) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="✅ Применить", callback_data=f"botcfg_preset_apply:{preset_key}" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="botcfg_action:presets" - ) - ], + [types.InlineKeyboardButton(text='✅ Применить', callback_data=f'botcfg_preset_apply:{preset_key}')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='botcfg_action:presets')], ] ) await callback.message.edit_text( - "\n".join(lines), - parse_mode="HTML", + '\n'.join(lines), + parse_mode='HTML', reply_markup=keyboard, ) await callback.answer() @@ -717,63 +678,55 @@ async def apply_preset( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 1) - preset_key = parts[1] if len(parts) > 1 else "" + parts = callback.data.split(':', 1) + preset_key = parts[1] if len(parts) > 1 else '' config = PRESET_CONFIGS.get(preset_key) if not config: - await callback.answer("Этот пресет недоступен", show_alert=True) + await callback.answer('Этот пресет недоступен', show_alert=True) return - applied: List[str] = [] + applied: list[str] = [] for setting_key, value in config.items(): try: await bot_configuration_service.set_value(db, setting_key, value) applied.append(setting_key) except ReadOnlySettingError: logging.getLogger(__name__).info( - "Пропускаем настройку %s из пресета %s: только для чтения", + 'Пропускаем настройку %s из пресета %s: только для чтения', setting_key, preset_key, ) except Exception as error: logging.getLogger(__name__).warning( - "Не удалось применить пресет %s для %s: %s", + 'Не удалось применить пресет %s для %s: %s', preset_key, setting_key, error, ) await db.commit() - title = PRESET_METADATA.get(preset_key, {}).get("title", preset_key) + title = PRESET_METADATA.get(preset_key, {}).get('title', preset_key) summary_lines = [ - f"✅ Пресет {title} применен", - "", - f"Изменено параметров: {len(applied)}", + f'✅ Пресет {title} применен', + '', + f'Изменено параметров: {len(applied)}', ] if applied: - summary_lines.append("\n".join(f"• {key}" for key in applied)) + summary_lines.append('\n'.join(f'• {key}' for key in applied)) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ К пресетам", callback_data="botcfg_action:presets" - ) - ], - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", callback_data="admin_bot_config" - ) - ], + [types.InlineKeyboardButton(text='⬅️ К пресетам', callback_data='botcfg_action:presets')], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='admin_bot_config')], ] ) await callback.message.edit_text( - "\n".join(summary_lines), - parse_mode="HTML", + '\n'.join(summary_lines), + parse_mode='HTML', reply_markup=keyboard, ) - await callback.answer("Настройки обновлены", show_alert=False) + await callback.answer('Настройки обновлены', show_alert=False) @admin_required @@ -785,34 +738,34 @@ async def export_settings( state: FSMContext, ): categories = bot_configuration_service.get_categories() - keys: List[str] = [] + keys: list[str] = [] for category_key, _label, _count in categories: for definition in bot_configuration_service.get_settings_for_category(category_key): keys.append(definition.key) keys = sorted(set(keys)) lines = [ - "# RemnaWave bot configuration export", - f"# Generated at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}", + '# RemnaWave bot configuration export', + f'# Generated at {datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")}', ] for setting_key in keys: current_value = bot_configuration_service.get_current_value(setting_key) raw_value = bot_configuration_service.serialize_value(setting_key, current_value) if raw_value is None: - raw_value = "" - lines.append(f"{setting_key}={raw_value}") + raw_value = '' + lines.append(f'{setting_key}={raw_value}') - content = "\n".join(lines) - filename = f"bot-settings-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.env" - file = types.BufferedInputFile(content.encode("utf-8"), filename=filename) + content = '\n'.join(lines) + filename = f'bot-settings-{datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.env' + file = types.BufferedInputFile(content.encode('utf-8'), filename=filename) await callback.message.answer_document( document=file, - caption="📤 Экспорт текущих настроек", - parse_mode="HTML", + caption='📤 Экспорт текущих настроек', + parse_mode='HTML', ) - await callback.answer("Файл готов", show_alert=False) + await callback.answer('Файл готов', show_alert=False) @admin_required @@ -824,26 +777,20 @@ async def start_import_settings( state: FSMContext, ): await state.set_state(BotConfigStates.waiting_for_import_file) - await state.update_data(botcfg_origin="bot_config") + await state.update_data(botcfg_origin='bot_config') keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Главное меню", callback_data="admin_bot_config" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Главное меню', callback_data='admin_bot_config')]] ) await callback.message.edit_text( - "📥 Импорт настроек\n\n" - "Прикрепите .env файл или отправьте текстом пары KEY=value.\n" - "Неизвестные параметры будут проигнорированы.", - parse_mode="HTML", + '📥 Импорт настроек\n\n' + 'Прикрепите .env файл или отправьте текстом пары KEY=value.\n' + 'Неизвестные параметры будут проигнорированы.', + parse_mode='HTML', reply_markup=keyboard, ) - await callback.answer("Загрузите файл .env", show_alert=False) + await callback.answer('Загрузите файл .env', show_alert=False) @admin_required @@ -854,34 +801,34 @@ async def handle_import_message( db: AsyncSession, state: FSMContext, ): - if message.chat.type != "private": + if message.chat.type != 'private': return data = await state.get_data() - if data.get("botcfg_origin") != "bot_config": + if data.get('botcfg_origin') != 'bot_config': return - content = "" + content = '' if message.document: buffer = io.BytesIO() await message.document.download(destination=buffer) buffer.seek(0) - content = buffer.read().decode("utf-8", errors="ignore") + content = buffer.read().decode('utf-8', errors='ignore') else: - content = message.text or "" + content = message.text or '' parsed = _parse_env_content(content) if not parsed: await message.answer( - "❌ Не удалось найти параметры в файле. Убедитесь, что используется формат KEY=value.", - parse_mode="HTML", + '❌ Не удалось найти параметры в файле. Убедитесь, что используется формат KEY=value.', + parse_mode='HTML', ) await state.clear() return - applied: List[str] = [] - skipped: List[str] = [] - errors: List[str] = [] + applied: list[str] = [] + skipped: list[str] = [] + errors: list[str] = [] for setting_key, raw_value in parsed.items(): try: @@ -890,16 +837,14 @@ async def handle_import_message( skipped.append(setting_key) continue - value_to_apply: Optional[object] + value_to_apply: object | None try: - if raw_value in {"", '""'}: + if raw_value in {'', '""'}: value_to_apply = None else: - value_to_apply = bot_configuration_service.deserialize_value( - setting_key, raw_value - ) + value_to_apply = bot_configuration_service.deserialize_value(setting_key, raw_value) except Exception as error: - errors.append(f"{setting_key}: {error}") + errors.append(f'{setting_key}: {error}') continue if bot_configuration_service.is_read_only(setting_key): @@ -914,33 +859,25 @@ async def handle_import_message( await db.commit() summary_lines = [ - "📥 Импорт завершен", - f"Обновлено параметров: {len(applied)}", + '📥 Импорт завершен', + f'Обновлено параметров: {len(applied)}', ] if applied: - summary_lines.append("\n".join(f"• {key}" for key in applied)) + summary_lines.append('\n'.join(f'• {key}' for key in applied)) if skipped: - summary_lines.append("\nПропущено (неизвестные ключи):") - summary_lines.append("\n".join(f"• {key}" for key in skipped)) + summary_lines.append('\nПропущено (неизвестные ключи):') + summary_lines.append('\n'.join(f'• {key}' for key in skipped)) if errors: - summary_lines.append("\nОшибки разбора:") - summary_lines.append("\n".join(f"• {html.escape(err)}" for err in errors)) + summary_lines.append('\nОшибки разбора:') + summary_lines.append('\n'.join(f'• {html.escape(err)}' for err in errors)) keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", callback_data="admin_bot_config" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='admin_bot_config')]] ) - await message.answer( - "\n".join(summary_lines), parse_mode="HTML", reply_markup=keyboard - ) + await message.answer('\n'.join(summary_lines), parse_mode='HTML', reply_markup=keyboard) await state.clear() @@ -952,40 +889,28 @@ async def show_settings_history( db: AsyncSession, state: FSMContext, ): - result = await db.execute( - select(SystemSetting).order_by(SystemSetting.updated_at.desc()).limit(10) - ) + result = await db.execute(select(SystemSetting).order_by(SystemSetting.updated_at.desc()).limit(10)) rows = result.scalars().all() - lines = ["🕘 История изменений", ""] + lines = ['🕘 История изменений', ''] if rows: for row in rows: timestamp = row.updated_at or row.created_at - ts_text = timestamp.strftime("%d.%m %H:%M") if timestamp else "—" + ts_text = timestamp.strftime('%d.%m %H:%M') if timestamp else '—' try: parsed_value = bot_configuration_service.deserialize_value(row.key, row.value) - formatted_value = bot_configuration_service.format_value_human( - row.key, parsed_value - ) + formatted_value = bot_configuration_service.format_value_human(row.key, parsed_value) except Exception: - formatted_value = row.value or "—" - lines.append(f"{ts_text} • {row.key} = {formatted_value}") + formatted_value = row.value or '—' + lines.append(f'{ts_text} • {row.key} = {formatted_value}') else: - lines.append("История изменений пуста.") + lines.append('История изменений пуста.') keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Главное меню", callback_data="admin_bot_config" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Главное меню', callback_data='admin_bot_config')]] ) - await callback.message.edit_text( - "\n".join(lines), parse_mode="HTML", reply_markup=keyboard - ) + await callback.message.edit_text('\n'.join(lines), parse_mode='HTML', reply_markup=keyboard) await callback.answer() @@ -998,28 +923,20 @@ async def show_help( state: FSMContext, ): text = ( - "❓ Как работать с панелью\n\n" - "• Навигируйте по категориям, чтобы увидеть связанные настройки.\n" - "• Значок ✳️ рядом с параметром означает, что значение переопределено.\n" - "• Используйте 🔍 поиск для быстрого доступа к нужной настройке.\n" - "• Экспортируйте .env перед крупными изменениями, чтобы иметь резервную копию.\n" - "• Импорт позволяет восстановить конфигурацию или применить шаблон.\n" - "• Все секретные ключи скрываются в интерфейсе автоматически." + '❓ Как работать с панелью\n\n' + '• Навигируйте по категориям, чтобы увидеть связанные настройки.\n' + '• Значок ✳️ рядом с параметром означает, что значение переопределено.\n' + '• Используйте 🔍 поиск для быстрого доступа к нужной настройке.\n' + '• Экспортируйте .env перед крупными изменениями, чтобы иметь резервную копию.\n' + '• Импорт позволяет восстановить конфигурацию или применить шаблон.\n' + '• Все секретные ключи скрываются в интерфейсе автоматически.' ) keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="🏠 Главное меню", callback_data="admin_bot_config" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='admin_bot_config')]] ) - await callback.message.edit_text( - text, parse_mode="HTML", reply_markup=keyboard - ) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) await callback.answer() @@ -1036,7 +953,7 @@ async def _store_setting_context( setting_group_key=group_key, setting_category_page=category_page, setting_settings_page=settings_page, - botcfg_origin="bot_config", + botcfg_origin='bot_config', botcfg_timestamp=time.time(), ) @@ -1050,21 +967,21 @@ class BotConfigInputFilter(BaseFilter): message: types.Message, state: FSMContext, ) -> bool: - if not message.text or message.text.startswith("/"): + if not message.text or message.text.startswith('/'): return False - if message.chat.type != "private": + if message.chat.type != 'private': return False data = await state.get_data() - if data.get("botcfg_origin") != "bot_config": + if data.get('botcfg_origin') != 'bot_config': return False - if not data.get("setting_key"): + if not data.get('setting_key'): return False - timestamp = data.get("botcfg_timestamp") + timestamp = data.get('botcfg_timestamp') if timestamp is None: return True @@ -1074,16 +991,16 @@ class BotConfigInputFilter(BaseFilter): return False -def _chunk(buttons: Iterable[types.InlineKeyboardButton], size: int) -> Iterable[List[types.InlineKeyboardButton]]: +def _chunk(buttons: Iterable[types.InlineKeyboardButton], size: int) -> Iterable[list[types.InlineKeyboardButton]]: buttons_list = list(buttons) for index in range(0, len(buttons_list), size): yield buttons_list[index : index + size] -def _parse_category_payload(payload: str) -> Tuple[str, str, int, int]: - parts = payload.split(":") +def _parse_category_payload(payload: str) -> tuple[str, str, int, int]: + parts = payload.split(':') group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY - category_key = parts[2] if len(parts) > 2 else "" + category_key = parts[2] if len(parts) > 2 else '' def _safe_int(value: str, default: int = 1) -> int: try: @@ -1096,8 +1013,8 @@ def _parse_category_payload(payload: str) -> Tuple[str, str, int, int]: return group_key, category_key, category_page, settings_page -def _parse_group_payload(payload: str) -> Tuple[str, int]: - parts = payload.split(":") +def _parse_group_payload(payload: str) -> tuple[str, int]: + parts = payload.split(':') group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: page = max(1, int(parts[2])) @@ -1106,14 +1023,14 @@ def _parse_group_payload(payload: str) -> Tuple[str, int]: return group_key, page -def _get_grouped_categories() -> List[Tuple[str, str, List[Tuple[str, str, int]]]]: +def _get_grouped_categories() -> list[tuple[str, str, list[tuple[str, str, int]]]]: categories = bot_configuration_service.get_categories() categories_map = {key: (label, count) for key, label, count in categories} used: set[str] = set() - grouped: List[Tuple[str, str, List[Tuple[str, str, int]]]] = [] + grouped: list[tuple[str, str, list[tuple[str, str, int]]]] = [] for group_key, title, category_keys in CATEGORY_GROUP_DEFINITIONS: - items: List[Tuple[str, str, int]] = [] + items: list[tuple[str, str, int]] = [] for category_key in category_keys: if category_key in categories_map: label, count = categories_map[category_key] @@ -1122,11 +1039,7 @@ def _get_grouped_categories() -> List[Tuple[str, str, List[Tuple[str, str, int]] if items: grouped.append((group_key, title, items)) - remaining = [ - (key, label, count) - for key, (label, count) in categories_map.items() - if key not in used - ] + remaining = [(key, label, count) for key, (label, count) in categories_map.items() if key not in used] if remaining: remaining.sort(key=lambda item: item[1]) @@ -1140,14 +1053,14 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: rows: list[list[types.InlineKeyboardButton]] = [] for group_key, title, items in grouped: - total = sum(count for _, _, count in items) + sum(count for _, _, count in items) status_icon, status_text = _get_group_status(group_key) - button_text = f"{status_icon} {title} — {status_text}" + button_text = f'{status_icon} {title} — {status_text}' rows.append( [ types.InlineKeyboardButton( text=button_text, - callback_data=f"botcfg_group:{group_key}:1", + callback_data=f'botcfg_group:{group_key}:1', ) ] ) @@ -1155,12 +1068,12 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: rows.append( [ types.InlineKeyboardButton( - text="🔍 Найти настройку", - callback_data="botcfg_action:search", + text='🔍 Найти настройку', + callback_data='botcfg_action:search', ), types.InlineKeyboardButton( - text="🎯 Пресеты", - callback_data="botcfg_action:presets", + text='🎯 Пресеты', + callback_data='botcfg_action:presets', ), ] ) @@ -1168,12 +1081,12 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: rows.append( [ types.InlineKeyboardButton( - text="📤 Экспорт .env", - callback_data="botcfg_action:export", + text='📤 Экспорт .env', + callback_data='botcfg_action:export', ), types.InlineKeyboardButton( - text="📥 Импорт .env", - callback_data="botcfg_action:import", + text='📥 Импорт .env', + callback_data='botcfg_action:import', ), ] ) @@ -1181,12 +1094,12 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: rows.append( [ types.InlineKeyboardButton( - text="🕘 История", - callback_data="botcfg_action:history", + text='🕘 История', + callback_data='botcfg_action:history', ), types.InlineKeyboardButton( - text="❓ Помощь", - callback_data="botcfg_action:help", + text='❓ Помощь', + callback_data='botcfg_action:help', ), ] ) @@ -1194,8 +1107,8 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: rows.append( [ types.InlineKeyboardButton( - text="⬅️ Назад в админку", - callback_data="admin_submenu_settings", + text='⬅️ Назад в админку', + callback_data='admin_submenu_settings', ) ] ) @@ -1206,7 +1119,7 @@ def _build_groups_keyboard() -> types.InlineKeyboardMarkup: def _build_categories_keyboard( group_key: str, group_title: str, - categories: List[Tuple[str, str, int]], + categories: list[tuple[str, str, int]], page: int = 1, ) -> types.InlineKeyboardMarkup: total_pages = max(1, math.ceil(len(categories) / CATEGORY_PAGE_SIZE)) @@ -1218,18 +1131,18 @@ def _build_categories_keyboard( rows: list[list[types.InlineKeyboardButton]] = [] - buttons: List[types.InlineKeyboardButton] = [] + buttons: list[types.InlineKeyboardButton] = [] for category_key, label, count in sliced: overrides = 0 for definition in bot_configuration_service.get_settings_for_category(category_key): if bot_configuration_service.has_override(definition.key): overrides += 1 - badge = "✳️ •" if overrides else "•" - button_text = f"{badge} {label} ({count})" + badge = '✳️ •' if overrides else '•' + button_text = f'{badge} {label} ({count})' buttons.append( types.InlineKeyboardButton( text=button_text, - callback_data=f"botcfg_cat:{group_key}:{category_key}:{page}:1", + callback_data=f'botcfg_cat:{group_key}:{category_key}:{page}:1', ) ) @@ -1241,21 +1154,21 @@ def _build_categories_keyboard( if page > 1: nav_row.append( types.InlineKeyboardButton( - text="⬅️", - callback_data=f"botcfg_group:{group_key}:{page - 1}", + text='⬅️', + callback_data=f'botcfg_group:{group_key}:{page - 1}', ) ) nav_row.append( types.InlineKeyboardButton( - text=f"[{page}/{total_pages}]", - callback_data="botcfg_group:noop", + text=f'[{page}/{total_pages}]', + callback_data='botcfg_group:noop', ) ) if page < total_pages: nav_row.append( types.InlineKeyboardButton( - text="➡️", - callback_data=f"botcfg_group:{group_key}:{page + 1}", + text='➡️', + callback_data=f'botcfg_group:{group_key}:{page + 1}', ) ) rows.append(nav_row) @@ -1263,8 +1176,8 @@ def _build_categories_keyboard( rows.append( [ types.InlineKeyboardButton( - text="⬅️ К разделам", - callback_data="admin_bot_config", + text='⬅️ К разделам', + callback_data='admin_bot_config', ) ] ) @@ -1290,14 +1203,12 @@ def _build_settings_keyboard( rows: list[list[types.InlineKeyboardButton]] = [] texts = get_texts(language) - if category_key == "REMNAWAVE": + if category_key == 'REMNAWAVE': rows.append( [ types.InlineKeyboardButton( - text="🔌 Проверить подключение", - callback_data=( - f"botcfg_test_remnawave:{group_key}:{category_key}:{category_page}:{page}" - ), + text='🔌 Проверить подключение', + callback_data=(f'botcfg_test_remnawave:{group_key}:{category_key}:{category_page}:{page}'), ) ] ) @@ -1307,38 +1218,36 @@ def _build_settings_keyboard( def _test_button(text: str, method: str) -> types.InlineKeyboardButton: return types.InlineKeyboardButton( text=text, - callback_data=( - f"botcfg_test_payment:{method}:{group_key}:{category_key}:{category_page}:{page}" - ), + callback_data=(f'botcfg_test_payment:{method}:{group_key}:{category_key}:{category_page}:{page}'), ) - if category_key == "YOOKASSA": - label = texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)") - test_payment_buttons.append([_test_button(f"{label} · тест", "yookassa")]) - elif category_key == "TRIBUTE": - label = texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)") - test_payment_buttons.append([_test_button(f"{label} · тест", "tribute")]) - elif category_key == "MULENPAY": + if category_key == 'YOOKASSA': + label = texts.t('PAYMENT_CARD_YOOKASSA', '💳 Банковская карта (YooKassa)') + test_payment_buttons.append([_test_button(f'{label} · тест', 'yookassa')]) + elif category_key == 'TRIBUTE': + label = texts.t('PAYMENT_CARD_TRIBUTE', '💳 Банковская карта (Tribute)') + test_payment_buttons.append([_test_button(f'{label} · тест', 'tribute')]) + elif category_key == 'MULENPAY': label = texts.t( - "PAYMENT_CARD_MULENPAY", - "💳 Банковская карта ({mulenpay_name})", + 'PAYMENT_CARD_MULENPAY', + '💳 Банковская карта ({mulenpay_name})', ).format(mulenpay_name=settings.get_mulenpay_display_name()) - test_payment_buttons.append([_test_button(f"{label} · тест", "mulenpay")]) - elif category_key == "WATA": - label = texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)") - test_payment_buttons.append([_test_button(f"{label} · тест", "wata")]) - elif category_key == "PAL24": - label = texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)") - test_payment_buttons.append([_test_button(f"{label} · тест", "pal24")]) - elif category_key == "TELEGRAM": - label = texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars") - test_payment_buttons.append([_test_button(f"{label} · тест", "stars")]) - elif category_key == "CRYPTOBOT": - label = texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)") - test_payment_buttons.append([_test_button(f"{label} · тест", "cryptobot")]) - elif category_key == "FREEKASSA": - label = texts.t("PAYMENT_FREEKASSA", "💳 Freekassa") - test_payment_buttons.append([_test_button(f"{label} · тест", "freekassa")]) + test_payment_buttons.append([_test_button(f'{label} · тест', 'mulenpay')]) + elif category_key == 'WATA': + label = texts.t('PAYMENT_CARD_WATA', '💳 Банковская карта (WATA)') + test_payment_buttons.append([_test_button(f'{label} · тест', 'wata')]) + elif category_key == 'PAL24': + label = texts.t('PAYMENT_CARD_PAL24', '💳 Банковская карта (PayPalych)') + test_payment_buttons.append([_test_button(f'{label} · тест', 'pal24')]) + elif category_key == 'TELEGRAM': + label = texts.t('PAYMENT_TELEGRAM_STARS', '⭐ Telegram Stars') + test_payment_buttons.append([_test_button(f'{label} · тест', 'stars')]) + elif category_key == 'CRYPTOBOT': + label = texts.t('PAYMENT_CRYPTOBOT', '🪙 Криптовалюта (CryptoBot)') + test_payment_buttons.append([_test_button(f'{label} · тест', 'cryptobot')]) + elif category_key == 'FREEKASSA': + label = texts.t('PAYMENT_FREEKASSA', '💳 Freekassa') + test_payment_buttons.append([_test_button(f'{label} · тест', 'freekassa')]) if test_payment_buttons: rows.extend(test_payment_buttons) @@ -1347,20 +1256,18 @@ def _build_settings_keyboard( current_value = bot_configuration_service.get_current_value(definition.key) value_preview = bot_configuration_service.format_value_for_list(definition.key) icon = _get_setting_icon(definition, current_value) - override_badge = "✳️" if bot_configuration_service.has_override(definition.key) else "•" - button_text = f"{override_badge} {icon} {definition.display_name}" - if value_preview != "—": - button_text += f" · {value_preview}" + override_badge = '✳️' if bot_configuration_service.has_override(definition.key) else '•' + button_text = f'{override_badge} {icon} {definition.display_name}' + if value_preview != '—': + button_text += f' · {value_preview}' if len(button_text) > 64: - button_text = button_text[:63] + "…" + button_text = button_text[:63] + '…' callback_token = bot_configuration_service.get_callback_token(definition.key) rows.append( [ types.InlineKeyboardButton( text=button_text, - callback_data=( - f"botcfg_setting:{group_key}:{category_page}:{page}:{callback_token}" - ), + callback_data=(f'botcfg_setting:{group_key}:{category_page}:{page}:{callback_token}'), ) ] ) @@ -1370,34 +1277,28 @@ def _build_settings_keyboard( if page > 1: nav_row.append( types.InlineKeyboardButton( - text="⬅️", - callback_data=( - f"botcfg_cat:{group_key}:{category_key}:{category_page}:{page - 1}" - ), + text='⬅️', + callback_data=(f'botcfg_cat:{group_key}:{category_key}:{category_page}:{page - 1}'), ) ) - nav_row.append( - types.InlineKeyboardButton( - text=f"[{page}/{total_pages}]", callback_data="botcfg_cat_page:noop" - ) - ) + nav_row.append(types.InlineKeyboardButton(text=f'[{page}/{total_pages}]', callback_data='botcfg_cat_page:noop')) if page < total_pages: nav_row.append( types.InlineKeyboardButton( - text="➡️", - callback_data=( - f"botcfg_cat:{group_key}:{category_key}:{category_page}:{page + 1}" - ), + text='➡️', + callback_data=(f'botcfg_cat:{group_key}:{category_key}:{category_page}:{page + 1}'), ) ) rows.append(nav_row) - rows.append([ - types.InlineKeyboardButton( - text="⬅️ К категориям", - callback_data=f"botcfg_group:{group_key}:{category_page}", - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text='⬅️ К категориям', + callback_data=f'botcfg_group:{group_key}:{category_page}', + ) + ] + ) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -1422,13 +1323,13 @@ def _build_setting_keyboard( if choice_token is None: continue button_text = option.label - if current_value == option.value and not button_text.startswith("✅"): - button_text = f"✅ {button_text}" + if current_value == option.value and not button_text.startswith('✅'): + button_text = f'✅ {button_text}' choice_buttons.append( types.InlineKeyboardButton( text=button_text, callback_data=( - f"botcfg_choice:{group_key}:{category_page}:{settings_page}:{callback_token}:{choice_token}" + f'botcfg_choice:{group_key}:{category_page}:{settings_page}:{callback_token}:{choice_token}' ), ) ) @@ -1436,62 +1337,66 @@ def _build_setting_keyboard( for chunk in _chunk(choice_buttons, 2): rows.append(list(chunk)) - if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="🌍 Выбрать сквад", - callback_data=( - f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1" - ), - ) - ]) + if key == 'SIMPLE_SUBSCRIPTION_SQUAD_UUID' and not is_read_only: + rows.append( + [ + types.InlineKeyboardButton( + text='🌍 Выбрать сквад', + callback_data=( + f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1' + ), + ) + ] + ) if definition.python_type is bool and not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="🔁 Переключить", - callback_data=( - f"botcfg_toggle:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text='🔁 Переключить', + callback_data=(f'botcfg_toggle:{group_key}:{category_page}:{settings_page}:{callback_token}'), + ) + ] + ) if not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="✏️ Изменить", - callback_data=( - f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text='✏️ Изменить', + callback_data=(f'botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}'), + ) + ] + ) if bot_configuration_service.has_override(key) and not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="♻️ Сбросить", - callback_data=( - f"botcfg_reset:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text='♻️ Сбросить', + callback_data=(f'botcfg_reset:{group_key}:{category_page}:{settings_page}:{callback_token}'), + ) + ] + ) if is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="🔒 Только для чтения", - callback_data="botcfg_group:noop", - ) - ]) - - rows.append([ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=( - f"botcfg_cat:{group_key}:{definition.category_key}:{category_page}:{settings_page}" - ), + rows.append( + [ + types.InlineKeyboardButton( + text='🔒 Только для чтения', + callback_data='botcfg_group:noop', + ) + ] ) - ]) + + rows.append( + [ + types.InlineKeyboardButton( + text='⬅️ Назад', + callback_data=(f'botcfg_cat:{group_key}:{definition.category_key}:{category_page}:{settings_page}'), + ) + ] + ) return types.InlineKeyboardMarkup(inline_keyboard=rows) @@ -1502,57 +1407,57 @@ def _render_setting_text(key: str) -> str: definition = bot_configuration_service.get_definition(key) - description = guidance.get("description") or "—" - format_hint = guidance.get("format") or "—" - example = guidance.get("example") or "—" - warning = guidance.get("warning") or "—" - dependencies = guidance.get("dependencies") or "—" - type_label = guidance.get("type") or summary.get("type") or definition.type_label + description = guidance.get('description') or '—' + format_hint = guidance.get('format') or '—' + example = guidance.get('example') or '—' + warning = guidance.get('warning') or '—' + dependencies = guidance.get('dependencies') or '—' + type_label = guidance.get('type') or summary.get('type') or definition.type_label lines = [ - f"🧩 {summary['name']}", - f"🔑 Ключ: {summary['key']}", - f"📁 Категория: {summary['category_label']}", - f"📝 Тип: {type_label}", - f"📌 Текущее: {summary['current']}", + f'🧩 {summary["name"]}', + f'🔑 Ключ: {summary["key"]}', + f'📁 Категория: {summary["category_label"]}', + f'📝 Тип: {type_label}', + f'📌 Текущее: {summary["current"]}', ] - original_value = summary.get("original") - if original_value not in {None, ""}: - lines.append(f"📦 По умолчанию: {original_value}") + original_value = summary.get('original') + if original_value not in {None, ''}: + lines.append(f'📦 По умолчанию: {original_value}') - lines.append(f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}") + lines.append(f'✳️ Переопределено: {"Да" if summary["has_override"] else "Нет"}') - if summary.get("is_read_only"): - lines.append("🔒 Режим: Только для чтения (управляется автоматически)") + if summary.get('is_read_only'): + lines.append('🔒 Режим: Только для чтения (управляется автоматически)') - lines.append("") + lines.append('') if description: - lines.append(f"📘 Описание: {description}") + lines.append(f'📘 Описание: {description}') if format_hint: - lines.append(f"📐 Формат: {format_hint}") + lines.append(f'📐 Формат: {format_hint}') if example: - lines.append(f"💡 Пример: {example}") + lines.append(f'💡 Пример: {example}') if warning: - lines.append(f"⚠️ Важно: {warning}") + lines.append(f'⚠️ Важно: {warning}') if dependencies: - lines.append(f"🔗 Связанные: {dependencies}") + lines.append(f'🔗 Связанные: {dependencies}') choices = bot_configuration_service.get_choice_options(key) if choices: current_raw = bot_configuration_service.get_current_value(key) - lines.append("") - lines.append("📋 Доступные значения:") + lines.append('') + lines.append('📋 Доступные значения:') for option in choices: - marker = "✅" if current_raw == option.value else "•" + marker = '✅' if current_raw == option.value else '•' value_display = bot_configuration_service.format_value_human(key, option.value) - description = option.description or "" - base_line = f"{marker} {option.label} — {value_display}" + description = option.description or '' + base_line = f'{marker} {option.label} — {value_display}' if description: - base_line += f"\n└ {description}" + base_line += f'\n└ {description}' lines.append(base_line) - return "\n".join(lines) + return '\n'.join(lines) @admin_required @@ -1569,7 +1474,7 @@ async def show_bot_config_menu( await callback.message.edit_text( overview, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1586,7 +1491,7 @@ async def show_bot_config_group( group_lookup = {key: (title, items) for key, title, items in grouped} if group_key not in group_lookup: - await callback.answer("Эта группа больше недоступна", show_alert=True) + await callback.answer('Эта группа больше недоступна', show_alert=True) return group_title, items = group_lookup[group_key] @@ -1598,24 +1503,24 @@ async def show_bot_config_group( clean_title = raw_title if icon and raw_title.startswith(icon): clean_title = raw_title[len(icon) :].strip() - elif " " in raw_title: - possible_icon, remainder = raw_title.split(" ", 1) + elif ' ' in raw_title: + possible_icon, remainder = raw_title.split(' ', 1) if possible_icon: icon = possible_icon clean_title = remainder.strip() - lines = [f"{icon} {clean_title}"] + lines = [f'{icon} {clean_title}'] if status_text: - lines.append(f"Статус: {status_icon} {status_text}") - lines.append(f"🏠 → {clean_title}") + lines.append(f'Статус: {status_icon} {status_text}') + lines.append(f'🏠 → {clean_title}') if description: - lines.append("") + lines.append('') lines.append(description) - lines.append("") - lines.append("📂 Категории группы:") + lines.append('') + lines.append('📂 Категории группы:') await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1627,25 +1532,23 @@ async def show_bot_config_category( db_user: User, db: AsyncSession, ): - group_key, category_key, category_page, settings_page = _parse_category_payload( - callback.data - ) + group_key, category_key, category_page, settings_page = _parse_category_payload(callback.data) definitions = bot_configuration_service.get_settings_for_category(category_key) if not definitions: - await callback.answer("В этой категории пока нет настроек", show_alert=True) + await callback.answer('В этой категории пока нет настроек', show_alert=True) return category_label = definitions[0].category_label category_description = bot_configuration_service.get_category_description(category_key) group_meta = _get_group_meta(group_key) - group_title = str(group_meta.get("title", group_key)) + group_title = str(group_meta.get('title', group_key)) group_icon = _get_group_icon(group_key) raw_group_title = group_title.strip() if group_icon and raw_group_title.startswith(group_icon): group_plain_title = raw_group_title[len(group_icon) :].strip() - elif " " in raw_group_title: - possible_icon, remainder = raw_group_title.split(" ", 1) + elif ' ' in raw_group_title: + possible_icon, remainder = raw_group_title.split(' ', 1) group_plain_title = remainder.strip() if possible_icon: group_icon = possible_icon @@ -1659,17 +1562,17 @@ async def show_bot_config_category( settings_page, ) text_lines = [ - f"🗂 {category_label}", - f"🏠 → {group_plain_title} → {category_label}", + f'🗂 {category_label}', + f'🏠 → {group_plain_title} → {category_label}', ] if category_description: text_lines.append(category_description) - text_lines.append("") - text_lines.append("📋 Список настроек категории:") + text_lines.append('') + text_lines.append('📋 Список настроек категории:') await callback.message.edit_text( - "\n".join(text_lines), + '\n'.join(text_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1682,7 +1585,7 @@ async def show_simple_subscription_squad_selector( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 5) + parts = callback.data.split(':', 5) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -1692,16 +1595,16 @@ async def show_simple_subscription_squad_selector( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return - if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID": - await callback.answer("Эта настройка больше недоступна", show_alert=True) + if key != 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': + await callback.answer('Эта настройка больше недоступна', show_alert=True) return try: @@ -1728,8 +1631,8 @@ async def show_simple_subscription_squad_selector( limit=limit, ) - current_uuid = bot_configuration_service.get_current_value(key) or "" - current_display = "Любой доступный" + current_uuid = bot_configuration_service.get_current_value(key) or '' + current_display = 'Любой доступный' if current_uuid: selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None) @@ -1741,82 +1644,82 @@ async def show_simple_subscription_squad_selector( current_display = current_uuid lines = [ - "🌍 Выберите сквад для простой покупки", - "", - f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —", - "", + '🌍 Выберите сквад для простой покупки', + '', + f'Текущий выбор: {html.escape(current_display)}' if current_display else 'Текущий выбор: —', + '', ] if total_count == 0: - lines.append("❌ Доступные сервера не найдены.") + lines.append('❌ Доступные сервера не найдены.') else: - lines.append("Выберите сервер из списка ниже.") + lines.append('Выберите сервер из списка ниже.') if total_pages > 1: - lines.append(f"Страница {page}/{total_pages}") + lines.append(f'Страница {page}/{total_pages}') - text = "\n".join(lines) + text = '\n'.join(lines) - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] for server in squads: - status_icon = ( - "✅" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒") - ) + status_icon = '✅' if server.squad_uuid == current_uuid else ('🟢' if server.is_available else '🔒') label_parts = [status_icon, server.display_name] if server.country_code: - label_parts.append(f"({server.country_code.upper()})") + label_parts.append(f'({server.country_code.upper()})') if isinstance(server.price_kopeks, int) and server.price_kopeks > 0: try: - label_parts.append(f"— {settings.format_price(server.price_kopeks)}") + label_parts.append(f'— {settings.format_price(server.price_kopeks)}') except Exception: pass - label = " ".join(label_parts) + label = ' '.join(label_parts) - keyboard_rows.append([ - types.InlineKeyboardButton( - text=label, - callback_data=( - f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}" - ), - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=label, + callback_data=( + f'botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}' + ), + ) + ] + ) if total_pages > 1: - nav_row: List[types.InlineKeyboardButton] = [] + nav_row: list[types.InlineKeyboardButton] = [] if page > 1: nav_row.append( types.InlineKeyboardButton( - text="⬅️", + text='⬅️', callback_data=( - f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}" + f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}' ), ) ) if page < total_pages: nav_row.append( types.InlineKeyboardButton( - text="➡️", + text='➡️', callback_data=( - f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}" + f'botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}' ), ) ) if nav_row: keyboard_rows.append(nav_row) - keyboard_rows.append([ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=( - f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}" - ), - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='⬅️ Назад', + callback_data=(f'botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}'), + ) + ] + ) await callback.message.edit_text( text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1829,7 +1732,7 @@ async def select_simple_subscription_squad( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 6) + parts = callback.data.split(':', 6) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -1839,35 +1742,35 @@ async def select_simple_subscription_squad( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: server_id = int(parts[5]) if len(parts) > 5 else None except ValueError: server_id = None if server_id is None: - await callback.answer("Не удалось определить сервер", show_alert=True) + await callback.answer('Не удалось определить сервер', show_alert=True) return try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("Сервер не найден", show_alert=True) + await callback.answer('Сервер не найден', show_alert=True) return try: await bot_configuration_service.set_value(db, key, server.squad_uuid) except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return await db.commit() @@ -1882,7 +1785,7 @@ async def select_simple_subscription_squad( category_page=category_page, settings_page=settings_page, ) - await callback.answer("Сквад выбран") + await callback.answer('Сквад выбран') @admin_required @@ -1892,9 +1795,9 @@ async def test_remnawave_connection( db_user: User, db: AsyncSession, ): - parts = callback.data.split(":", 5) + parts = callback.data.split(':', 5) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY - category_key = parts[2] if len(parts) > 2 else "REMNAWAVE" + category_key = parts[2] if len(parts) > 2 else 'REMNAWAVE' try: category_page = max(1, int(parts[3])) if len(parts) > 3 else 1 @@ -1909,20 +1812,20 @@ async def test_remnawave_connection( service = RemnaWaveService() result = await service.test_api_connection() - status = result.get("status") + status = result.get('status') message: str - if status == "connected": - message = "✅ Подключение успешно" - elif status == "not_configured": - message = f"⚠️ {result.get('message', 'RemnaWave API не настроен')}" + if status == 'connected': + message = '✅ Подключение успешно' + elif status == 'not_configured': + message = f'⚠️ {result.get("message", "RemnaWave API не настроен")}' else: - base_message = result.get("message", "Ошибка подключения") - status_code = result.get("status_code") + base_message = result.get('message', 'Ошибка подключения') + status_code = result.get('status_code') if status_code: - message = f"❌ {base_message} (HTTP {status_code})" + message = f'❌ {base_message} (HTTP {status_code})' else: - message = f"❌ {base_message}" + message = f'❌ {base_message}' definitions = bot_configuration_service.get_settings_for_category(category_key) if definitions: @@ -1949,10 +1852,10 @@ async def test_payment_provider( db_user: User, db: AsyncSession, ): - parts = callback.data.split(":", 6) - method = parts[1] if len(parts) > 1 else "" + parts = callback.data.split(':', 6) + method = parts[1] if len(parts) > 1 else '' group_key = parts[2] if len(parts) > 2 else CATEGORY_FALLBACK_KEY - category_key = parts[3] if len(parts) > 3 else "PAYMENT" + category_key = parts[3] if len(parts) > 3 else 'PAYMENT' try: category_page = max(1, int(parts[4])) if len(parts) > 4 else 1 @@ -1985,60 +1888,60 @@ async def test_payment_provider( except Exception: pass - if method == "yookassa": + if method == 'yookassa': if not settings.is_yookassa_enabled(): - await callback.answer("❌ YooKassa отключена", show_alert=True) + await callback.answer('❌ YooKassa отключена', show_alert=True) return amount_kopeks = 10 * 100 - description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id), + description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),) payment_result = await payment_service.create_yookassa_payment( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=f"Тестовый платеж (админ): {description}", + description=f'Тестовый платеж (админ): {description}', metadata={ - "user_telegram_id": str(db_user.telegram_id), - "purpose": "admin_test_payment", - "provider": "yookassa", + 'user_telegram_id': str(db_user.telegram_id), + 'purpose': 'admin_test_payment', + 'provider': 'yookassa', }, ) - if not payment_result or not payment_result.get("confirmation_url"): - await callback.answer("❌ Не удалось создать тестовый платеж YooKassa", show_alert=True) + if not payment_result or not payment_result.get('confirmation_url'): + await callback.answer('❌ Не удалось создать тестовый платеж YooKassa', show_alert=True) await _refresh_markup() return - confirmation_url = payment_result["confirmation_url"] + confirmation_url = payment_result['confirmation_url'] message_text = ( - "🧪 Тестовый платеж YooKassa\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"🆔 ID: {payment_result['yookassa_payment_id']}" + '🧪 Тестовый платеж YooKassa\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'🆔 ID: {payment_result["yookassa_payment_id"]}' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="💳 Оплатить картой", + text='💳 Оплатить картой', url=confirmation_url, ) ], [ types.InlineKeyboardButton( - text="📊 Проверить статус", - callback_data=f"check_yookassa_{payment_result['local_payment_id']}", + text='📊 Проверить статус', + callback_data=f'check_yookassa_{payment_result["local_payment_id"]}', ) ], ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж YooKassa отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж YooKassa отправлена', show_alert=True) await _refresh_markup() return - if method == "tribute": + if method == 'tribute': if not settings.TRIBUTE_ENABLED: - await callback.answer("❌ Tribute отключен", show_alert=True) + await callback.answer('❌ Tribute отключен', show_alert=True) return tribute_service = TributeService(callback.bot) @@ -2046,42 +1949,42 @@ async def test_payment_provider( payment_url = await tribute_service.create_payment_link( user_id=db_user.telegram_id, amount_kopeks=10 * 100, - description="Тестовый платеж Tribute (админ)", + description='Тестовый платеж Tribute (админ)', ) except Exception: payment_url = None if not payment_url: - await callback.answer("❌ Не удалось создать платеж Tribute", show_alert=True) + await callback.answer('❌ Не удалось создать платеж Tribute', show_alert=True) await _refresh_markup() return message_text = ( - "🧪 Тестовый платеж Tribute\n\n" - f"💰 Сумма: {texts.format_price(10 * 100)}\n" - "🔗 Нажмите кнопку ниже, чтобы открыть ссылку на оплату." + '🧪 Тестовый платеж Tribute\n\n' + f'💰 Сумма: {texts.format_price(10 * 100)}\n' + '🔗 Нажмите кнопку ниже, чтобы открыть ссылку на оплату.' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="💳 Перейти к оплате", + text='💳 Перейти к оплате', url=payment_url, ) ] ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж Tribute отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж Tribute отправлена', show_alert=True) await _refresh_markup() return - if method == "mulenpay": + if method == 'mulenpay': mulenpay_name = settings.get_mulenpay_display_name() mulenpay_name_html = settings.get_mulenpay_display_name_html() if not settings.is_mulenpay_enabled(): await callback.answer( - f"❌ {mulenpay_name} отключен", + f'❌ {mulenpay_name} отключен', show_alert=True, ) return @@ -2091,51 +1994,51 @@ async def test_payment_provider( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=f"Тестовый платеж {mulenpay_name} (админ)", + description=f'Тестовый платеж {mulenpay_name} (админ)', language=language, ) - if not payment_result or not payment_result.get("payment_url"): + if not payment_result or not payment_result.get('payment_url'): await callback.answer( - f"❌ Не удалось создать платеж {mulenpay_name}", + f'❌ Не удалось создать платеж {mulenpay_name}', show_alert=True, ) await _refresh_markup() return - payment_url = payment_result["payment_url"] + payment_url = payment_result['payment_url'] message_text = ( - f"🧪 Тестовый платеж {mulenpay_name_html}\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"🆔 ID: {payment_result['mulen_payment_id']}" + f'🧪 Тестовый платеж {mulenpay_name_html}\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'🆔 ID: {payment_result["mulen_payment_id"]}' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="💳 Перейти к оплате", + text='💳 Перейти к оплате', url=payment_url, ) ], [ types.InlineKeyboardButton( - text="📊 Проверить статус", - callback_data=f"check_mulenpay_{payment_result['local_payment_id']}", + text='📊 Проверить статус', + callback_data=f'check_mulenpay_{payment_result["local_payment_id"]}', ) ], ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') await callback.answer( - f"✅ Ссылка на платеж {mulenpay_name} отправлена", + f'✅ Ссылка на платеж {mulenpay_name} отправлена', show_alert=True, ) await _refresh_markup() return - if method == "pal24": + if method == 'pal24': if not settings.is_pal24_enabled(): - await callback.answer("❌ PayPalych отключен", show_alert=True) + await callback.answer('❌ PayPalych отключен', show_alert=True) return amount_kopeks = 10 * 100 @@ -2143,25 +2046,21 @@ async def test_payment_provider( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description="Тестовый платеж PayPalych (админ)", - language=language or "ru", + description='Тестовый платеж PayPalych (админ)', + language=language or 'ru', ) if not payment_result: - await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True) + await callback.answer('❌ Не удалось создать платеж PayPalych', show_alert=True) await _refresh_markup() return - sbp_url = ( - payment_result.get("sbp_url") - or payment_result.get("transfer_url") - or payment_result.get("link_url") - ) - card_url = payment_result.get("card_url") - fallback_url = payment_result.get("link_page_url") or payment_result.get("link_url") + sbp_url = payment_result.get('sbp_url') or payment_result.get('transfer_url') or payment_result.get('link_url') + card_url = payment_result.get('card_url') + fallback_url = payment_result.get('link_page_url') or payment_result.get('link_url') if not (sbp_url or card_url or fallback_url): - await callback.answer("❌ Не удалось создать платеж PayPalych", show_alert=True) + await callback.answer('❌ Не удалось создать платеж PayPalych', show_alert=True) await _refresh_markup() return @@ -2169,108 +2068,114 @@ async def test_payment_provider( sbp_url = fallback_url default_sbp_text = texts.t( - "PAL24_SBP_PAY_BUTTON", - "🏦 Оплатить через PayPalych (СБП)", + 'PAL24_SBP_PAY_BUTTON', + '🏦 Оплатить через PayPalych (СБП)', ) sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) default_card_text = texts.t( - "PAL24_CARD_PAY_BUTTON", - "💳 Оплатить банковской картой (PayPalych)", + 'PAL24_CARD_PAY_BUTTON', + '💳 Оплатить банковской картой (PayPalych)', ) card_button_text = settings.get_pal24_card_button_text(default_card_text) pay_rows: list[list[types.InlineKeyboardButton]] = [] if sbp_url: - pay_rows.append([ - types.InlineKeyboardButton( - text=sbp_button_text, - url=sbp_url, - ) - ]) + pay_rows.append( + [ + types.InlineKeyboardButton( + text=sbp_button_text, + url=sbp_url, + ) + ] + ) if card_url and card_url != sbp_url: - pay_rows.append([ - types.InlineKeyboardButton( - text=card_button_text, - url=card_url, - ) - ]) + pay_rows.append( + [ + types.InlineKeyboardButton( + text=card_button_text, + url=card_url, + ) + ] + ) if not pay_rows and fallback_url: - pay_rows.append([ - types.InlineKeyboardButton( - text=sbp_button_text, - url=fallback_url, - ) - ]) + pay_rows.append( + [ + types.InlineKeyboardButton( + text=sbp_button_text, + url=fallback_url, + ) + ] + ) message_text = ( - "🧪 Тестовый платеж PayPalych\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"🆔 Bill ID: {payment_result['bill_id']}" + '🧪 Тестовый платеж PayPalych\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'🆔 Bill ID: {payment_result["bill_id"]}' ) keyboard_rows = pay_rows + [ [ types.InlineKeyboardButton( - text="📊 Проверить статус", - callback_data=f"check_pal24_{payment_result['local_payment_id']}", + text='📊 Проверить статус', + callback_data=f'check_pal24_{payment_result["local_payment_id"]}', ) ], ] reply_markup = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж PayPalych отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж PayPalych отправлена', show_alert=True) await _refresh_markup() return - if method == "stars": + if method == 'stars': if not settings.TELEGRAM_STARS_ENABLED: - await callback.answer("❌ Telegram Stars отключены", show_alert=True) + await callback.answer('❌ Telegram Stars отключены', show_alert=True) return stars_rate = settings.get_stars_rate() amount_kopeks = max(1, int(round(stars_rate * 100))) - payload = f"admin_stars_test_{db_user.id}_{int(time.time())}" + payload = f'admin_stars_test_{db_user.id}_{int(time.time())}' try: invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, - description="Тестовый платеж Telegram Stars (админ)", + description='Тестовый платеж Telegram Stars (админ)', payload=payload, ) except Exception: invoice_link = None if not invoice_link: - await callback.answer("❌ Не удалось создать платеж Telegram Stars", show_alert=True) + await callback.answer('❌ Не удалось создать платеж Telegram Stars', show_alert=True) await _refresh_markup() return stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_kopeks / 100) message_text = ( - "🧪 Тестовый платеж Telegram Stars\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"⭐ К оплате: {stars_amount}" + '🧪 Тестовый платеж Telegram Stars\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'⭐ К оплате: {stars_amount}' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Открыть счет"), + text=texts.t('PAYMENT_TELEGRAM_STARS', '⭐ Открыть счет'), url=invoice_link, ) ] ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж Stars отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж Stars отправлена', show_alert=True) await _refresh_markup() return - if method == "cryptobot": + if method == 'cryptobot': if not settings.is_cryptobot_enabled(): - await callback.answer("❌ CryptoBot отключен", show_alert=True) + await callback.answer('❌ CryptoBot отключен', show_alert=True) return amount_rubles = 100.0 @@ -2291,54 +2196,52 @@ async def test_payment_provider( user_id=db_user.id, amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=f"Тестовый платеж CryptoBot {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)", - payload=f"admin_cryptobot_test_{db_user.id}_{int(time.time())}", + description=f'Тестовый платеж CryptoBot {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)', + payload=f'admin_cryptobot_test_{db_user.id}_{int(time.time())}', ) if not payment_result: - await callback.answer("❌ Не удалось создать платеж CryptoBot", show_alert=True) + await callback.answer('❌ Не удалось создать платеж CryptoBot', show_alert=True) await _refresh_markup() return payment_url = ( - payment_result.get("bot_invoice_url") - or payment_result.get("mini_app_invoice_url") - or payment_result.get("web_app_invoice_url") + payment_result.get('bot_invoice_url') + or payment_result.get('mini_app_invoice_url') + or payment_result.get('web_app_invoice_url') ) if not payment_url: - await callback.answer("❌ Не удалось получить ссылку на оплату CryptoBot", show_alert=True) + await callback.answer('❌ Не удалось получить ссылку на оплату CryptoBot', show_alert=True) await _refresh_markup() return amount_kopeks = int(amount_rubles * 100) message_text = ( - "🧪 Тестовый платеж CryptoBot\n\n" - f"💰 Сумма к зачислению: {texts.format_price(amount_kopeks)}\n" - f"💵 К оплате: {amount_usd:.2f} USD\n" - f"🪙 Актив: {payment_result['asset']}" + '🧪 Тестовый платеж CryptoBot\n\n' + f'💰 Сумма к зачислению: {texts.format_price(amount_kopeks)}\n' + f'💵 К оплате: {amount_usd:.2f} USD\n' + f'🪙 Актив: {payment_result["asset"]}' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ - [ - types.InlineKeyboardButton(text="🪙 Открыть счет", url=payment_url) - ], + [types.InlineKeyboardButton(text='🪙 Открыть счет', url=payment_url)], [ types.InlineKeyboardButton( - text="📊 Проверить статус", - callback_data=f"check_cryptobot_{payment_result['local_payment_id']}", + text='📊 Проверить статус', + callback_data=f'check_cryptobot_{payment_result["local_payment_id"]}', ) ], ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж CryptoBot отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж CryptoBot отправлена', show_alert=True) await _refresh_markup() return - if method == "freekassa": + if method == 'freekassa': if not settings.is_freekassa_enabled(): - await callback.answer("❌ Freekassa отключена", show_alert=True) + await callback.answer('❌ Freekassa отключена', show_alert=True) return amount_kopeks = settings.FREEKASSA_MIN_AMOUNT_KOPEKS @@ -2346,38 +2249,38 @@ async def test_payment_provider( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description="Тестовый платеж Freekassa (админ)", - email=getattr(db_user, "email", None), + description='Тестовый платеж Freekassa (админ)', + email=getattr(db_user, 'email', None), language=db_user.language or settings.DEFAULT_LANGUAGE, ) - if not payment_result or not payment_result.get("payment_url"): - await callback.answer("❌ Не удалось создать тестовый платеж Freekassa", show_alert=True) + if not payment_result or not payment_result.get('payment_url'): + await callback.answer('❌ Не удалось создать тестовый платеж Freekassa', show_alert=True) await _refresh_markup() return - payment_url = payment_result["payment_url"] + payment_url = payment_result['payment_url'] message_text = ( - "🧪 Тестовый платеж Freekassa\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"🆔 Order ID: {payment_result['order_id']}" + '🧪 Тестовый платеж Freekassa\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'🆔 Order ID: {payment_result["order_id"]}' ) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="💳 Перейти к оплате", + text='💳 Перейти к оплате', url=payment_url, ) ] ] ) - await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("✅ Ссылка на платеж Freekassa отправлена", show_alert=True) + await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('✅ Ссылка на платеж Freekassa отправлена', show_alert=True) await _refresh_markup() return - await callback.answer("❌ Неизвестный способ тестирования платежа", show_alert=True) + await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True) await _refresh_markup() @@ -2389,7 +2292,7 @@ async def show_bot_config_setting( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 4) + parts = callback.data.split(':', 4) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -2399,11 +2302,11 @@ async def show_bot_config_setting( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) @@ -2426,7 +2329,7 @@ async def start_edit_setting( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 4) + parts = callback.data.split(':', 4) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -2436,14 +2339,14 @@ async def start_edit_setting( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return definition = bot_configuration_service.get_definition(key) @@ -2451,12 +2354,12 @@ async def start_edit_setting( texts = get_texts(db_user.language) instructions = [ - "✏️ Редактирование настройки", - f"Название: {summary['name']}", - f"Ключ: {summary['key']}", - f"Тип: {summary['type']}", - f"Текущее значение: {summary['current']}", - "\nОтправьте новое значение сообщением.", + '✏️ Редактирование настройки', + f'Название: {summary["name"]}', + f'Ключ: {summary["key"]}', + f'Тип: {summary["type"]}', + f'Текущее значение: {summary["current"]}', + '\nОтправьте новое значение сообщением.', ] if definition.is_optional: @@ -2465,15 +2368,13 @@ async def start_edit_setting( instructions.append("Для отмены отправьте 'cancel'.") await callback.message.edit_text( - "\n".join(instructions), + '\n'.join(instructions), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=( - f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}" - ), + callback_data=(f'botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}'), ) ] ] @@ -2500,38 +2401,38 @@ async def handle_edit_setting( state: FSMContext, ): data = await state.get_data() - key = data.get("setting_key") - group_key = data.get("setting_group_key", CATEGORY_FALLBACK_KEY) - category_page = data.get("setting_category_page", 1) - settings_page = data.get("setting_settings_page", 1) + key = data.get('setting_key') + group_key = data.get('setting_group_key', CATEGORY_FALLBACK_KEY) + category_page = data.get('setting_category_page', 1) + settings_page = data.get('setting_settings_page', 1) if not key: - await message.answer("Не удалось определить редактируемую настройку. Попробуйте снова.") + await message.answer('Не удалось определить редактируемую настройку. Попробуйте снова.') await state.clear() return if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") + await message.answer('⚠️ Эта настройка доступна только для чтения.') await state.clear() return try: - value = bot_configuration_service.parse_user_value(key, message.text or "") + value = bot_configuration_service.parse_user_value(key, message.text or '') except ValueError as error: - await message.answer(f"⚠️ {error}") + await message.answer(f'⚠️ {error}') return try: await bot_configuration_service.set_value(db, key, value) except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") + await message.answer('⚠️ Эта настройка доступна только для чтения.') await state.clear() return await db.commit() text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) - await message.answer("✅ Настройка обновлена") + await message.answer('✅ Настройка обновлена') await message.answer(text, reply_markup=keyboard) await state.clear() await _store_setting_context( @@ -2553,36 +2454,36 @@ async def handle_direct_setting_input( ): data = await state.get_data() - key = data.get("setting_key") - group_key = data.get("setting_group_key", CATEGORY_FALLBACK_KEY) - category_page = int(data.get("setting_category_page", 1) or 1) - settings_page = int(data.get("setting_settings_page", 1) or 1) + key = data.get('setting_key') + group_key = data.get('setting_group_key', CATEGORY_FALLBACK_KEY) + category_page = int(data.get('setting_category_page', 1) or 1) + settings_page = int(data.get('setting_settings_page', 1) or 1) if not key: return if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") + await message.answer('⚠️ Эта настройка доступна только для чтения.') await state.clear() return try: - value = bot_configuration_service.parse_user_value(key, message.text or "") + value = bot_configuration_service.parse_user_value(key, message.text or '') except ValueError as error: - await message.answer(f"⚠️ {error}") + await message.answer(f'⚠️ {error}') return try: await bot_configuration_service.set_value(db, key, value) except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") + await message.answer('⚠️ Эта настройка доступна только для чтения.') await state.clear() return await db.commit() text = _render_setting_text(key) keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page) - await message.answer("✅ Настройка обновлена") + await message.answer('✅ Настройка обновлена') await message.answer(text, reply_markup=keyboard) await state.clear() @@ -2603,7 +2504,7 @@ async def reset_setting( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 4) + parts = callback.data.split(':', 4) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -2613,19 +2514,19 @@ async def reset_setting( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return try: await bot_configuration_service.reset_value(db, key) except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return await db.commit() @@ -2639,7 +2540,7 @@ async def reset_setting( category_page=category_page, settings_page=settings_page, ) - await callback.answer("Сброшено к значению по умолчанию") + await callback.answer('Сброшено к значению по умолчанию') @admin_required @@ -2650,7 +2551,7 @@ async def toggle_setting( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 4) + parts = callback.data.split(':', 4) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -2660,21 +2561,21 @@ async def toggle_setting( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" + token = parts[4] if len(parts) > 4 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return current = bot_configuration_service.get_current_value(key) new_value = not bool(current) try: await bot_configuration_service.set_value(db, key, new_value) except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return await db.commit() @@ -2688,7 +2589,7 @@ async def toggle_setting( category_page=category_page, settings_page=settings_page, ) - await callback.answer("Обновлено") + await callback.answer('Обновлено') @admin_required @@ -2699,7 +2600,7 @@ async def apply_setting_choice( db: AsyncSession, state: FSMContext, ): - parts = callback.data.split(":", 5) + parts = callback.data.split(':', 5) group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY try: category_page = max(1, int(parts[2])) if len(parts) > 2 else 1 @@ -2709,28 +2610,28 @@ async def apply_setting_choice( settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1 except ValueError: settings_page = 1 - token = parts[4] if len(parts) > 4 else "" - choice_token = parts[5] if len(parts) > 5 else "" + token = parts[4] if len(parts) > 4 else '' + choice_token = parts[5] if len(parts) > 5 else '' try: key = bot_configuration_service.resolve_callback_token(token) except KeyError: - await callback.answer("Эта настройка больше недоступна", show_alert=True) + await callback.answer('Эта настройка больше недоступна', show_alert=True) return if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return try: value = bot_configuration_service.resolve_choice_token(key, choice_token) except KeyError: - await callback.answer("Это значение больше недоступно", show_alert=True) + await callback.answer('Это значение больше недоступно', show_alert=True) return try: await bot_configuration_service.set_value(db, key, value) except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) + await callback.answer('Эта настройка доступна только для чтения', show_alert=True) return await db.commit() @@ -2744,89 +2645,89 @@ async def apply_setting_choice( category_page=category_page, settings_page=settings_page, ) - await callback.answer("Значение обновлено") + await callback.answer('Значение обновлено') def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_bot_config_menu, - F.data == "admin_bot_config", + F.data == 'admin_bot_config', ) dp.callback_query.register( start_settings_search, - F.data == "botcfg_action:search", + F.data == 'botcfg_action:search', ) dp.callback_query.register( show_presets, - F.data == "botcfg_action:presets", + F.data == 'botcfg_action:presets', ) dp.callback_query.register( apply_preset, - F.data.startswith("botcfg_preset_apply:"), + F.data.startswith('botcfg_preset_apply:'), ) dp.callback_query.register( preview_preset, - F.data.startswith("botcfg_preset:") & (~F.data.startswith("botcfg_preset_apply:")), + F.data.startswith('botcfg_preset:') & (~F.data.startswith('botcfg_preset_apply:')), ) dp.callback_query.register( export_settings, - F.data == "botcfg_action:export", + F.data == 'botcfg_action:export', ) dp.callback_query.register( start_import_settings, - F.data == "botcfg_action:import", + F.data == 'botcfg_action:import', ) dp.callback_query.register( show_settings_history, - F.data == "botcfg_action:history", + F.data == 'botcfg_action:history', ) dp.callback_query.register( show_help, - F.data == "botcfg_action:help", + F.data == 'botcfg_action:help', ) dp.callback_query.register( show_bot_config_group, - F.data.startswith("botcfg_group:") & (~F.data.endswith(":noop")), + F.data.startswith('botcfg_group:') & (~F.data.endswith(':noop')), ) dp.callback_query.register( show_bot_config_category, - F.data.startswith("botcfg_cat:"), + F.data.startswith('botcfg_cat:'), ) dp.callback_query.register( test_remnawave_connection, - F.data.startswith("botcfg_test_remnawave:"), + F.data.startswith('botcfg_test_remnawave:'), ) dp.callback_query.register( test_payment_provider, - F.data.startswith("botcfg_test_payment:"), + F.data.startswith('botcfg_test_payment:'), ) dp.callback_query.register( select_simple_subscription_squad, - F.data.startswith("botcfg_simple_squad_select:"), + F.data.startswith('botcfg_simple_squad_select:'), ) dp.callback_query.register( show_simple_subscription_squad_selector, - F.data.startswith("botcfg_simple_squad:"), + F.data.startswith('botcfg_simple_squad:'), ) dp.callback_query.register( show_bot_config_setting, - F.data.startswith("botcfg_setting:"), + F.data.startswith('botcfg_setting:'), ) dp.callback_query.register( start_edit_setting, - F.data.startswith("botcfg_edit:"), + F.data.startswith('botcfg_edit:'), ) dp.callback_query.register( reset_setting, - F.data.startswith("botcfg_reset:"), + F.data.startswith('botcfg_reset:'), ) dp.callback_query.register( toggle_setting, - F.data.startswith("botcfg_toggle:"), + F.data.startswith('botcfg_toggle:'), ) dp.callback_query.register( apply_setting_choice, - F.data.startswith("botcfg_choice:"), + F.data.startswith('botcfg_choice:'), ) dp.message.register( handle_direct_setting_input, diff --git a/app/handlers/admin/bulk_ban.py b/app/handlers/admin/bulk_ban.py index 2f7a28fd..cb83c7bd 100644 --- a/app/handlers/admin/bulk_ban.py +++ b/app/handlers/admin/bulk_ban.py @@ -1,71 +1,64 @@ """ Обработчики команд для массовой блокировки пользователей """ + import logging + from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings + from app.database.models import User from app.services.bulk_ban_service import bulk_ban_service from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -from app.keyboards.admin import get_admin_users_keyboard + logger = logging.getLogger(__name__) @admin_required @error_handler -async def start_bulk_ban_process( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_bulk_ban_process(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Начало процесса массовой блокировки пользователей """ await callback.message.edit_text( - "🛑 Массовая блокировка пользователей\n\n" - "Введите список Telegram ID для блокировки.\n\n" - "Форматы ввода:\n" - "• По одному ID на строку\n" - "• Через запятую\n" - "• Через пробел\n\n" - "Пример:\n" - "123456789\n" - "987654321\n" - "111222333\n\n" - "Или:\n" - "123456789, 987654321, 111222333\n\n" - "Для отмены используйте команду /cancel", - parse_mode="HTML", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")] - ]) + '🛑 Массовая блокировка пользователей\n\n' + 'Введите список Telegram ID для блокировки.\n\n' + 'Форматы ввода:\n' + '• По одному ID на строку\n' + '• Через запятую\n' + '• Через пробел\n\n' + 'Пример:\n' + '123456789\n' + '987654321\n' + '111222333\n\n' + 'Или:\n' + '123456789, 987654321, 111222333\n\n' + 'Для отмены используйте команду /cancel', + parse_mode='HTML', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_users')]] + ), ) - + await state.set_state(AdminStates.waiting_for_bulk_ban_list) await callback.answer() @admin_required @error_handler -async def process_bulk_ban_list( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_bulk_ban_list(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): """ Обработка списка Telegram ID и выполнение массовой блокировки """ if not message.text: await message.answer( - "❌ Отправьте текстовое сообщение со списком Telegram ID", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + '❌ Отправьте текстовое сообщение со списком Telegram ID', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) return @@ -73,91 +66,91 @@ async def process_bulk_ban_list( if not input_text: await message.answer( - "❌ Введите корректный список Telegram ID", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + '❌ Введите корректный список Telegram ID', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) return - + # Парсим ID из текста try: telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text) except Exception as e: - logger.error(f"Ошибка парсинга Telegram ID: {e}") + logger.error(f'Ошибка парсинга Telegram ID: {e}') await message.answer( - "❌ Ошибка при обработке списка ID. Проверьте формат ввода.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + '❌ Ошибка при обработке списка ID. Проверьте формат ввода.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) return - + if not telegram_ids: await message.answer( - "❌ Не найдено корректных Telegram ID в списке", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + '❌ Не найдено корректных Telegram ID в списке', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) return - + if len(telegram_ids) > 1000: # Ограничение на количество ID за раз await message.answer( - f"❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + f'❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) return - + # Выполняем массовую блокировку try: successfully_banned, not_found, error_ids = await bulk_ban_service.ban_users_by_telegram_ids( db=db, admin_user_id=db_user.id, telegram_ids=telegram_ids, - reason="Массовая блокировка администратором", + reason='Массовая блокировка администратором', bot=message.bot, notify_admin=True, - admin_name=db_user.full_name + admin_name=db_user.full_name, ) - + # Подготавливаем сообщение с результатами - result_text = f"✅ Массовая блокировка завершена\n\n" - result_text += f"📊 Результаты:\n" - result_text += f"✅ Успешно заблокировано: {successfully_banned}\n" - result_text += f"❌ Не найдено: {not_found}\n" - result_text += f"💥 Ошибок: {len(error_ids)}\n\n" - result_text += f"📈 Всего обработано: {len(telegram_ids)}" - + result_text = '✅ Массовая блокировка завершена\n\n' + result_text += '📊 Результаты:\n' + result_text += f'✅ Успешно заблокировано: {successfully_banned}\n' + result_text += f'❌ Не найдено: {not_found}\n' + result_text += f'💥 Ошибок: {len(error_ids)}\n\n' + result_text += f'📈 Всего обработано: {len(telegram_ids)}' + if successfully_banned > 0: - result_text += f"\n🎯 Процент успеха: {round((successfully_banned/len(telegram_ids))*100, 1)}%" - + result_text += f'\n🎯 Процент успеха: {round((successfully_banned / len(telegram_ids)) * 100, 1)}%' + # Добавляем информацию об ошибках, если есть if error_ids: - result_text += f"\n\n⚠️ Telegram ID с ошибками:\n" - result_text += f"{', '.join(map(str, error_ids[:10]))}" # Показываем первые 10 + result_text += '\n\n⚠️ Telegram ID с ошибками:\n' + result_text += f'{", ".join(map(str, error_ids[:10]))}' # Показываем первые 10 if len(error_ids) > 10: - result_text += f" и еще {len(error_ids) - 10}..." - + result_text += f' и еще {len(error_ids) - 10}...' + await message.answer( result_text, - parse_mode="HTML", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👥 К пользователям", callback_data="admin_users")] - ]) + parse_mode='HTML', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='👥 К пользователям', callback_data='admin_users')]] + ), ) - + except Exception as e: - logger.error(f"Ошибка при выполнении массовой блокировки: {e}") + logger.error(f'Ошибка при выполнении массовой блокировки: {e}') await message.answer( - "❌ Произошла ошибка при выполнении массовой блокировки", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] - ]) + '❌ Произошла ошибка при выполнении массовой блокировки', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔙 Назад', callback_data='admin_users')]] + ), ) - + await state.clear() @@ -166,13 +159,7 @@ def register_bulk_ban_handlers(dp): Регистрация обработчиков команд для массовой блокировки """ # Обработчик команды начала массовой блокировки - dp.callback_query.register( - start_bulk_ban_process, - lambda c: c.data == "admin_bulk_ban_start" - ) - + dp.callback_query.register(start_bulk_ban_process, lambda c: c.data == 'admin_bulk_ban_start') + # Обработчик текстового сообщения с ID для блокировки - dp.message.register( - process_bulk_ban_list, - AdminStates.waiting_for_bulk_ban_list - ) + dp.message.register(process_bulk_ban_list, AdminStates.waiting_for_bulk_ban_list) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index 4cc1b9ff..701c87b3 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -1,8 +1,7 @@ import logging import re -from typing import List -from aiogram import Bot, Dispatcher, types, F +from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -33,100 +32,77 @@ from app.localization.texts import get_texts from app.states import AdminStates from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) -_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$") +_CAMPAIGN_PARAM_REGEX = re.compile(r'^[A-Za-z0-9_-]{3,32}$') _CAMPAIGNS_PAGE_SIZE = 5 def _format_campaign_summary(campaign, texts) -> str: - status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена" + status = '🟢 Активна' if campaign.is_active else '⚪️ Выключена' if campaign.is_balance_bonus: bonus_text = texts.format_price(campaign.balance_bonus_kopeks) - bonus_info = f"💰 Бонус на баланс: {bonus_text}" + bonus_info = f'💰 Бонус на баланс: {bonus_text}' elif campaign.is_subscription_bonus: traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0) device_limit = campaign.subscription_device_limit if device_limit is None: device_limit = settings.DEFAULT_DEVICE_LIMIT bonus_info = ( - "📱 Пробная подписка: {days} д.\n" - "🌐 Трафик: {traffic}\n" - "📱 Устройства: {devices}" - ).format( - days=campaign.subscription_duration_days or 0, - traffic=traffic_text, - devices=device_limit, + f'📱 Пробная подписка: {campaign.subscription_duration_days or 0} д.\n' + f'🌐 Трафик: {traffic_text}\n' + f'📱 Устройства: {device_limit}' ) elif campaign.is_tariff_bonus: - tariff_name = "Не выбран" + tariff_name = 'Не выбран' if hasattr(campaign, 'tariff') and campaign.tariff: tariff_name = campaign.tariff.name - bonus_info = ( - "🎁 Тариф: {tariff_name}\n" - "📅 Длительность: {days} д." - ).format( - tariff_name=tariff_name, - days=campaign.tariff_duration_days or 0, - ) + bonus_info = f'🎁 Тариф: {tariff_name}\n📅 Длительность: {campaign.tariff_duration_days or 0} д.' elif campaign.is_none_bonus: - bonus_info = "🔗 Только ссылка (без награды)" + bonus_info = '🔗 Только ссылка (без награды)' else: - bonus_info = "❓ Неизвестный тип бонуса" + bonus_info = '❓ Неизвестный тип бонуса' return ( - f"{campaign.name}\n" - f"Стартовый параметр: {campaign.start_parameter}\n" - f"Статус: {status}\n" - f"{bonus_info}\n" + f'{campaign.name}\n' + f'Стартовый параметр: {campaign.start_parameter}\n' + f'Статус: {status}\n' + f'{bonus_info}\n' ) -async def _get_bot_deep_link( - callback: types.CallbackQuery, start_parameter: str -) -> str: +async def _get_bot_deep_link(callback: types.CallbackQuery, start_parameter: str) -> str: bot = await callback.bot.get_me() - return f"https://t.me/{bot.username}?start={start_parameter}" + return f'https://t.me/{bot.username}?start={start_parameter}' -async def _get_bot_deep_link_from_message( - message: types.Message, start_parameter: str -) -> str: +async def _get_bot_deep_link_from_message(message: types.Message, start_parameter: str) -> str: bot = await message.bot.get_me() - return f"https://t.me/{bot.username}?start={start_parameter}" + return f'https://t.me/{bot.username}?start={start_parameter}' def _build_campaign_servers_keyboard( servers, - selected_uuids: List[str], + selected_uuids: list[str], *, - toggle_prefix: str = "campaign_toggle_server_", - save_callback: str = "campaign_servers_save", - back_callback: str = "admin_campaigns", + toggle_prefix: str = 'campaign_toggle_server_', + save_callback: str = 'campaign_servers_save', + back_callback: str = 'admin_campaigns', ) -> types.InlineKeyboardMarkup: - keyboard: List[List[types.InlineKeyboardButton]] = [] + keyboard: list[list[types.InlineKeyboardButton]] = [] for server in servers[:20]: is_selected = server.squad_uuid in selected_uuids - emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒") - text = f"{emoji} {server.display_name}" - keyboard.append( - [ - types.InlineKeyboardButton( - text=text, callback_data=f"{toggle_prefix}{server.id}" - ) - ] - ) + emoji = '✅' if is_selected else ('⚪' if server.is_available else '🔒') + text = f'{emoji} {server.display_name}' + keyboard.append([types.InlineKeyboardButton(text=text, callback_data=f'{toggle_prefix}{server.id}')]) keyboard.append( [ - types.InlineKeyboardButton( - text="✅ Сохранить", callback_data=save_callback - ), - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data=back_callback - ), + types.InlineKeyboardButton(text='✅ Сохранить', callback_data=save_callback), + types.InlineKeyboardButton(text='⬅️ Назад', callback_data=back_callback), ] ) @@ -143,11 +119,7 @@ async def _render_campaign_edit_menu( use_caption: bool = False, ): texts = get_texts(language) - text = ( - "✏️ Редактирование кампании\n\n" - f"{_format_campaign_summary(campaign, texts)}\n" - "Выберите, что изменить:" - ) + text = f'✏️ Редактирование кампании\n\n{_format_campaign_summary(campaign, texts)}\nВыберите, что изменить:' edit_kwargs = dict( chat_id=chat_id, @@ -157,7 +129,7 @@ async def _render_campaign_edit_menu( bonus_type=campaign.bonus_type, language=language, ), - parse_mode="HTML", + parse_mode='HTML', ) if use_caption: @@ -183,12 +155,12 @@ async def show_campaigns_menu( overview = await get_campaigns_overview(db) text = ( - "📣 Рекламные кампании\n\n" - f"Всего кампаний: {overview['total']}\n" - f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n" - f"Регистраций: {overview['registrations']}\n" - f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n" - f"Выдано подписок: {overview['subscription_total']}" + '📣 Рекламные кампании\n\n' + f'Всего кампаний: {overview["total"]}\n' + f'Активных: {overview["active"]} | Выключены: {overview["inactive"]}\n' + f'Регистраций: {overview["registrations"]}\n' + f'Выдано баланса: {texts.format_price(overview["balance_total"])}\n' + f'Выдано подписок: {overview["subscription_total"]}' ) await callback.message.edit_text( @@ -208,27 +180,17 @@ async def show_campaigns_overall_stats( texts = get_texts(db_user.language) overview = await get_campaigns_overview(db) - text = ["📊 Общая статистика кампаний\n"] - text.append(f"Всего кампаний: {overview['total']}") - text.append( - f"Активны: {overview['active']}, выключены: {overview['inactive']}" - ) - text.append(f"Всего регистраций: {overview['registrations']}") - text.append( - f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}" - ) - text.append(f"Выдано подписок: {overview['subscription_total']}") + text = ['📊 Общая статистика кампаний\n'] + text.append(f'Всего кампаний: {overview["total"]}') + text.append(f'Активны: {overview["active"]}, выключены: {overview["inactive"]}') + text.append(f'Всего регистраций: {overview["registrations"]}') + text.append(f'Суммарно выдано баланса: {texts.format_price(overview["balance_total"])}') + text.append(f'Выдано подписок: {overview["subscription_total"]}') await callback.message.edit_text( - "\n".join(text), + '\n'.join(text), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), ) await callback.answer() @@ -244,9 +206,9 @@ async def show_campaigns_list( texts = get_texts(db_user.language) page = 1 - if callback.data.startswith("admin_campaigns_list_page_"): + if callback.data.startswith('admin_campaigns_list_page_'): try: - page = int(callback.data.split("_")[-1]) + page = int(callback.data.split('_')[-1]) except ValueError: page = 1 @@ -261,48 +223,38 @@ async def show_campaigns_list( if not campaigns: await callback.message.edit_text( - "❌ Рекламные кампании не найдены.", + '❌ Рекламные кампании не найдены.', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="➕ Создать", callback_data="admin_campaigns_create" - ) - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ], + [types.InlineKeyboardButton(text='➕ Создать', callback_data='admin_campaigns_create')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')], ] ), ) await callback.answer() return - text_lines = ["📋 Список кампаний\n"] + text_lines = ['📋 Список кампаний\n'] for campaign in campaigns: registrations = len(campaign.registrations or []) - total_balance = sum( - r.balance_bonus_kopeks or 0 for r in campaign.registrations or [] - ) - status = "🟢" if campaign.is_active else "⚪" + total_balance = sum(r.balance_bonus_kopeks or 0 for r in campaign.registrations or []) + status = '🟢' if campaign.is_active else '⚪' line = ( - f"{status} {campaign.name}{campaign.start_parameter}\n" - f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}" + f'{status} {campaign.name}{campaign.start_parameter}\n' + f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}' ) if campaign.is_subscription_bonus: - line += f", подписка: {campaign.subscription_duration_days or 0} д." + line += f', подписка: {campaign.subscription_duration_days or 0} д.' else: - line += ", бонус: баланс" + line += ', бонус: баланс' text_lines.append(line) keyboard_rows = [ [ types.InlineKeyboardButton( - text=f"🔍 {campaign.name}", - callback_data=f"admin_campaign_manage_{campaign.id}", + text=f'🔍 {campaign.name}', + callback_data=f'admin_campaign_manage_{campaign.id}', ) ] for campaign in campaigns @@ -311,15 +263,15 @@ async def show_campaigns_list( pagination = get_admin_pagination_keyboard( current_page=page, total_pages=total_pages, - callback_prefix="admin_campaigns_list", - back_callback="admin_campaigns", + callback_prefix='admin_campaigns_list', + back_callback='admin_campaigns', language=db_user.language, ) keyboard_rows.extend(pagination.inline_keyboard) await callback.message.edit_text( - "\n".join(text_lines), + '\n'.join(text_lines), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) await callback.answer() @@ -332,65 +284,41 @@ async def show_campaign_detail( db_user: User, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return texts = get_texts(db_user.language) stats = await get_campaign_statistics(db, campaign_id) deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) - text = ["📣 Управление кампанией\n"] + text = ['📣 Управление кампанией\n'] text.append(_format_campaign_summary(campaign, texts)) - text.append(f"🔗 Ссылка: {deep_link}") - text.append("\n📊 Статистика") - text.append(f"• Регистраций: {stats['registrations']}") + text.append(f'🔗 Ссылка: {deep_link}') + text.append('\n📊 Статистика') + text.append(f'• Регистраций: {stats["registrations"]}') + text.append(f'• Выдано баланса: {texts.format_price(stats["balance_issued"])}') + text.append(f'• Выдано подписок: {stats["subscription_issued"]}') + text.append(f'• Доход: {texts.format_price(stats["total_revenue_kopeks"])}') + text.append(f'• Получили триал: {stats["trial_users_count"]} (активно: {stats["active_trials_count"]})') text.append( - f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}" + '• Конверсий в оплату: ' + f'{stats["conversion_count"]}' + f' / пользователей с оплатой: {stats["paid_users_count"]}' ) - text.append(f"• Выдано подписок: {stats['subscription_issued']}") - text.append( - f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}" - ) - text.append( - "• Получили триал: " - f"{stats['trial_users_count']}" - f" (активно: {stats['active_trials_count']})" - ) - text.append( - "• Конверсий в оплату: " - f"{stats['conversion_count']}" - f" / пользователей с оплатой: {stats['paid_users_count']}" - ) - text.append( - "• Конверсия в оплату: " - f"{stats['conversion_rate']:.1f}%" - ) - text.append( - "• Конверсия триала: " - f"{stats['trial_conversion_rate']:.1f}%" - ) - text.append( - "• Средний доход на пользователя: " - f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}" - ) - text.append( - "• Средний первый платеж: " - f"{texts.format_price(stats['avg_first_payment_kopeks'])}" - ) - if stats["last_registration"]: - text.append( - f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" - ) + text.append(f'• Конверсия в оплату: {stats["conversion_rate"]:.1f}%') + text.append(f'• Конверсия триала: {stats["trial_conversion_rate"]:.1f}%') + text.append(f'• Средний доход на пользователя: {texts.format_price(stats["avg_revenue_per_user_kopeks"])}') + text.append(f'• Средний первый платеж: {texts.format_price(stats["avg_first_payment_kopeks"])}') + if stats['last_registration']: + text.append(f'• Последняя: {stats["last_registration"].strftime("%d.%m.%Y %H:%M")}') await callback.message.edit_text( - "\n".join(text), - reply_markup=get_campaign_management_keyboard( - campaign.id, campaign.is_active, db_user.language - ), + '\n'.join(text), + reply_markup=get_campaign_management_keyboard(campaign.id, campaign.is_active, db_user.language), ) await callback.answer() @@ -403,12 +331,12 @@ async def show_campaign_edit_menu( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await state.clear() - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return await state.clear() @@ -434,10 +362,10 @@ async def start_edit_campaign_name( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return await state.clear() @@ -451,16 +379,16 @@ async def start_edit_campaign_name( await callback.message.edit_text( ( - "✏️ Изменение названия кампании\n\n" - f"Текущее название: {campaign.name}\n" - "Введите новое название (3-100 символов):" + '✏️ Изменение названия кампании\n\n' + f'Текущее название: {campaign.name}\n' + 'Введите новое название (3-100 символов):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -478,32 +406,30 @@ async def process_edit_campaign_name( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return new_name = message.text.strip() if len(new_name) < 3 or len(new_name) > 100: - await message.answer( - "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." - ) + await message.answer('❌ Название должно содержать от 3 до 100 символов. Попробуйте снова.') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return await update_campaign(db, campaign, name=new_name) await state.clear() - await message.answer("✅ Название обновлено.") + await message.answer('✅ Название обновлено.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -523,10 +449,10 @@ async def start_edit_campaign_start_parameter( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return await state.clear() @@ -540,16 +466,16 @@ async def start_edit_campaign_start_parameter( await callback.message.edit_text( ( - "🔗 Изменение стартового параметра\n\n" - f"Текущий параметр: {campaign.start_parameter}\n" - "Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):" + '🔗 Изменение стартового параметра\n\n' + f'Текущий параметр: {campaign.start_parameter}\n' + 'Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -567,37 +493,35 @@ async def process_edit_campaign_start_parameter( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return new_param = message.text.strip() if not _CAMPAIGN_PARAM_REGEX.match(new_param): - await message.answer( - "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." - ) + await message.answer('❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа.') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return existing = await get_campaign_by_start_parameter(db, new_param) if existing and existing.id != campaign_id: - await message.answer("❌ Такой параметр уже используется. Введите другой вариант.") + await message.answer('❌ Такой параметр уже используется. Введите другой вариант.') return await update_campaign(db, campaign, start_parameter=new_param) await state.clear() - await message.answer("✅ Стартовый параметр обновлен.") + await message.answer('✅ Стартовый параметр обновлен.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -617,14 +541,14 @@ async def start_edit_campaign_balance_bonus( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not campaign.is_balance_bonus: - await callback.answer("❌ У кампании другой тип бонуса", show_alert=True) + await callback.answer('❌ У кампании другой тип бонуса', show_alert=True) return await state.clear() @@ -638,16 +562,16 @@ async def start_edit_campaign_balance_bonus( await callback.message.edit_text( ( - "💰 Изменение бонуса на баланс\n\n" - f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n" - "Введите новую сумму в рублях (например, 100 или 99.5):" + '💰 Изменение бонуса на баланс\n\n' + f'Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n' + 'Введите новую сумму в рублях (например, 100 или 99.5):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -665,42 +589,42 @@ async def process_edit_campaign_balance_bonus( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return try: - amount_rubles = float(message.text.replace(",", ".")) + amount_rubles = float(message.text.replace(',', '.')) except ValueError: - await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + await message.answer('❌ Введите корректную сумму (например, 100 или 99.5)') return if amount_rubles <= 0: - await message.answer("❌ Сумма должна быть больше нуля") + await message.answer('❌ Сумма должна быть больше нуля') return amount_kopeks = int(round(amount_rubles * 100)) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return if not campaign.is_balance_bonus: - await message.answer("❌ У кампании другой тип бонуса") + await message.answer('❌ У кампании другой тип бонуса') await state.clear() return await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks) await state.clear() - await message.answer("✅ Бонус обновлен.") + await message.answer('✅ Бонус обновлен.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -716,13 +640,11 @@ async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool: if campaign.is_balance_bonus: if isinstance(message_or_callback, types.CallbackQuery): await message_or_callback.answer( - "❌ Для этой кампании доступен только бонус на баланс", + '❌ Для этой кампании доступен только бонус на баланс', show_alert=True, ) else: - await message_or_callback.answer( - "❌ Для этой кампании нельзя изменить параметры подписки" - ) + await message_or_callback.answer('❌ Для этой кампании нельзя изменить параметры подписки') return False return True @@ -735,10 +657,10 @@ async def start_edit_campaign_subscription_days( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): @@ -755,16 +677,16 @@ async def start_edit_campaign_subscription_days( await callback.message.edit_text( ( - "📅 Изменение длительности подписки\n\n" - f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n" - "Введите новое количество дней (1-730):" + '📅 Изменение длительности подписки\n\n' + f'Текущее значение: {campaign.subscription_duration_days or 0} д.\n' + 'Введите новое количество дней (1-730):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -782,25 +704,25 @@ async def process_edit_campaign_subscription_days( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return try: days = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите число дней (1-730)") + await message.answer('❌ Введите число дней (1-730)') return if days <= 0 or days > 730: - await message.answer("❌ Длительность должна быть от 1 до 730 дней") + await message.answer('❌ Длительность должна быть от 1 до 730 дней') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return @@ -811,10 +733,10 @@ async def process_edit_campaign_subscription_days( await update_campaign(db, campaign, subscription_duration_days=days) await state.clear() - await message.answer("✅ Длительность подписки обновлена.") + await message.answer('✅ Длительность подписки обновлена.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -834,10 +756,10 @@ async def start_edit_campaign_subscription_traffic( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): @@ -853,20 +775,20 @@ async def start_edit_campaign_subscription_traffic( ) current_traffic = campaign.subscription_traffic_gb or 0 - traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ" + traffic_text = 'безлимит' if current_traffic == 0 else f'{current_traffic} ГБ' await callback.message.edit_text( ( - "🌐 Изменение лимита трафика\n\n" - f"Текущее значение: {traffic_text}\n" - "Введите новый лимит в ГБ (0 = безлимит, максимум 10000):" + '🌐 Изменение лимита трафика\n\n' + f'Текущее значение: {traffic_text}\n' + 'Введите новый лимит в ГБ (0 = безлимит, максимум 10000):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -884,25 +806,25 @@ async def process_edit_campaign_subscription_traffic( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return try: traffic = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите целое число (0 или больше)") + await message.answer('❌ Введите целое число (0 или больше)') return if traffic < 0 or traffic > 10000: - await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + await message.answer('❌ Лимит трафика должен быть от 0 до 10000 ГБ') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return @@ -913,10 +835,10 @@ async def process_edit_campaign_subscription_traffic( await update_campaign(db, campaign, subscription_traffic_gb=traffic) await state.clear() - await message.answer("✅ Лимит трафика обновлен.") + await message.answer('✅ Лимит трафика обновлен.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -936,10 +858,10 @@ async def start_edit_campaign_subscription_devices( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): @@ -960,16 +882,16 @@ async def start_edit_campaign_subscription_devices( await callback.message.edit_text( ( - "📱 Изменение лимита устройств\n\n" - f"Текущее значение: {current_devices}\n" - f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):" + '📱 Изменение лимита устройств\n\n' + f'Текущее значение: {current_devices}\n' + f'Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):' ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -987,27 +909,25 @@ async def process_edit_campaign_subscription_devices( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return try: devices = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите целое число устройств") + await message.answer('❌ Введите целое число устройств') return if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: - await message.answer( - f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" - ) + await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return @@ -1018,10 +938,10 @@ async def process_edit_campaign_subscription_devices( await update_campaign(db, campaign, subscription_device_limit=devices) await state.clear() - await message.answer("✅ Лимит устройств обновлен.") + await message.answer('✅ Лимит устройств обновлен.') - edit_message_id = data.get("campaign_edit_message_id") - edit_message_is_caption = data.get("campaign_edit_message_is_caption", False) + edit_message_id = data.get('campaign_edit_message_id') + edit_message_is_caption = data.get('campaign_edit_message_is_caption', False) if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -1041,10 +961,10 @@ async def start_edit_campaign_subscription_servers( state: FSMContext, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): @@ -1053,7 +973,7 @@ async def start_edit_campaign_subscription_servers( servers, _ = await get_all_server_squads(db, available_only=False) if not servers: await callback.answer( - "❌ Не найдены доступные серверы. Добавьте серверы перед изменением.", + '❌ Не найдены доступные серверы. Добавьте серверы перед изменением.', show_alert=True, ) return @@ -1073,16 +993,16 @@ async def start_edit_campaign_subscription_servers( keyboard = _build_campaign_servers_keyboard( servers, selected, - toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", - save_callback=f"campaign_edit_servers_save_{campaign_id}", - back_callback=f"admin_campaign_edit_{campaign_id}", + toggle_prefix=f'campaign_edit_toggle_{campaign_id}_', + save_callback=f'campaign_edit_servers_save_{campaign_id}', + back_callback=f'admin_campaign_edit_{campaign_id}', ) await callback.message.edit_text( ( - "🌍 Редактирование доступных серверов\n\n" - "Нажмите на сервер, чтобы добавить или убрать его из кампании.\n" - "После выбора нажмите \"✅ Сохранить\"." + '🌍 Редактирование доступных серверов\n\n' + 'Нажмите на сервер, чтобы добавить или убрать его из кампании.\n' + 'После выбора нажмите "✅ Сохранить".' ), reply_markup=keyboard, ) @@ -1097,26 +1017,26 @@ async def toggle_edit_campaign_server( state: FSMContext, db: AsyncSession, ): - parts = callback.data.split("_") + parts = callback.data.split('_') try: server_id = int(parts[-1]) except (ValueError, IndexError): - await callback.answer("❌ Не удалось определить сервер", show_alert=True) + await callback.answer('❌ Не удалось определить сервер', show_alert=True) return data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await callback.answer('❌ Сессия редактирования устарела', show_alert=True) await state.clear() return server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден", show_alert=True) + await callback.answer('❌ Сервер не найден', show_alert=True) return - selected = list(data.get("campaign_subscription_squads", [])) + selected = list(data.get('campaign_subscription_squads', [])) if server.squad_uuid in selected: selected.remove(server.squad_uuid) @@ -1129,9 +1049,9 @@ async def toggle_edit_campaign_server( keyboard = _build_campaign_servers_keyboard( servers, selected, - toggle_prefix=f"campaign_edit_toggle_{campaign_id}_", - save_callback=f"campaign_edit_servers_save_{campaign_id}", - back_callback=f"admin_campaign_edit_{campaign_id}", + toggle_prefix=f'campaign_edit_toggle_{campaign_id}_', + save_callback=f'campaign_edit_servers_save_{campaign_id}', + back_callback=f'admin_campaign_edit_{campaign_id}', ) await callback.message.edit_reply_markup(reply_markup=keyboard) @@ -1147,21 +1067,21 @@ async def save_edit_campaign_subscription_servers( db: AsyncSession, ): data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await callback.answer("❌ Сессия редактирования устарела", show_alert=True) + await callback.answer('❌ Сессия редактирования устарела', show_alert=True) await state.clear() return - selected = list(data.get("campaign_subscription_squads", [])) + selected = list(data.get('campaign_subscription_squads', [])) if not selected: - await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + await callback.answer('❗ Выберите хотя бы один сервер', show_alert=True) return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: await state.clear() - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not await _ensure_subscription_campaign(callback, campaign): @@ -1181,7 +1101,7 @@ async def save_edit_campaign_subscription_servers( db_user.language, use_caption=use_caption, ) - await callback.answer("✅ Сохранено") + await callback.answer('✅ Сохранено') @admin_required @@ -1191,16 +1111,16 @@ async def toggle_campaign_status( db_user: User, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return new_status = not campaign.is_active await update_campaign(db, campaign, is_active=new_status) - status_text = "включена" if new_status else "выключена" - logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text) + status_text = 'включена' if new_status else 'выключена' + logger.info('🔄 Кампания %s переключена: %s', campaign_id, status_text) await show_campaign_detail(callback, db_user, db) @@ -1212,33 +1132,31 @@ async def show_campaign_stats( db_user: User, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return texts = get_texts(db_user.language) stats = await get_campaign_statistics(db, campaign_id) - text = ["📊 Статистика кампании\n"] + text = ['📊 Статистика кампании\n'] text.append(_format_campaign_summary(campaign, texts)) - text.append(f"Регистраций: {stats['registrations']}") - text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}") - text.append(f"Выдано подписок: {stats['subscription_issued']}") - if stats["last_registration"]: - text.append( - f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}" - ) + text.append(f'Регистраций: {stats["registrations"]}') + text.append(f'Выдано баланса: {texts.format_price(stats["balance_issued"])}') + text.append(f'Выдано подписок: {stats["subscription_issued"]}') + if stats['last_registration']: + text.append(f'Последняя регистрация: {stats["last_registration"].strftime("%d.%m.%Y %H:%M")}') await callback.message.edit_text( - "\n".join(text), + '\n'.join(text), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"admin_campaign_manage_{campaign_id}", + text='⬅️ Назад', + callback_data=f'admin_campaign_manage_{campaign_id}', ) ] ] @@ -1254,24 +1172,24 @@ async def confirm_delete_campaign( db_user: User, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return text = ( - "🗑️ Удаление кампании\n\n" - f"Название: {campaign.name}\n" - f"Параметр: {campaign.start_parameter}\n\n" - "Вы уверены, что хотите удалить кампанию?" + '🗑️ Удаление кампании\n\n' + f'Название: {campaign.name}\n' + f'Параметр: {campaign.start_parameter}\n\n' + 'Вы уверены, что хотите удалить кампанию?' ) await callback.message.edit_text( text, reply_markup=get_confirmation_keyboard( - confirm_action=f"admin_campaign_delete_confirm_{campaign_id}", - cancel_action=f"admin_campaign_manage_{campaign_id}", + confirm_action=f'admin_campaign_delete_confirm_{campaign_id}', + cancel_action=f'admin_campaign_manage_{campaign_id}', ), ) await callback.answer() @@ -1284,18 +1202,18 @@ async def delete_campaign_confirmed( db_user: User, db: AsyncSession, ): - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return await delete_campaign(db, campaign) await callback.message.edit_text( - "✅ Кампания удалена.", + '✅ Кампания удалена.', reply_markup=get_admin_campaigns_keyboard(db_user.language), ) - await callback.answer("Удалено") + await callback.answer('Удалено') @admin_required @@ -1308,15 +1226,9 @@ async def start_campaign_creation( ): await state.clear() await callback.message.edit_text( - "🆕 Создание рекламной кампании\n\nВведите название кампании:", + '🆕 Создание рекламной кампании\n\nВведите название кампании:', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), ) await state.set_state(AdminStates.creating_campaign_name) @@ -1333,15 +1245,13 @@ async def process_campaign_name( ): name = message.text.strip() if len(name) < 3 or len(name) > 100: - await message.answer( - "❌ Название должно содержать от 3 до 100 символов. Попробуйте снова." - ) + await message.answer('❌ Название должно содержать от 3 до 100 символов. Попробуйте снова.') return await state.update_data(campaign_name=name) await state.set_state(AdminStates.creating_campaign_start) await message.answer( - "🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):", + '🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):', ) @@ -1355,22 +1265,18 @@ async def process_campaign_start_parameter( ): start_param = message.text.strip() if not _CAMPAIGN_PARAM_REGEX.match(start_param): - await message.answer( - "❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа." - ) + await message.answer('❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа.') return existing = await get_campaign_by_start_parameter(db, start_param) if existing: - await message.answer( - "❌ Кампания с таким параметром уже существует. Введите другой параметр." - ) + await message.answer('❌ Кампания с таким параметром уже существует. Введите другой параметр.') return await state.update_data(campaign_start_parameter=start_param) await state.set_state(AdminStates.creating_campaign_bonus) await message.answer( - "🎯 Выберите тип бонуса для кампании:", + '🎯 Выберите тип бонуса для кампании:', reply_markup=get_campaign_bonus_type_keyboard(db_user.language), ) @@ -1384,84 +1290,70 @@ async def select_campaign_bonus_type( db: AsyncSession, ): # Определяем тип бонуса из callback_data - if callback.data.endswith("balance"): - bonus_type = "balance" - elif callback.data.endswith("subscription"): - bonus_type = "subscription" - elif callback.data.endswith("tariff"): - bonus_type = "tariff" - elif callback.data.endswith("none"): - bonus_type = "none" + if callback.data.endswith('balance'): + bonus_type = 'balance' + elif callback.data.endswith('subscription'): + bonus_type = 'subscription' + elif callback.data.endswith('tariff'): + bonus_type = 'tariff' + elif callback.data.endswith('none'): + bonus_type = 'none' else: - bonus_type = "balance" + bonus_type = 'balance' await state.update_data(campaign_bonus_type=bonus_type) - if bonus_type == "balance": + if bonus_type == 'balance': await state.set_state(AdminStates.creating_campaign_balance) await callback.message.edit_text( - "💰 Введите сумму бонуса на баланс (в рублях):", + '💰 Введите сумму бонуса на баланс (в рублях):', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), ) - elif bonus_type == "subscription": + elif bonus_type == 'subscription': await state.set_state(AdminStates.creating_campaign_subscription_days) await callback.message.edit_text( - "📅 Введите длительность пробной подписки в днях (1-730):", + '📅 Введите длительность пробной подписки в днях (1-730):', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), ) - elif bonus_type == "tariff": + elif bonus_type == 'tariff': # Показываем выбор тарифа tariffs = await get_all_tariffs(db, include_inactive=False) if not tariffs: await callback.answer( - "❌ Нет доступных тарифов. Сначала создайте тариф.", + '❌ Нет доступных тарифов. Сначала создайте тариф.', show_alert=True, ) return keyboard = [] for tariff in tariffs[:15]: # Максимум 15 тарифов - keyboard.append([ - types.InlineKeyboardButton( - text=f"🎁 {tariff.name}", - callback_data=f"campaign_select_tariff_{tariff.id}", - ) - ]) - keyboard.append([ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'🎁 {tariff.name}', + callback_data=f'campaign_select_tariff_{tariff.id}', + ) + ] ) - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]) await state.set_state(AdminStates.creating_campaign_tariff_select) await callback.message.edit_text( - "🎁 Выберите тариф для выдачи:", + '🎁 Выберите тариф для выдачи:', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) - elif bonus_type == "none": + elif bonus_type == 'none': # Сразу создаём кампанию без бонуса data = await state.get_data() campaign = await create_campaign( db, - name=data["campaign_name"], - start_parameter=data["campaign_start_parameter"], - bonus_type="none", + name=data['campaign_name'], + start_parameter=data['campaign_start_parameter'], + bonus_type='none', created_by=db_user.id, ) await state.clear() @@ -1469,17 +1361,11 @@ async def select_campaign_bonus_type( deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) - text = ( - "✅ Кампания создана!\n\n" - f"{summary}\n" - f"🔗 Ссылка: {deep_link}" - ) + text = f'✅ Кампания создана!\n\n{summary}\n🔗 Ссылка: {deep_link}' await callback.message.edit_text( text, - reply_markup=get_campaign_management_keyboard( - campaign.id, campaign.is_active, db_user.language - ), + reply_markup=get_campaign_management_keyboard(campaign.id, campaign.is_active, db_user.language), ) await callback.answer() @@ -1494,13 +1380,13 @@ async def process_campaign_balance_value( db: AsyncSession, ): try: - amount_rubles = float(message.text.replace(",", ".")) + amount_rubles = float(message.text.replace(',', '.')) except ValueError: - await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)") + await message.answer('❌ Введите корректную сумму (например, 100 или 99.5)') return if amount_rubles <= 0: - await message.answer("❌ Сумма должна быть больше нуля") + await message.answer('❌ Сумма должна быть больше нуля') return amount_kopeks = int(round(amount_rubles * 100)) @@ -1508,9 +1394,9 @@ async def process_campaign_balance_value( campaign = await create_campaign( db, - name=data["campaign_name"], - start_parameter=data["campaign_start_parameter"], - bonus_type="balance", + name=data['campaign_name'], + start_parameter=data['campaign_start_parameter'], + bonus_type='balance', balance_bonus_kopeks=amount_kopeks, created_by=db_user.id, ) @@ -1520,17 +1406,11 @@ async def process_campaign_balance_value( deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) - text = ( - "✅ Кампания создана!\n\n" - f"{summary}\n" - f"🔗 Ссылка: {deep_link}" - ) + text = f'✅ Кампания создана!\n\n{summary}\n🔗 Ссылка: {deep_link}' await message.answer( text, - reply_markup=get_campaign_management_keyboard( - campaign.id, campaign.is_active, db_user.language - ), + reply_markup=get_campaign_management_keyboard(campaign.id, campaign.is_active, db_user.language), ) @@ -1545,16 +1425,16 @@ async def process_campaign_subscription_days( try: days = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите число дней (1-730)") + await message.answer('❌ Введите число дней (1-730)') return if days <= 0 or days > 730: - await message.answer("❌ Длительность должна быть от 1 до 730 дней") + await message.answer('❌ Длительность должна быть от 1 до 730 дней') return await state.update_data(campaign_subscription_days=days) await state.set_state(AdminStates.creating_campaign_subscription_traffic) - await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):") + await message.answer('🌐 Введите лимит трафика в ГБ (0 = безлимит):') @admin_required @@ -1568,18 +1448,16 @@ async def process_campaign_subscription_traffic( try: traffic = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите целое число (0 или больше)") + await message.answer('❌ Введите целое число (0 или больше)') return if traffic < 0 or traffic > 10000: - await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ") + await message.answer('❌ Лимит трафика должен быть от 0 до 10000 ГБ') return await state.update_data(campaign_subscription_traffic=traffic) await state.set_state(AdminStates.creating_campaign_subscription_devices) - await message.answer( - f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):" - ) + await message.answer(f'📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):') @admin_required @@ -1593,13 +1471,11 @@ async def process_campaign_subscription_devices( try: devices = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите целое число устройств") + await message.answer('❌ Введите целое число устройств') return if devices < 1 or devices > settings.MAX_DEVICES_LIMIT: - await message.answer( - f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}" - ) + await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}') return await state.update_data(campaign_subscription_devices=devices) @@ -1609,14 +1485,14 @@ async def process_campaign_subscription_devices( servers, _ = await get_all_server_squads(db, available_only=False) if not servers: await message.answer( - "❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.", + '❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.', ) await state.clear() return keyboard = _build_campaign_servers_keyboard(servers, []) await message.answer( - "🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).", + '🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).', reply_markup=keyboard, ) @@ -1629,14 +1505,14 @@ async def toggle_campaign_server( state: FSMContext, db: AsyncSession, ): - server_id = int(callback.data.split("_")[-1]) + server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден", show_alert=True) + await callback.answer('❌ Сервер не найден', show_alert=True) return data = await state.get_data() - selected = list(data.get("campaign_subscription_squads", [])) + selected = list(data.get('campaign_subscription_squads', [])) if server.squad_uuid in selected: selected.remove(server.squad_uuid) @@ -1661,20 +1537,20 @@ async def finalize_campaign_subscription( db: AsyncSession, ): data = await state.get_data() - selected = data.get("campaign_subscription_squads", []) + selected = data.get('campaign_subscription_squads', []) if not selected: - await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True) + await callback.answer('❗ Выберите хотя бы один сервер', show_alert=True) return campaign = await create_campaign( db, - name=data["campaign_name"], - start_parameter=data["campaign_start_parameter"], - bonus_type="subscription", - subscription_duration_days=data.get("campaign_subscription_days"), - subscription_traffic_gb=data.get("campaign_subscription_traffic"), - subscription_device_limit=data.get("campaign_subscription_devices"), + name=data['campaign_name'], + start_parameter=data['campaign_start_parameter'], + bonus_type='subscription', + subscription_duration_days=data.get('campaign_subscription_days'), + subscription_traffic_gb=data.get('campaign_subscription_traffic'), + subscription_device_limit=data.get('campaign_subscription_devices'), subscription_squads=selected, created_by=db_user.id, ) @@ -1684,17 +1560,11 @@ async def finalize_campaign_subscription( deep_link = await _get_bot_deep_link(callback, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) - text = ( - "✅ Кампания создана!\n\n" - f"{summary}\n" - f"🔗 Ссылка: {deep_link}" - ) + text = f'✅ Кампания создана!\n\n{summary}\n🔗 Ссылка: {deep_link}' await callback.message.edit_text( text, - reply_markup=get_campaign_management_keyboard( - campaign.id, campaign.is_active, db_user.language - ), + reply_markup=get_campaign_management_keyboard(campaign.id, campaign.is_active, db_user.language), ) await callback.answer() @@ -1708,26 +1578,19 @@ async def select_campaign_tariff( db: AsyncSession, ): """Обработка выбора тарифа для кампании.""" - tariff_id = int(callback.data.split("_")[-1]) + tariff_id = int(callback.data.split('_')[-1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("❌ Тариф не найден", show_alert=True) + await callback.answer('❌ Тариф не найден', show_alert=True) return await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name) await state.set_state(AdminStates.creating_campaign_tariff_days) await callback.message.edit_text( - f"🎁 Выбран тариф: {tariff.name}\n\n" - "📅 Введите длительность тарифа в днях (1-730):", + f'🎁 Выбран тариф: {tariff.name}\n\n📅 Введите длительность тарифа в днях (1-730):', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data="admin_campaigns" - ) - ] - ] + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), ) await callback.answer() @@ -1745,26 +1608,26 @@ async def process_campaign_tariff_days( try: days = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите число дней (1-730)") + await message.answer('❌ Введите число дней (1-730)') return if days <= 0 or days > 730: - await message.answer("❌ Длительность должна быть от 1 до 730 дней") + await message.answer('❌ Длительность должна быть от 1 до 730 дней') return data = await state.get_data() - tariff_id = data.get("campaign_tariff_id") + tariff_id = data.get('campaign_tariff_id') if not tariff_id: - await message.answer("❌ Тариф не выбран. Начните создание кампании заново.") + await message.answer('❌ Тариф не выбран. Начните создание кампании заново.') await state.clear() return campaign = await create_campaign( db, - name=data["campaign_name"], - start_parameter=data["campaign_start_parameter"], - bonus_type="tariff", + name=data['campaign_name'], + start_parameter=data['campaign_start_parameter'], + bonus_type='tariff', tariff_id=tariff_id, tariff_duration_days=days, created_by=db_user.id, @@ -1778,17 +1641,11 @@ async def process_campaign_tariff_days( deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter) texts = get_texts(db_user.language) summary = _format_campaign_summary(campaign, texts) - text = ( - "✅ Кампания создана!\n\n" - f"{summary}\n" - f"🔗 Ссылка: {deep_link}" - ) + text = f'✅ Кампания создана!\n\n{summary}\n🔗 Ссылка: {deep_link}' await message.answer( text, - reply_markup=get_campaign_management_keyboard( - campaign.id, campaign.is_active, db_user.language - ), + reply_markup=get_campaign_management_keyboard(campaign.id, campaign.is_active, db_user.language), ) @@ -1801,10 +1658,10 @@ async def start_edit_campaign_tariff( db: AsyncSession, ): """Начало редактирования тарифа кампании.""" - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not campaign.is_tariff_bonus: @@ -1813,33 +1670,29 @@ async def start_edit_campaign_tariff( tariffs = await get_all_tariffs(db, include_inactive=False) if not tariffs: - await callback.answer("❌ Нет доступных тарифов", show_alert=True) + await callback.answer('❌ Нет доступных тарифов', show_alert=True) return keyboard = [] for tariff in tariffs[:15]: is_current = campaign.tariff_id == tariff.id - emoji = "✅" if is_current else "🎁" - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {tariff.name}", - callback_data=f"campaign_edit_set_tariff_{campaign_id}_{tariff.id}", - ) - ]) - keyboard.append([ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data=f"admin_campaign_edit_{campaign_id}" + emoji = '✅' if is_current else '🎁' + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {tariff.name}', + callback_data=f'campaign_edit_set_tariff_{campaign_id}_{tariff.id}', + ) + ] ) - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_campaign_edit_{campaign_id}')]) - current_tariff_name = "Не выбран" + current_tariff_name = 'Не выбран' if campaign.tariff: current_tariff_name = campaign.tariff.name await callback.message.edit_text( - f"🎁 Изменение тарифа кампании\n\n" - f"Текущий тариф: {current_tariff_name}\n" - "Выберите новый тариф:", + f'🎁 Изменение тарифа кампании\n\nТекущий тариф: {current_tariff_name}\nВыберите новый тариф:', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() @@ -1854,18 +1707,18 @@ async def set_campaign_tariff( db: AsyncSession, ): """Установка тарифа для кампании.""" - parts = callback.data.split("_") + parts = callback.data.split('_') campaign_id = int(parts[-2]) tariff_id = int(parts[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("❌ Тариф не найден", show_alert=True) + await callback.answer('❌ Тариф не найден', show_alert=True) return await update_campaign(db, campaign, tariff_id=tariff_id) @@ -1889,10 +1742,10 @@ async def start_edit_campaign_tariff_days( db: AsyncSession, ): """Начало редактирования длительности тарифа.""" - campaign_id = int(callback.data.split("_")[-1]) + campaign_id = int(callback.data.split('_')[-1]) campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await callback.answer("❌ Кампания не найдена", show_alert=True) + await callback.answer('❌ Кампания не найдена', show_alert=True) return if not campaign.is_tariff_bonus: @@ -1907,15 +1760,15 @@ async def start_edit_campaign_tariff_days( ) await callback.message.edit_text( - f"📅 Изменение длительности тарифа\n\n" - f"Текущее значение: {campaign.tariff_duration_days or 0} д.\n" - "Введите новое количество дней (1-730):", + f'📅 Изменение длительности тарифа\n\n' + f'Текущее значение: {campaign.tariff_duration_days or 0} д.\n' + 'Введите новое количество дней (1-730):', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_campaign_edit_{campaign_id}", + text='❌ Отмена', + callback_data=f'admin_campaign_edit_{campaign_id}', ) ] ] @@ -1934,34 +1787,34 @@ async def process_edit_campaign_tariff_days( ): """Обработка ввода новой длительности тарифа.""" data = await state.get_data() - campaign_id = data.get("editing_campaign_id") + campaign_id = data.get('editing_campaign_id') if not campaign_id: - await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.") + await message.answer('❌ Сессия редактирования устарела. Попробуйте снова.') await state.clear() return try: days = int(message.text.strip()) except ValueError: - await message.answer("❌ Введите число дней (1-730)") + await message.answer('❌ Введите число дней (1-730)') return if days <= 0 or days > 730: - await message.answer("❌ Длительность должна быть от 1 до 730 дней") + await message.answer('❌ Длительность должна быть от 1 до 730 дней') return campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - await message.answer("❌ Кампания не найдена") + await message.answer('❌ Кампания не найдена') await state.clear() return await update_campaign(db, campaign, tariff_duration_days=days) await state.clear() - await message.answer("✅ Длительность тарифа обновлена.") + await message.answer('✅ Длительность тарифа обновлена.') - edit_message_id = data.get("campaign_edit_message_id") + edit_message_id = data.get('campaign_edit_message_id') if edit_message_id: await _render_campaign_edit_menu( message.bot, @@ -1973,100 +1826,60 @@ async def process_edit_campaign_tariff_days( def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns") - dp.callback_query.register( - show_campaigns_overall_stats, F.data == "admin_campaigns_stats" - ) - dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list") - dp.callback_query.register( - show_campaigns_list, F.data.startswith("admin_campaigns_list_page_") - ) - dp.callback_query.register( - start_campaign_creation, F.data == "admin_campaigns_create" - ) - dp.callback_query.register( - show_campaign_stats, F.data.startswith("admin_campaign_stats_") - ) - dp.callback_query.register( - show_campaign_detail, F.data.startswith("admin_campaign_manage_") - ) - dp.callback_query.register( - start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_") - ) + dp.callback_query.register(show_campaigns_menu, F.data == 'admin_campaigns') + dp.callback_query.register(show_campaigns_overall_stats, F.data == 'admin_campaigns_stats') + dp.callback_query.register(show_campaigns_list, F.data == 'admin_campaigns_list') + dp.callback_query.register(show_campaigns_list, F.data.startswith('admin_campaigns_list_page_')) + dp.callback_query.register(start_campaign_creation, F.data == 'admin_campaigns_create') + dp.callback_query.register(show_campaign_stats, F.data.startswith('admin_campaign_stats_')) + dp.callback_query.register(show_campaign_detail, F.data.startswith('admin_campaign_manage_')) + dp.callback_query.register(start_edit_campaign_name, F.data.startswith('admin_campaign_edit_name_')) dp.callback_query.register( start_edit_campaign_start_parameter, - F.data.startswith("admin_campaign_edit_start_"), + F.data.startswith('admin_campaign_edit_start_'), ) dp.callback_query.register( start_edit_campaign_balance_bonus, - F.data.startswith("admin_campaign_edit_balance_"), + F.data.startswith('admin_campaign_edit_balance_'), ) dp.callback_query.register( start_edit_campaign_subscription_days, - F.data.startswith("admin_campaign_edit_sub_days_"), + F.data.startswith('admin_campaign_edit_sub_days_'), ) dp.callback_query.register( start_edit_campaign_subscription_traffic, - F.data.startswith("admin_campaign_edit_sub_traffic_"), + F.data.startswith('admin_campaign_edit_sub_traffic_'), ) dp.callback_query.register( start_edit_campaign_subscription_devices, - F.data.startswith("admin_campaign_edit_sub_devices_"), + F.data.startswith('admin_campaign_edit_sub_devices_'), ) dp.callback_query.register( start_edit_campaign_subscription_servers, - F.data.startswith("admin_campaign_edit_sub_servers_"), + F.data.startswith('admin_campaign_edit_sub_servers_'), ) dp.callback_query.register( save_edit_campaign_subscription_servers, - F.data.startswith("campaign_edit_servers_save_"), - ) - dp.callback_query.register( - toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_") + F.data.startswith('campaign_edit_servers_save_'), ) + dp.callback_query.register(toggle_edit_campaign_server, F.data.startswith('campaign_edit_toggle_')) # Tariff handlers ДОЛЖНЫ быть ПЕРЕД общим admin_campaign_edit_ - dp.callback_query.register( - start_edit_campaign_tariff_days, F.data.startswith("admin_campaign_edit_tariff_days_") - ) - dp.callback_query.register( - start_edit_campaign_tariff, F.data.startswith("admin_campaign_edit_tariff_") - ) + dp.callback_query.register(start_edit_campaign_tariff_days, F.data.startswith('admin_campaign_edit_tariff_days_')) + dp.callback_query.register(start_edit_campaign_tariff, F.data.startswith('admin_campaign_edit_tariff_')) # Общий паттерн ПОСЛЕДНИМ - dp.callback_query.register( - show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_") - ) - dp.callback_query.register( - delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_") - ) - dp.callback_query.register( - confirm_delete_campaign, F.data.startswith("admin_campaign_delete_") - ) - dp.callback_query.register( - toggle_campaign_status, F.data.startswith("admin_campaign_toggle_") - ) - dp.callback_query.register( - finalize_campaign_subscription, F.data == "campaign_servers_save" - ) - dp.callback_query.register( - toggle_campaign_server, F.data.startswith("campaign_toggle_server_") - ) - dp.callback_query.register( - select_campaign_bonus_type, F.data.startswith("campaign_bonus_") - ) - dp.callback_query.register( - select_campaign_tariff, F.data.startswith("campaign_select_tariff_") - ) - dp.callback_query.register( - set_campaign_tariff, F.data.startswith("campaign_edit_set_tariff_") - ) + dp.callback_query.register(show_campaign_edit_menu, F.data.startswith('admin_campaign_edit_')) + dp.callback_query.register(delete_campaign_confirmed, F.data.startswith('admin_campaign_delete_confirm_')) + dp.callback_query.register(confirm_delete_campaign, F.data.startswith('admin_campaign_delete_')) + dp.callback_query.register(toggle_campaign_status, F.data.startswith('admin_campaign_toggle_')) + dp.callback_query.register(finalize_campaign_subscription, F.data == 'campaign_servers_save') + dp.callback_query.register(toggle_campaign_server, F.data.startswith('campaign_toggle_server_')) + dp.callback_query.register(select_campaign_bonus_type, F.data.startswith('campaign_bonus_')) + dp.callback_query.register(select_campaign_tariff, F.data.startswith('campaign_select_tariff_')) + dp.callback_query.register(set_campaign_tariff, F.data.startswith('campaign_edit_set_tariff_')) dp.message.register(process_campaign_name, AdminStates.creating_campaign_name) - dp.message.register( - process_campaign_start_parameter, AdminStates.creating_campaign_start - ) - dp.message.register( - process_campaign_balance_value, AdminStates.creating_campaign_balance - ) + dp.message.register(process_campaign_start_parameter, AdminStates.creating_campaign_start) + dp.message.register(process_campaign_balance_value, AdminStates.creating_campaign_balance) dp.message.register( process_campaign_subscription_days, AdminStates.creating_campaign_subscription_days, @@ -2079,9 +1892,7 @@ def register_handlers(dp: Dispatcher): process_campaign_subscription_devices, AdminStates.creating_campaign_subscription_devices, ) - dp.message.register( - process_edit_campaign_name, AdminStates.editing_campaign_name - ) + dp.message.register(process_edit_campaign_name, AdminStates.editing_campaign_name) dp.message.register( process_edit_campaign_start_parameter, AdminStates.editing_campaign_start, diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index a135b741..65ff4ebd 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -1,6 +1,6 @@ import logging import math -from datetime import datetime, timezone, time +from datetime import UTC, datetime, time from zoneinfo import ZoneInfo from aiogram import Dispatcher, F, types @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.referral_contest import ( create_referral_contest, + delete_referral_contest, get_contest_events_count, get_contest_leaderboard, get_referral_contest, @@ -17,7 +18,6 @@ from app.database.crud.referral_contest import ( list_referral_contests, toggle_referral_contest, update_referral_contest, - delete_referral_contest, ) from app.keyboards.admin import ( get_admin_contests_keyboard, @@ -30,6 +30,7 @@ from app.localization.texts import get_texts from app.states import AdminStates from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) PAGE_SIZE = 5 @@ -38,47 +39,46 @@ PAGE_SIZE = 5 def _ensure_timezone(tz_name: str) -> ZoneInfo: try: return ZoneInfo(tz_name) - except Exception: # noqa: BLE001 - logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) - return ZoneInfo("UTC") + except Exception: + logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + return ZoneInfo('UTC') def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str: - start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc) - end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc) + start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=UTC) + end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=UTC) start_local = start_local.astimezone(tz) end_local = end_local.astimezone(tz) - status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t( - "ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен" + status = ( + texts.t('ADMIN_CONTEST_STATUS_ACTIVE', '🟢 Активен') + if contest.is_active + else texts.t('ADMIN_CONTEST_STATUS_INACTIVE', '⚪️ Выключен') ) - period = ( - f"{start_local.strftime('%d.%m %H:%M')} — " - f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})" - ) + period = f'{start_local.strftime("%d.%m %H:%M")} — {end_local.strftime("%d.%m %H:%M")} ({tz.key})' - summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00" + summary_time = contest.daily_summary_time.strftime('%H:%M') if contest.daily_summary_time else '12:00' summary_times = contest.daily_summary_times or summary_time parts = [ - f"{status}", - f"Период: {period}", - f"Дневная сводка: {summary_times}", + f'{status}', + f'Период: {period}', + f'Дневная сводка: {summary_times}', ] if contest.prize_text: - parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text)) + parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text)) if contest.last_daily_summary_date: parts.append( - texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format( - date=contest.last_daily_summary_date.strftime("%d.%m") + texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format( + date=contest.last_daily_summary_date.strftime('%d.%m') ) ) - return "\n".join(parts) + return '\n'.join(parts) def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None: try: - dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M") + dt = datetime.strptime(value.strip(), '%d.%m.%Y %H:%M') except ValueError: return None return dt.replace(tzinfo=tz) @@ -86,14 +86,14 @@ def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None: def _parse_time(value: str): try: - return datetime.strptime(value.strip(), "%H:%M").time() + return datetime.strptime(value.strip(), '%H:%M').time() except ValueError: return None def _parse_times(value: str) -> list[time]: times: list[time] = [] - for part in value.split(","): + for part in value.split(','): part = part.strip() if not part: continue @@ -115,8 +115,8 @@ async def show_contests_menu( if not settings.is_contests_enabled(): await callback.message.edit_text( texts.t( - "ADMIN_CONTESTS_DISABLED", - "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.", + 'ADMIN_CONTESTS_DISABLED', + 'Конкурсы отключены через переменную окружения CONTESTS_ENABLED.', ), reply_markup=get_admin_contests_root_keyboard(db_user.language), ) @@ -124,7 +124,7 @@ async def show_contests_menu( return await callback.message.edit_text( - texts.t("ADMIN_CONTESTS_TITLE", "🏆 Конкурсы\n\nВыберите действие:"), + texts.t('ADMIN_CONTESTS_TITLE', '🏆 Конкурсы\n\nВыберите действие:'), reply_markup=get_admin_contests_root_keyboard(db_user.language), ) await callback.answer() @@ -140,7 +140,7 @@ async def show_referral_contests_menu( texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_CONTESTS_TITLE", "🏆 Конкурсы\n\nВыберите действие:"), + texts.t('ADMIN_CONTESTS_TITLE', '🏆 Конкурсы\n\nВыберите действие:'), reply_markup=get_admin_contests_keyboard(db_user.language), ) await callback.answer() @@ -156,17 +156,17 @@ async def list_contests( if not settings.is_contests_enabled(): await callback.answer( get_texts(db_user.language).t( - "ADMIN_CONTESTS_DISABLED", - "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.", + 'ADMIN_CONTESTS_DISABLED', + 'Конкурсы отключены через переменную окружения CONTESTS_ENABLED.', ), show_alert=True, ) return page = 1 - if callback.data.startswith("admin_contests_list_page_"): + if callback.data.startswith('admin_contests_list_page_'): try: - page = int(callback.data.split("_")[-1]) + page = int(callback.data.split('_')[-1]) except Exception: page = 1 @@ -178,25 +178,25 @@ async def list_contests( contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset) texts = get_texts(db_user.language) - lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 Конкурсы\n")] + lines = [texts.t('ADMIN_CONTESTS_LIST_HEADER', '🏆 Конкурсы\n')] if not contests: - lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов.")) + lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.')) else: for contest in contests: - lines.append(f"• {contest.title} (#{contest.id})") + lines.append(f'• {contest.title} (#{contest.id})') contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) lines.append(_format_contest_summary(contest, texts, contest_tz)) - lines.append("") + lines.append('') keyboard_rows: list[list[types.InlineKeyboardButton]] = [] for contest in contests: - title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..." + title = contest.title if len(contest.title) <= 25 else contest.title[:22] + '...' keyboard_rows.append( [ types.InlineKeyboardButton( - text=f"🔎 {title}", - callback_data=f"admin_contest_view_{contest.id}", + text=f'🔎 {title}', + callback_data=f'admin_contest_view_{contest.id}', ) ] ) @@ -204,14 +204,14 @@ async def list_contests( pagination = get_admin_pagination_keyboard( page, total_pages, - "admin_contests_list", - back_callback="admin_contests", + 'admin_contests_list', + back_callback='admin_contests', language=db_user.language, ) keyboard_rows.extend(pagination.inline_keyboard) await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) await callback.answer() @@ -226,17 +226,17 @@ async def show_contest_details( ): if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) texts = get_texts(db_user.language) if not contest: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) @@ -244,30 +244,30 @@ async def show_contest_details( total_events = await get_contest_events_count(db, contest.id) lines = [ - f"🏆 {contest.title}", + f'🏆 {contest.title}', _format_contest_summary(contest, texts, tz), - texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: {count}").format(count=total_events), + texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: {count}').format(count=total_events), ] if contest.description: - lines.append("") + lines.append('') lines.append(contest.description) if leaderboard: - lines.append("") - lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:")) + lines.append('') + lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:')) for idx, (user, score, _) in enumerate(leaderboard, start=1): - lines.append(f"{idx}. {user.full_name} — {score}") + lines.append(f'{idx}. {user.full_name} — {score}') await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=get_referral_contest_manage_keyboard( contest.id, is_active=contest.is_active, can_delete=( not contest.is_active - and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at) - < datetime.now(timezone.utc) + and (contest.end_at.replace(tzinfo=UTC) if contest.end_at.tzinfo is None else contest.end_at) + < datetime.now(UTC) ), language=db_user.language, ), @@ -284,16 +284,16 @@ async def toggle_contest( ): if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer("Конкурс не найден", show_alert=True) + await callback.answer('Конкурс не найден', show_alert=True) return await toggle_referral_contest(db, contest, not contest.is_active) @@ -309,10 +309,10 @@ async def prompt_edit_summary_times( state: FSMContext, ): texts = get_texts(db_user.language) - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return await state.set_state(AdminStates.editing_referral_contest_summary_times) await state.update_data(contest_id=contest_id) @@ -321,15 +321,15 @@ async def prompt_edit_summary_times( [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_contest_view_{contest_id}", + callback_data=f'admin_contest_view_{contest_id}', ) ] ] ) await callback.message.edit_text( texts.t( - "ADMIN_CONTEST_ENTER_DAILY_TIME", - "Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).", + 'ADMIN_CONTEST_ENTER_DAILY_TIME', + 'Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).', ), reply_markup=kb, ) @@ -346,24 +346,24 @@ async def process_edit_summary_times( ): texts = get_texts(db_user.language) data = await state.get_data() - contest_id = data.get("contest_id") + contest_id = data.get('contest_id') if not contest_id: await message.answer(texts.ERROR) await state.clear() return - times = _parse_times(message.text or "") - summary_time = times[0] if times else _parse_time(message.text or "") + times = _parse_times(message.text or '') + summary_time = times[0] if times else _parse_time(message.text or '') if not summary_time: await message.answer( - texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00") + texts.t('ADMIN_CONTEST_INVALID_TIME', 'Не удалось распознать время. Формат: 12:00 или 12:00,18:00') ) await state.clear() return contest = await get_referral_contest(db, int(contest_id)) if not contest: - await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден.")) + await message.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.')) await state.clear() return @@ -371,10 +371,10 @@ async def process_edit_summary_times( db, contest, daily_summary_time=summary_time, - daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None, + daily_summary_times=','.join(t.strftime('%H:%M') for t in times) if times else None, ) - await message.answer(texts.t("ADMIN_UPDATED", "Обновлено")) + await message.answer(texts.t('ADMIN_UPDATED', 'Обновлено')) await state.clear() @@ -386,22 +386,22 @@ async def delete_contest( db: AsyncSession, ): texts = get_texts(db_user.language) - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return now_utc = datetime.utcnow() if contest.is_active or contest.end_at > now_utc: await callback.answer( - texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."), + texts.t('ADMIN_CONTEST_DELETE_RESTRICT', 'Удалять можно только завершённые конкурсы.'), show_alert=True, ) return await delete_referral_contest(db, contest) - await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_DELETED', 'Конкурс удалён.'), show_alert=True) await list_contests(callback, db_user, db) @@ -414,33 +414,33 @@ async def show_leaderboard( ): if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) texts = get_texts(db_user.language) if not contest: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return leaderboard = await get_contest_leaderboard(db, contest_id, limit=10) if not leaderboard: - await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True) return lines = [ - texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"), + texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'), ] for idx, (user, score, _) in enumerate(leaderboard, start=1): - user_id_display = user.telegram_id or user.email or f"#{user.id}" - lines.append(f"{idx}. {user.full_name} ({user_id_display}) — {score}") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + lines.append(f'{idx}. {user.full_name} ({user_id_display}) — {score}') await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=get_referral_contest_manage_keyboard( contest_id, is_active=contest.is_active, language=db_user.language ), @@ -459,7 +459,7 @@ async def start_contest_creation( texts = get_texts(db_user.language) if not settings.is_contests_enabled(): await callback.answer( - texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + texts.t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return @@ -468,8 +468,8 @@ async def start_contest_creation( await state.set_state(AdminStates.creating_referral_contest_mode) await callback.message.edit_text( texts.t( - "ADMIN_CONTEST_MODE_PROMPT", - "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.", + 'ADMIN_CONTEST_MODE_PROMPT', + 'Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.', ), reply_markup=get_contest_mode_keyboard(db_user.language), ) @@ -485,11 +485,11 @@ async def select_contest_mode( state: FSMContext, ): texts = get_texts(db_user.language) - mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered" + mode = 'referral_paid' if callback.data == 'admin_contest_mode_paid' else 'referral_registered' await state.update_data(contest_type=mode) await state.set_state(AdminStates.creating_referral_contest_title) await callback.message.edit_text( - texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"), + texts.t('ADMIN_CONTEST_ENTER_TITLE', 'Введите название конкурса:'), reply_markup=None, ) await callback.answer() @@ -504,7 +504,7 @@ async def process_title(message: types.Message, state: FSMContext, db_user, db: await state.update_data(title=title) await state.set_state(AdminStates.creating_referral_contest_description) await message.answer( - texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):") + texts.t('ADMIN_CONTEST_ENTER_DESCRIPTION', "Опишите конкурс (или отправьте '-' чтобы пропустить):") ) @@ -512,14 +512,14 @@ async def process_title(message: types.Message, state: FSMContext, db_user, db: @error_handler async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession): description = message.text.strip() - if description in {"-", "skip", "пропустить"}: + if description in {'-', 'skip', 'пропустить'}: description = None await state.update_data(description=description) await state.set_state(AdminStates.creating_referral_contest_prize) texts = get_texts(db_user.language) await message.answer( - texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):") + texts.t('ADMIN_CONTEST_ENTER_PRIZE', "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):") ) @@ -527,7 +527,7 @@ async def process_description(message: types.Message, state: FSMContext, db_user @error_handler async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession): prize = message.text.strip() - if prize in {"-", "skip", "пропустить"}: + if prize in {'-', 'skip', 'пропустить'}: prize = None await state.update_data(prize=prize) @@ -535,8 +535,8 @@ async def process_prize(message: types.Message, state: FSMContext, db_user, db: texts = get_texts(db_user.language) await message.answer( texts.t( - "ADMIN_CONTEST_ENTER_START", - "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:", + 'ADMIN_CONTEST_ENTER_START', + 'Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:', ) ) @@ -550,7 +550,7 @@ async def process_start_date(message: types.Message, state: FSMContext, db_user, if not start_dt: await message.answer( - texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00") + texts.t('ADMIN_CONTEST_INVALID_DATE', 'Не удалось распознать дату. Формат: 01.06.2024 12:00') ) return @@ -558,8 +558,8 @@ async def process_start_date(message: types.Message, state: FSMContext, db_user, await state.set_state(AdminStates.creating_referral_contest_end) await message.answer( texts.t( - "ADMIN_CONTEST_ENTER_END", - "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:", + 'ADMIN_CONTEST_ENTER_END', + 'Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:', ) ) @@ -573,18 +573,18 @@ async def process_end_date(message: types.Message, state: FSMContext, db_user, d if not end_dt: await message.answer( - texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00") + texts.t('ADMIN_CONTEST_INVALID_DATE', 'Не удалось распознать дату. Формат: 01.06.2024 12:00') ) return data = await state.get_data() - start_raw = data.get("start_at") + start_raw = data.get('start_at') start_dt = datetime.fromisoformat(start_raw) if start_raw else None if start_dt and end_dt <= start_dt: await message.answer( texts.t( - "ADMIN_CONTEST_END_BEFORE_START", - "Дата окончания должна быть позже даты начала.", + 'ADMIN_CONTEST_END_BEFORE_START', + 'Дата окончания должна быть позже даты начала.', ) ) return @@ -593,8 +593,8 @@ async def process_end_date(message: types.Message, state: FSMContext, db_user, d await state.set_state(AdminStates.creating_referral_contest_time) await message.answer( texts.t( - "ADMIN_CONTEST_ENTER_DAILY_TIME", - "Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).", + 'ADMIN_CONTEST_ENTER_DAILY_TIME', + 'Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).', ) ) @@ -602,48 +602,40 @@ async def process_end_date(message: types.Message, state: FSMContext, db_user, d @admin_required @error_handler async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession): - times = _parse_times(message.text or "") + times = _parse_times(message.text or '') summary_time = times[0] if times else _parse_time(message.text) texts = get_texts(db_user.language) if not summary_time: await message.answer( - texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00") + texts.t('ADMIN_CONTEST_INVALID_TIME', 'Не удалось распознать время. Формат: 12:00 или 12:00,18:00') ) return data = await state.get_data() tz = _ensure_timezone(settings.TIMEZONE) - start_at_raw = data.get("start_at") - end_at_raw = data.get("end_at") + start_at_raw = data.get('start_at') + end_at_raw = data.get('end_at') if not start_at_raw or not end_at_raw: - await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату.")) + await message.answer(texts.t('ADMIN_CONTEST_INVALID_DATE', 'Не удалось распознать дату.')) return - start_at = ( - datetime.fromisoformat(start_at_raw) - .astimezone(timezone.utc) - .replace(tzinfo=None) - ) - end_at = ( - datetime.fromisoformat(end_at_raw) - .astimezone(timezone.utc) - .replace(tzinfo=None) - ) + start_at = datetime.fromisoformat(start_at_raw).astimezone(UTC).replace(tzinfo=None) + end_at = datetime.fromisoformat(end_at_raw).astimezone(UTC).replace(tzinfo=None) - contest_type = data.get("contest_type") or "referral_paid" + contest_type = data.get('contest_type') or 'referral_paid' contest = await create_referral_contest( db, - title=data.get("title"), - description=data.get("description"), - prize_text=data.get("prize"), + title=data.get('title'), + description=data.get('description'), + prize_text=data.get('prize'), contest_type=contest_type, start_at=start_at, end_at=end_at, daily_summary_time=summary_time, - daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None, + daily_summary_times=','.join(t.strftime('%H:%M') for t in times) if times else None, timezone_name=tz.key, created_by=db_user.id, ) @@ -651,7 +643,7 @@ async def finalize_contest_creation(message: types.Message, state: FSMContext, d await state.clear() await message.answer( - texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"), + texts.t('ADMIN_CONTEST_CREATED', 'Конкурс создан!'), reply_markup=get_referral_contest_manage_keyboard( contest.id, is_active=contest.is_active, @@ -669,39 +661,40 @@ async def show_detailed_stats( ): if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer("Конкурс не найден.", show_alert=True) + await callback.answer('Конкурс не найден.', show_alert=True) return from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) # Общее сообщение с основной статистикой general_lines = [ - "📈 Статистика конкурса", - f"🏆 {contest.title}", - "", - f"👥 Участников (рефереров): {stats['total_participants']}", - f"📨 Приглашено рефералов: {stats['total_invited']}", - "", - f"💳 Рефералов оплатили: {stats.get('paid_count', 0)}", - f"❌ Рефералов не оплатили: {stats.get('unpaid_count', 0)}", - "", - "💰 СУММЫ:", - f" 🛒 Покупки подписок: {stats.get('subscription_total', 0) // 100} руб.", - f" 📥 Пополнения баланса: {stats.get('deposit_total', 0) // 100} руб.", + '📈 Статистика конкурса', + f'🏆 {contest.title}', + '', + f'👥 Участников (рефереров): {stats["total_participants"]}', + f'📨 Приглашено рефералов: {stats["total_invited"]}', + '', + f'💳 Рефералов оплатили: {stats.get("paid_count", 0)}', + f'❌ Рефералов не оплатили: {stats.get("unpaid_count", 0)}', + '', + '💰 СУММЫ:', + f' 🛒 Покупки подписок: {stats.get("subscription_total", 0) // 100} руб.', + f' 📥 Пополнения баланса: {stats.get("deposit_total", 0) // 100} руб.', ] await callback.message.edit_text( - "\n".join(general_lines), + '\n'.join(general_lines), reply_markup=get_referral_contest_manage_keyboard( contest_id, is_active=contest.is_active, language=db_user.language ), @@ -722,12 +715,13 @@ async def show_detailed_stats_page( ): if contest_id is None or stats is None: # Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page} - parts = callback.data.split("_") + parts = callback.data.split('_') contest_id = int(parts[5]) # contest_id после page page = int(parts[7]) # page после второго page # Получаем stats если не переданы from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) participants = stats['participants'] @@ -737,29 +731,31 @@ async def show_detailed_stats_page( page = max(1, min(page, total_pages)) offset = (page - 1) * PAGE_SIZE - page_participants = participants[offset:offset + PAGE_SIZE] + page_participants = participants[offset : offset + PAGE_SIZE] - lines = [f"📊 По участникам (страница {page}/{total_pages}):"] + lines = [f'📊 По участникам (страница {page}/{total_pages}):'] for p in page_participants: - lines.extend([ - f"• {p['full_name']}", - f" 📨 Приглашено: {p['total_referrals']}", - f" 💰 Оплатили: {p['paid_referrals']}", - f" ❌ Не оплатили: {p['unpaid_referrals']}", - f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.", - "" # Пустая строка для разделения - ]) + lines.extend( + [ + f'• {p["full_name"]}', + f' 📨 Приглашено: {p["total_referrals"]}', + f' 💰 Оплатили: {p["paid_referrals"]}', + f' ❌ Не оплатили: {p["unpaid_referrals"]}', + f' 💵 Сумма: {p["total_paid_amount"] // 100} руб.', + '', # Пустая строка для разделения + ] + ) pagination = get_admin_pagination_keyboard( page, total_pages, - f"admin_contest_detailed_stats_page_{contest_id}", - back_callback=f"admin_contest_view_{contest_id}", + f'admin_contest_detailed_stats_page_{contest_id}', + back_callback=f'admin_contest_view_{contest_id}', language=db_user.language, ) await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=pagination, ) @@ -776,26 +772,27 @@ async def sync_contest( """Синхронизировать события конкурса с реальными платежами.""" if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer("Конкурс не найден.", show_alert=True) + await callback.answer('Конкурс не найден.', show_alert=True) return - await callback.answer("🔄 Синхронизация запущена...", show_alert=False) + await callback.answer('🔄 Синхронизация запущена...', show_alert=False) from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.sync_contest(db, contest_id) - if "error" in stats: + if 'error' in stats: await callback.message.answer( - f"❌ Ошибка синхронизации:\n{stats['error']}", + f'❌ Ошибка синхронизации:\n{stats["error"]}', ) return @@ -805,56 +802,59 @@ async def sync_contest( end_str = stats.get('contest_end', contest.end_at.isoformat()) lines = [ - "✅ Синхронизация завершена!", - "", - f"📊 Конкурс: {contest.title}", - f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}", - f"🔍 Фильтр транзакций:", - f" {start_str}", - f" {end_str}", - "", - f"📝 Рефералов в периоде: {stats.get('total_events', 0)}", - f"⚠️ Отфильтровано (вне периода): {stats.get('filtered_out_events', 0)}", - f"📊 Всего событий в БД: {stats.get('total_all_events', 0)}", - "", - f"🔄 Обновлено сумм: {stats.get('updated', 0)}", - f"⏭ Без изменений: {stats.get('skipped', 0)}", - "", - f"💳 Рефералов оплатили: {stats.get('paid_count', 0)}", - f"❌ Рефералов не оплатили: {stats.get('unpaid_count', 0)}", - "", - "💰 СУММЫ:", - f" 🛒 Покупки подписок: {stats.get('subscription_total', 0) // 100} руб.", - f" 📥 Пополнения баланса: {stats.get('deposit_total', 0) // 100} руб.", + '✅ Синхронизация завершена!', + '', + f'📊 Конкурс: {contest.title}', + f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}', + '🔍 Фильтр транзакций:', + f' {start_str}', + f' {end_str}', + '', + f'📝 Рефералов в периоде: {stats.get("total_events", 0)}', + f'⚠️ Отфильтровано (вне периода): {stats.get("filtered_out_events", 0)}', + f'📊 Всего событий в БД: {stats.get("total_all_events", 0)}', + '', + f'🔄 Обновлено сумм: {stats.get("updated", 0)}', + f'⏭ Без изменений: {stats.get("skipped", 0)}', + '', + f'💳 Рефералов оплатили: {stats.get("paid_count", 0)}', + f'❌ Рефералов не оплатили: {stats.get("unpaid_count", 0)}', + '', + '💰 СУММЫ:', + f' 🛒 Покупки подписок: {stats.get("subscription_total", 0) // 100} руб.', + f' 📥 Пополнения баланса: {stats.get("deposit_total", 0) // 100} руб.', ] - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - back_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")] - ]) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='⬅️ Назад к конкурсу', callback_data=f'admin_contest_view_{contest_id}')] + ] + ) await callback.message.answer( - "\n".join(lines), - parse_mode="HTML", + '\n'.join(lines), + parse_mode='HTML', reply_markup=back_keyboard, ) # Обновляем основное сообщение с новой статистикой detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) general_lines = [ - f"🏆 {contest.title}", - f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}", - "", - f"👥 Участников (рефереров): {detailed_stats['total_participants']}", - f"📨 Приглашено рефералов: {detailed_stats['total_invited']}", - "", - f"💳 Рефералов оплатили: {detailed_stats.get('paid_count', 0)}", - f"❌ Рефералов не оплатили: {detailed_stats.get('unpaid_count', 0)}", - f"🛒 Покупки подписок: {detailed_stats['total_paid_amount'] // 100} руб.", + f'🏆 {contest.title}', + f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}', + '', + f'👥 Участников (рефереров): {detailed_stats["total_participants"]}', + f'📨 Приглашено рефералов: {detailed_stats["total_invited"]}', + '', + f'💳 Рефералов оплатили: {detailed_stats.get("paid_count", 0)}', + f'❌ Рефералов не оплатили: {detailed_stats.get("unpaid_count", 0)}', + f'🛒 Покупки подписок: {detailed_stats["total_paid_amount"] // 100} руб.', ] await callback.message.edit_text( - "\n".join(general_lines), + '\n'.join(general_lines), reply_markup=get_referral_contest_manage_keyboard( contest_id, is_active=contest.is_active, language=db_user.language ), @@ -871,109 +871,109 @@ async def debug_contest_transactions( """Показать транзакции рефералов конкурса для отладки.""" if not settings.is_contests_enabled(): await callback.answer( - get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + get_texts(db_user.language).t('ADMIN_CONTESTS_DISABLED', 'Конкурсы отключены.'), show_alert=True, ) return - contest_id = int(callback.data.split("_")[-1]) + contest_id = int(callback.data.split('_')[-1]) contest = await get_referral_contest(db, contest_id) if not contest: - await callback.answer("Конкурс не найден.", show_alert=True) + await callback.answer('Конкурс не найден.', show_alert=True) return - await callback.answer("🔍 Загружаю данные...", show_alert=False) + await callback.answer('🔍 Загружаю данные...', show_alert=False) from app.database.crud.referral_contest import debug_contest_transactions as debug_txs + debug_data = await debug_txs(db, contest_id, limit=10) - if "error" in debug_data: - await callback.message.answer(f"❌ Ошибка: {debug_data['error']}") + if 'error' in debug_data: + await callback.message.answer(f'❌ Ошибка: {debug_data["error"]}') return deposit_total = debug_data.get('deposit_total_kopeks', 0) // 100 subscription_total = debug_data.get('subscription_total_kopeks', 0) // 100 lines = [ - "🔍 Отладка транзакций конкурса", - "", - f"📊 Конкурс: {contest.title}", - f"📅 Период фильтрации:", - f" Начало: {debug_data.get('contest_start')}", - f" Конец: {debug_data.get('contest_end')}", - f"👥 Рефералов в периоде: {debug_data.get('referral_count', 0)}", - f"⚠️ Отфильтровано (вне периода): {debug_data.get('filtered_out', 0)}", - f"📊 Всего событий в БД: {debug_data.get('total_all_events', 0)}", - "", - "💰 СУММЫ:", - f" 📥 Пополнения баланса: {deposit_total} руб.", - f" 🛒 Покупки подписок: {subscription_total} руб.", - "", + '🔍 Отладка транзакций конкурса', + '', + f'📊 Конкурс: {contest.title}', + '📅 Период фильтрации:', + f' Начало: {debug_data.get("contest_start")}', + f' Конец: {debug_data.get("contest_end")}', + f'👥 Рефералов в периоде: {debug_data.get("referral_count", 0)}', + f'⚠️ Отфильтровано (вне периода): {debug_data.get("filtered_out", 0)}', + f'📊 Всего событий в БД: {debug_data.get("total_all_events", 0)}', + '', + '💰 СУММЫ:', + f' 📥 Пополнения баланса: {deposit_total} руб.', + f' 🛒 Покупки подписок: {subscription_total} руб.', + '', ] # Показываем транзакции В периоде txs_in = debug_data.get('transactions_in_period', []) if txs_in: - lines.append(f"✅ Транзакции в периоде (первые {len(txs_in)}):") + lines.append(f'✅ Транзакции в периоде (первые {len(txs_in)}):') for tx in txs_in[:5]: # Показываем максимум 5 lines.append( - f" • {tx['created_at'][:10]} | " - f"{tx['type']} | " - f"{tx['amount_kopeks'] // 100}₽ | " - f"user={tx['user_id']}" + f' • {tx["created_at"][:10]} | {tx["type"]} | {tx["amount_kopeks"] // 100}₽ | user={tx["user_id"]}' ) if len(txs_in) > 5: - lines.append(f" ... и ещё {len(txs_in) - 5}") + lines.append(f' ... и ещё {len(txs_in) - 5}') else: - lines.append("✅ Транзакций в периоде: 0") + lines.append('✅ Транзакций в периоде: 0') - lines.append("") + lines.append('') # Показываем транзакции ВНЕ периода txs_out = debug_data.get('transactions_outside_period', []) if txs_out: - lines.append(f"❌ Транзакции вне периода (первые {len(txs_out)}):") + lines.append(f'❌ Транзакции вне периода (первые {len(txs_out)}):') for tx in txs_out[:5]: lines.append( - f" • {tx['created_at'][:10]} | " - f"{tx['type']} | " - f"{tx['amount_kopeks'] // 100}₽ | " - f"user={tx['user_id']}" + f' • {tx["created_at"][:10]} | {tx["type"]} | {tx["amount_kopeks"] // 100}₽ | user={tx["user_id"]}' ) if len(txs_out) > 5: - lines.append(f" ... и ещё {len(txs_out) - 5}") + lines.append(f' ... и ещё {len(txs_out) - 5}') else: - lines.append("❌ Транзакций вне периода: 0") + lines.append('❌ Транзакций вне периода: 0') - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - back_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")] - ]) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + back_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='⬅️ Назад к конкурсу', callback_data=f'admin_contest_view_{contest_id}')] + ] + ) await callback.message.answer( - "\n".join(lines), - parse_mode="HTML", + '\n'.join(lines), + parse_mode='HTML', reply_markup=back_keyboard, ) def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_contests_menu, F.data == "admin_contests") - dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") - dp.callback_query.register(list_contests, F.data == "admin_contests_list") - dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_")) - dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_")) - dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_")) - dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_")) - dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_")) - dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_")) - dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_")) - dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_")) - dp.callback_query.register(sync_contest, F.data.startswith("admin_contest_sync_")) - dp.callback_query.register(debug_contest_transactions, F.data.startswith("admin_contest_debug_")) - dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create") - dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"])) + dp.callback_query.register(show_contests_menu, F.data == 'admin_contests') + dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral') + dp.callback_query.register(list_contests, F.data == 'admin_contests_list') + dp.callback_query.register(list_contests, F.data.startswith('admin_contests_list_page_')) + dp.callback_query.register(show_contest_details, F.data.startswith('admin_contest_view_')) + dp.callback_query.register(toggle_contest, F.data.startswith('admin_contest_toggle_')) + dp.callback_query.register(prompt_edit_summary_times, F.data.startswith('admin_contest_edit_times_')) + dp.callback_query.register(delete_contest, F.data.startswith('admin_contest_delete_')) + dp.callback_query.register(show_leaderboard, F.data.startswith('admin_contest_leaderboard_')) + dp.callback_query.register(show_detailed_stats, F.data.startswith('admin_contest_detailed_stats_')) + dp.callback_query.register(show_detailed_stats_page, F.data.startswith('admin_contest_detailed_stats_page_')) + dp.callback_query.register(sync_contest, F.data.startswith('admin_contest_sync_')) + dp.callback_query.register(debug_contest_transactions, F.data.startswith('admin_contest_debug_')) + dp.callback_query.register(start_contest_creation, F.data == 'admin_contests_create') + dp.callback_query.register( + select_contest_mode, F.data.in_(['admin_contest_mode_paid', 'admin_contest_mode_registered']) + ) dp.message.register(process_title, AdminStates.creating_referral_contest_title) dp.message.register(process_description, AdminStates.creating_referral_contest_description) diff --git a/app/handlers/admin/daily_contests.py b/app/handlers/admin/daily_contests.py index ac1b4e9c..7fc6c013 100644 --- a/app/handlers/admin/daily_contests.py +++ b/app/handlers/admin/daily_contests.py @@ -1,13 +1,11 @@ import json import logging from datetime import datetime, timedelta -from typing import Dict -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.crud.contest import ( clear_attempts, create_round, @@ -17,8 +15,6 @@ from app.database.crud.contest import ( ) from app.database.models import ContestTemplate from app.keyboards.admin import ( - get_admin_contests_keyboard, - get_admin_contests_root_keyboard, get_daily_contest_manage_keyboard, ) from app.localization.texts import get_texts @@ -26,16 +22,17 @@ from app.services.contest_rotation_service import contest_rotation_service from app.states import AdminStates from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) -EDITABLE_FIELDS: Dict[str, Dict] = { - "prize_type": {"type": str, "label": "тип приза (days/balance/custom)"}, - "prize_value": {"type": str, "label": "значение приза"}, - "max_winners": {"type": int, "min": 1, "label": "макс. победителей"}, - "attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"}, - "times_per_day": {"type": int, "min": 1, "label": "раундов в день"}, - "schedule_times": {"type": str, "label": "расписание HH:MM через запятую"}, - "cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"}, +EDITABLE_FIELDS: dict[str, dict] = { + 'prize_type': {'type': str, 'label': 'тип приза (days/balance/custom)'}, + 'prize_value': {'type': str, 'label': 'значение приза'}, + 'max_winners': {'type': int, 'min': 1, 'label': 'макс. победителей'}, + 'attempts_per_user': {'type': int, 'min': 1, 'label': 'попыток на пользователя'}, + 'times_per_day': {'type': int, 'min': 1, 'label': 'раундов в день'}, + 'schedule_times': {'type': str, 'label': 'расписание HH:MM через запятую'}, + 'cooldown_hours': {'type': int, 'min': 1, 'label': 'длительность раунда (часы)'}, } @@ -53,33 +50,47 @@ async def show_daily_contests( texts = get_texts(db_user.language) templates = await list_templates(db, enabled_only=False) - lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")] + lines = [texts.t('ADMIN_DAILY_CONTESTS_TITLE', '📆 Ежедневные конкурсы')] if not templates: - lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов.")) + lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.')) else: for tpl in templates: - status = "🟢" if tpl.is_enabled else "⚪️" - prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value - lines.append(f"{status} {tpl.name} (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}") + status = '🟢' if tpl.is_enabled else '⚪️' + prize_info = f'{tpl.prize_value} ({tpl.prize_type})' if tpl.prize_type else tpl.prize_value + lines.append(f'{status} {tpl.name} (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}') keyboard_rows = [] if templates: - keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")]) - keyboard_rows.append([types.InlineKeyboardButton(text="� Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")]) - keyboard_rows.append([types.InlineKeyboardButton(text="� Запустить все активные конкурсы", callback_data="admin_daily_start_all")]) + keyboard_rows.append( + [types.InlineKeyboardButton(text='❌ Закрыть все активные раунды', callback_data='admin_daily_close_all')] + ) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='� Сбросить попытки во всех активных раундах', callback_data='admin_daily_reset_all_attempts' + ) + ] + ) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='� Запустить все активные конкурсы', callback_data='admin_daily_start_all' + ) + ] + ) for tpl in templates: keyboard_rows.append( [ types.InlineKeyboardButton( - text=f"⚙️ {tpl.name}", - callback_data=f"admin_daily_contest_{tpl.id}", + text=f'⚙️ {tpl.name}', + callback_data=f'admin_daily_contest_{tpl.id}', ) ] ) - keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")]) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_contests')]) await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) await callback.answer() @@ -94,29 +105,28 @@ async def show_daily_contest( ): texts = get_texts(db_user.language) try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except Exception: - await callback.answer("Некорректный id", show_alert=True) + await callback.answer('Некорректный id', show_alert=True) return tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return - prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value lines = [ - f"🏷 {tpl.name} (slug: {tpl.slug})", - f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}", - f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}", - f"Макс победителей: {tpl.max_winners}", - f"Попыток/польз: {tpl.attempts_per_user}", - f"Раундов в день: {tpl.times_per_day}", - f"Расписание: {tpl.schedule_times or '-'}", - f"Длительность раунда: {tpl.cooldown_hours} ч.", + f'🏷 {tpl.name} (slug: {tpl.slug})', + f'{texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if tpl.is_enabled else texts.t("ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен")}', + f'Тип приза: {tpl.prize_type or "days"} | Значение: {tpl.prize_value or "1"}', + f'Макс победителей: {tpl.max_winners}', + f'Попыток/польз: {tpl.attempts_per_user}', + f'Раундов в день: {tpl.times_per_day}', + f'Расписание: {tpl.schedule_times or "-"}', + f'Длительность раунда: {tpl.cooldown_hours} ч.', ] await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language), ) await callback.answer() @@ -130,14 +140,14 @@ async def toggle_daily_contest( db: AsyncSession, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return tpl.is_enabled = not tpl.is_enabled await db.commit() - await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено")) + await callback.answer(texts.t('ADMIN_UPDATED', 'Обновлено')) await show_daily_contest(callback, db_user, db) @@ -149,10 +159,10 @@ async def start_round_now( db: AsyncSession, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return if not tpl.is_enabled: @@ -163,7 +173,7 @@ async def start_round_now( payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] now = datetime.utcnow() ends = now + timedelta(hours=tpl.cooldown_hours) - round_obj = await create_round( + await create_round( db, template=tpl, starts_at=now, @@ -175,7 +185,7 @@ async def start_round_now( now.replace(tzinfo=None), ends.replace(tzinfo=None), ) - await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True) + await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Раунд запущен'), show_alert=True) await show_daily_contest(callback, db_user, db) @@ -187,17 +197,18 @@ async def manual_start_round( db: AsyncSession, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return # Проверяем, есть ли уже активный раунд для этого шаблона from app.database.crud.contest import get_active_round_by_template + exists = await get_active_round_by_template(db, tpl.id) if exists: - await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True) + await callback.answer(texts.t('ADMIN_ROUND_ALREADY_ACTIVE', 'Раунд уже активен.'), show_alert=True) await show_daily_contest(callback, db_user, db) return @@ -205,7 +216,7 @@ async def manual_start_round( payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] now = datetime.utcnow() ends = now + timedelta(hours=tpl.cooldown_hours) - round_obj = await create_round( + await create_round( db, template=tpl, starts_at=now, @@ -219,7 +230,7 @@ async def manual_start_round( now.replace(tzinfo=None), ends.replace(tzinfo=None), ) - await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True) + await callback.answer(texts.t('ADMIN_ROUND_STARTED', 'Тестовый раунд запущен'), show_alert=True) await show_daily_contest(callback, db_user, db) @@ -232,13 +243,13 @@ async def prompt_edit_field( state: FSMContext, ): texts = get_texts(db_user.language) - parts = callback.data.split("_") + parts = callback.data.split('_') template_id = int(parts[3]) - field = "_".join(parts[4:]) # поле может содержать подчеркивания + field = '_'.join(parts[4:]) # поле может содержать подчеркивания tpl = await _get_template(db, template_id) if not tpl or field not in EDITABLE_FIELDS: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return meta = EDITABLE_FIELDS[field] @@ -249,16 +260,16 @@ async def prompt_edit_field( [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_daily_contest_{template_id}", + callback_data=f'admin_daily_contest_{template_id}', ) ] ] ) await callback.message.edit_text( texts.t( - "ADMIN_CONTEST_FIELD_PROMPT", - "Введите новое значение для {label}:", - ).format(label=meta.get("label", field)), + 'ADMIN_CONTEST_FIELD_PROMPT', + 'Введите новое значение для {label}:', + ).format(label=meta.get('label', field)), reply_markup=kb, ) await callback.answer() @@ -274,8 +285,8 @@ async def process_edit_field( ): texts = get_texts(db_user.language) data = await state.get_data() - template_id = data.get("template_id") - field = data.get("field") + template_id = data.get('template_id') + field = data.get('field') if not template_id or not field or field not in EDITABLE_FIELDS: await message.answer(texts.ERROR) await state.clear() @@ -283,21 +294,21 @@ async def process_edit_field( tpl = await _get_template(db, template_id) if not tpl: - await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден.")) + await message.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.')) await state.clear() return meta = EDITABLE_FIELDS[field] - raw = message.text or "" + raw = message.text or '' try: - if meta["type"] is int: + if meta['type'] is int: value = int(raw) - if meta.get("min") is not None and value < meta["min"]: - raise ValueError("min") + if meta.get('min') is not None and value < meta['min']: + raise ValueError('min') else: value = raw.strip() except Exception: - await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число")) + await message.answer(texts.t('ADMIN_INVALID_NUMBER', 'Некорректное число')) await state.clear() return @@ -307,12 +318,12 @@ async def process_edit_field( [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_daily_contest_{template_id}", + callback_data=f'admin_daily_contest_{template_id}', ) ] ] ) - await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb) + await message.answer(texts.t('ADMIN_UPDATED', 'Обновлено'), reply_markup=back_kb) await state.clear() @@ -325,27 +336,28 @@ async def edit_payload( state: FSMContext, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return await state.set_state(AdminStates.editing_daily_contest_value) - await state.update_data(template_id=template_id, field="payload") + await state.update_data(template_id=template_id, field='payload') payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2) kb = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_daily_contest_{template_id}", + callback_data=f'admin_daily_contest_{template_id}', ) ] ] ) await callback.message.edit_text( - texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"{payload_json}", + texts.t('ADMIN_CONTEST_PAYLOAD_PROMPT', 'Отправьте JSON payload для игры (словарь настроек):\n') + + f'{payload_json}', reply_markup=kb, ) await callback.answer() @@ -361,24 +373,24 @@ async def process_payload( ): texts = get_texts(db_user.language) data = await state.get_data() - template_id = data.get("template_id") + template_id = data.get('template_id') if not template_id: await message.answer(texts.ERROR) await state.clear() return try: - payload = json.loads(message.text or "{}") + payload = json.loads(message.text or '{}') if not isinstance(payload, dict): raise ValueError except Exception: - await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON")) + await message.answer(texts.t('ADMIN_INVALID_JSON', 'Некорректный JSON')) await state.clear() return tpl = await _get_template(db, template_id) if not tpl: - await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден.")) + await message.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.')) await state.clear() return @@ -388,12 +400,12 @@ async def process_payload( [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_daily_contest_{template_id}", + callback_data=f'admin_daily_contest_{template_id}', ) ] ] ) - await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb) + await message.answer(texts.t('ADMIN_UPDATED', 'Обновлено'), reply_markup=back_kb) await state.clear() @@ -407,12 +419,13 @@ async def start_all_contests( texts = get_texts(db_user.language) templates = await list_templates(db, enabled_only=True) if not templates: - await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTESTS_EMPTY', 'Нет активных конкурсов.'), show_alert=True) return started_count = 0 for tpl in templates: from app.database.crud.contest import get_active_round_by_template + exists = await get_active_round_by_template(db, tpl.id) if exists: continue # уже запущен @@ -420,7 +433,7 @@ async def start_all_contests( payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] now = datetime.utcnow() ends = now + timedelta(hours=tpl.cooldown_hours) - round_obj = await create_round( + await create_round( db, template=tpl, starts_at=now, @@ -434,7 +447,7 @@ async def start_all_contests( ) started_count += 1 - message = f"Запущено конкурсов: {started_count}" + message = f'Запущено конкурсов: {started_count}' await callback.answer(message, show_alert=True) await show_daily_contests(callback, db_user, db) @@ -446,18 +459,19 @@ async def close_all_rounds( db_user, db: AsyncSession, ): - texts = get_texts(db_user.language) + get_texts(db_user.language) from app.database.crud.contest import get_active_rounds + active_rounds = await get_active_rounds(db) if not active_rounds: - await callback.answer("Нет активных раундов", show_alert=True) + await callback.answer('Нет активных раундов', show_alert=True) return for rnd in active_rounds: - rnd.status = "finished" + rnd.status = 'finished' await db.commit() - await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True) + await callback.answer(f'Закрыто раундов: {len(active_rounds)}', show_alert=True) await show_daily_contests(callback, db_user, db) @@ -468,11 +482,12 @@ async def reset_all_attempts( db_user, db: AsyncSession, ): - texts = get_texts(db_user.language) + get_texts(db_user.language) from app.database.crud.contest import get_active_rounds + active_rounds = await get_active_rounds(db) if not active_rounds: - await callback.answer("Нет активных раундов", show_alert=True) + await callback.answer('Нет активных раундов', show_alert=True) return total_deleted = 0 @@ -480,7 +495,7 @@ async def reset_all_attempts( deleted = await clear_attempts(db, rnd.id) total_deleted += deleted - await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True) + await callback.answer(f'Попытки сброшены: {total_deleted}', show_alert=True) await show_daily_contests(callback, db_user, db) @@ -492,20 +507,21 @@ async def reset_attempts( db: AsyncSession, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return - from app.database.crud.contest import get_active_round_by_template, clear_attempts + from app.database.crud.contest import clear_attempts, get_active_round_by_template + round_obj = await get_active_round_by_template(db, tpl.id) if not round_obj: - await callback.answer("Нет активного раунда", show_alert=True) + await callback.answer('Нет активного раунда', show_alert=True) return deleted_count = await clear_attempts(db, round_obj.id) - await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True) + await callback.answer(f'Попытки сброшены: {deleted_count}', show_alert=True) await show_daily_contest(callback, db_user, db) @@ -517,39 +533,40 @@ async def close_round( db: AsyncSession, ): texts = get_texts(db_user.language) - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) tpl = await _get_template(db, template_id) if not tpl: - await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True) return from app.database.crud.contest import get_active_round_by_template + round_obj = await get_active_round_by_template(db, tpl.id) if not round_obj: - await callback.answer("Нет активного раунда", show_alert=True) + await callback.answer('Нет активного раунда', show_alert=True) return - round_obj.status = "finished" + round_obj.status = 'finished' await db.commit() await db.refresh(round_obj) - await callback.answer("Раунд закрыт", show_alert=True) + await callback.answer('Раунд закрыт', show_alert=True) await show_daily_contest(callback, db_user, db) def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily") - dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_")) - dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_")) - dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all") - dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_")) - dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_")) - dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all") - dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts") - dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_")) - dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_")) - dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_")) - dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_")) + dp.callback_query.register(show_daily_contests, F.data == 'admin_contests_daily') + dp.callback_query.register(show_daily_contest, F.data.startswith('admin_daily_contest_')) + dp.callback_query.register(toggle_daily_contest, F.data.startswith('admin_daily_toggle_')) + dp.callback_query.register(start_all_contests, F.data == 'admin_daily_start_all') + dp.callback_query.register(start_round_now, F.data.startswith('admin_daily_start_')) + dp.callback_query.register(manual_start_round, F.data.startswith('admin_daily_manual_')) + dp.callback_query.register(close_all_rounds, F.data == 'admin_daily_close_all') + dp.callback_query.register(reset_all_attempts, F.data == 'admin_daily_reset_all_attempts') + dp.callback_query.register(reset_attempts, F.data.startswith('admin_daily_reset_attempts_')) + dp.callback_query.register(close_round, F.data.startswith('admin_daily_close_')) + dp.callback_query.register(prompt_edit_field, F.data.startswith('admin_daily_edit_')) + dp.callback_query.register(edit_payload, F.data.startswith('admin_daily_payload_')) dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field) dp.message.register(process_payload, AdminStates.editing_daily_contest_value) diff --git a/app/handlers/admin/faq.py b/app/handlers/admin/faq.py index 90b461c5..884ebe27 100644 --- a/app/handlers/admin/faq.py +++ b/app/handlers/admin/faq.py @@ -13,16 +13,17 @@ from app.states import AdminStates from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags + logger = logging.getLogger(__name__) def _format_timestamp(value: datetime | None) -> str: if not value: - return "" + return '' try: - return value.strftime("%d.%m.%Y %H:%M") + return value.strftime('%d.%m.%Y %H:%M') except Exception: - return "" + return '' async def _build_overview( @@ -49,81 +50,79 @@ async def _build_overview( active_pages = sum(1 for page in pages if page.is_active) description = texts.t( - "ADMIN_FAQ_DESCRIPTION", - "FAQ отображается в разделе «Инфо».", + 'ADMIN_FAQ_DESCRIPTION', + 'FAQ отображается в разделе «Инфо».', ) if setting and not setting.is_enabled: status_text = texts.t( - "ADMIN_FAQ_STATUS_DISABLED", - "⚠️ Показ FAQ выключен.", + 'ADMIN_FAQ_STATUS_DISABLED', + '⚠️ Показ FAQ выключен.', ) elif active_pages: status_text = texts.t( - "ADMIN_FAQ_STATUS_ENABLED", - "✅ FAQ включён. Активных страниц: {count}.", + 'ADMIN_FAQ_STATUS_ENABLED', + '✅ FAQ включён. Активных страниц: {count}.', ).format(count=active_pages) elif total_pages: status_text = texts.t( - "ADMIN_FAQ_STATUS_ENABLED_EMPTY", - "⚠️ FAQ включён, но нет активных страниц.", + 'ADMIN_FAQ_STATUS_ENABLED_EMPTY', + '⚠️ FAQ включён, но нет активных страниц.', ) else: status_text = texts.t( - "ADMIN_FAQ_STATUS_EMPTY", - "⚠️ FAQ ещё не настроен.", + 'ADMIN_FAQ_STATUS_EMPTY', + '⚠️ FAQ ещё не настроен.', ) pages_overview = texts.t( - "ADMIN_FAQ_PAGES_EMPTY", - "Страницы ещё не созданы.", + 'ADMIN_FAQ_PAGES_EMPTY', + 'Страницы ещё не созданы.', ) if pages: rows: list[str] = [] for index, page in enumerate(pages, start=1): - title = (page.title or "").strip() + title = (page.title or '').strip() if not title: - title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + title = texts.t('FAQ_PAGE_UNTITLED', 'Без названия') if len(title) > 60: - title = f"{title[:57]}..." + title = f'{title[:57]}...' status_label = texts.t( - "ADMIN_FAQ_PAGE_STATUS_ACTIVE", - "✅ Активна", + 'ADMIN_FAQ_PAGE_STATUS_ACTIVE', + '✅ Активна', ) if not page.is_active: status_label = texts.t( - "ADMIN_FAQ_PAGE_STATUS_INACTIVE", - "🚫 Выключена", + 'ADMIN_FAQ_PAGE_STATUS_INACTIVE', + '🚫 Выключена', ) - updated = _format_timestamp(getattr(page, "updated_at", None)) - updated_block = f" ({updated})" if updated else "" - rows.append( - f"{index}. {html.escape(title)} — {status_label}{updated_block}" - ) + updated = _format_timestamp(getattr(page, 'updated_at', None)) + updated_block = f' ({updated})' if updated else '' + rows.append(f'{index}. {html.escape(title)} — {status_label}{updated_block}') pages_list_header = texts.t( - "ADMIN_FAQ_PAGES_OVERVIEW", - "Список страниц:\n{items}", + 'ADMIN_FAQ_PAGES_OVERVIEW', + 'Список страниц:\n{items}', ) - pages_overview = pages_list_header.format(items="\n".join(rows)) + pages_overview = pages_list_header.format(items='\n'.join(rows)) language_block = texts.t( - "ADMIN_FAQ_LANGUAGE", - "Язык: {lang}", + 'ADMIN_FAQ_LANGUAGE', + 'Язык: {lang}', ).format(lang=normalized_language) stats_block = texts.t( - "ADMIN_FAQ_PAGE_STATS", - "Всего страниц: {total}", + 'ADMIN_FAQ_PAGE_STATS', + 'Всего страниц: {total}', ).format(total=total_pages) - header = texts.t("ADMIN_FAQ_HEADER", "❓ FAQ") + header = texts.t('ADMIN_FAQ_HEADER', '❓ FAQ') actions_prompt = texts.t( - "ADMIN_FAQ_ACTION_PROMPT", - "Выберите действие:", + 'ADMIN_FAQ_ACTION_PROMPT', + 'Выберите действие:', ) message_parts = [ @@ -136,63 +135,73 @@ async def _build_overview( actions_prompt, ] - overview_text = "\n\n".join(part for part in message_parts if part) + overview_text = '\n\n'.join(part for part in message_parts if part) buttons: list[list[types.InlineKeyboardButton]] = [] - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_FAQ_ADD_PAGE_BUTTON", - "➕ Добавить страницу", - ), - callback_data="admin_faq_create", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_FAQ_ADD_PAGE_BUTTON', + '➕ Добавить страницу', + ), + callback_data='admin_faq_create', + ) + ] + ) for page in pages[:25]: - title = (page.title or "").strip() + title = (page.title or '').strip() if not title: - title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + title = texts.t('FAQ_PAGE_UNTITLED', 'Без названия') if len(title) > 40: - title = f"{title[:37]}..." - buttons.append([ - types.InlineKeyboardButton( - text=f"{page.display_order}. {title}", - callback_data=f"admin_faq_page:{page.id}", - ) - ]) + title = f'{title[:37]}...' + buttons.append( + [ + types.InlineKeyboardButton( + text=f'{page.display_order}. {title}', + callback_data=f'admin_faq_page:{page.id}', + ) + ] + ) toggle_text = texts.t( - "ADMIN_FAQ_ENABLE_BUTTON", - "✅ Включить показ", + 'ADMIN_FAQ_ENABLE_BUTTON', + '✅ Включить показ', ) if setting and setting.is_enabled: toggle_text = texts.t( - "ADMIN_FAQ_DISABLE_BUTTON", - "🚫 Отключить показ", + 'ADMIN_FAQ_DISABLE_BUTTON', + '🚫 Отключить показ', ) - buttons.append([ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_faq_toggle", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data='admin_faq_toggle', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_HTML_HELP", "ℹ️ HTML помощь"), - callback_data="admin_faq_help", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_HTML_HELP', 'ℹ️ HTML помощь'), + callback_data='admin_faq_help', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_settings", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data='admin_submenu_settings', + ) + ] + ) return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons) @@ -225,13 +234,13 @@ async def toggle_faq( if setting.is_enabled: alert_text = texts.t( - "ADMIN_FAQ_ENABLED_ALERT", - "✅ FAQ включён.", + 'ADMIN_FAQ_ENABLED_ALERT', + '✅ FAQ включён.', ) else: alert_text = texts.t( - "ADMIN_FAQ_DISABLED_ALERT", - "🚫 FAQ отключён.", + 'ADMIN_FAQ_DISABLED_ALERT', + '🚫 FAQ отключён.', ) overview_text, markup = await _build_overview(db_user, db) @@ -258,18 +267,18 @@ async def start_create_faq_page( await callback.message.edit_text( texts.t( - "ADMIN_FAQ_ENTER_TITLE", - "Введите заголовок для новой страницы FAQ:", + 'ADMIN_FAQ_ENTER_TITLE', + 'Введите заголовок для новой страницы FAQ:', ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_FAQ_CANCEL_BUTTON", - "⬅️ Отмена", + 'ADMIN_FAQ_CANCEL_BUTTON', + '⬅️ Отмена', ), - callback_data="admin_faq_cancel", + callback_data='admin_faq_cancel', ) ] ] @@ -299,13 +308,13 @@ async def process_new_faq_title( db: AsyncSession, ): texts = get_texts(db_user.language) - title = (message.text or "").strip() + title = (message.text or '').strip() if not title: await message.answer( texts.t( - "ADMIN_FAQ_TITLE_EMPTY", - "❌ Заголовок не может быть пустым.", + 'ADMIN_FAQ_TITLE_EMPTY', + '❌ Заголовок не может быть пустым.', ) ) return @@ -313,8 +322,8 @@ async def process_new_faq_title( if len(title) > 255: await message.answer( texts.t( - "ADMIN_FAQ_TITLE_TOO_LONG", - "❌ Заголовок слишком длинный. Максимум 255 символов.", + 'ADMIN_FAQ_TITLE_TOO_LONG', + '❌ Заголовок слишком длинный. Максимум 255 символов.', ) ) return @@ -324,8 +333,8 @@ async def process_new_faq_title( await message.answer( texts.t( - "ADMIN_FAQ_ENTER_CONTENT", - "Отправьте содержимое страницы FAQ. Допускается HTML.", + 'ADMIN_FAQ_ENTER_CONTENT', + 'Отправьте содержимое страницы FAQ. Допускается HTML.', ) ) @@ -339,13 +348,13 @@ async def process_new_faq_content( db: AsyncSession, ): texts = get_texts(db_user.language) - content = message.text or "" + content = message.text or '' if len(content) > 6000: await message.answer( texts.t( - "ADMIN_FAQ_CONTENT_TOO_LONG", - "❌ Текст слишком длинный. Максимум 6000 символов.", + 'ADMIN_FAQ_CONTENT_TOO_LONG', + '❌ Текст слишком длинный. Максимум 6000 символов.', ) ) return @@ -353,8 +362,8 @@ async def process_new_faq_content( if not content.strip(): await message.answer( texts.t( - "ADMIN_FAQ_CONTENT_EMPTY", - "❌ Текст не может быть пустым.", + 'ADMIN_FAQ_CONTENT_EMPTY', + '❌ Текст не может быть пустым.', ) ) return @@ -363,15 +372,15 @@ async def process_new_faq_content( if not is_valid: await message.answer( texts.t( - "ADMIN_FAQ_HTML_ERROR", - "❌ Ошибка в HTML: {error}", + 'ADMIN_FAQ_HTML_ERROR', + '❌ Ошибка в HTML: {error}', ).format(error=error_message) ) return data = await state.get_data() - title = data.get("faq_title") or texts.t("FAQ_PAGE_UNTITLED", "Без названия") - language = data.get("faq_language", db_user.language) + title = data.get('faq_title') or texts.t('FAQ_PAGE_UNTITLED', 'Без названия') + language = data.get('faq_language', db_user.language) await FaqService.create_page( db, @@ -381,7 +390,7 @@ async def process_new_faq_content( ) logger.info( - "Админ %s создал страницу FAQ (%d символов)", + 'Админ %s создал страницу FAQ (%d символов)', db_user.telegram_id, len(content), ) @@ -389,8 +398,8 @@ async def process_new_faq_content( await state.clear() success_text = texts.t( - "ADMIN_FAQ_PAGE_CREATED", - "✅ Страница FAQ создана.", + 'ADMIN_FAQ_PAGE_CREATED', + '✅ Страница FAQ создана.', ) reply_markup = types.InlineKeyboardMarkup( @@ -398,10 +407,10 @@ async def process_new_faq_content( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_FAQ_BACK_TO_LIST", - "⬅️ К настройкам FAQ", + 'ADMIN_FAQ_BACK_TO_LIST', + '⬅️ К настройкам FAQ', ), - callback_data="admin_faq", + callback_data='admin_faq', ) ] ] @@ -419,7 +428,7 @@ async def show_faq_page_details( ): texts = get_texts(db_user.language) - raw_id = (callback.data or "").split(":", 1)[-1] + raw_id = (callback.data or '').split(':', 1)[-1] try: page_id = int(raw_id) except ValueError: @@ -437,116 +446,127 @@ async def show_faq_page_details( if not page: await callback.answer( texts.t( - "ADMIN_FAQ_PAGE_NOT_FOUND", - "⚠️ Страница не найдена.", + 'ADMIN_FAQ_PAGE_NOT_FOUND', + '⚠️ Страница не найдена.', ), show_alert=True, ) return - header = texts.t("ADMIN_FAQ_PAGE_HEADER", "📄 Страница FAQ") - title = (page.title or "").strip() or texts.t("FAQ_PAGE_UNTITLED", "Без названия") + header = texts.t('ADMIN_FAQ_PAGE_HEADER', '📄 Страница FAQ') + title = (page.title or '').strip() or texts.t('FAQ_PAGE_UNTITLED', 'Без названия') status_label = texts.t( - "ADMIN_FAQ_PAGE_STATUS_ACTIVE", - "✅ Активна", + 'ADMIN_FAQ_PAGE_STATUS_ACTIVE', + '✅ Активна', ) if not page.is_active: status_label = texts.t( - "ADMIN_FAQ_PAGE_STATUS_INACTIVE", - "🚫 Выключена", + 'ADMIN_FAQ_PAGE_STATUS_INACTIVE', + '🚫 Выключена', ) - updated_at = _format_timestamp(getattr(page, "updated_at", None)) - updated_block = "" + updated_at = _format_timestamp(getattr(page, 'updated_at', None)) + updated_block = '' if updated_at: updated_block = texts.t( - "ADMIN_FAQ_PAGE_UPDATED", - "Обновлено: {timestamp}", + 'ADMIN_FAQ_PAGE_UPDATED', + 'Обновлено: {timestamp}', ).format(timestamp=updated_at) - preview = (page.content or "").strip() + preview = (page.content or '').strip() preview_text = texts.t( - "ADMIN_FAQ_PAGE_PREVIEW_EMPTY", - "Текст ещё не задан.", + 'ADMIN_FAQ_PAGE_PREVIEW_EMPTY', + 'Текст ещё не задан.', ) if preview: preview_trimmed = preview[:400] if len(preview) > 400: - preview_trimmed += "..." - preview_text = ( - texts.t("ADMIN_FAQ_PAGE_PREVIEW", "Превью:\n{content}") - .format(content=html.escape(preview_trimmed)) + preview_trimmed += '...' + preview_text = texts.t('ADMIN_FAQ_PAGE_PREVIEW', 'Превью:\n{content}').format( + content=html.escape(preview_trimmed) ) message_parts = [ header, texts.t( - "ADMIN_FAQ_PAGE_TITLE", - "Заголовок: {title}", + 'ADMIN_FAQ_PAGE_TITLE', + 'Заголовок: {title}', ).format(title=html.escape(title)), texts.t( - "ADMIN_FAQ_PAGE_STATUS", - "Статус: {status}", + 'ADMIN_FAQ_PAGE_STATUS', + 'Статус: {status}', ).format(status=status_label), preview_text, updated_block, ] - message_text = "\n\n".join(part for part in message_parts if part) + message_text = '\n\n'.join(part for part in message_parts if part) buttons: list[list[types.InlineKeyboardButton]] = [] - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_EDIT_TITLE_BUTTON", "✏️ Изменить заголовок"), - callback_data=f"admin_faq_edit_title:{page.id}", - ) - ]) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_EDIT_CONTENT_BUTTON", "📝 Изменить текст"), - callback_data=f"admin_faq_edit_content:{page.id}", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_EDIT_TITLE_BUTTON', '✏️ Изменить заголовок'), + callback_data=f'admin_faq_edit_title:{page.id}', + ) + ] + ) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_EDIT_CONTENT_BUTTON', '📝 Изменить текст'), + callback_data=f'admin_faq_edit_content:{page.id}', + ) + ] + ) - toggle_text = texts.t("ADMIN_FAQ_PAGE_ENABLE_BUTTON", "✅ Включить страницу") + toggle_text = texts.t('ADMIN_FAQ_PAGE_ENABLE_BUTTON', '✅ Включить страницу') if page.is_active: toggle_text = texts.t( - "ADMIN_FAQ_PAGE_DISABLE_BUTTON", - "🚫 Выключить страницу", + 'ADMIN_FAQ_PAGE_DISABLE_BUTTON', + '🚫 Выключить страницу', ) - buttons.append([ - types.InlineKeyboardButton( - text=toggle_text, - callback_data=f"admin_faq_toggle_page:{page.id}", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data=f'admin_faq_toggle_page:{page.id}', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_PAGE_MOVE_UP", "⬆️ Выше"), - callback_data=f"admin_faq_move:{page.id}:up", - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_PAGE_MOVE_DOWN", "⬇️ Ниже"), - callback_data=f"admin_faq_move:{page.id}:down", - ), - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_PAGE_MOVE_UP', '⬆️ Выше'), + callback_data=f'admin_faq_move:{page.id}:up', + ), + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_PAGE_MOVE_DOWN', '⬇️ Ниже'), + callback_data=f'admin_faq_move:{page.id}:down', + ), + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_PAGE_DELETE_BUTTON", "🗑️ Удалить"), - callback_data=f"admin_faq_delete:{page.id}", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_PAGE_DELETE_BUTTON', '🗑️ Удалить'), + callback_data=f'admin_faq_delete:{page.id}', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), - callback_data="admin_faq", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_BACK_TO_LIST', '⬅️ К настройкам FAQ'), + callback_data='admin_faq', + ) + ] + ) await callback.message.edit_text( message_text, @@ -565,7 +585,7 @@ async def start_edit_faq_title( ): texts = get_texts(db_user.language) - raw_id = (callback.data or "").split(":", 1)[-1] + raw_id = (callback.data or '').split(':', 1)[-1] try: page_id = int(raw_id) except ValueError: @@ -583,8 +603,8 @@ async def start_edit_faq_title( if not page: await callback.answer( texts.t( - "ADMIN_FAQ_PAGE_NOT_FOUND", - "⚠️ Страница не найдена.", + 'ADMIN_FAQ_PAGE_NOT_FOUND', + '⚠️ Страница не найдена.', ), show_alert=True, ) @@ -595,18 +615,18 @@ async def start_edit_faq_title( await callback.message.edit_text( texts.t( - "ADMIN_FAQ_EDIT_TITLE_PROMPT", - "Введите новый заголовок для страницы:", + 'ADMIN_FAQ_EDIT_TITLE_PROMPT', + 'Введите новый заголовок для страницы:', ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_FAQ_CANCEL_BUTTON", - "⬅️ Отмена", + 'ADMIN_FAQ_CANCEL_BUTTON', + '⬅️ Отмена', ), - callback_data=f"admin_faq_page:{page.id}", + callback_data=f'admin_faq_page:{page.id}', ) ] ] @@ -624,13 +644,13 @@ async def process_edit_faq_title( db: AsyncSession, ): texts = get_texts(db_user.language) - title = (message.text or "").strip() + title = (message.text or '').strip() if not title: await message.answer( texts.t( - "ADMIN_FAQ_TITLE_EMPTY", - "❌ Заголовок не может быть пустым.", + 'ADMIN_FAQ_TITLE_EMPTY', + '❌ Заголовок не может быть пустым.', ) ) return @@ -638,18 +658,18 @@ async def process_edit_faq_title( if len(title) > 255: await message.answer( texts.t( - "ADMIN_FAQ_TITLE_TOO_LONG", - "❌ Заголовок слишком длинный. Максимум 255 символов.", + 'ADMIN_FAQ_TITLE_TOO_LONG', + '❌ Заголовок слишком длинный. Максимум 255 символов.', ) ) return data = await state.get_data() - page_id = data.get("faq_page_id") + page_id = data.get('faq_page_id') if not page_id: await state.clear() - await message.answer(texts.t("ADMIN_FAQ_UNEXPECTED_STATE", "⚠️ Состояние сброшено.")) + await message.answer(texts.t('ADMIN_FAQ_UNEXPECTED_STATE', '⚠️ Состояние сброшено.')) return page = await FaqService.get_page( @@ -662,7 +682,7 @@ async def process_edit_faq_title( if not page: await message.answer( - texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + texts.t('ADMIN_FAQ_PAGE_NOT_FOUND', '⚠️ Страница не найдена.'), ) await state.clear() return @@ -671,12 +691,16 @@ async def process_edit_faq_title( await state.clear() await message.answer( - texts.t("ADMIN_FAQ_TITLE_UPDATED", "✅ Заголовок обновлён."), + texts.t('ADMIN_FAQ_TITLE_UPDATED', '✅ Заголовок обновлён.'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), - callback_data="admin_faq", - )]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_BACK_TO_LIST', '⬅️ К настройкам FAQ'), + callback_data='admin_faq', + ) + ] + ] ), ) @@ -691,7 +715,7 @@ async def start_edit_faq_content( ): texts = get_texts(db_user.language) - raw_id = (callback.data or "").split(":", 1)[-1] + raw_id = (callback.data or '').split(':', 1)[-1] try: page_id = int(raw_id) except ValueError: @@ -708,7 +732,7 @@ async def start_edit_faq_content( if not page: await callback.answer( - texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + texts.t('ADMIN_FAQ_PAGE_NOT_FOUND', '⚠️ Страница не найдена.'), show_alert=True, ) return @@ -718,18 +742,18 @@ async def start_edit_faq_content( await callback.message.edit_text( texts.t( - "ADMIN_FAQ_EDIT_CONTENT_PROMPT", - "Отправьте новый текст для страницы FAQ.", + 'ADMIN_FAQ_EDIT_CONTENT_PROMPT', + 'Отправьте новый текст для страницы FAQ.', ), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_FAQ_CANCEL_BUTTON", - "⬅️ Отмена", + 'ADMIN_FAQ_CANCEL_BUTTON', + '⬅️ Отмена', ), - callback_data=f"admin_faq_page:{page.id}", + callback_data=f'admin_faq_page:{page.id}', ) ] ] @@ -747,13 +771,13 @@ async def process_edit_faq_content( db: AsyncSession, ): texts = get_texts(db_user.language) - content = message.text or "" + content = message.text or '' if len(content) > 6000: await message.answer( texts.t( - "ADMIN_FAQ_CONTENT_TOO_LONG", - "❌ Текст слишком длинный. Максимум 6000 символов.", + 'ADMIN_FAQ_CONTENT_TOO_LONG', + '❌ Текст слишком длинный. Максимум 6000 символов.', ) ) return @@ -761,8 +785,8 @@ async def process_edit_faq_content( if not content.strip(): await message.answer( texts.t( - "ADMIN_FAQ_CONTENT_EMPTY", - "❌ Текст не может быть пустым.", + 'ADMIN_FAQ_CONTENT_EMPTY', + '❌ Текст не может быть пустым.', ) ) return @@ -771,18 +795,18 @@ async def process_edit_faq_content( if not is_valid: await message.answer( texts.t( - "ADMIN_FAQ_HTML_ERROR", - "❌ Ошибка в HTML: {error}", + 'ADMIN_FAQ_HTML_ERROR', + '❌ Ошибка в HTML: {error}', ).format(error=error_message) ) return data = await state.get_data() - page_id = data.get("faq_page_id") + page_id = data.get('faq_page_id') if not page_id: await state.clear() - await message.answer(texts.t("ADMIN_FAQ_UNEXPECTED_STATE", "⚠️ Состояние сброшено.")) + await message.answer(texts.t('ADMIN_FAQ_UNEXPECTED_STATE', '⚠️ Состояние сброшено.')) return page = await FaqService.get_page( @@ -795,7 +819,7 @@ async def process_edit_faq_content( if not page: await message.answer( - texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + texts.t('ADMIN_FAQ_PAGE_NOT_FOUND', '⚠️ Страница не найдена.'), ) await state.clear() return @@ -804,12 +828,16 @@ async def process_edit_faq_content( await state.clear() await message.answer( - texts.t("ADMIN_FAQ_CONTENT_UPDATED", "✅ Текст страницы обновлён."), + texts.t('ADMIN_FAQ_CONTENT_UPDATED', '✅ Текст страницы обновлён.'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), - callback_data="admin_faq", - )]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_BACK_TO_LIST', '⬅️ К настройкам FAQ'), + callback_data='admin_faq', + ) + ] + ] ), ) @@ -823,7 +851,7 @@ async def toggle_faq_page( ): texts = get_texts(db_user.language) - parts = (callback.data or "").split(":") + parts = (callback.data or '').split(':') try: page_id = int(parts[1]) except (ValueError, IndexError): @@ -840,7 +868,7 @@ async def toggle_faq_page( if not page: await callback.answer( - texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + texts.t('ADMIN_FAQ_PAGE_NOT_FOUND', '⚠️ Страница не найдена.'), show_alert=True, ) return @@ -848,13 +876,13 @@ async def toggle_faq_page( updated_page = await FaqService.update_page(db, page, is_active=not page.is_active) alert_text = texts.t( - "ADMIN_FAQ_PAGE_ENABLED_ALERT", - "✅ Страница включена.", + 'ADMIN_FAQ_PAGE_ENABLED_ALERT', + '✅ Страница включена.', ) if not updated_page.is_active: alert_text = texts.t( - "ADMIN_FAQ_PAGE_DISABLED_ALERT", - "🚫 Страница выключена.", + 'ADMIN_FAQ_PAGE_DISABLED_ALERT', + '🚫 Страница выключена.', ) await callback.answer(alert_text, show_alert=True) @@ -870,7 +898,7 @@ async def delete_faq_page( ): texts = get_texts(db_user.language) - parts = (callback.data or "").split(":") + parts = (callback.data or '').split(':') try: page_id = int(parts[1]) except (ValueError, IndexError): @@ -887,7 +915,7 @@ async def delete_faq_page( if not page: await callback.answer( - texts.t("ADMIN_FAQ_PAGE_NOT_FOUND", "⚠️ Страница не найдена."), + texts.t('ADMIN_FAQ_PAGE_NOT_FOUND', '⚠️ Страница не найдена.'), show_alert=True, ) return @@ -909,7 +937,7 @@ async def delete_faq_page( await FaqService.reorder_pages(db, db_user.language, remaining_sorted) await callback.answer( - texts.t("ADMIN_FAQ_PAGE_DELETED", "🗑️ Страница удалена."), + texts.t('ADMIN_FAQ_PAGE_DELETED', '🗑️ Страница удалена.'), show_alert=True, ) @@ -925,7 +953,7 @@ async def move_faq_page( ): texts = get_texts(db_user.language) - parts = (callback.data or "").split(":") + parts = (callback.data or '').split(':') try: page_id = int(parts[1]) direction = parts[2] @@ -952,12 +980,12 @@ async def move_faq_page( await callback.answer() return - if direction == "up" and index > 0: + if direction == 'up' and index > 0: pages_sorted[index - 1], pages_sorted[index] = ( pages_sorted[index], pages_sorted[index - 1], ) - elif direction == "down" and index < len(pages_sorted) - 1: + elif direction == 'down' and index < len(pages_sorted) - 1: pages_sorted[index + 1], pages_sorted[index] = ( pages_sorted[index], pages_sorted[index + 1], @@ -969,7 +997,7 @@ async def move_faq_page( await FaqService.reorder_pages(db, db_user.language, pages_sorted) await callback.answer( - texts.t("ADMIN_FAQ_PAGE_REORDERED", "✅ Порядок обновлён."), + texts.t('ADMIN_FAQ_PAGE_REORDERED', '✅ Порядок обновлён.'), show_alert=True, ) await show_faq_page_details(callback, db_user, db) @@ -986,12 +1014,14 @@ async def show_faq_html_help( texts = get_texts(db_user.language) help_text = get_html_help_text() - buttons = [[ - types.InlineKeyboardButton( - text=texts.t("ADMIN_FAQ_BACK_TO_LIST", "⬅️ К настройкам FAQ"), - callback_data="admin_faq", - ) - ]] + buttons = [ + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_FAQ_BACK_TO_LIST', '⬅️ К настройкам FAQ'), + callback_data='admin_faq', + ) + ] + ] await callback.message.edit_text( help_text, @@ -1003,47 +1033,47 @@ async def show_faq_html_help( def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_faq_management, - F.data == "admin_faq", + F.data == 'admin_faq', ) dp.callback_query.register( toggle_faq, - F.data == "admin_faq_toggle", + F.data == 'admin_faq_toggle', ) dp.callback_query.register( start_create_faq_page, - F.data == "admin_faq_create", + F.data == 'admin_faq_create', ) dp.callback_query.register( cancel_faq_creation, - F.data == "admin_faq_cancel", + F.data == 'admin_faq_cancel', ) dp.callback_query.register( show_faq_page_details, - F.data.startswith("admin_faq_page:"), + F.data.startswith('admin_faq_page:'), ) dp.callback_query.register( start_edit_faq_title, - F.data.startswith("admin_faq_edit_title:"), + F.data.startswith('admin_faq_edit_title:'), ) dp.callback_query.register( start_edit_faq_content, - F.data.startswith("admin_faq_edit_content:"), + F.data.startswith('admin_faq_edit_content:'), ) dp.callback_query.register( toggle_faq_page, - F.data.startswith("admin_faq_toggle_page:"), + F.data.startswith('admin_faq_toggle_page:'), ) dp.callback_query.register( delete_faq_page, - F.data.startswith("admin_faq_delete:"), + F.data.startswith('admin_faq_delete:'), ) dp.callback_query.register( move_faq_page, - F.data.startswith("admin_faq_move:"), + F.data.startswith('admin_faq_move:'), ) dp.callback_query.register( show_faq_html_help, - F.data == "admin_faq_help", + F.data == 'admin_faq_help', ) dp.message.register( @@ -1062,4 +1092,3 @@ def register_handlers(dp: Dispatcher) -> None: process_edit_faq_content, AdminStates.editing_faq_content, ) - diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 844e339c..218602d0 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -1,376 +1,342 @@ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.filters import Command +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User -from app.keyboards.admin import ( - get_admin_main_keyboard, - get_admin_users_submenu_keyboard, - get_admin_promo_submenu_keyboard, - get_admin_communications_submenu_keyboard, - get_admin_support_submenu_keyboard, - get_admin_settings_submenu_keyboard, - get_admin_system_submenu_keyboard -) -from app.localization.texts import get_texts -from app.handlers.admin import support_settings as support_settings_handlers -from app.utils.decorators import admin_required, error_handler -from app.services.support_settings_service import SupportSettingsService from app.database.crud.rules import clear_all_rules, get_rules_statistics -from app.localization.texts import clear_rules_cache -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from app.database.crud.ticket import TicketCRUD +from app.database.models import User +from app.handlers.admin import support_settings as support_settings_handlers +from app.keyboards.admin import ( + get_admin_communications_submenu_keyboard, + get_admin_main_keyboard, + get_admin_promo_submenu_keyboard, + get_admin_settings_submenu_keyboard, + get_admin_support_submenu_keyboard, + get_admin_system_submenu_keyboard, + get_admin_users_submenu_keyboard, +) +from app.localization.texts import clear_rules_cache, get_texts +from app.services.support_settings_service import SupportSettingsService +from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_admin_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_admin_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) - + admin_text = texts.ADMIN_PANEL try: from app.services.remnawave_service import RemnaWaveService + remnawave_service = RemnaWaveService() stats = await remnawave_service.get_system_statistics() - system_stats = stats.get("system", {}) - users_online = system_stats.get("users_online", 0) - users_today = system_stats.get("users_last_day", 0) - users_week = system_stats.get("users_last_week", 0) + system_stats = stats.get('system', {}) + users_online = system_stats.get('users_online', 0) + users_today = system_stats.get('users_last_day', 0) + users_week = system_stats.get('users_last_week', 0) admin_text = admin_text.replace( - "\n\nВыберите раздел для управления:", + '\n\nВыберите раздел для управления:', ( - f"\n\n- 🟢 Онлайн сейчас: {users_online}" - f"\n- 📅 Онлайн сегодня: {users_today}" - f"\n- 🗓️ На этой неделе: {users_week}" - "\n\nВыберите раздел для управления:" + f'\n\n- 🟢 Онлайн сейчас: {users_online}' + f'\n- 📅 Онлайн сегодня: {users_today}' + f'\n- 🗓️ На этой неделе: {users_week}' + '\n\nВыберите раздел для управления:' ), ) except Exception as e: - logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}") - - await callback.message.edit_text( - admin_text, - reply_markup=get_admin_main_keyboard(db_user.language) - ) + logger.error(f'Не удалось получить статистику Remnawave для админ-панели: {e}') + + await callback.message.edit_text(admin_text, reply_markup=get_admin_main_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler -async def show_users_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_users_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") + - texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"), + texts.t('ADMIN_USERS_SUBMENU_TITLE', '👥 **Управление пользователями и подписками**\n\n') + + texts.t('ADMIN_SUBMENU_SELECT_SECTION', 'Выберите нужный раздел:'), reply_markup=get_admin_users_submenu_keyboard(db_user.language), - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() @admin_required @error_handler -async def show_promo_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promo_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") + - texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"), + texts.t('ADMIN_PROMO_SUBMENU_TITLE', '💰 **Промокоды и статистика**\n\n') + + texts.t('ADMIN_SUBMENU_SELECT_SECTION', 'Выберите нужный раздел:'), reply_markup=get_admin_promo_submenu_keyboard(db_user.language), - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() @admin_required @error_handler -async def show_communications_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_communications_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") + - texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"), + texts.t('ADMIN_COMMUNICATIONS_SUBMENU_TITLE', '📨 **Коммуникации**\n\n') + + texts.t('ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION', 'Управление рассылками и текстами интерфейса:'), reply_markup=get_admin_communications_submenu_keyboard(db_user.language), - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() @admin_required @error_handler -async def show_support_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) # Moderators have access only to tickets and not to settings - is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) - + is_moderator_only = not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator( + callback.from_user.id + ) + kb = get_admin_support_submenu_keyboard(db_user.language) if is_moderator_only: # Rebuild keyboard to include only tickets and back to main menu - kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]) + kb = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_TICKETS', '🎫 Тикеты поддержки'), callback_data='admin_tickets' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ) await callback.message.edit_text( - texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + ( - texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.") + texts.t('ADMIN_SUPPORT_SUBMENU_TITLE', '🛟 **Поддержка**\n\n') + + ( + texts.t('ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR', 'Доступ к тикетам.') if is_moderator_only - else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:") + else texts.t('ADMIN_SUPPORT_SUBMENU_DESCRIPTION', 'Управление тикетами и настройками поддержки:') ), reply_markup=kb, - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() # Moderator panel entry (from main menu quick button) -async def show_moderator_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) - kb = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")] - ]) + kb = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_TICKETS', '🎫 Тикеты поддержки'), callback_data='admin_tickets' + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), callback_data='back_to_menu' + ) + ], + ] + ) await callback.message.edit_text( - texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ Модерация поддержки") + "\n\n" + - texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."), - parse_mode="HTML", - reply_markup=kb + texts.t('ADMIN_SUPPORT_MODERATION_TITLE', '🧑‍⚖️ Модерация поддержки') + + '\n\n' + + texts.t('ADMIN_SUPPORT_MODERATION_DESCRIPTION', 'Доступ к тикетам поддержки.'), + parse_mode='HTML', + reply_markup=kb, ) await callback.answer() @admin_required @error_handler -async def show_support_audit( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_support_audit(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) # pagination page = 1 - if callback.data.startswith("admin_support_audit_page_"): + if callback.data.startswith('admin_support_audit_page_'): try: - page = int(callback.data.split("_")[-1]) + page = int(callback.data.split('_')[-1]) except Exception: page = 1 per_page = 10 total = await TicketCRUD.count_support_audit(db) total_pages = max(1, (total + per_page - 1) // per_page) - if page < 1: - page = 1 - if page > total_pages: - page = total_pages + page = max(page, 1) + page = min(page, total_pages) offset = (page - 1) * per_page logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset) - lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 Аудит модераторов"), ""] + lines = [texts.t('ADMIN_SUPPORT_AUDIT_TITLE', '🧾 Аудит модераторов'), ''] if not logs: - lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто")) + lines.append(texts.t('ADMIN_SUPPORT_AUDIT_EMPTY', 'Пока пусто')) else: for log in logs: role = ( - texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор") + texts.t('ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR', 'Модератор') if getattr(log, 'is_moderator', False) - else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ") + else texts.t('ADMIN_SUPPORT_AUDIT_ROLE_ADMIN', 'Админ') ) ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else '' action_map = { - 'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"), - 'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"), - 'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"), - 'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"), - 'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"), + 'close_ticket': texts.t('ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET', 'Закрытие тикета'), + 'block_user_timed': texts.t('ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED', 'Блокировка (время)'), + 'block_user_perm': texts.t('ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM', 'Блокировка (навсегда)'), + 'close_all_tickets': texts.t( + 'ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS', 'Массовое закрытие тикетов' + ), + 'unblock_user': texts.t('ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK', 'Снятие блока'), } action_text = action_map.get(log.action, log.action) - ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else "" + ticket_part = f' тикет #{log.ticket_id}' if log.ticket_id else '' details = log.details or {} - extra = "" + extra = '' if log.action == 'block_user_timed' and 'minutes' in details: - extra = f" ({details['minutes']} мин)" + extra = f' ({details["minutes"]} мин)' elif log.action == 'close_all_tickets' and 'count' in details: - extra = f" ({details['count']})" - actor_id_display = log.actor_telegram_id or f"user#{log.actor_user_id}" if log.actor_user_id else "unknown" - lines.append(f"{ts} • {role} {actor_id_display} — {action_text}{ticket_part}{extra}") + extra = f' ({details["count"]})' + actor_id_display = log.actor_telegram_id or f'user#{log.actor_user_id}' if log.actor_user_id else 'unknown' + lines.append(f'{ts} • {role} {actor_id_display} — {action_text}{ticket_part}{extra}') # keyboard with pagination nav_row = [] if total_pages > 1: if page > 1: - nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}")) - nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")) + nav_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'admin_support_audit_page_{page - 1}')) + nav_row.append(InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='current_page')) if page < total_pages: - nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}")) + nav_row.append(InlineKeyboardButton(text='➡️', callback_data=f'admin_support_audit_page_{page + 1}')) kb_rows = [] if nav_row: kb_rows.append(nav_row) - kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")]) + kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_support')]) kb = InlineKeyboardMarkup(inline_keyboard=kb_rows) - await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb) + await callback.message.edit_text('\n'.join(lines), parse_mode='HTML', reply_markup=kb) await callback.answer() @admin_required @error_handler -async def show_settings_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_settings_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") + - texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"), + texts.t('ADMIN_SETTINGS_SUBMENU_TITLE', '⚙️ **Настройки системы**\n\n') + + texts.t('ADMIN_SETTINGS_SUBMENU_DESCRIPTION', 'Управление Remnawave, мониторингом и другими настройками:'), reply_markup=get_admin_settings_submenu_keyboard(db_user.language), - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() @admin_required @error_handler -async def show_system_submenu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_system_submenu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") + - texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"), + texts.t('ADMIN_SYSTEM_SUBMENU_TITLE', '🛠️ **Системные функции**\n\n') + + texts.t( + 'ADMIN_SYSTEM_SUBMENU_DESCRIPTION', 'Отчеты, обновления, логи, резервные копии и системные операции:' + ), reply_markup=get_admin_system_submenu_keyboard(db_user.language), - parse_mode="Markdown" + parse_mode='Markdown', ) await callback.answer() - @admin_required @error_handler -async def clear_rules_command( - message: types.Message, - db_user: User, - db: AsyncSession -): +async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSession): try: stats = await get_rules_statistics(db) - + if stats['total_active'] == 0: await message.reply( - "ℹ️ Правила уже очищены\n\n" - "В системе нет активных правил. Используются стандартные правила по умолчанию." + 'ℹ️ Правила уже очищены\n\n' + 'В системе нет активных правил. Используются стандартные правила по умолчанию.' ) return - + success = await clear_all_rules(db, db_user.language) - + if success: clear_rules_cache() - + await message.reply( - f"✅ Правила успешно очищены!\n\n" - f"📊 Статистика:\n" - f"• Очищено правил: {stats['total_active']}\n" - f"• Язык: {db_user.language}\n" - f"• Выполнил: {db_user.full_name}\n\n" - f"Теперь используются стандартные правила по умолчанию." + f'✅ Правила успешно очищены!\n\n' + f'📊 Статистика:\n' + f'• Очищено правил: {stats["total_active"]}\n' + f'• Язык: {db_user.language}\n' + f'• Выполнил: {db_user.full_name}\n\n' + f'Теперь используются стандартные правила по умолчанию.' ) - - logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})") + + logger.info(f'Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})') else: - await message.reply( - "⚠️ Нет правил для очистки\n\n" - "Активные правила не найдены." - ) - + await message.reply('⚠️ Нет правил для очистки\n\nАктивные правила не найдены.') + except Exception as e: - logger.error(f"Ошибка при очистке правил командой: {e}") + logger.error(f'Ошибка при очистке правил командой: {e}') await message.reply( - "❌ Ошибка при очистке правил\n\n" - f"Произошла ошибка: {str(e)}\n" - "Попробуйте через админ-панель или повторите позже." + '❌ Ошибка при очистке правил\n\n' + f'Произошла ошибка: {e!s}\n' + 'Попробуйте через админ-панель или повторите позже.' ) @admin_required @error_handler -async def rules_stats_command( - message: types.Message, - db_user: User, - db: AsyncSession -): +async def rules_stats_command(message: types.Message, db_user: User, db: AsyncSession): try: stats = await get_rules_statistics(db) - + if 'error' in stats: - await message.reply(f"❌ Ошибка получения статистики: {stats['error']}") + await message.reply(f'❌ Ошибка получения статистики: {stats["error"]}') return - - text = f"📊 Статистика правил сервиса\n\n" - text += f"📋 Общая информация:\n" - text += f"• Активных правил: {stats['total_active']}\n" - text += f"• Всего в истории: {stats['total_all_time']}\n" - text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n" - + + text = '📊 Статистика правил сервиса\n\n' + text += '📋 Общая информация:\n' + text += f'• Активных правил: {stats["total_active"]}\n' + text += f'• Всего в истории: {stats["total_all_time"]}\n' + text += f'• Поддерживаемых языков: {stats["total_languages"]}\n\n' + if stats['languages']: - text += f"🌐 По языкам:\n" + text += '🌐 По языкам:\n' for lang, lang_stats in stats['languages'].items(): - text += f"• {lang}: {lang_stats['active_count']} правил, " - text += f"{lang_stats['content_length']} символов\n" + text += f'• {lang}: {lang_stats["active_count"]} правил, ' + text += f'{lang_stats["content_length"]} символов\n' if lang_stats['last_updated']: - text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n" + text += f' Обновлено: {lang_stats["last_updated"].strftime("%d.%m.%Y %H:%M")}\n' else: - text += "ℹ️ Активных правил нет - используются правила по умолчанию" - + text += 'ℹ️ Активных правил нет - используются правила по умолчанию' + await message.reply(text) - + except Exception as e: - logger.error(f"Ошибка при получении статистики правил: {e}") - await message.reply( - f"❌ Ошибка получения статистики\n\n" - f"Произошла ошибка: {str(e)}" - ) + logger.error(f'Ошибка при получении статистики правил: {e}') + await message.reply(f'❌ Ошибка получения статистики\n\nПроизошла ошибка: {e!s}') @admin_required @error_handler -async def admin_commands_help( - message: types.Message, - db_user: User, - db: AsyncSession -): +async def admin_commands_help(message: types.Message, db_user: User, db: AsyncSession): help_text = """ 🔧 Доступные админские команды: @@ -387,67 +353,33 @@ async def admin_commands_help( ⚠️ Важно: Все команды логируются и требуют админских прав. """ - + await message.reply(help_text) def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_admin_panel, F.data == 'admin_panel') + + dp.callback_query.register(show_users_submenu, F.data == 'admin_submenu_users') + + dp.callback_query.register(show_promo_submenu, F.data == 'admin_submenu_promo') + + dp.callback_query.register(show_communications_submenu, F.data == 'admin_submenu_communications') + + dp.callback_query.register(show_support_submenu, F.data == 'admin_submenu_support') dp.callback_query.register( - show_admin_panel, - F.data == "admin_panel" - ) - - dp.callback_query.register( - show_users_submenu, - F.data == "admin_submenu_users" - ) - - dp.callback_query.register( - show_promo_submenu, - F.data == "admin_submenu_promo" - ) - - dp.callback_query.register( - show_communications_submenu, - F.data == "admin_submenu_communications" - ) - - dp.callback_query.register( - show_support_submenu, - F.data == "admin_submenu_support" - ) - dp.callback_query.register( - show_support_audit, - F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_") - ) - - dp.callback_query.register( - show_settings_submenu, - F.data == "admin_submenu_settings" - ) - - dp.callback_query.register( - show_system_submenu, - F.data == "admin_submenu_system" - ) - dp.callback_query.register( - show_moderator_panel, - F.data == "moderator_panel" + show_support_audit, F.data.in_(['admin_support_audit']) | F.data.startswith('admin_support_audit_page_') ) + + dp.callback_query.register(show_settings_submenu, F.data == 'admin_submenu_settings') + + dp.callback_query.register(show_system_submenu, F.data == 'admin_submenu_system') + dp.callback_query.register(show_moderator_panel, F.data == 'moderator_panel') # Support settings module support_settings_handlers.register_handlers(dp) - - dp.message.register( - clear_rules_command, - Command("clear_rules") - ) - - dp.message.register( - rules_stats_command, - Command("rules_stats") - ) - - dp.message.register( - admin_commands_help, - Command("admin_help") - ) + + dp.message.register(clear_rules_command, Command('clear_rules')) + + dp.message.register(rules_stats_command, Command('rules_stats')) + + dp.message.register(admin_commands_help, Command('admin_help')) diff --git a/app/handlers/admin/maintenance.py b/app/handlers/admin/maintenance.py index 82b67ced..0fa7c07a 100644 --- a/app/handlers/admin/maintenance.py +++ b/app/handlers/admin/maintenance.py @@ -1,16 +1,17 @@ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.models import User -from app.services.maintenance_service import maintenance_service -from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard +from app.keyboards.admin import get_admin_main_keyboard, get_maintenance_keyboard from app.localization.texts import get_texts +from app.services.maintenance_service import maintenance_service from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @@ -21,53 +22,49 @@ class MaintenanceStates(StatesGroup): @admin_required @error_handler -async def show_maintenance_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - texts = get_texts(db_user.language) - +async def show_maintenance_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + get_texts(db_user.language) + status_info = maintenance_service.get_status_info() - + try: from app.services.remnawave_service import RemnaWaveService + rw_service = RemnaWaveService() panel_status = await rw_service.get_panel_status_summary() except Exception as e: - logger.error(f"Ошибка получения статуса панели: {e}") - panel_status = {"description": "❓ Не удалось проверить", "has_issues": True} - - status_emoji = "🔧" if status_info["is_active"] else "✅" - status_text = "Включен" if status_info["is_active"] else "Выключен" - - api_emoji = "✅" if status_info["api_status"] else "❌" - api_text = "Доступно" if status_info["api_status"] else "Недоступно" - - monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️" - monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен" - - enabled_info = "" - if status_info["is_active"] and status_info["enabled_at"]: - enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S") - enabled_info = f"\n📅 Включен: {enabled_time}" - if status_info["reason"]: - enabled_info += f"\n📝 Причина: {status_info['reason']}" - - last_check_info = "" - if status_info["last_check"]: - last_check_time = status_info["last_check"].strftime("%H:%M:%S") - last_check_info = f"\n🕐 Последняя проверка: {last_check_time}" - - failures_info = "" - if status_info["consecutive_failures"] > 0: - failures_info = f"\n⚠️ Неудачных проверок подряд: {status_info['consecutive_failures']}" - - panel_info = f"\n🌐 Панель Remnawave: {panel_status['description']}" - if panel_status.get("response_time"): - panel_info += f"\n⚡ Время отклика: {panel_status['response_time']}с" - + logger.error(f'Ошибка получения статуса панели: {e}') + panel_status = {'description': '❓ Не удалось проверить', 'has_issues': True} + + status_emoji = '🔧' if status_info['is_active'] else '✅' + status_text = 'Включен' if status_info['is_active'] else 'Выключен' + + api_emoji = '✅' if status_info['api_status'] else '❌' + api_text = 'Доступно' if status_info['api_status'] else 'Недоступно' + + monitoring_emoji = '🔄' if status_info['monitoring_active'] else '⏹️' + monitoring_text = 'Запущен' if status_info['monitoring_active'] else 'Остановлен' + + enabled_info = '' + if status_info['is_active'] and status_info['enabled_at']: + enabled_time = status_info['enabled_at'].strftime('%d.%m.%Y %H:%M:%S') + enabled_info = f'\n📅 Включен: {enabled_time}' + if status_info['reason']: + enabled_info += f'\n📝 Причина: {status_info["reason"]}' + + last_check_info = '' + if status_info['last_check']: + last_check_time = status_info['last_check'].strftime('%H:%M:%S') + last_check_info = f'\n🕐 Последняя проверка: {last_check_time}' + + failures_info = '' + if status_info['consecutive_failures'] > 0: + failures_info = f'\n⚠️ Неудачных проверок подряд: {status_info["consecutive_failures"]}' + + panel_info = f'\n🌐 Панель Remnawave: {panel_status["description"]}' + if panel_status.get('response_time'): + panel_info += f'\n⚡ Время отклика: {panel_status["response_time"]}с' + message_text = f""" 🔧 Управление техническими работами @@ -84,356 +81,280 @@ async def show_maintenance_panel( ℹ️ В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ. """ - + await callback.message.edit_text( message_text, reply_markup=get_maintenance_keyboard( - db_user.language, - status_info["is_active"], - status_info["monitoring_active"], - panel_status.get("has_issues", False) - ) + db_user.language, + status_info['is_active'], + status_info['monitoring_active'], + panel_status.get('has_issues', False), + ), ) await callback.answer() @admin_required @error_handler -async def toggle_maintenance_mode( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def toggle_maintenance_mode(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): is_active = maintenance_service.is_maintenance_active() - + if is_active: success = await maintenance_service.disable_maintenance() if success: - await callback.answer("Режим техработ выключен", show_alert=True) + await callback.answer('Режим техработ выключен', show_alert=True) else: - await callback.answer("Ошибка выключения режима техработ", show_alert=True) + await callback.answer('Ошибка выключения режима техработ', show_alert=True) else: await state.set_state(MaintenanceStates.waiting_for_reason) await callback.message.edit_text( - "🔧 Включение режима техработ\n\nВведите причину включения техработ или отправьте /skip для пропуска:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")] - ]) + '🔧 Включение режима техработ\n\nВведите причину включения техработ или отправьте /skip для пропуска:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='maintenance_panel')]] + ), ) - + await callback.answer() @admin_required @error_handler -async def process_maintenance_reason( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_maintenance_reason(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): current_state = await state.get_state() - + if current_state != MaintenanceStates.waiting_for_reason: return - + reason = None - if message.text and message.text != "/skip": - reason = message.text[:200] - + if message.text and message.text != '/skip': + reason = message.text[:200] + success = await maintenance_service.enable_maintenance(reason=reason, auto=False) - + if success: - response_text = "Режим техработ включен" + response_text = 'Режим техработ включен' if reason: - response_text += f"\nПричина: {reason}" + response_text += f'\nПричина: {reason}' else: - response_text = "Ошибка включения режима техработ" - + response_text = 'Ошибка включения режима техработ' + await message.answer(response_text) await state.clear() - - status_info = maintenance_service.get_status_info() + + maintenance_service.get_status_info() await message.answer( - "Вернуться к панели управления техработами:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")] - ]) + 'Вернуться к панели управления техработами:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔧 Панель техработ', callback_data='maintenance_panel')]] + ), ) -@admin_required +@admin_required @error_handler -async def toggle_monitoring( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_monitoring(callback: types.CallbackQuery, db_user: User, db: AsyncSession): status_info = maintenance_service.get_status_info() - - if status_info["monitoring_active"]: + + if status_info['monitoring_active']: success = await maintenance_service.stop_monitoring() - message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга" + message = 'Мониторинг остановлен' if success else 'Ошибка остановки мониторинга' else: success = await maintenance_service.start_monitoring() - message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга" - + message = 'Мониторинг запущен' if success else 'Ошибка запуска мониторинга' + await callback.answer(message, show_alert=True) - + await show_maintenance_panel(callback, db_user, db, None) @admin_required @error_handler -async def force_api_check( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - await callback.answer("Проверка API...", show_alert=False) - +async def force_api_check(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.answer('Проверка API...', show_alert=False) + check_result = await maintenance_service.force_api_check() - - if check_result["success"]: - status_text = "доступно" if check_result["api_available"] else "недоступно" - message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с" + + if check_result['success']: + status_text = 'доступно' if check_result['api_available'] else 'недоступно' + message = f'API {status_text}\nВремя ответа: {check_result["response_time"]}с' else: - message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}" - + message = f'Ошибка проверки: {check_result.get("error", "Неизвестная ошибка")}' + await callback.message.answer(message) - + await show_maintenance_panel(callback, db_user, db, None) @admin_required @error_handler -async def check_panel_status( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - await callback.answer("Проверка статуса панели...", show_alert=False) - +async def check_panel_status(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.answer('Проверка статуса панели...', show_alert=False) + try: from app.services.remnawave_service import RemnaWaveService + rw_service = RemnaWaveService() - + status_data = await rw_service.check_panel_health() - + status_text = { - "online": "🟢 Панель работает нормально", - "offline": "🔴 Панель недоступна", - "degraded": "🟡 Панель работает со сбоями" - }.get(status_data["status"], "❓ Статус неизвестен") - + 'online': '🟢 Панель работает нормально', + 'offline': '🔴 Панель недоступна', + 'degraded': '🟡 Панель работает со сбоями', + }.get(status_data['status'], '❓ Статус неизвестен') + message_parts = [ - f"🌐 Статус панели Remnawave\n", - f"{status_text}", - f"⚡ Время отклика: {status_data.get('response_time', 0)}с", - f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}", - f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}" + '🌐 Статус панели Remnawave\n', + f'{status_text}', + f'⚡ Время отклика: {status_data.get("response_time", 0)}с', + f'👥 Пользователей онлайн: {status_data.get("users_online", 0)}', + f'🖥️ Нод онлайн: {status_data.get("nodes_online", 0)}/{status_data.get("total_nodes", 0)}', ] - attempts_used = status_data.get("attempts_used") + attempts_used = status_data.get('attempts_used') if attempts_used: - message_parts.append(f"🔁 Попыток проверки: {attempts_used}") + message_parts.append(f'🔁 Попыток проверки: {attempts_used}') + + if status_data.get('api_error'): + message_parts.append(f'❌ Ошибка: {status_data["api_error"][:100]}') + + message = '\n'.join(message_parts) + + await callback.message.answer(message, parse_mode='HTML') - if status_data.get("api_error"): - message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}") - - message = "\n".join(message_parts) - - await callback.message.answer(message, parse_mode="HTML") - except Exception as e: - await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}") + await callback.message.answer(f'❌ Ошибка проверки статуса: {e!s}') @admin_required @error_handler -async def send_manual_notification( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def send_manual_notification(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): await state.set_state(MaintenanceStates.waiting_for_notification_message) - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"), - types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline") - ], - [ - types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"), - types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance") - ], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='🟢 Онлайн', callback_data='manual_notify_online'), + types.InlineKeyboardButton(text='🔴 Офлайн', callback_data='manual_notify_offline'), + ], + [ + types.InlineKeyboardButton(text='🟡 Проблемы', callback_data='manual_notify_degraded'), + types.InlineKeyboardButton(text='🔧 Обслуживание', callback_data='manual_notify_maintenance'), + ], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='maintenance_panel')], + ] + ) + await callback.message.edit_text( - "📢 Ручная отправка уведомления\n\nВыберите статус для уведомления:", - reply_markup=keyboard + '📢 Ручная отправка уведомления\n\nВыберите статус для уведомления:', reply_markup=keyboard ) @admin_required @error_handler -async def handle_manual_notification( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def handle_manual_notification(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): status_map = { - "manual_notify_online": "online", - "manual_notify_offline": "offline", - "manual_notify_degraded": "degraded", - "manual_notify_maintenance": "maintenance" + 'manual_notify_online': 'online', + 'manual_notify_offline': 'offline', + 'manual_notify_degraded': 'degraded', + 'manual_notify_maintenance': 'maintenance', } - + status = status_map.get(callback.data) if not status: - await callback.answer("Неизвестный статус") + await callback.answer('Неизвестный статус') return - + await state.update_data(notification_status=status) - + status_names = { - "online": "🟢 Онлайн", - "offline": "🔴 Офлайн", - "degraded": "🟡 Проблемы", - "maintenance": "🔧 Обслуживание" + 'online': '🟢 Онлайн', + 'offline': '🔴 Офлайн', + 'degraded': '🟡 Проблемы', + 'maintenance': '🔧 Обслуживание', } - + await callback.message.edit_text( - f"📢 Отправка уведомления: {status_names[status]}\n\n" - f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")] - ]) + f'📢 Отправка уведомления: {status_names[status]}\n\n' + f'Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='maintenance_panel')]] + ), ) @admin_required @error_handler -async def process_notification_message( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_notification_message(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): current_state = await state.get_state() - + if current_state != MaintenanceStates.waiting_for_notification_message: return - + data = await state.get_data() - status = data.get("notification_status") - + status = data.get('notification_status') + if not status: - await message.answer("Ошибка: статус не выбран") + await message.answer('Ошибка: статус не выбран') await state.clear() return - - notification_message = "" - if message.text and message.text != "/skip": + + notification_message = '' + if message.text and message.text != '/skip': notification_message = message.text[:300] - + try: from app.services.remnawave_service import RemnaWaveService + rw_service = RemnaWaveService() - - success = await rw_service.send_manual_status_notification( - message.bot, - status, - notification_message - ) - + + success = await rw_service.send_manual_status_notification(message.bot, status, notification_message) + if success: - await message.answer("✅ Уведомление отправлено") + await message.answer('✅ Уведомление отправлено') else: - await message.answer("❌ Ошибка отправки уведомления") - + await message.answer('❌ Ошибка отправки уведомления') + except Exception as e: - logger.error(f"Ошибка отправки ручного уведомления: {e}") - await message.answer(f"❌ Ошибка: {str(e)}") - + logger.error(f'Ошибка отправки ручного уведомления: {e}') + await message.answer(f'❌ Ошибка: {e!s}') + await state.clear() - + await message.answer( - "Вернуться к панели техработ:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")] - ]) + 'Вернуться к панели техработ:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🔧 Панель техработ', callback_data='maintenance_panel')]] + ), ) @admin_required @error_handler -async def back_to_admin_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def back_to_admin_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) - - await callback.message.edit_text( - texts.ADMIN_PANEL, - reply_markup=get_admin_main_keyboard(db_user.language) - ) + + await callback.message.edit_text(texts.ADMIN_PANEL, reply_markup=get_admin_main_keyboard(db_user.language)) await callback.answer() def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_maintenance_panel, - F.data == "maintenance_panel" - ) - - dp.callback_query.register( - toggle_maintenance_mode, - F.data == "maintenance_toggle" - ) - - dp.callback_query.register( - toggle_monitoring, - F.data == "maintenance_monitoring" - ) - - dp.callback_query.register( - force_api_check, - F.data == "maintenance_check_api" - ) - - dp.callback_query.register( - check_panel_status, - F.data == "maintenance_check_panel" - ) - - dp.callback_query.register( - send_manual_notification, - F.data == "maintenance_manual_notify" - ) - - dp.callback_query.register( - handle_manual_notification, - F.data.startswith("manual_notify_") - ) - - dp.callback_query.register( - back_to_admin_panel, - F.data == "admin_panel" - ) - - dp.message.register( - process_maintenance_reason, - MaintenanceStates.waiting_for_reason - ) - - dp.message.register( - process_notification_message, - MaintenanceStates.waiting_for_notification_message - ) + dp.callback_query.register(show_maintenance_panel, F.data == 'maintenance_panel') + + dp.callback_query.register(toggle_maintenance_mode, F.data == 'maintenance_toggle') + + dp.callback_query.register(toggle_monitoring, F.data == 'maintenance_monitoring') + + dp.callback_query.register(force_api_check, F.data == 'maintenance_check_api') + + dp.callback_query.register(check_panel_status, F.data == 'maintenance_check_panel') + + dp.callback_query.register(send_manual_notification, F.data == 'maintenance_manual_notify') + + dp.callback_query.register(handle_manual_notification, F.data.startswith('manual_notify_')) + + dp.callback_query.register(back_to_admin_panel, F.data == 'admin_panel') + + dp.message.register(process_maintenance_reason, MaintenanceStates.waiting_for_reason) + + dp.message.register(process_notification_message, MaintenanceStates.waiting_for_notification_message) diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 698294ad..1f6b9cac 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -1,79 +1,73 @@ +import asyncio import html import logging -import asyncio from datetime import datetime, timedelta -from typing import Optional -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import InterfaceError -from sqlalchemy import select, func, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import AdminStates -from app.database.models import ( - User, - UserStatus, - Subscription, - SubscriptionStatus, - BroadcastHistory, - Tariff, -) -from app.database.database import AsyncSessionLocal -from app.keyboards.admin import ( - get_admin_messages_keyboard, get_broadcast_target_keyboard, - get_custom_criteria_keyboard, get_broadcast_history_keyboard, - get_admin_pagination_keyboard, get_broadcast_media_keyboard, - get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media, - BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS, - get_broadcast_button_config, get_broadcast_button_labels, get_pinned_message_keyboard -) -from app.localization.texts import get_texts -from app.database.crud.user import get_users_list from app.database.crud.subscription import get_expiring_subscriptions from app.database.crud.tariff import get_all_tariffs -from app.utils.decorators import admin_required, error_handler -from app.utils.miniapp_buttons import build_miniapp_or_callback_button +from app.database.crud.user import get_users_list +from app.database.database import AsyncSessionLocal +from app.database.models import ( + BroadcastHistory, + Subscription, + SubscriptionStatus, + User, + UserStatus, +) +from app.keyboards.admin import ( + BROADCAST_BUTTON_ROWS, + DEFAULT_BROADCAST_BUTTONS, + get_admin_messages_keyboard, + get_broadcast_button_config, + get_broadcast_button_labels, + get_broadcast_history_keyboard, + get_broadcast_media_keyboard, + get_broadcast_target_keyboard, + get_custom_criteria_keyboard, + get_media_confirm_keyboard, + get_pinned_message_keyboard, + get_updated_message_buttons_selector_keyboard_with_media, +) +from app.localization.texts import get_texts from app.services.pinned_message_service import ( broadcast_pinned_message, get_active_pinned_message, set_active_pinned_message, unpin_active_pinned_message, ) +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.miniapp_buttons import build_miniapp_or_callback_button + logger = logging.getLogger(__name__) -async def safe_edit_or_send_text( - callback: types.CallbackQuery, - text: str, - reply_markup=None, - parse_mode: str = "HTML" -): +async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply_markup=None, parse_mode: str = 'HTML'): """ Безопасно редактирует сообщение или удаляет и отправляет новое. Нужно для случаев, когда текущее сообщение - медиа (фото/видео), которое нельзя отредактировать через edit_text. """ try: - await callback.message.edit_text( - text, - reply_markup=reply_markup, - parse_mode=parse_mode - ) + await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode) except TelegramBadRequest as e: - if "there is no text in the message to edit" in str(e): + if 'there is no text in the message to edit' in str(e): # Сообщение - медиа без текста, удаляем и отправляем новое try: await callback.message.delete() except Exception: pass await callback.bot.send_message( - chat_id=callback.message.chat.id, - text=text, - reply_markup=reply_markup, - parse_mode=parse_mode + chat_id=callback.message.chat.id, text=text, reply_markup=reply_markup, parse_mode=parse_mode ) else: raise @@ -83,23 +77,25 @@ BUTTON_ROWS = BROADCAST_BUTTON_ROWS DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS TEXT_MENU_MINIAPP_BUTTON_KEYS = { - "balance", - "referrals", - "promocode", - "connect", - "subscription", + 'balance', + 'referrals', + 'promocode', + 'connect', + 'subscription', } -def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup: +def get_message_buttons_selector_keyboard(language: str = 'ru') -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language) -def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup: +def get_updated_message_buttons_selector_keyboard( + selected_buttons: list, language: str = 'ru' +) -> types.InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language) -def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]: +def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None: selected_buttons = selected_buttons or [] keyboard: list[list[types.InlineKeyboardButton]] = [] button_config_map = get_broadcast_button_config(language) @@ -113,16 +109,13 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS: row_buttons.append( build_miniapp_or_callback_button( - text=button_config["text"], - callback_data=button_config["callback"], + text=button_config['text'], + callback_data=button_config['callback'], ) ) else: row_buttons.append( - types.InlineKeyboardButton( - text=button_config["text"], - callback_data=button_config["callback"] - ) + types.InlineKeyboardButton(text=button_config['text'], callback_data=button_config['callback']) ) if row_buttons: keyboard.append(row_buttons) @@ -152,7 +145,7 @@ async def _persist_broadcast_result( return except InterfaceError as error: logger.warning( - "Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз", + 'Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз', exc_info=error, ) await db.rollback() @@ -162,7 +155,7 @@ async def _persist_broadcast_result( retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id) if not retry_history: logger.critical( - "Не удалось найти запись BroadcastHistory #%s для повторной записи результатов", + 'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов', broadcast_history.id, ) return @@ -173,23 +166,19 @@ async def _persist_broadcast_result( retry_history.completed_at = broadcast_history.completed_at await retry_session.commit() logger.info( - "Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)", + 'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)', broadcast_history.id, ) except Exception as retry_error: logger.critical( - "Не удалось сохранить результаты рассылки после восстановления подключения", + 'Не удалось сохранить результаты рассылки после восстановления подключения', exc_info=retry_error, ) @admin_required @error_handler -async def show_messages_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_messages_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): text = """ 📨 Управление рассылками @@ -202,12 +191,9 @@ async def show_messages_menu( ⚠️ Будьте осторожны с массовыми рассылками! """ - + await safe_edit_or_send_text( - callback, - text, - reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML" + callback, text, reply_markup=get_admin_messages_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() @@ -224,46 +210,40 @@ async def show_pinned_message_menu( pinned_message = await get_active_pinned_message(db) if pinned_message: - content_preview = html.escape(pinned_message.content or "") + content_preview = html.escape(pinned_message.content or '') last_updated = pinned_message.updated_at or pinned_message.created_at - timestamp_text = last_updated.strftime("%d.%m.%Y %H:%M") if last_updated else "—" - media_line = "" + timestamp_text = last_updated.strftime('%d.%m.%Y %H:%M') if last_updated else '—' + media_line = '' if pinned_message.media_type: - media_label = "Фото" if pinned_message.media_type == "photo" else "Видео" - media_line = f"📎 Медиа: {media_label}\n" - position_line = ( - "⬆️ Отправлять перед меню" - if pinned_message.send_before_menu - else "⬇️ Отправлять после меню" - ) + media_label = 'Фото' if pinned_message.media_type == 'photo' else 'Видео' + media_line = f'📎 Медиа: {media_label}\n' + position_line = '⬆️ Отправлять перед меню' if pinned_message.send_before_menu else '⬇️ Отправлять после меню' start_mode_line = ( - "🔁 При каждом /start" - if pinned_message.send_on_every_start - else "🚫 Только один раз и при обновлении" + '🔁 При каждом /start' if pinned_message.send_on_every_start else '🚫 Только один раз и при обновлении' ) body = ( - "📌 Закрепленное сообщение\n\n" - "📝 Текущий текст:\n" - f"{content_preview}\n\n" - f"{media_line}" - f"{position_line}\n" - f"{start_mode_line}\n" - f"🕒 Обновлено: {timestamp_text}" + '📌 Закрепленное сообщение\n\n' + '📝 Текущий текст:\n' + f'{content_preview}\n\n' + f'{media_line}' + f'{position_line}\n' + f'{start_mode_line}\n' + f'🕒 Обновлено: {timestamp_text}' ) else: body = ( - "📌 Закрепленное сообщение\n\n" - "Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей." + '📌 Закрепленное сообщение\n\n' + 'Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей.' ) await callback.message.edit_text( body, reply_markup=get_pinned_message_keyboard( db_user.language, - send_before_menu=getattr(pinned_message, "send_before_menu", True), - send_on_every_start=getattr(pinned_message, "send_on_every_start", True), + send_before_menu=getattr(pinned_message, 'send_before_menu', True), + send_on_every_start=getattr(pinned_message, 'send_on_every_start', True), ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -277,13 +257,13 @@ async def prompt_pinned_message_update( ): await state.set_state(AdminStates.editing_pinned_message) await callback.message.edit_text( - "✏️ Новое закрепленное сообщение\n\n" - "Пришлите текст, фото или видео, которое нужно закрепить.\n" - "Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_pinned_message")] - ]), - parse_mode="HTML", + '✏️ Новое закрепленное сообщение\n\n' + 'Пришлите текст, фото или видео, которое нужно закрепить.\n' + 'Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_pinned_message')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -298,7 +278,7 @@ async def toggle_pinned_message_position( ): pinned_message = await get_active_pinned_message(db) if not pinned_message: - await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True) + await callback.answer('Сначала задайте закрепленное сообщение', show_alert=True) return pinned_message.send_before_menu = not pinned_message.send_before_menu @@ -318,7 +298,7 @@ async def toggle_pinned_message_start_mode( ): pinned_message = await get_active_pinned_message(db) if not pinned_message: - await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True) + await callback.answer('Сначала задайте закрепленное сообщение', show_alert=True) return pinned_message.send_on_every_start = not pinned_message.send_on_every_start @@ -338,13 +318,12 @@ async def delete_pinned_message( ): pinned_message = await get_active_pinned_message(db) if not pinned_message: - await callback.answer("Закрепленное сообщение уже отсутствует", show_alert=True) + await callback.answer('Закрепленное сообщение уже отсутствует', show_alert=True) return await callback.message.edit_text( - "🗑️ Удаление закрепленного сообщения\n\n" - "Подождите, пока бот открепит сообщение у пользователей...", - parse_mode="HTML", + '🗑️ Удаление закрепленного сообщения\n\nПодождите, пока бот открепит сообщение у пользователей...', + parse_mode='HTML', ) unpinned_count, failed_count, deleted = await unpin_active_pinned_message( @@ -354,22 +333,22 @@ async def delete_pinned_message( if not deleted: await callback.message.edit_text( - "❌ Не удалось найти активное закрепленное сообщение для удаления", + '❌ Не удалось найти активное закрепленное сообщение для удаления', reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return total = unpinned_count + failed_count await callback.message.edit_text( - "✅ Закрепленное сообщение удалено\n\n" - f"👥 Чатов обработано: {total}\n" - f"✅ Откреплено: {unpinned_count}\n" - f"⚠️ Ошибок: {failed_count}\n\n" - "Новое сообщение можно задать кнопкой \"Обновить\".", + '✅ Закрепленное сообщение удалено\n\n' + f'👥 Чатов обработано: {total}\n' + f'✅ Откреплено: {unpinned_count}\n' + f'⚠️ Ошибок: {failed_count}\n\n' + 'Новое сообщение можно задать кнопкой "Обновить".', reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() @@ -383,21 +362,21 @@ async def process_pinned_message_update( db: AsyncSession, ): texts = get_texts(db_user.language) - media_type: Optional[str] = None - media_file_id: Optional[str] = None + media_type: str | None = None + media_file_id: str | None = None if message.photo: - media_type = "photo" + media_type = 'photo' media_file_id = message.photo[-1].file_id elif message.video: - media_type = "video" + media_type = 'video' media_file_id = message.video.file_id - pinned_text = message.html_text or message.caption_html or message.text or message.caption or "" + pinned_text = message.html_text or message.caption_html or message.text or message.caption or '' if not pinned_text and not media_file_id: await message.answer( - texts.t("ADMIN_PINNED_NO_CONTENT", "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.") + texts.t('ADMIN_PINNED_NO_CONTENT', '❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.') ) return @@ -410,7 +389,7 @@ async def process_pinned_message_update( media_file_id=media_file_id, ) except ValueError as validation_error: - await message.answer(f"❌ {validation_error}") + await message.answer(f'❌ {validation_error}') return # Сообщение сохранено, спрашиваем о рассылке @@ -419,14 +398,14 @@ async def process_pinned_message_update( await message.answer( texts.t( - "ADMIN_PINNED_SAVED_ASK_BROADCAST", - "📌 Сообщение сохранено!\n\n" - "Выберите, как доставить сообщение пользователям:\n\n" - "• Разослать сейчас — отправит и закрепит у всех активных пользователей\n" - "• Только при /start — пользователи увидят при следующем запуске бота", + 'ADMIN_PINNED_SAVED_ASK_BROADCAST', + '📌 Сообщение сохранено!\n\n' + 'Выберите, как доставить сообщение пользователям:\n\n' + '• Разослать сейчас — отправит и закрепит у всех активных пользователей\n' + '• Только при /start — пользователи увидят при следующем запуске бота', ), reply_markup=get_pinned_broadcast_confirm_keyboard(db_user.language, pinned_message.id), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(AdminStates.confirming_pinned_broadcast) @@ -443,25 +422,24 @@ async def handle_pinned_broadcast_now( texts = get_texts(db_user.language) # Получаем ID сообщения из callback_data - pinned_message_id = int(callback.data.split(":")[1]) + pinned_message_id = int(callback.data.split(':')[1]) # Получаем сообщение из БД from sqlalchemy import select + from app.database.models import PinnedMessage - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == pinned_message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == pinned_message_id)) pinned_message = result.scalar_one_or_none() if not pinned_message: - await callback.answer("❌ Сообщение не найдено", show_alert=True) + await callback.answer('❌ Сообщение не найдено', show_alert=True) await state.clear() return await callback.message.edit_text( - texts.t("ADMIN_PINNED_SAVING", "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей..."), - parse_mode="HTML", + texts.t('ADMIN_PINNED_SAVING', '📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей...'), + parse_mode='HTML', ) sent_count, failed_count = await broadcast_pinned_message( @@ -473,14 +451,14 @@ async def handle_pinned_broadcast_now( total = sent_count + failed_count await callback.message.edit_text( texts.t( - "ADMIN_PINNED_UPDATED", - "✅ Закрепленное сообщение обновлено\n\n" - "👥 Получателей: {total}\n" - "✅ Отправлено: {sent}\n" - "⚠️ Ошибок: {failed}", + 'ADMIN_PINNED_UPDATED', + '✅ Закрепленное сообщение обновлено\n\n' + '👥 Получателей: {total}\n' + '✅ Отправлено: {sent}\n' + '⚠️ Ошибок: {failed}', ).format(total=total, sent=sent_count, failed=failed_count), reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() @@ -498,50 +476,40 @@ async def handle_pinned_broadcast_skip( await callback.message.edit_text( texts.t( - "ADMIN_PINNED_SAVED_NO_BROADCAST", - "✅ Закрепленное сообщение сохранено\n\n" - "Рассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.", + 'ADMIN_PINNED_SAVED_NO_BROADCAST', + '✅ Закрепленное сообщение сохранено\n\n' + 'Рассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.', ), reply_markup=get_admin_messages_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() @admin_required @error_handler -async def show_broadcast_targets( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def show_broadcast_targets(callback: types.CallbackQuery, db_user: User, state: FSMContext): await callback.message.edit_text( - "🎯 Выбор целевой аудитории\n\n" - "Выберите категорию пользователей для рассылки:", + '🎯 Выбор целевой аудитории\n\nВыберите категорию пользователей для рассылки:', reply_markup=get_broadcast_target_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def show_tariff_filter( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_tariff_filter(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает список тарифов для фильтрации рассылки.""" tariffs = await get_all_tariffs(db, include_inactive=False) if not tariffs: await callback.message.edit_text( - "❌ Нет доступных тарифов\n\n" - "Создайте тарифы в разделе управления тарифами.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")] - ]), - parse_mode="HTML" + '❌ Нет доступных тарифов\n\nСоздайте тарифы в разделе управления тарифами.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_msg_by_sub')]] + ), + parse_mode='HTML', ) await callback.answer() return @@ -549,12 +517,9 @@ async def show_tariff_filter( # Получаем количество подписчиков на каждом тарифе tariff_counts = {} for tariff in tariffs: - count_query = ( - select(func.count(Subscription.id)) - .where( - Subscription.tariff_id == tariff.id, - Subscription.status == SubscriptionStatus.ACTIVE.value, - ) + count_query = select(func.count(Subscription.id)).where( + Subscription.tariff_id == tariff.id, + Subscription.status == SubscriptionStatus.ACTIVE.value, ) result = await db.execute(count_query) tariff_counts[tariff.id] = result.scalar() or 0 @@ -562,47 +527,43 @@ async def show_tariff_filter( buttons = [] for tariff in tariffs: count = tariff_counts.get(tariff.id, 0) - buttons.append([ - types.InlineKeyboardButton( - text=f"{tariff.name} ({count} чел.)", - callback_data=f"broadcast_tariff_{tariff.id}" - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=f'{tariff.name} ({count} чел.)', callback_data=f'broadcast_tariff_{tariff.id}' + ) + ] + ) - buttons.append([types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_msg_by_sub")]) + buttons.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_msg_by_sub')]) await callback.message.edit_text( - "📦 Рассылка по тарифу\n\n" - "Выберите тариф для рассылки пользователям с активной подпиской на этот тариф:", + '📦 Рассылка по тарифу\n\nВыберите тариф для рассылки пользователям с активной подпиской на этот тариф:', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def show_messages_history( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_messages_history(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = 1 if '_page_' in callback.data: page = int(callback.data.split('_page_')[1]) - + limit = 10 offset = (page - 1) * limit - + stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) broadcasts = result.scalars().all() - + count_stmt = select(func.count(BroadcastHistory.id)) count_result = await db.execute(count_stmt) total_count = count_result.scalar() or 0 total_pages = (total_count + limit - 1) // limit - + if not broadcasts: text = """ 📋 История рассылок @@ -610,19 +571,24 @@ async def show_messages_history( ❌ История рассылок пуста. Отправьте первую рассылку, чтобы увидеть её здесь. """ - keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]] + keyboard = [[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_messages')]] else: - text = f"📋 История рассылок (страница {page}/{total_pages})\n\n" - + text = f'📋 История рассылок (страница {page}/{total_pages})\n\n' + for broadcast in broadcasts: - status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳" - success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0 - - message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text - + status_emoji = '✅' if broadcast.status == 'completed' else '❌' if broadcast.status == 'failed' else '⏳' + success_rate = ( + round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0 + ) + + message_preview = ( + broadcast.message_text[:100] + '...' if len(broadcast.message_text) > 100 else broadcast.message_text + ) + import html - message_preview = html.escape(message_preview) - + + message_preview = html.escape(message_preview) + text += f""" {status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')} 📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%) @@ -631,28 +597,20 @@ async def show_messages_history( 📝 Сообщение: {message_preview} ━━━━━━━━━━━━━━━━━━━━━━━ """ - + keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard - + await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def show_custom_broadcast( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): - +async def show_custom_broadcast(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): stats = await get_users_statistics(db) - + text = f""" 📝 Рассылка по критериям @@ -674,106 +632,95 @@ async def show_custom_broadcast( Выберите критерий для фильтрации: """ - + await callback.message.edit_text( - text, - reply_markup=get_custom_criteria_keyboard(db_user.language), - parse_mode="HTML" + text, reply_markup=get_custom_criteria_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def select_custom_criteria( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def select_custom_criteria(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): criteria = callback.data.replace('criteria_', '') - + criteria_names = { - "today": "Зарегистрированные сегодня", - "week": "Зарегистрированные за неделю", - "month": "Зарегистрированные за месяц", - "active_today": "Активные сегодня", - "inactive_week": "Неактивные 7+ дней", - "inactive_month": "Неактивные 30+ дней", - "referrals": "Пришедшие через рефералов", - "direct": "Прямая регистрация" + 'today': 'Зарегистрированные сегодня', + 'week': 'Зарегистрированные за неделю', + 'month': 'Зарегистрированные за месяц', + 'active_today': 'Активные сегодня', + 'inactive_week': 'Неактивные 7+ дней', + 'inactive_month': 'Неактивные 30+ дней', + 'referrals': 'Пришедшие через рефералов', + 'direct': 'Прямая регистрация', } - + user_count = await get_custom_users_count(db, criteria) - - await state.update_data(broadcast_target=f"custom_{criteria}") - + + await state.update_data(broadcast_target=f'custom_{criteria}') + await callback.message.edit_text( - f"📨 Создание рассылки\n\n" - f"🎯 Критерий: {criteria_names.get(criteria, criteria)}\n" - f"👥 Получателей: {user_count}\n\n" - f"Введите текст сообщения для рассылки:\n\n" - f"Поддерживается HTML разметка", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] - ]), - parse_mode="HTML" + f'📨 Создание рассылки\n\n' + f'🎯 Критерий: {criteria_names.get(criteria, criteria)}\n' + f'👥 Получателей: {user_count}\n\n' + f'Введите текст сообщения для рассылки:\n\n' + f'Поддерживается HTML разметка', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_messages')]] + ), + parse_mode='HTML', ) - + await state.set_state(AdminStates.waiting_for_broadcast_message) await callback.answer() @admin_required @error_handler -async def select_broadcast_target( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): - raw_target = callback.data[len("broadcast_"):] +async def select_broadcast_target(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): + raw_target = callback.data[len('broadcast_') :] target_aliases = { - "no_sub": "no", + 'no_sub': 'no', } target = target_aliases.get(raw_target, raw_target) target_names = { - "all": "Всем пользователям", - "active": "С активной подпиской", - "trial": "С триальной подпиской", - "no": "Без подписки", - "expiring": "С истекающей подпиской", - "expired": "С истекшей подпиской", - "active_zero": "Активная подписка, трафик 0 ГБ", - "trial_zero": "Триальная подписка, трафик 0 ГБ", + 'all': 'Всем пользователям', + 'active': 'С активной подпиской', + 'trial': 'С триальной подпиской', + 'no': 'Без подписки', + 'expiring': 'С истекающей подпиской', + 'expired': 'С истекшей подпиской', + 'active_zero': 'Активная подписка, трафик 0 ГБ', + 'trial_zero': 'Триальная подписка, трафик 0 ГБ', } # Обработка фильтра по тарифу target_name = target_names.get(target, target) - if target.startswith("tariff_"): - tariff_id = int(target.split("_")[1]) + if target.startswith('tariff_'): + tariff_id = int(target.split('_')[1]) from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if tariff: - target_name = f"Тариф «{tariff.name}»" + target_name = f'Тариф «{tariff.name}»' else: - target_name = f"Тариф #{tariff_id}" + target_name = f'Тариф #{tariff_id}' user_count = await get_target_users_count(db, target) await state.update_data(broadcast_target=target) await callback.message.edit_text( - f"📨 Создание рассылки\n\n" - f"🎯 Аудитория: {target_name}\n" - f"👥 Получателей: {user_count}\n\n" - f"Введите текст сообщения для рассылки:\n\n" - f"Поддерживается HTML разметка", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] - ]), - parse_mode="HTML" + f'📨 Создание рассылки\n\n' + f'🎯 Аудитория: {target_name}\n' + f'👥 Получателей: {user_count}\n\n' + f'Введите текст сообщения для рассылки:\n\n' + f'Поддерживается HTML разметка', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_messages')]] + ), + parse_mode='HTML', ) await state.set_state(AdminStates.waiting_for_broadcast_message) @@ -782,37 +729,29 @@ async def select_broadcast_target( @admin_required @error_handler -async def process_broadcast_message( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_broadcast_message(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): broadcast_text = message.text - + if len(broadcast_text) > 4000: - await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)") + await message.answer('❌ Сообщение слишком длинное (максимум 4000 символов)') return - + await state.update_data(broadcast_message=broadcast_text) - + await message.answer( - "🖼️ Добавление медиафайла\n\n" - "Вы можете добавить к сообщению фото, видео или документ.\n" - "Или пропустить этот шаг.\n\n" - "Выберите тип медиа:", + '🖼️ Добавление медиафайла\n\n' + 'Вы можете добавить к сообщению фото, видео или документ.\n' + 'Или пропустить этот шаг.\n\n' + 'Выберите тип медиа:', reply_markup=get_broadcast_media_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) + @admin_required @error_handler -async def handle_media_selection( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - if callback.data == "skip_media": +async def handle_media_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext): + if callback.data == 'skip_media': await state.update_data(has_media=False) await show_button_selector_callback(callback, db_user, state) return @@ -820,23 +759,19 @@ async def handle_media_selection( media_type = callback.data.replace('add_media_', '') media_instructions = { - "photo": "📷 Отправьте фотографию для рассылки:", - "video": "🎥 Отправьте видео для рассылки:", - "document": "📄 Отправьте документ для рассылки:" + 'photo': '📷 Отправьте фотографию для рассылки:', + 'video': '🎥 Отправьте видео для рассылки:', + 'document': '📄 Отправьте документ для рассылки:', } - await state.update_data( - media_type=media_type, - waiting_for_media=True - ) + await state.update_data(media_type=media_type, waiting_for_media=True) instruction_text = ( - f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n" - f"Размер файла не должен превышать 50 МБ" + f'{media_instructions.get(media_type, "Отправьте медиафайл:")}\n\nРазмер файла не должен превышать 50 МБ' + ) + instruction_keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_messages')]] ) - instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")] - ]) # Проверяем, является ли текущее сообщение медиа-сообщением is_media_message = ( @@ -854,138 +789,106 @@ async def handle_media_selection( await callback.message.delete() except Exception: pass - await callback.message.answer( - instruction_text, - reply_markup=instruction_keyboard, - parse_mode="HTML" - ) + await callback.message.answer(instruction_text, reply_markup=instruction_keyboard, parse_mode='HTML') else: - await callback.message.edit_text( - instruction_text, - reply_markup=instruction_keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(instruction_text, reply_markup=instruction_keyboard, parse_mode='HTML') await state.set_state(AdminStates.waiting_for_broadcast_media) await callback.answer() + @admin_required @error_handler -async def process_broadcast_media( - message: types.Message, - db_user: User, - state: FSMContext -): +async def process_broadcast_media(message: types.Message, db_user: User, state: FSMContext): data = await state.get_data() expected_type = data.get('media_type') - + media_file_id = None media_type = None - - if message.photo and expected_type == "photo": + + if message.photo and expected_type == 'photo': media_file_id = message.photo[-1].file_id - media_type = "photo" - elif message.video and expected_type == "video": + media_type = 'photo' + elif message.video and expected_type == 'video': media_file_id = message.video.file_id - media_type = "video" - elif message.document and expected_type == "document": + media_type = 'video' + elif message.document and expected_type == 'document': media_file_id = message.document.file_id - media_type = "document" + media_type = 'document' else: - await message.answer( - f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции." - ) + await message.answer(f'❌ Пожалуйста, отправьте {expected_type} как указано в инструкции.') return - + await state.update_data( - has_media=True, - media_file_id=media_file_id, - media_type=media_type, - media_caption=message.caption + has_media=True, media_file_id=media_file_id, media_type=media_type, media_caption=message.caption ) - + await show_media_preview(message, db_user, state) -async def show_media_preview( - message: types.Message, - db_user: User, - state: FSMContext -): + +async def show_media_preview(message: types.Message, db_user: User, state: FSMContext): data = await state.get_data() media_type = data.get('media_type') media_file_id = data.get('media_file_id') - - preview_text = f"🖼️ Медиафайл добавлен\n\n" \ - f"📎 Тип: {media_type}\n" \ - f"✅ Файл сохранен и готов к отправке\n\n" \ - f"Что делать дальше?" - + + preview_text = ( + f'🖼️ Медиафайл добавлен\n\n' + f'📎 Тип: {media_type}\n' + f'✅ Файл сохранен и готов к отправке\n\n' + f'Что делать дальше?' + ) + # Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа # чтобы показать именно загруженное фото from app.utils.message_patch import _original_answer - - if media_type == "photo" and media_file_id: + + if media_type == 'photo' and media_file_id: # Показываем предпросмотр с загруженным фото await message.bot.send_photo( chat_id=message.chat.id, photo=media_file_id, caption=preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # Для других типов медиа или если нет фото, используем обычное сообщение - await _original_answer(message, preview_text, - reply_markup=get_media_confirm_keyboard(db_user.language), - parse_mode="HTML") - -@admin_required -@error_handler -async def handle_media_confirmation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - action = callback.data - - if action == "confirm_media": - await show_button_selector_callback(callback, db_user, state) - elif action == "replace_media": - data = await state.get_data() - media_type = data.get('media_type', 'photo') - await handle_media_selection(callback, db_user, state) - elif action == "skip_media": - await state.update_data( - has_media=False, - media_file_id=None, - media_type=None, - media_caption=None + await _original_answer( + message, preview_text, reply_markup=get_media_confirm_keyboard(db_user.language), parse_mode='HTML' ) - await show_button_selector_callback(callback, db_user, state) + @admin_required @error_handler -async def handle_change_media( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def handle_media_confirmation(callback: types.CallbackQuery, db_user: User, state: FSMContext): + action = callback.data + + if action == 'confirm_media': + await show_button_selector_callback(callback, db_user, state) + elif action == 'replace_media': + data = await state.get_data() + data.get('media_type', 'photo') + await handle_media_selection(callback, db_user, state) + elif action == 'skip_media': + await state.update_data(has_media=False, media_file_id=None, media_type=None, media_caption=None) + await show_button_selector_callback(callback, db_user, state) + + +@admin_required +@error_handler +async def handle_change_media(callback: types.CallbackQuery, db_user: User, state: FSMContext): await safe_edit_or_send_text( callback, - "🖼️ Изменение медиафайла\n\n" - "Выберите новый тип медиа:", + '🖼️ Изменение медиафайла\n\nВыберите новый тип медиа:', reply_markup=get_broadcast_media_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def show_button_selector_callback( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def show_button_selector_callback(callback: types.CallbackQuery, db_user: User, state: FSMContext): data = await state.get_data() has_media = data.get('has_media', False) selected_buttons = data.get('selected_buttons') @@ -994,10 +897,10 @@ async def show_button_selector_callback( selected_buttons = list(DEFAULT_SELECTED_BUTTONS) await state.update_data(selected_buttons=selected_buttons) - media_info = "" + media_info = '' if has_media: media_type = data.get('media_type', 'файл') - media_info = f"\n🖼️ Медиафайл: {media_type} добавлен" + media_info = f'\n🖼️ Медиафайл: {media_type} добавлен' text = f""" 📘 Выбор дополнительных кнопок @@ -1016,9 +919,7 @@ async def show_button_selector_callback( Выберите нужные кнопки и нажмите "Продолжить": """ - keyboard = get_updated_message_buttons_selector_keyboard_with_media( - selected_buttons, has_media, db_user.language - ) + keyboard = get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, has_media, db_user.language) # Проверяем, является ли текущее сообщение медиа-сообщением # (фото, видео, документ и т.д.) - для них нельзя использовать edit_text @@ -1037,27 +938,15 @@ async def show_button_selector_callback( await callback.message.delete() except Exception: pass # Игнорируем ошибки удаления - await callback.message.answer( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(text, reply_markup=keyboard, parse_mode='HTML') else: - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() @admin_required @error_handler -async def show_button_selector( - message: types.Message, - db_user: User, - state: FSMContext -): +async def show_button_selector(message: types.Message, db_user: User, state: FSMContext): data = await state.get_data() selected_buttons = data.get('selected_buttons') if selected_buttons is None: @@ -1083,24 +972,14 @@ async def show_button_selector( Выберите нужные кнопки и нажмите "Продолжить": """ - keyboard = get_updated_message_buttons_selector_keyboard_with_media( - selected_buttons, has_media, db_user.language - ) + keyboard = get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, has_media, db_user.language) - await message.answer( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await message.answer(text, reply_markup=keyboard, parse_mode='HTML') @admin_required @error_handler -async def toggle_button_selection( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def toggle_button_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext): button_type = callback.data.replace('btn_', '') data = await state.get_data() selected_buttons = data.get('selected_buttons') @@ -1117,9 +996,7 @@ async def toggle_button_selection( await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) - keyboard = get_updated_message_buttons_selector_keyboard_with_media( - selected_buttons, has_media, db_user.language - ) + keyboard = get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, has_media, db_user.language) await callback.message.edit_reply_markup(reply_markup=keyboard) await callback.answer() @@ -1127,12 +1004,7 @@ async def toggle_button_selection( @admin_required @error_handler -async def confirm_button_selection( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def confirm_button_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') @@ -1142,27 +1014,27 @@ async def confirm_button_selection( await state.update_data(selected_buttons=selected_buttons) has_media = data.get('has_media', False) media_type = data.get('media_type') - - user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', '')) + + user_count = ( + await get_target_users_count(db, target) + if not target.startswith('custom_') + else await get_custom_users_count(db, target.replace('custom_', '')) + ) target_display = get_target_display_name(target) - - media_info = "" + + media_info = '' if has_media: - media_type_names = { - "photo": "Фотография", - "video": "Видео", - "document": "Документ" - } - media_info = f"\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}" - + media_type_names = {'photo': 'Фотография', 'video': 'Видео', 'document': 'Документ'} + media_info = f'\n🖼️ Медиафайл: {media_type_names.get(media_type, media_type)}' + ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row] button_labels = get_broadcast_button_labels(db_user.language) selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons] if selected_names: - buttons_info = f"\n📘 Кнопки: {', '.join(selected_names)}" + buttons_info = f'\n📘 Кнопки: {", ".join(selected_names)}' else: - buttons_info = "\n📘 Кнопки: отсутствуют" - + buttons_info = '\n📘 Кнопки: отсутствуют' + preview_text = f""" 📨 Предварительный просмотр рассылки @@ -1176,25 +1048,21 @@ async def confirm_button_selection( Подтвердить отправку? """ - + keyboard = [ [ - types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"), - types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons") + types.InlineKeyboardButton(text='✅ Отправить', callback_data='admin_confirm_broadcast'), + types.InlineKeyboardButton(text='📘 Изменить кнопки', callback_data='edit_buttons'), ] ] - + if has_media: - keyboard.append([ - types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media") - ]) - - keyboard.append([ - types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages") - ]) - + keyboard.append([types.InlineKeyboardButton(text='🖼️ Изменить медиа', callback_data='change_media')]) + + keyboard.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_messages')]) + # Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение - if has_media and media_type == "photo": + if has_media and media_type == 'photo': media_file_id = data.get('media_file_id') if media_file_id: # Удаляем текущее сообщение и отправляем новое с фото @@ -1207,7 +1075,7 @@ async def confirm_button_selection( photo=media_file_id, caption=preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) else: # Если нет file_id, используем safe редактирование @@ -1215,15 +1083,12 @@ async def confirm_button_selection( callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) else: # Для текстовых сообщений или других типов медиа используем safe редактирование await safe_edit_or_send_text( - callback, - preview_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + callback, preview_text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @@ -1231,12 +1096,7 @@ async def confirm_button_selection( @admin_required @error_handler -async def confirm_broadcast( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() target = data.get('broadcast_target') message_text = data.get('broadcast_message') @@ -1247,20 +1107,16 @@ async def confirm_broadcast( media_type = data.get('media_type') media_file_id = data.get('media_file_id') media_caption = data.get('media_caption') - + await safe_edit_or_send_text( - callback, - "📨 Начинаю рассылку...\n\n" - "⏳ Это может занять несколько минут.", - reply_markup=None, - parse_mode="HTML" + callback, '📨 Начинаю рассылку...\n\n⏳ Это может занять несколько минут.', reply_markup=None, parse_mode='HTML' ) if target.startswith('custom_'): users = await get_custom_users(db, target.replace('custom_', '')) else: users = await get_target_users(db, target) - + broadcast_history = BroadcastHistory( target_type=target, message_text=message_text, @@ -1273,17 +1129,17 @@ async def confirm_broadcast( failed_count=0, admin_id=db_user.id, admin_name=db_user.full_name, - status="in_progress" + status='in_progress', ) db.add(broadcast_history) await db.commit() await db.refresh(broadcast_history) - + sent_count = 0 failed_count = 0 - + broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language) - + # Ограничение на количество одновременных отправок и базовая задержка между сообщениями, # чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках max_concurrent_sends = 5 @@ -1294,65 +1150,61 @@ async def confirm_broadcast( """Отправляет одно сообщение рассылки с семафором ограничения""" # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug("Пропуск email-пользователя %s при рассылке", user.id) + logger.debug('Пропуск email-пользователя %s при рассылке', user.id) return False, None async with semaphore: for attempt in range(3): try: if has_media and media_file_id: - if media_type == "photo": + if media_type == 'photo': await callback.bot.send_photo( chat_id=user.telegram_id, photo=media_file_id, caption=message_text, - parse_mode="HTML", - reply_markup=broadcast_keyboard + parse_mode='HTML', + reply_markup=broadcast_keyboard, ) - elif media_type == "video": + elif media_type == 'video': await callback.bot.send_video( chat_id=user.telegram_id, video=media_file_id, caption=message_text, - parse_mode="HTML", - reply_markup=broadcast_keyboard + parse_mode='HTML', + reply_markup=broadcast_keyboard, ) - elif media_type == "document": + elif media_type == 'document': await callback.bot.send_document( chat_id=user.telegram_id, document=media_file_id, caption=message_text, - parse_mode="HTML", - reply_markup=broadcast_keyboard + parse_mode='HTML', + reply_markup=broadcast_keyboard, ) else: await callback.bot.send_message( chat_id=user.telegram_id, text=message_text, - parse_mode="HTML", - reply_markup=broadcast_keyboard + parse_mode='HTML', + reply_markup=broadcast_keyboard, ) await asyncio.sleep(per_message_delay) return True, user.telegram_id except TelegramRetryAfter as e: retry_delay = min(e.retry_after + 1, 30) - logger.warning( - f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек." - ) + logger.warning(f'Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек.') await asyncio.sleep(retry_delay) except TelegramForbiddenError: # Пользователь мог удалить бота или запретить сообщения - logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden") + logger.info(f'Рассылка недоступна для пользователя {user.telegram_id}: Forbidden') return False, user.telegram_id except TelegramBadRequest as e: - logger.error( - f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}" - ) + logger.error(f'Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}') return False, user.telegram_id except Exception as e: logger.error( - f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}" + f'Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}' ) await asyncio.sleep(0.5 * (attempt + 1)) @@ -1361,7 +1213,7 @@ async def confirm_broadcast( # Отправляем сообщения пакетами для эффективности batch_size = 50 for i in range(0, len(users), batch_size): - batch = users[i:i + batch_size] + batch = users[i : i + batch_size] tasks = [send_single_broadcast(user) for user in batch] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -1377,8 +1229,8 @@ async def confirm_broadcast( # Небольшая задержка между пакетами для снижения нагрузки на API await asyncio.sleep(0.25) - - status = "completed" if failed_count == 0 else "partial" + + status = 'completed' if failed_count == 0 else 'partial' await _persist_broadcast_result( db=db, broadcast_history=broadcast_history, @@ -1386,11 +1238,11 @@ async def confirm_broadcast( failed_count=failed_count, status=status, ) - - media_info = "" + + media_info = '' if has_media: - media_info = f"\n🖼️ Медиафайл: {media_type}" - + media_info = f'\n🖼️ Медиафайл: {media_type}' + result_text = f""" ✅ Рассылка завершена! @@ -1402,47 +1254,54 @@ async def confirm_broadcast( Администратор: {db_user.full_name} """ - + try: await callback.message.edit_text( result_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] - ]), - parse_mode="HTML" + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]] + ), + parse_mode='HTML', ) except TelegramBadRequest as e: error_msg = str(e).lower() - if "message to edit not found" in error_msg or "there is no text" in error_msg or "message can't be edited" in error_msg: + if ( + 'message to edit not found' in error_msg + or 'there is no text' in error_msg + or "message can't be edited" in error_msg + ): # Сообщение удалено или это медиа - отправляем новое await callback.bot.send_message( chat_id=callback.message.chat.id, text=result_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")] - ]), - parse_mode="HTML" + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')] + ] + ), + parse_mode='HTML', ) else: raise await state.clear() - logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})") + logger.info(f'Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})') async def get_target_users_count(db: AsyncSession, target: str) -> int: """Быстрый подсчёт пользователей через SQL COUNT вместо загрузки всех в память.""" - from sqlalchemy import func as sql_func, distinct from datetime import datetime, timedelta + from sqlalchemy import distinct, func as sql_func + base_filter = User.status == UserStatus.ACTIVE.value - if target == "all": + if target == 'all': query = select(sql_func.count(User.id)).where(base_filter) result = await db.execute(query) return result.scalar() or 0 - if target == "active": + if target == 'active': # Активные платные подписки (не триал) query = ( select(sql_func.count(distinct(User.id))) @@ -1456,7 +1315,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "trial": + if target == 'trial': # Триальные подписки (без проверки is_active, как в оригинале) query = ( select(sql_func.count(distinct(User.id))) @@ -1469,7 +1328,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "no": + if target == 'no': # Без активной подписки - используем NOT EXISTS для корректности subquery = ( select(Subscription.id) @@ -1479,14 +1338,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: ) .exists() ) - query = ( - select(sql_func.count(User.id)) - .where(base_filter, ~subquery) - ) + query = select(sql_func.count(User.id)).where(base_filter, ~subquery) result = await db.execute(query) return result.scalar() or 0 - if target == "expiring": + if target == 'expiring': # Истекающие в ближайшие 3 дня now = datetime.utcnow() expiry_threshold = now + timedelta(days=3) @@ -1503,7 +1359,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "expiring_subscribers": + if target == 'expiring_subscribers': # Истекающие в ближайшие 7 дней now = datetime.utcnow() expiry_threshold = now + timedelta(days=7) @@ -1520,7 +1376,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "expired": + if target == 'expired': # Истекшие подписки now = datetime.utcnow() expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] @@ -1533,13 +1389,13 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: Subscription.status.in_(expired_statuses), and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), and_(Subscription.id == None, User.has_had_paid_subscription == True), - ) + ), ) ) result = await db.execute(query) return result.scalar() or 0 - if target == "expired_subscribers": + if target == 'expired_subscribers': # То же что и expired now = datetime.utcnow() expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value] @@ -1552,13 +1408,13 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: Subscription.status.in_(expired_statuses), and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value), and_(Subscription.id == None, User.has_had_paid_subscription == True), - ) + ), ) ) result = await db.execute(query) return result.scalar() or 0 - if target == "active_zero": + if target == 'active_zero': # Активные платные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) @@ -1573,7 +1429,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "trial_zero": + if target == 'trial_zero': # Триальные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) @@ -1588,7 +1444,7 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: result = await db.execute(query) return result.scalar() or 0 - if target == "zero": + if target == 'zero': # Все активные с нулевым трафиком query = ( select(sql_func.count(distinct(User.id))) @@ -1603,8 +1459,8 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int: return result.scalar() or 0 # Фильтр по тарифу - if target.startswith("tariff_"): - tariff_id = int(target.split("_")[1]) + if target.startswith('tariff_'): + tariff_id = int(target.split('_')[1]) query = ( select(sql_func.count(distinct(User.id))) .join(Subscription, User.id == Subscription.user_id) @@ -1642,37 +1498,27 @@ async def get_target_users(db: AsyncSession, target: str) -> list: users.extend(batch) offset += batch_size - if target == "all": + if target == 'all': return users - if target == "active": + if target == 'active': return [ user for user in users - if user.subscription - and user.subscription.is_active - and not user.subscription.is_trial + if user.subscription and user.subscription.is_active and not user.subscription.is_trial ] - if target == "trial": - return [ - user - for user in users - if user.subscription and user.subscription.is_trial - ] + if target == 'trial': + return [user for user in users if user.subscription and user.subscription.is_trial] - if target == "no": - return [ - user - for user in users - if not user.subscription or not user.subscription.is_active - ] + if target == 'no': + return [user for user in users if not user.subscription or not user.subscription.is_active] - if target == "expiring": + if target == 'expiring': expiring_subs = await get_expiring_subscriptions(db, 3) return [sub.user for sub in expiring_subs if sub.user] - if target == "expired": + if target == 'expired': now = datetime.utcnow() expired_statuses = { SubscriptionStatus.EXPIRED.value, @@ -1692,7 +1538,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list: expired_users.append(user) return expired_users - if target == "active_zero": + if target == 'active_zero': return [ user for user in users @@ -1702,7 +1548,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list: and (user.subscription.traffic_used_gb or 0) <= 0 ] - if target == "trial_zero": + if target == 'trial_zero': return [ user for user in users @@ -1712,20 +1558,18 @@ async def get_target_users(db: AsyncSession, target: str) -> list: and (user.subscription.traffic_used_gb or 0) <= 0 ] - if target == "zero": + if target == 'zero': return [ user for user in users - if user.subscription - and user.subscription.is_active - and (user.subscription.traffic_used_gb or 0) <= 0 + if user.subscription and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0 ] - if target == "expiring_subscribers": + if target == 'expiring_subscribers': expiring_subs = await get_expiring_subscriptions(db, 7) return [sub.user for sub in expiring_subs if sub.user] - if target == "expired_subscribers": + if target == 'expired_subscribers': now = datetime.utcnow() expired_statuses = { SubscriptionStatus.EXPIRED.value, @@ -1745,15 +1589,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list: expired_users.append(user) return expired_users - if target == "canceled_subscribers": + if target == 'canceled_subscribers': return [ user for user in users - if user.subscription - and user.subscription.status == SubscriptionStatus.DISABLED.value + if user.subscription and user.subscription.status == SubscriptionStatus.DISABLED.value ] - if target == "trial_ending": + if target == 'trial_ending': now = datetime.utcnow() in_3_days = now + timedelta(days=3) return [ @@ -1765,71 +1608,57 @@ async def get_target_users(db: AsyncSession, target: str) -> list: and user.subscription.end_date <= in_3_days ] - if target == "trial_expired": + if target == 'trial_expired': now = datetime.utcnow() return [ user for user in users - if user.subscription - and user.subscription.is_trial - and user.subscription.end_date <= now + if user.subscription and user.subscription.is_trial and user.subscription.end_date <= now ] - if target == "autopay_failed": + if target == 'autopay_failed': from app.database.models import SubscriptionEvent + week_ago = datetime.utcnow() - timedelta(days=7) - stmt = select(SubscriptionEvent.user_id).where( - and_( - SubscriptionEvent.event_type == "autopay_failed", - SubscriptionEvent.occurred_at >= week_ago, + stmt = ( + select(SubscriptionEvent.user_id) + .where( + and_( + SubscriptionEvent.event_type == 'autopay_failed', + SubscriptionEvent.occurred_at >= week_ago, + ) ) - ).distinct() + .distinct() + ) result = await db.execute(stmt) failed_user_ids = set(result.scalars().all()) return [user for user in users if user.id in failed_user_ids] - if target == "low_balance": + if target == 'low_balance': threshold_kopeks = 10000 # 100 рублей return [ - user - for user in users - if (user.balance_kopeks or 0) < threshold_kopeks - and (user.balance_kopeks or 0) > 0 + user for user in users if (user.balance_kopeks or 0) < threshold_kopeks and (user.balance_kopeks or 0) > 0 ] - if target == "inactive_30d": + if target == 'inactive_30d': threshold = datetime.utcnow() - timedelta(days=30) - return [ - user - for user in users - if user.last_activity and user.last_activity < threshold - ] + return [user for user in users if user.last_activity and user.last_activity < threshold] - if target == "inactive_60d": + if target == 'inactive_60d': threshold = datetime.utcnow() - timedelta(days=60) - return [ - user - for user in users - if user.last_activity and user.last_activity < threshold - ] + return [user for user in users if user.last_activity and user.last_activity < threshold] - if target == "inactive_90d": + if target == 'inactive_90d': threshold = datetime.utcnow() - timedelta(days=90) - return [ - user - for user in users - if user.last_activity and user.last_activity < threshold - ] + return [user for user in users if user.last_activity and user.last_activity < threshold] # Фильтр по тарифу - if target.startswith("tariff_"): - tariff_id = int(target.split("_")[1]) + if target.startswith('tariff_'): + tariff_id = int(target.split('_')[1]) return [ user for user in users - if user.subscription - and user.subscription.is_active - and user.subscription.tariff_id == tariff_id + if user.subscription and user.subscription.is_active and user.subscription.tariff_id == tariff_id ] return [] @@ -1845,45 +1674,26 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list: today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) - - if criteria == "today": - stmt = select(User).where( - and_(User.status == "active", User.created_at >= today) - ) - elif criteria == "week": - stmt = select(User).where( - and_(User.status == "active", User.created_at >= week_ago) - ) - elif criteria == "month": - stmt = select(User).where( - and_(User.status == "active", User.created_at >= month_ago) - ) - elif criteria == "active_today": - stmt = select(User).where( - and_(User.status == "active", User.last_activity >= today) - ) - elif criteria == "inactive_week": - stmt = select(User).where( - and_(User.status == "active", User.last_activity < week_ago) - ) - elif criteria == "inactive_month": - stmt = select(User).where( - and_(User.status == "active", User.last_activity < month_ago) - ) - elif criteria == "referrals": - stmt = select(User).where( - and_(User.status == "active", User.referred_by_id.isnot(None)) - ) - elif criteria == "direct": - stmt = select(User).where( - and_( - User.status == "active", - User.referred_by_id.is_(None) - ) - ) + + if criteria == 'today': + stmt = select(User).where(and_(User.status == 'active', User.created_at >= today)) + elif criteria == 'week': + stmt = select(User).where(and_(User.status == 'active', User.created_at >= week_ago)) + elif criteria == 'month': + stmt = select(User).where(and_(User.status == 'active', User.created_at >= month_ago)) + elif criteria == 'active_today': + stmt = select(User).where(and_(User.status == 'active', User.last_activity >= today)) + elif criteria == 'inactive_week': + stmt = select(User).where(and_(User.status == 'active', User.last_activity < week_ago)) + elif criteria == 'inactive_month': + stmt = select(User).where(and_(User.status == 'active', User.last_activity < month_ago)) + elif criteria == 'referrals': + stmt = select(User).where(and_(User.status == 'active', User.referred_by_id.isnot(None))) + elif criteria == 'direct': + stmt = select(User).where(and_(User.status == 'active', User.referred_by_id.is_(None))) else: return [] - + result = await db.execute(stmt) return result.scalars().all() @@ -1893,88 +1703,80 @@ async def get_users_statistics(db: AsyncSession) -> dict: today = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) - + stats = {} - - stats['today'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.created_at >= today) + + stats['today'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.created_at >= today))) or 0 + ) + + stats['week'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.created_at >= week_ago))) + or 0 + ) + + stats['month'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.created_at >= month_ago))) + or 0 + ) + + stats['active_today'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.last_activity >= today))) + or 0 + ) + + stats['inactive_week'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.last_activity < week_ago))) + or 0 + ) + + stats['inactive_month'] = ( + await db.scalar( + select(func.count(User.id)).where(and_(User.status == 'active', User.last_activity < month_ago)) ) - ) or 0 - - stats['week'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.created_at >= week_ago) + or 0 + ) + + stats['referrals'] = ( + await db.scalar( + select(func.count(User.id)).where(and_(User.status == 'active', User.referred_by_id.isnot(None))) ) - ) or 0 - - stats['month'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.created_at >= month_ago) - ) - ) or 0 - - stats['active_today'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.last_activity >= today) - ) - ) or 0 - - stats['inactive_week'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.last_activity < week_ago) - ) - ) or 0 - - stats['inactive_month'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.last_activity < month_ago) - ) - ) or 0 - - stats['referrals'] = await db.scalar( - select(func.count(User.id)).where( - and_(User.status == "active", User.referred_by_id.isnot(None)) - ) - ) or 0 - - stats['direct'] = await db.scalar( - select(func.count(User.id)).where( - and_( - User.status == "active", - User.referred_by_id.is_(None) - ) - ) - ) or 0 - + or 0 + ) + + stats['direct'] = ( + await db.scalar(select(func.count(User.id)).where(and_(User.status == 'active', User.referred_by_id.is_(None)))) + or 0 + ) + return stats def get_target_name(target_type: str) -> str: names = { - "all": "Всем пользователям", - "active": "С активной подпиской", - "trial": "С триальной подпиской", - "no": "Без подписки", - "sub": "Без подписки", - "expiring": "С истекающей подпиской", - "expired": "С истекшей подпиской", - "active_zero": "Активная подписка, трафик 0 ГБ", - "trial_zero": "Триальная подписка, трафик 0 ГБ", - "zero": "Подписка, трафик 0 ГБ", - "custom_today": "Зарегистрированные сегодня", - "custom_week": "Зарегистрированные за неделю", - "custom_month": "Зарегистрированные за месяц", - "custom_active_today": "Активные сегодня", - "custom_inactive_week": "Неактивные 7+ дней", - "custom_inactive_month": "Неактивные 30+ дней", - "custom_referrals": "Через рефералов", - "custom_direct": "Прямая регистрация" + 'all': 'Всем пользователям', + 'active': 'С активной подпиской', + 'trial': 'С триальной подпиской', + 'no': 'Без подписки', + 'sub': 'Без подписки', + 'expiring': 'С истекающей подпиской', + 'expired': 'С истекшей подпиской', + 'active_zero': 'Активная подписка, трафик 0 ГБ', + 'trial_zero': 'Триальная подписка, трафик 0 ГБ', + 'zero': 'Подписка, трафик 0 ГБ', + 'custom_today': 'Зарегистрированные сегодня', + 'custom_week': 'Зарегистрированные за неделю', + 'custom_month': 'Зарегистрированные за месяц', + 'custom_active_today': 'Активные сегодня', + 'custom_inactive_week': 'Неактивные 7+ дней', + 'custom_inactive_month': 'Неактивные 30+ дней', + 'custom_referrals': 'Через рефералов', + 'custom_direct': 'Прямая регистрация', } # Обработка фильтра по тарифу - if target_type.startswith("tariff_"): - tariff_id = target_type.split("_")[1] - return f"По тарифу #{tariff_id}" + if target_type.startswith('tariff_'): + tariff_id = target_type.split('_')[1] + return f'По тарифу #{tariff_id}' return names.get(target_type, target_type) @@ -1983,30 +1785,30 @@ def get_target_display_name(target: str) -> str: def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_messages_menu, F.data == "admin_messages") - dp.callback_query.register(show_pinned_message_menu, F.data == "admin_pinned_message") - dp.callback_query.register(toggle_pinned_message_position, F.data == "admin_pinned_message_position") - dp.callback_query.register(toggle_pinned_message_start_mode, F.data == "admin_pinned_message_start_mode") - dp.callback_query.register(delete_pinned_message, F.data == "admin_pinned_message_delete") - dp.callback_query.register(prompt_pinned_message_update, F.data == "admin_pinned_message_edit") - dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:")) - dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:")) - dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"])) - dp.callback_query.register(show_tariff_filter, F.data == "broadcast_by_tariff") - dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_")) - dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast") - - dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history")) - dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom") - dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_")) - - dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_")) - dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm") - dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons") - dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_")) - dp.callback_query.register(handle_media_selection, F.data == "skip_media") - dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"])) - dp.callback_query.register(handle_change_media, F.data == "change_media") + dp.callback_query.register(show_messages_menu, F.data == 'admin_messages') + dp.callback_query.register(show_pinned_message_menu, F.data == 'admin_pinned_message') + dp.callback_query.register(toggle_pinned_message_position, F.data == 'admin_pinned_message_position') + dp.callback_query.register(toggle_pinned_message_start_mode, F.data == 'admin_pinned_message_start_mode') + dp.callback_query.register(delete_pinned_message, F.data == 'admin_pinned_message_delete') + dp.callback_query.register(prompt_pinned_message_update, F.data == 'admin_pinned_message_edit') + dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith('admin_pinned_broadcast_now:')) + dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith('admin_pinned_broadcast_skip:')) + dp.callback_query.register(show_broadcast_targets, F.data.in_(['admin_msg_all', 'admin_msg_by_sub'])) + dp.callback_query.register(show_tariff_filter, F.data == 'broadcast_by_tariff') + dp.callback_query.register(select_broadcast_target, F.data.startswith('broadcast_')) + dp.callback_query.register(confirm_broadcast, F.data == 'admin_confirm_broadcast') + + dp.callback_query.register(show_messages_history, F.data.startswith('admin_msg_history')) + dp.callback_query.register(show_custom_broadcast, F.data == 'admin_msg_custom') + dp.callback_query.register(select_custom_criteria, F.data.startswith('criteria_')) + + dp.callback_query.register(toggle_button_selection, F.data.startswith('btn_')) + dp.callback_query.register(confirm_button_selection, F.data == 'buttons_confirm') + dp.callback_query.register(show_button_selector_callback, F.data == 'edit_buttons') + dp.callback_query.register(handle_media_selection, F.data.startswith('add_media_')) + dp.callback_query.register(handle_media_selection, F.data == 'skip_media') + dp.callback_query.register(handle_media_confirmation, F.data.in_(['confirm_media', 'replace_media'])) + dp.callback_query.register(handle_change_media, F.data == 'change_media') dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message) dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media) dp.message.register(process_pinned_message_update, AdminStates.editing_pinned_message) diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 3c5d28bc..8d964e51 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1,37 +1,38 @@ import asyncio import logging from datetime import datetime, timedelta -from aiogram import Router, F -from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton + +from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest from aiogram.filters import Command from aiogram.fsm.context import FSMContext -from aiogram.exceptions import TelegramBadRequest +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from app.config import settings from app.database.database import AsyncSessionLocal +from app.keyboards.admin import get_monitoring_keyboard +from app.localization.texts import get_texts from app.services.monitoring_service import monitoring_service from app.services.nalogo_queue_service import nalogo_queue_service +from app.services.notification_settings_service import NotificationSettingsService from app.services.traffic_monitoring_service import ( - traffic_monitoring_service, traffic_monitoring_scheduler, ) +from app.states import AdminStates from app.utils.decorators import admin_required from app.utils.pagination import paginate_list -from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard -from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService -from app.states import AdminStates + logger = logging.getLogger(__name__) router = Router() def _format_toggle(enabled: bool) -> str: - return "🟢 Вкл" if enabled else "🔴 Выкл" + return '🟢 Вкл' if enabled else '🔴 Выкл' def _build_notification_settings_view(language: str): - texts = get_texts(language) + get_texts(language) config = NotificationSettingsService.get_config() second_percent = NotificationSettingsService.get_second_wave_discount_percent() @@ -40,48 +41,122 @@ def _build_notification_settings_view(language: str): third_hours = NotificationSettingsService.get_third_wave_valid_hours() third_days = NotificationSettingsService.get_third_wave_trigger_days() - trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) - trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) - trial_channel_status = _format_toggle( - config["trial_channel_unsubscribed"].get("enabled", True) - ) - expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) - second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) - third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True)) + trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True)) + trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True)) + expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True)) + second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True)) + third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True)) summary_text = ( - "🔔 Уведомления пользователям\n\n" - f"• 1 час после триала: {trial_1h_status}\n" - f"• 24 часа после триала: {trial_24h_status}\n" - f"• Отписка от канала: {trial_channel_status}\n" - f"• 1 день после истечения: {expired_1d_status}\n" - f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" - f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + '🔔 Уведомления пользователям\n\n' + f'• 1 час после триала: {trial_1h_status}\n' + f'• 24 часа после триала: {trial_24h_status}\n' + f'• Отписка от канала: {trial_channel_status}\n' + f'• 1 день после истечения: {expired_1d_status}\n' + f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n' + f'• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}' ) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], - [InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")], - [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], - [InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")], - [InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")], - [InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")], - [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], - [InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")], - [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], - [InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")], - [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], - [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], - [InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")], - [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], - [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], - [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], - [InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h' + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h' + ) + ], + [ + InlineKeyboardButton( + text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h' + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h' + ) + ], + [ + InlineKeyboardButton( + text=f'{trial_channel_status} • Отписка от канала', + callback_data='admin_mon_notify_toggle_trial_channel', + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: отписка от канала', callback_data='admin_mon_notify_preview_trial_channel' + ) + ], + [ + InlineKeyboardButton( + text=f'{expired_1d_status} • 1 день после истечения', + callback_data='admin_mon_notify_toggle_expired_1d', + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: 1 день после истечения', callback_data='admin_mon_notify_preview_expired_1d' + ) + ], + [ + InlineKeyboardButton( + text=f'{second_wave_status} • 2-3 дня со скидкой', + callback_data='admin_mon_notify_toggle_expired_2d', + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: скидка 2-3 день', callback_data='admin_mon_notify_preview_expired_2d' + ) + ], + [ + InlineKeyboardButton( + text=f'✏️ Скидка 2-3 дня: {second_percent}%', callback_data='admin_mon_notify_edit_2d_percent' + ) + ], + [ + InlineKeyboardButton( + text=f'⏱️ Срок скидки 2-3 дня: {second_hours} ч', callback_data='admin_mon_notify_edit_2d_hours' + ) + ], + [ + InlineKeyboardButton( + text=f'{third_wave_status} • {third_days} дней со скидкой', + callback_data='admin_mon_notify_toggle_expired_nd', + ) + ], + [ + InlineKeyboardButton( + text='🧪 Тест: скидка спустя дни', callback_data='admin_mon_notify_preview_expired_nd' + ) + ], + [ + InlineKeyboardButton( + text=f'✏️ Скидка {third_days} дней: {third_percent}%', + callback_data='admin_mon_notify_edit_nd_percent', + ) + ], + [ + InlineKeyboardButton( + text=f'⏱️ Срок скидки {third_days} дней: {third_hours} ч', + callback_data='admin_mon_notify_edit_nd_hours', + ) + ], + [ + InlineKeyboardButton( + text=f'📆 Порог уведомления: {third_days} дн.', callback_data='admin_mon_notify_edit_nd_threshold' + ) + ], + [InlineKeyboardButton(text='🧪 Отправить все тесты', callback_data='admin_mon_notify_preview_all')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_settings')], + ] + ) return summary_text, keyboard @@ -91,96 +166,96 @@ def _build_notification_preview_message(language: str, notification_type: str): now = datetime.now() price_30_days = settings.format_price(settings.PRICE_30_DAYS) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - header = "🧪 Тестовое уведомление мониторинга\n\n" + header = '🧪 Тестовое уведомление мониторинга\n\n' - if notification_type == "trial_inactive_1h": + if notification_type == 'trial_inactive_1h': template = texts.get( - "TRIAL_INACTIVE_1H", + 'TRIAL_INACTIVE_1H', ( - "⏳ Прошёл час, а подключения нет\n\n" - "Если возникли сложности с запуском — воспользуйтесь инструкциями." + '⏳ Прошёл час, а подключения нет\n\n' + 'Если возникли сложности с запуском — воспользуйтесь инструкциями.' ), ) message = template.format( price=price_30_days, - end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"), + end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'), ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', ) ], [ InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', ) ], [ InlineKeyboardButton( - text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), - callback_data="menu_support", + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), + callback_data='menu_support', ) ], ] ) - elif notification_type == "trial_inactive_24h": + elif notification_type == 'trial_inactive_24h': template = texts.get( - "TRIAL_INACTIVE_24H", + 'TRIAL_INACTIVE_24H', ( - "⏳ Вы ещё не подключились к VPN\n\n" - "Прошли сутки с активации тестового периода, но трафик не зафиксирован." - "\n\nНажмите кнопку ниже, чтобы подключиться." + '⏳ Вы ещё не подключились к VPN\n\n' + 'Прошли сутки с активации тестового периода, но трафик не зафиксирован.' + '\n\nНажмите кнопку ниже, чтобы подключиться.' ), ) message = template.format( price=price_30_days, - end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'), ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', ) ], [ InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', ) ], [ InlineKeyboardButton( - text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), - callback_data="menu_support", + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), + callback_data='menu_support', ) ], ] ) - elif notification_type == "trial_channel_unsubscribed": + elif notification_type == 'trial_channel_unsubscribed': template = texts.get( - "TRIAL_CHANNEL_UNSUBSCRIBED", + 'TRIAL_CHANNEL_UNSUBSCRIBED', ( - "🚫 Доступ приостановлен\n\n" - "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n" - "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ." + '🚫 Доступ приостановлен\n\n' + 'Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n' + 'Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.' ), ) - check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался") + check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался') message = template.format(check_button=check_button) buttons: list[list[InlineKeyboardButton]] = [] if settings.CHANNEL_LINK: buttons.append( [ InlineKeyboardButton( - text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), + text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'), url=settings.CHANNEL_LINK, ) ] @@ -189,138 +264,138 @@ def _build_notification_preview_message(language: str, notification_type: str): [ InlineKeyboardButton( text=check_button, - callback_data="sub_channel_check", + callback_data='sub_channel_check', ) ] ) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - elif notification_type == "expired_1d": + elif notification_type == 'expired_1d': template = texts.get( - "SUBSCRIPTION_EXPIRED_1D", + 'SUBSCRIPTION_EXPIRED_1D', ( - "⛔ Подписка закончилась\n\n" - "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + '⛔ Подписка закончилась\n\n' + 'Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис.' ), ) message = template.format( - end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"), + end_date=(now - timedelta(days=1)).strftime('%d.%m.%Y %H:%M'), price=price_30_days, ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', ) ], [ InlineKeyboardButton( - text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), - callback_data="balance_topup", + text=texts.t('BALANCE_TOPUP', '💳 Пополнить баланс'), + callback_data='balance_topup', ) ], [ InlineKeyboardButton( - text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), - callback_data="menu_support", + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), + callback_data='menu_support', ) ], ] ) - elif notification_type == "expired_2d": + elif notification_type == 'expired_2d': percent = NotificationSettingsService.get_second_wave_discount_percent() valid_hours = NotificationSettingsService.get_second_wave_valid_hours() template = texts.get( - "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + 'SUBSCRIPTION_EXPIRED_SECOND_WAVE', ( - "🔥 Скидка {percent}% на продление\n\n" - "Активируйте предложение, чтобы получить дополнительную скидку. " - "Она суммируется с вашей промогруппой и действует до {expires_at}." + '🔥 Скидка {percent}% на продление\n\n' + 'Активируйте предложение, чтобы получить дополнительную скидку. ' + 'Она суммируется с вашей промогруппой и действует до {expires_at}.' ), ) message = template.format( percent=percent, - expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), trigger_days=3, ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="🎁 Получить скидку", - callback_data="claim_discount_preview", + text='🎁 Получить скидку', + callback_data='claim_discount_preview', ) ], [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', ) ], [ InlineKeyboardButton( - text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), - callback_data="balance_topup", + text=texts.t('BALANCE_TOPUP', '💳 Пополнить баланс'), + callback_data='balance_topup', ) ], [ InlineKeyboardButton( - text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), - callback_data="menu_support", + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), + callback_data='menu_support', ) ], ] ) - elif notification_type == "expired_nd": + elif notification_type == 'expired_nd': percent = NotificationSettingsService.get_third_wave_discount_percent() valid_hours = NotificationSettingsService.get_third_wave_valid_hours() trigger_days = NotificationSettingsService.get_third_wave_trigger_days() template = texts.get( - "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + 'SUBSCRIPTION_EXPIRED_THIRD_WAVE', ( - "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. " - "Она суммируется с промогруппой и действует до {expires_at}." + '🎁 Индивидуальная скидка {percent}%\n\n' + 'Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. ' + 'Она суммируется с промогруппой и действует до {expires_at}.' ), ) message = template.format( percent=percent, trigger_days=trigger_days, - expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), + expires_at=(now + timedelta(hours=valid_hours)).strftime('%d.%m.%Y %H:%M'), ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text="🎁 Получить скидку", - callback_data="claim_discount_preview", + text='🎁 Получить скидку', + callback_data='claim_discount_preview', ) ], [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', ) ], [ InlineKeyboardButton( - text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), - callback_data="balance_topup", + text=texts.t('BALANCE_TOPUP', '💳 Пополнить баланс'), + callback_data='balance_topup', ) ], [ InlineKeyboardButton( - text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), - callback_data="menu_support", + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), + callback_data='menu_support', ) ], ] ) else: - raise ValueError(f"Unsupported notification type: {notification_type}") + raise ValueError(f'Unsupported notification type: {notification_type}') - footer = "\n\nСообщение отправлено только вам для проверки оформления." + footer = '\n\nСообщение отправлено только вам для проверки оформления.' return header + message + footer, keyboard @@ -329,15 +404,15 @@ async def _send_notification_preview(bot, chat_id: int, language: str, notificat await bot.send_message( chat_id, message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) async def _render_notification_settings(callback: CallbackQuery) -> None: - language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE text, keyboard = _build_notification_settings_view(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) async def _render_notification_settings_for_state( @@ -350,44 +425,45 @@ async def _render_notification_settings_for_state( text, keyboard = _build_notification_settings_view(language) edit_kwargs = { - "text": text, - "chat_id": chat_id, - "message_id": message_id, - "parse_mode": "HTML", - "reply_markup": keyboard, + 'text': text, + 'chat_id': chat_id, + 'message_id': message_id, + 'parse_mode': 'HTML', + 'reply_markup': keyboard, } if business_connection_id: - edit_kwargs["business_connection_id"] = business_connection_id + edit_kwargs['business_connection_id'] = business_connection_id try: await bot.edit_message_text(**edit_kwargs) except TelegramBadRequest as exc: - if "no text in the message to edit" in (exc.message or "").lower(): + if 'no text in the message to edit' in (exc.message or '').lower(): caption_kwargs = { - "chat_id": chat_id, - "message_id": message_id, - "caption": text, - "parse_mode": "HTML", - "reply_markup": keyboard, + 'chat_id': chat_id, + 'message_id': message_id, + 'caption': text, + 'parse_mode': 'HTML', + 'reply_markup': keyboard, } if business_connection_id: - caption_kwargs["business_connection_id"] = business_connection_id + caption_kwargs['business_connection_id'] = business_connection_id await bot.edit_message_caption(**caption_kwargs) else: raise -@router.callback_query(F.data == "admin_monitoring") + +@router.callback_query(F.data == 'admin_monitoring') @admin_required async def admin_monitoring_menu(callback: CallbackQuery): try: async with AsyncSessionLocal() as db: status = await monitoring_service.get_monitoring_status(db) - running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен" - last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда" + running_status = '🟢 Работает' if status['is_running'] else '🔴 Остановлен' + last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else 'Никогда' text = f""" 🔍 Система мониторинга @@ -407,200 +483,203 @@ async def admin_monitoring_menu(callback: CallbackQuery): language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE keyboard = get_monitoring_keyboard(language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка в админ меню мониторинга: {e}") - await callback.answer("❌ Ошибка получения данных", show_alert=True) + logger.error(f'Ошибка в админ меню мониторинга: {e}') + await callback.answer('❌ Ошибка получения данных', show_alert=True) -@router.callback_query(F.data == "admin_mon_settings") +@router.callback_query(F.data == 'admin_mon_settings') @admin_required async def admin_monitoring_settings(callback: CallbackQuery): try: - language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + global_status = ( + '🟢 Включены' if NotificationSettingsService.are_notifications_globally_enabled() else '🔴 Отключены' + ) second_percent = NotificationSettingsService.get_second_wave_discount_percent() third_percent = NotificationSettingsService.get_third_wave_discount_percent() third_days = NotificationSettingsService.get_third_wave_trigger_days() text = ( - "⚙️ Настройки мониторинга\n\n" - f"🔔 Уведомления пользователям: {global_status}\n" - f"• Скидка 2-3 дня: {second_percent}%\n" - f"• Скидка после {third_days} дней: {third_percent}%\n\n" - "Выберите раздел для настройки." + '⚙️ Настройки мониторинга\n\n' + f'🔔 Уведомления пользователям: {global_status}\n' + f'• Скидка 2-3 дня: {second_percent}%\n' + f'• Скидка после {third_days} дней: {third_percent}%\n\n' + 'Выберите раздел для настройки.' ) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🔔 Уведомления пользователям', callback_data='admin_mon_notify_settings')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')], + ] + ) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка отображения настроек мониторинга: {e}") - await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + logger.error(f'Ошибка отображения настроек мониторинга: {e}') + await callback.answer('❌ Не удалось открыть настройки', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_settings") +@router.callback_query(F.data == 'admin_mon_notify_settings') @admin_required async def admin_notify_settings(callback: CallbackQuery): try: await _render_notification_settings(callback) except Exception as e: - logger.error(f"Ошибка отображения настроек уведомлений: {e}") - await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + logger.error(f'Ошибка отображения настроек уведомлений: {e}') + await callback.answer('❌ Не удалось загрузить настройки', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h') @admin_required async def toggle_trial_1h_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h") +@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h') @admin_required async def preview_trial_1h_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send trial 1h preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send trial 1h preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h') @admin_required async def toggle_trial_24h_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h") +@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h') @admin_required async def preview_trial_24h_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send trial 24h preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send trial 24h preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel") +@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel') @admin_required async def toggle_trial_channel_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled() NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel") +@router.callback_query(F.data == 'admin_mon_notify_preview_trial_channel') @admin_required async def preview_trial_channel_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_channel_unsubscribed') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send trial channel preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send trial channel preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@router.callback_query(F.data == 'admin_mon_notify_toggle_expired_1d') @admin_required async def toggle_expired_1d_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_expired_1d_enabled() NotificationSettingsService.set_expired_1d_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d") +@router.callback_query(F.data == 'admin_mon_notify_preview_expired_1d') @admin_required async def preview_expired_1d_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_1d') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send expired 1d preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send expired 1d preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@router.callback_query(F.data == 'admin_mon_notify_toggle_expired_2d') @admin_required async def toggle_second_wave_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_second_wave_enabled() NotificationSettingsService.set_second_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d") +@router.callback_query(F.data == 'admin_mon_notify_preview_expired_2d') @admin_required async def preview_second_wave_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_2d') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send second wave preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send second wave preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@router.callback_query(F.data == 'admin_mon_notify_toggle_expired_nd') @admin_required async def toggle_third_wave_notification(callback: CallbackQuery): enabled = NotificationSettingsService.is_third_wave_enabled() NotificationSettingsService.set_third_wave_enabled(not enabled) - await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено') await _render_notification_settings(callback) -@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd") +@router.callback_query(F.data == 'admin_mon_notify_preview_expired_nd') @admin_required async def preview_third_wave_notification(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE - await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd") - await callback.answer("✅ Пример отправлен") + await _send_notification_preview(callback.bot, callback.from_user.id, language, 'expired_nd') + await callback.answer('✅ Пример отправлен') except Exception as exc: - logger.error("Failed to send third wave preview: %s", exc) - await callback.answer("❌ Не удалось отправить тест", show_alert=True) + logger.error('Failed to send third wave preview: %s', exc) + await callback.answer('❌ Не удалось отправить тест', show_alert=True) -@router.callback_query(F.data == "admin_mon_notify_preview_all") +@router.callback_query(F.data == 'admin_mon_notify_preview_all') @admin_required async def preview_all_notifications(callback: CallbackQuery): try: language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE chat_id = callback.from_user.id for notification_type in [ - "trial_inactive_1h", - "trial_inactive_24h", - "trial_channel_unsubscribed", - "expired_1d", - "expired_2d", - "expired_nd", + 'trial_inactive_1h', + 'trial_inactive_24h', + 'trial_channel_unsubscribed', + 'expired_1d', + 'expired_2d', + 'expired_nd', ]: await _send_notification_preview(callback.bot, chat_id, language, notification_type) - await callback.answer("✅ Все тестовые уведомления отправлены") + await callback.answer('✅ Все тестовые уведомления отправлены') except Exception as exc: - logger.error("Failed to send all notification previews: %s", exc) - await callback.answer("❌ Не удалось отправить тесты", show_alert=True) + logger.error('Failed to send all notification previews: %s', exc) + await callback.answer('❌ Не удалось отправить тесты', show_alert=True) async def _start_notification_value_edit( @@ -619,8 +698,8 @@ async def _start_notification_value_edit( settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, settings_business_connection_id=( - str(getattr(callback.message, "business_connection_id", None)) - if getattr(callback.message, "business_connection_id", None) is not None + str(getattr(callback.message, 'business_connection_id', None)) + if getattr(callback.message, 'business_connection_id', None) is not None else None ), settings_language=language, @@ -630,116 +709,116 @@ async def _start_notification_value_edit( await callback.message.answer(texts.get(prompt_key, default_prompt)) -@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@router.callback_query(F.data == 'admin_mon_notify_edit_2d_percent') @admin_required async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): await _start_notification_value_edit( callback, state, - "expired_second_wave", - "percent", - "NOTIFY_PROMPT_SECOND_PERCENT", - "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + 'expired_second_wave', + 'percent', + 'NOTIFY_PROMPT_SECOND_PERCENT', + 'Введите новый процент скидки для уведомления через 2-3 дня (0-100):', ) -@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@router.callback_query(F.data == 'admin_mon_notify_edit_2d_hours') @admin_required async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): await _start_notification_value_edit( callback, state, - "expired_second_wave", - "hours", - "NOTIFY_PROMPT_SECOND_HOURS", - "Введите количество часов действия скидки (1-168):", + 'expired_second_wave', + 'hours', + 'NOTIFY_PROMPT_SECOND_HOURS', + 'Введите количество часов действия скидки (1-168):', ) -@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@router.callback_query(F.data == 'admin_mon_notify_edit_nd_percent') @admin_required async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): await _start_notification_value_edit( callback, state, - "expired_third_wave", - "percent", - "NOTIFY_PROMPT_THIRD_PERCENT", - "Введите новый процент скидки для позднего предложения (0-100):", + 'expired_third_wave', + 'percent', + 'NOTIFY_PROMPT_THIRD_PERCENT', + 'Введите новый процент скидки для позднего предложения (0-100):', ) -@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@router.callback_query(F.data == 'admin_mon_notify_edit_nd_hours') @admin_required async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): await _start_notification_value_edit( callback, state, - "expired_third_wave", - "hours", - "NOTIFY_PROMPT_THIRD_HOURS", - "Введите количество часов действия скидки (1-168):", + 'expired_third_wave', + 'hours', + 'NOTIFY_PROMPT_THIRD_HOURS', + 'Введите количество часов действия скидки (1-168):', ) -@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@router.callback_query(F.data == 'admin_mon_notify_edit_nd_threshold') @admin_required async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): await _start_notification_value_edit( callback, state, - "expired_third_wave", - "trigger", - "NOTIFY_PROMPT_THIRD_DAYS", - "Через сколько дней после истечения отправлять предложение? (минимум 2):", + 'expired_third_wave', + 'trigger', + 'NOTIFY_PROMPT_THIRD_DAYS', + 'Через сколько дней после истечения отправлять предложение? (минимум 2):', ) -@router.callback_query(F.data == "admin_mon_start") +@router.callback_query(F.data == 'admin_mon_start') @admin_required async def start_monitoring_callback(callback: CallbackQuery): try: if monitoring_service.is_running: - await callback.answer("ℹ️ Мониторинг уже запущен") + await callback.answer('ℹ️ Мониторинг уже запущен') return - + if not monitoring_service.bot: monitoring_service.bot = callback.bot - + asyncio.create_task(monitoring_service.start_monitoring()) - - await callback.answer("✅ Мониторинг запущен!") - + + await callback.answer('✅ Мониторинг запущен!') + await admin_monitoring_menu(callback) - + except Exception as e: - logger.error(f"Ошибка запуска мониторинга: {e}") - await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True) + logger.error(f'Ошибка запуска мониторинга: {e}') + await callback.answer(f'❌ Ошибка запуска: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_stop") +@router.callback_query(F.data == 'admin_mon_stop') @admin_required async def stop_monitoring_callback(callback: CallbackQuery): try: if not monitoring_service.is_running: - await callback.answer("ℹ️ Мониторинг уже остановлен") + await callback.answer('ℹ️ Мониторинг уже остановлен') return - + monitoring_service.stop_monitoring() - await callback.answer("⏹️ Мониторинг остановлен!") - + await callback.answer('⏹️ Мониторинг остановлен!') + await admin_monitoring_menu(callback) - + except Exception as e: - logger.error(f"Ошибка остановки мониторинга: {e}") - await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True) + logger.error(f'Ошибка остановки мониторинга: {e}') + await callback.answer(f'❌ Ошибка остановки: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_force_check") +@router.callback_query(F.data == 'admin_mon_force_check') @admin_required async def force_check_callback(callback: CallbackQuery): try: - await callback.answer("⏳ Выполняем проверку подписок...") + await callback.answer('⏳ Выполняем проверку подписок...') async with AsyncSessionLocal() as db: results = await monitoring_service.force_check_subscriptions(db) @@ -757,19 +836,20 @@ async def force_check_callback(callback: CallbackQuery): Нажмите "Назад" для возврата в меню мониторинга. """ - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] - ]) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')]] + ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка принудительной проверки: {e}") - await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True) + logger.error(f'Ошибка принудительной проверки: {e}') + await callback.answer(f'❌ Ошибка проверки: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_traffic_check") +@router.callback_query(F.data == 'admin_mon_traffic_check') @admin_required async def traffic_check_callback(callback: CallbackQuery): """Ручная проверка трафика — использует snapshot и дельту.""" @@ -777,13 +857,12 @@ async def traffic_check_callback(callback: CallbackQuery): # Проверяем, включен ли мониторинг трафика if not traffic_monitoring_scheduler.is_enabled(): await callback.answer( - "⚠️ Мониторинг трафика отключен в настройках\n" - "Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env", - show_alert=True + '⚠️ Мониторинг трафика отключен в настройках\nВключите TRAFFIC_FAST_CHECK_ENABLED=true в .env', + show_alert=True, ) return - await callback.answer("⏳ Запускаем проверку трафика (дельта)...") + await callback.answer('⏳ Запускаем проверку трафика (дельта)...') # Используем run_fast_check — он сравнивает с snapshot и отправляет уведомления from app.services.traffic_monitoring_service import traffic_monitoring_scheduler_v2 @@ -810,82 +889,85 @@ async def traffic_check_callback(callback: CallbackQuery): """ if violations: - text += "\n⚠️ Превышения дельты:\n" + text += '\n⚠️ Превышения дельты:\n' for v in violations[:10]: name = v.full_name or v.user_uuid[:8] - text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n" + text += f'• {name}: +{v.used_traffic_gb:.1f} ГБ\n' if len(violations) > 10: - text += f"... и ещё {len(violations) - 10}\n" - text += "\n📨 Уведомления отправлены (с учётом кулдауна)" + text += f'... и ещё {len(violations) - 10}\n' + text += '\n📨 Уведомления отправлены (с учётом кулдауна)' else: - text += "\n✅ Превышений не обнаружено" + text += '\n✅ Превышений не обнаружено' - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_mon_traffic_check")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] - ]) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🔄 Повторить', callback_data='admin_mon_traffic_check')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')], + ] + ) + + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка проверки трафика: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка проверки трафика: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data.startswith("admin_mon_logs")) +@router.callback_query(F.data.startswith('admin_mon_logs')) @admin_required async def monitoring_logs_callback(callback: CallbackQuery): try: page = 1 - if "_page_" in callback.data: - page = int(callback.data.split("_page_")[1]) - + if '_page_' in callback.data: + page = int(callback.data.split('_page_')[1]) + async with AsyncSessionLocal() as db: all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000) if not all_logs: - text = "📋 Логи мониторинга пусты\n\nСистема еще не выполнила проверки." + text = '📋 Логи мониторинга пусты\n\nСистема еще не выполнила проверки.' keyboard = get_monitoring_logs_back_keyboard() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) return per_page = 8 paginated_logs = paginate_list(all_logs, page=page, per_page=per_page) - text = f"📋 Логи мониторинга (стр. {page}/{paginated_logs.total_pages})\n\n" + text = f'📋 Логи мониторинга (стр. {page}/{paginated_logs.total_pages})\n\n' for log in paginated_logs.items: - icon = "✅" if log['is_success'] else "❌" + icon = '✅' if log['is_success'] else '❌' time_str = log['created_at'].strftime('%m-%d %H:%M') event_type = log['event_type'].replace('_', ' ').title() message = log['message'] if len(message) > 45: - message = message[:45] + "..." + message = message[:45] + '...' - text += f"{icon} {time_str} {event_type}\n" - text += f" 📄 {message}\n\n" + text += f'{icon} {time_str} {event_type}\n' + text += f' 📄 {message}\n\n' total_success = sum(1 for log in all_logs if log['is_success']) total_failed = len(all_logs) - total_success success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0 - text += f"📊 Общая статистика:\n" - text += f"• Всего событий: {len(all_logs)}\n" - text += f"• Успешных: {total_success}\n" - text += f"• Ошибок: {total_failed}\n" - text += f"• Успешность: {success_rate}%" + text += '📊 Общая статистика:\n' + text += f'• Всего событий: {len(all_logs)}\n' + text += f'• Успешных: {total_success}\n' + text += f'• Ошибок: {total_failed}\n' + text += f'• Успешность: {success_rate}%' keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка получения логов: {e}") - await callback.answer("❌ Ошибка получения логов", show_alert=True) + logger.error(f'Ошибка получения логов: {e}') + await callback.answer('❌ Ошибка получения логов', show_alert=True) -@router.callback_query(F.data == "admin_mon_clear_logs") +@router.callback_query(F.data == 'admin_mon_clear_logs') @admin_required async def clear_logs_callback(callback: CallbackQuery): try: @@ -894,18 +976,18 @@ async def clear_logs_callback(callback: CallbackQuery): await db.commit() if deleted_count > 0: - await callback.answer(f"🗑️ Удалено {deleted_count} записей логов") + await callback.answer(f'🗑️ Удалено {deleted_count} записей логов') else: - await callback.answer("ℹ️ Логи уже пусты") + await callback.answer('ℹ️ Логи уже пусты') await monitoring_logs_callback(callback) except Exception as e: - logger.error(f"Ошибка очистки логов: {e}") - await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True) + logger.error(f'Ошибка очистки логов: {e}') + await callback.answer(f'❌ Ошибка очистки: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_test_notifications") +@router.callback_query(F.data == 'admin_mon_test_notifications') @admin_required async def test_notifications_callback(callback: CallbackQuery): try: @@ -921,26 +1003,23 @@ async def test_notifications_callback(callback: CallbackQuery): ✅ Если вы получили это сообщение, система уведомлений работает корректно! """ - - await callback.bot.send_message( - callback.from_user.id, - test_message, - parse_mode="HTML" - ) - - await callback.answer("✅ Тестовое уведомление отправлено!") - + + await callback.bot.send_message(callback.from_user.id, test_message, parse_mode='HTML') + + await callback.answer('✅ Тестовое уведомление отправлено!') + except Exception as e: - logger.error(f"Ошибка отправки тестового уведомления: {e}") - await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True) + logger.error(f'Ошибка отправки тестового уведомления: {e}') + await callback.answer(f'❌ Ошибка отправки: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_statistics") +@router.callback_query(F.data == 'admin_mon_statistics') @admin_required async def monitoring_statistics_callback(callback: CallbackQuery): try: async with AsyncSessionLocal() as db: from app.database.crud.subscription import get_subscriptions_statistics + sub_stats = await get_subscriptions_statistics(db) mon_status = await monitoring_service.get_monitoring_status(db) @@ -970,7 +1049,7 @@ async def monitoring_statistics_callback(callback: CallbackQuery): • Всего событий: {len(week_logs)} • Успешных: {week_success} • Ошибок: {week_errors} -• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}% +• Успешность: {round(week_success / len(week_logs) * 100, 1) if week_logs else 0}% 🔧 Система: • Интервал: {settings.MONITORING_INTERVAL} мин @@ -981,91 +1060,93 @@ async def monitoring_statistics_callback(callback: CallbackQuery): # Добавляем информацию о чеках NaloGO if settings.is_nalogo_enabled(): nalogo_status = await nalogo_queue_service.get_status() - queue_len = nalogo_status.get("queue_length", 0) - total_amount = nalogo_status.get("total_amount", 0) - running = nalogo_status.get("running", False) - pending_count = nalogo_status.get("pending_verification_count", 0) - pending_amount = nalogo_status.get("pending_verification_amount", 0) + queue_len = nalogo_status.get('queue_length', 0) + total_amount = nalogo_status.get('total_amount', 0) + running = nalogo_status.get('running', False) + pending_count = nalogo_status.get('pending_verification_count', 0) + pending_amount = nalogo_status.get('pending_verification_amount', 0) nalogo_section = f""" 🧾 Чеки NaloGO: • Сервис: {'🟢 Работает' if running else '🔴 Остановлен'} • В очереди: {queue_len} чек(ов)""" if queue_len > 0: - nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽" + nalogo_section += f'\n• На сумму: {total_amount:,.2f} ₽' if pending_count > 0: - nalogo_section += f"\n⚠️ Требуют проверки: {pending_count} ({pending_amount:,.2f} ₽)" + nalogo_section += f'\n⚠️ Требуют проверки: {pending_count} ({pending_amount:,.2f} ₽)' text += nalogo_section - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup buttons = [] # Кнопки для работы с чеками NaloGO if settings.is_nalogo_enabled(): nalogo_status = await nalogo_queue_service.get_status() nalogo_buttons = [] - if nalogo_status.get("queue_length", 0) > 0: - nalogo_buttons.append(InlineKeyboardButton( - text=f"🧾 Отправить ({nalogo_status['queue_length']})", - callback_data="admin_mon_nalogo_force_process" - )) - pending_count = nalogo_status.get("pending_verification_count", 0) + if nalogo_status.get('queue_length', 0) > 0: + nalogo_buttons.append( + InlineKeyboardButton( + text=f'🧾 Отправить ({nalogo_status["queue_length"]})', + callback_data='admin_mon_nalogo_force_process', + ) + ) + pending_count = nalogo_status.get('pending_verification_count', 0) if pending_count > 0: - nalogo_buttons.append(InlineKeyboardButton( - text=f"⚠️ Проверить ({pending_count})", - callback_data="admin_mon_nalogo_pending" - )) - nalogo_buttons.append(InlineKeyboardButton( - text="📊 Сверка чеков", - callback_data="admin_mon_receipts_missing" - )) + nalogo_buttons.append( + InlineKeyboardButton( + text=f'⚠️ Проверить ({pending_count})', callback_data='admin_mon_nalogo_pending' + ) + ) + nalogo_buttons.append( + InlineKeyboardButton(text='📊 Сверка чеков', callback_data='admin_mon_receipts_missing') + ) buttons.append(nalogo_buttons) - buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]) + buttons.append([InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')]) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка получения статистики: {e}") - await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True) + logger.error(f'Ошибка получения статистики: {e}') + await callback.answer(f'❌ Ошибка получения статистики: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_nalogo_force_process") +@router.callback_query(F.data == 'admin_mon_nalogo_force_process') @admin_required async def nalogo_force_process_callback(callback: CallbackQuery): """Принудительная отправка чеков из очереди.""" try: - await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False) + await callback.answer('🔄 Запускаю обработку очереди чеков...', show_alert=False) result = await nalogo_queue_service.force_process() - if "error" in result: - await callback.answer(f"❌ {result['error']}", show_alert=True) + if 'error' in result: + await callback.answer(f'❌ {result["error"]}', show_alert=True) return - message = result.get("message", "Готово") - processed = result.get("processed", 0) - remaining = result.get("remaining", 0) + result.get('message', 'Готово') + processed = result.get('processed', 0) + remaining = result.get('remaining', 0) if processed > 0: - text = f"✅ Обработано: {processed} чек(ов)" + text = f'✅ Обработано: {processed} чек(ов)' if remaining > 0: - text += f"\n⏳ Осталось в очереди: {remaining}" + text += f'\n⏳ Осталось в очереди: {remaining}' + elif remaining > 0: + text = f'⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)' else: - if remaining > 0: - text = f"⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)" - else: - text = "📭 Очередь пуста" + text = '📭 Очередь пуста' await callback.answer(text, show_alert=True) # Обновляем страницу статистики - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup # Перезагружаем статистику async with AsyncSessionLocal() as db: from app.database.crud.subscription import get_subscriptions_statistics + sub_stats = await get_subscriptions_statistics(db) mon_status = await monitoring_service.get_monitoring_status(db) @@ -1093,7 +1174,7 @@ async def nalogo_force_process_callback(callback: CallbackQuery): • Всего событий: {len(week_logs)} • Успешных: {week_success} • Ошибок: {week_errors} -• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}% +• Успешность: {round(week_success / len(week_logs) * 100, 1) if week_logs else 0}% 🔧 Система: • Интервал: {settings.MONITORING_INTERVAL} мин @@ -1103,16 +1184,16 @@ async def nalogo_force_process_callback(callback: CallbackQuery): if settings.is_nalogo_enabled(): nalogo_status = await nalogo_queue_service.get_status() - queue_len = nalogo_status.get("queue_length", 0) - total_amount = nalogo_status.get("total_amount", 0) - running = nalogo_status.get("running", False) + queue_len = nalogo_status.get('queue_length', 0) + total_amount = nalogo_status.get('total_amount', 0) + running = nalogo_status.get('running', False) nalogo_section = f""" 🧾 Чеки NaloGO: • Сервис: {'🟢 Работает' if running else '🔴 Остановлен'} • В очереди: {queue_len} чек(ов)""" if queue_len > 0: - nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽" + nalogo_section += f'\n• На сумму: {total_amount:,.2f} ₽' stats_text += nalogo_section buttons = [] @@ -1120,144 +1201,141 @@ async def nalogo_force_process_callback(callback: CallbackQuery): if settings.is_nalogo_enabled(): nalogo_status = await nalogo_queue_service.get_status() nalogo_buttons = [] - if nalogo_status.get("queue_length", 0) > 0: - nalogo_buttons.append(InlineKeyboardButton( - text=f"🧾 Отправить ({nalogo_status['queue_length']})", - callback_data="admin_mon_nalogo_force_process" - )) - nalogo_buttons.append(InlineKeyboardButton( - text="📊 Сверка чеков", - callback_data="admin_mon_receipts_missing" - )) + if nalogo_status.get('queue_length', 0) > 0: + nalogo_buttons.append( + InlineKeyboardButton( + text=f'🧾 Отправить ({nalogo_status["queue_length"]})', + callback_data='admin_mon_nalogo_force_process', + ) + ) + nalogo_buttons.append( + InlineKeyboardButton(text='📊 Сверка чеков', callback_data='admin_mon_receipts_missing') + ) buttons.append(nalogo_buttons) - buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]) + buttons.append([InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')]) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(stats_text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка принудительной обработки чеков: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка принудительной обработки чеков: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_nalogo_pending") +@router.callback_query(F.data == 'admin_mon_nalogo_pending') @admin_required async def nalogo_pending_callback(callback: CallbackQuery): """Просмотр чеков ожидающих ручной проверки.""" try: + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from app.services.nalogo_service import NaloGoService - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton nalogo_service = NaloGoService() receipts = await nalogo_service.get_pending_verification_receipts() if not receipts: - await callback.answer("✅ Нет чеков на проверку", show_alert=True) + await callback.answer('✅ Нет чеков на проверку', show_alert=True) return - text = f"⚠️ Чеки требующие проверки: {len(receipts)}\n\n" - text += "Проверьте в lknpd.nalog.ru созданы ли эти чеки.\n\n" + text = f'⚠️ Чеки требующие проверки: {len(receipts)}\n\n' + text += 'Проверьте в lknpd.nalog.ru созданы ли эти чеки.\n\n' buttons = [] for i, receipt in enumerate(receipts[:10], 1): - payment_id = receipt.get("payment_id", "unknown") - amount = receipt.get("amount", 0) - created_at = receipt.get("created_at", "")[:16].replace("T", " ") - error = receipt.get("error", "")[:50] + payment_id = receipt.get('payment_id', 'unknown') + amount = receipt.get('amount', 0) + created_at = receipt.get('created_at', '')[:16].replace('T', ' ') + error = receipt.get('error', '')[:50] - text += f"{i}. {amount:,.2f} ₽\n" - text += f" 📅 {created_at}\n" - text += f" 🆔 {payment_id[:20]}...\n" + text += f'{i}. {amount:,.2f} ₽\n' + text += f' 📅 {created_at}\n' + text += f' 🆔 {payment_id[:20]}...\n' if error: - text += f" ❌ {error}\n" - text += "\n" + text += f' ❌ {error}\n' + text += '\n' # Кнопки для каждого чека - buttons.append([ - InlineKeyboardButton( - text=f"✅ Создан ({i})", - callback_data=f"admin_nalogo_verified:{payment_id[:30]}" - ), - InlineKeyboardButton( - text=f"🔄 Отправить ({i})", - callback_data=f"admin_nalogo_retry:{payment_id[:30]}" - ), - ]) + buttons.append( + [ + InlineKeyboardButton( + text=f'✅ Создан ({i})', callback_data=f'admin_nalogo_verified:{payment_id[:30]}' + ), + InlineKeyboardButton( + text=f'🔄 Отправить ({i})', callback_data=f'admin_nalogo_retry:{payment_id[:30]}' + ), + ] + ) if len(receipts) > 10: - text += f"\n... и ещё {len(receipts) - 10} чек(ов)" + text += f'\n... и ещё {len(receipts) - 10} чек(ов)' - buttons.append([ - InlineKeyboardButton( - text="🗑 Очистить всё (проверено)", - callback_data="admin_nalogo_clear_pending" - ) - ]) - buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]) + buttons.append( + [InlineKeyboardButton(text='🗑 Очистить всё (проверено)', callback_data='admin_nalogo_clear_pending')] + ) + buttons.append([InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')]) keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка просмотра очереди проверки: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка просмотра очереди проверки: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data.startswith("admin_nalogo_verified:")) +@router.callback_query(F.data.startswith('admin_nalogo_verified:')) @admin_required async def nalogo_mark_verified_callback(callback: CallbackQuery): """Пометить чек как созданный в налоговой.""" try: from app.services.nalogo_service import NaloGoService - payment_id = callback.data.split(":", 1)[1] + payment_id = callback.data.split(':', 1)[1] nalogo_service = NaloGoService() # Помечаем как проверенный (чек был создан) - removed = await nalogo_service.mark_pending_as_verified( - payment_id, receipt_uuid=None, was_created=True - ) + removed = await nalogo_service.mark_pending_as_verified(payment_id, receipt_uuid=None, was_created=True) if removed: - await callback.answer(f"✅ Чек помечен как созданный", show_alert=True) + await callback.answer('✅ Чек помечен как созданный', show_alert=True) # Обновляем список await nalogo_pending_callback(callback) else: - await callback.answer("❌ Чек не найден", show_alert=True) + await callback.answer('❌ Чек не найден', show_alert=True) except Exception as e: - logger.error(f"Ошибка пометки чека: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка пометки чека: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data.startswith("admin_nalogo_retry:")) +@router.callback_query(F.data.startswith('admin_nalogo_retry:')) @admin_required async def nalogo_retry_callback(callback: CallbackQuery): """Повторно отправить чек в налоговую.""" try: from app.services.nalogo_service import NaloGoService - payment_id = callback.data.split(":", 1)[1] + payment_id = callback.data.split(':', 1)[1] nalogo_service = NaloGoService() - await callback.answer("🔄 Отправляю чек...", show_alert=False) + await callback.answer('🔄 Отправляю чек...', show_alert=False) receipt_uuid = await nalogo_service.retry_pending_receipt(payment_id) if receipt_uuid: - await callback.answer(f"✅ Чек создан: {receipt_uuid}", show_alert=True) + await callback.answer(f'✅ Чек создан: {receipt_uuid}', show_alert=True) # Обновляем список await nalogo_pending_callback(callback) else: - await callback.answer("❌ Не удалось создать чек", show_alert=True) + await callback.answer('❌ Не удалось создать чек', show_alert=True) except Exception as e: - logger.error(f"Ошибка повторной отправки чека: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка повторной отправки чека: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_nalogo_clear_pending") +@router.callback_query(F.data == 'admin_nalogo_clear_pending') @admin_required async def nalogo_clear_pending_callback(callback: CallbackQuery): """Очистить всю очередь проверки.""" @@ -1267,21 +1345,21 @@ async def nalogo_clear_pending_callback(callback: CallbackQuery): nalogo_service = NaloGoService() count = await nalogo_service.clear_pending_verification() - await callback.answer(f"✅ Очищено: {count} чек(ов)", show_alert=True) + await callback.answer(f'✅ Очищено: {count} чек(ов)', show_alert=True) # Возвращаемся на статистику await callback.message.edit_text( - "✅ Очередь проверки очищена", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")] - ]) + '✅ Очередь проверки очищена', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')]] + ), ) except Exception as e: - logger.error(f"Ошибка очистки очереди: {e}") - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка очистки очереди: {e}') + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_receipts_missing") +@router.callback_query(F.data == 'admin_mon_receipts_missing') @admin_required async def receipts_missing_callback(callback: CallbackQuery): """Сверка чеков по логам.""" @@ -1289,38 +1367,44 @@ async def receipts_missing_callback(callback: CallbackQuery): await _do_reconcile_logs(callback) -@router.callback_query(F.data == "admin_mon_receipts_link_old") +@router.callback_query(F.data == 'admin_mon_receipts_link_old') @admin_required async def receipts_link_old_callback(callback: CallbackQuery): """Привязать старые чеки из NaloGO к транзакциям по сумме и дате.""" try: - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - from sqlalchemy import select, and_ from datetime import date, timedelta - from app.database.models import Transaction, PaymentMethod, TransactionType + + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from sqlalchemy import and_, select + + from app.database.models import PaymentMethod, Transaction, TransactionType from app.services.nalogo_service import NaloGoService - await callback.answer("🔄 Загружаю чеки из NaloGO...", show_alert=False) + await callback.answer('🔄 Загружаю чеки из NaloGO...', show_alert=False) TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0) async with AsyncSessionLocal() as db: # Получаем старые транзакции без чеков - query = select(Transaction).where( - and_( - Transaction.type == TransactionType.DEPOSIT.value, - Transaction.payment_method == PaymentMethod.YOOKASSA.value, - Transaction.receipt_uuid.is_(None), - Transaction.is_completed == True, - Transaction.created_at < TRACKING_START_DATE, + query = ( + select(Transaction) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.payment_method == PaymentMethod.YOOKASSA.value, + Transaction.receipt_uuid.is_(None), + Transaction.is_completed == True, + Transaction.created_at < TRACKING_START_DATE, + ) ) - ).order_by(Transaction.created_at.desc()) + .order_by(Transaction.created_at.desc()) + ) result = await db.execute(query) transactions = result.scalars().all() if not transactions: - await callback.answer("✅ Нет старых транзакций для привязки", show_alert=True) + await callback.answer('✅ Нет старых транзакций для привязки', show_alert=True) return # Получаем чеки из NaloGO за последние 60 дней @@ -1335,14 +1419,14 @@ async def receipts_link_old_callback(callback: CallbackQuery): ) if not incomes: - await callback.answer("❌ Не удалось получить чеки из NaloGO", show_alert=True) + await callback.answer('❌ Не удалось получить чеки из NaloGO', show_alert=True) return # Создаём словарь чеков по сумме для быстрого поиска # Ключ: сумма в копейках, значение: список чеков incomes_by_amount = {} for income in incomes: - amount = float(income.get("totalAmount", income.get("amount", 0))) + amount = float(income.get('totalAmount', income.get('amount', 0))) amount_kopeks = int(amount * 100) if amount_kopeks not in incomes_by_amount: incomes_by_amount[amount_kopeks] = [] @@ -1355,14 +1439,15 @@ async def receipts_link_old_callback(callback: CallbackQuery): if matching_incomes: # Берём первый подходящий чек income = matching_incomes.pop(0) - receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid")) + receipt_uuid = income.get('approvedReceiptUuid', income.get('receiptUuid')) if receipt_uuid: t.receipt_uuid = receipt_uuid # Парсим дату чека - operation_time = income.get("operationTime") + operation_time = income.get('operationTime') if operation_time: try: from dateutil.parser import isoparse + t.receipt_created_at = isoparse(operation_time) except Exception: t.receipt_created_at = datetime.utcnow() @@ -1371,28 +1456,29 @@ async def receipts_link_old_callback(callback: CallbackQuery): if linked > 0: await db.commit() - text = f"🔗 Привязка завершена\n\n" - text += f"Всего транзакций: {len(transactions)}\n" - text += f"Чеков в NaloGO: {len(incomes)}\n" - text += f"Привязано: {linked}\n" - text += f"Не удалось привязать: {len(transactions) - linked}" + text = '🔗 Привязка завершена\n\n' + text += f'Всего транзакций: {len(transactions)}\n' + text += f'Чеков в NaloGO: {len(incomes)}\n' + text += f'Привязано: {linked}\n' + text += f'Не удалось привязать: {len(transactions) - linked}' - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')], + ] + ) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка привязки старых чеков: {e}", exc_info=True) - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка привязки старых чеков: {e}', exc_info=True) + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_receipts_reconcile") +@router.callback_query(F.data == 'admin_mon_receipts_reconcile') @admin_required async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMContext): """Меню выбора периода сверки.""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton # Очищаем состояние на случай если остался ввод даты await state.clear() @@ -1404,30 +1490,33 @@ async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMCo async def _do_reconcile_logs(callback: CallbackQuery): """Внутренняя функция сверки по логам.""" try: - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - from pathlib import Path import re from collections import defaultdict + from pathlib import Path - await callback.answer("🔄 Анализирую логи платежей...", show_alert=False) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + await callback.answer('🔄 Анализирую логи платежей...', show_alert=False) # Путь к файлу логов платежей (logs/current/) log_file_path = Path(settings.LOG_FILE).resolve() log_dir = log_file_path.parent - current_dir = log_dir / "current" + current_dir = log_dir / 'current' payments_log = current_dir / settings.LOG_PAYMENTS_FILE if not payments_log.exists(): try: await callback.message.edit_text( - "❌ Файл логов не найден\n\n" - f"Путь: {payments_log}\n\n" - "Логи появятся после первого успешного платежа.", - parse_mode="HTML", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")] - ]) + '❌ Файл логов не найден\n\n' + f'Путь: {payments_log}\n\n' + 'Логи появятся после первого успешного платежа.', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_mon_reconcile_logs')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')], + ] + ), ) except TelegramBadRequest: pass # Сообщение не изменилось @@ -1436,11 +1525,11 @@ async def _do_reconcile_logs(callback: CallbackQuery): # Паттерны для парсинга логов # Успешный платёж: "Успешно обработан платеж YooKassa 30e3c6fc-000f-5001-9000-1a9c8b242396: пользователь 1046 пополнил баланс на 200.0₽" payment_pattern = re.compile( - r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽" + r'(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽' ) # Чек создан: "Чек NaloGO создан для платежа 30e3c6fc-000f-5001-9000-1a9c8b242396: 243udsqtik" receipt_pattern = re.compile( - r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)" + r'(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)' ) # Читаем и парсим логи @@ -1448,34 +1537,28 @@ async def _do_reconcile_logs(callback: CallbackQuery): receipts = {} # payment_id -> {date, receipt_uuid} try: - with open(payments_log, "r", encoding="utf-8") as f: + with open(payments_log, encoding='utf-8') as f: for line in f: # Проверяем платежи match = payment_pattern.search(line) if match: date_str, payment_id, amount = match.groups() - payments[payment_id] = { - "date": date_str, - "amount": float(amount) - } + payments[payment_id] = {'date': date_str, 'amount': float(amount)} continue # Проверяем чеки match = receipt_pattern.search(line) if match: date_str, payment_id, receipt_uuid = match.groups() - receipts[payment_id] = { - "date": date_str, - "receipt_uuid": receipt_uuid - } + receipts[payment_id] = {'date': date_str, 'receipt_uuid': receipt_uuid} except Exception as e: - logger.error(f"Ошибка чтения логов: {e}") + logger.error(f'Ошибка чтения логов: {e}') await callback.message.edit_text( - f"❌ Ошибка чтения логов\n\n{str(e)}", - parse_mode="HTML", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")] - ]) + f'❌ Ошибка чтения логов\n\n{e!s}', + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')]] + ), ) return @@ -1483,108 +1566,107 @@ async def _do_reconcile_logs(callback: CallbackQuery): payments_without_receipts = [] for payment_id, payment_data in payments.items(): if payment_id not in receipts: - payments_without_receipts.append({ - "payment_id": payment_id, - "date": payment_data["date"], - "amount": payment_data["amount"] - }) + payments_without_receipts.append( + {'payment_id': payment_id, 'date': payment_data['date'], 'amount': payment_data['amount']} + ) # Группируем по датам by_date = defaultdict(list) for p in payments_without_receipts: - by_date[p["date"]].append(p) + by_date[p['date']].append(p) # Формируем отчёт total_payments = len(payments) total_receipts = len(receipts) missing_count = len(payments_without_receipts) - missing_amount = sum(p["amount"] for p in payments_without_receipts) + missing_amount = sum(p['amount'] for p in payments_without_receipts) - text = "📋 Сверка по логам\n\n" - text += f"📦 Всего платежей: {total_payments}\n" - text += f"🧾 Чеков создано: {total_receipts}\n\n" + text = '📋 Сверка по логам\n\n' + text += f'📦 Всего платежей: {total_payments}\n' + text += f'🧾 Чеков создано: {total_receipts}\n\n' if missing_count == 0: - text += "✅ Все платежи имеют чеки!" + text += '✅ Все платежи имеют чеки!' else: - text += f"⚠️ Без чеков: {missing_count} платежей на {missing_amount:,.2f} ₽\n\n" + text += f'⚠️ Без чеков: {missing_count} платежей на {missing_amount:,.2f} ₽\n\n' # Показываем по датам (последние) sorted_dates = sorted(by_date.keys(), reverse=True) for date_str in sorted_dates[:7]: date_payments = by_date[date_str] - date_amount = sum(p["amount"] for p in date_payments) - text += f"• {date_str}: {len(date_payments)} шт. на {date_amount:,.2f} ₽\n" + date_amount = sum(p['amount'] for p in date_payments) + text += f'• {date_str}: {len(date_payments)} шт. на {date_amount:,.2f} ₽\n' if len(sorted_dates) > 7: - text += f"\n...и ещё {len(sorted_dates) - 7} дней" + text += f'\n...и ещё {len(sorted_dates) - 7} дней' - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")], - [InlineKeyboardButton(text="📄 Детали", callback_data="admin_mon_reconcile_logs_details")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_mon_reconcile_logs')], + [InlineKeyboardButton(text='📄 Детали', callback_data='admin_mon_reconcile_logs_details')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_statistics')], + ] + ) try: - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except TelegramBadRequest: pass # Сообщение не изменилось except TelegramBadRequest: pass # Игнорируем если сообщение не изменилось except Exception as e: - logger.error(f"Ошибка сверки по логам: {e}", exc_info=True) - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка сверки по логам: {e}', exc_info=True) + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) -@router.callback_query(F.data == "admin_mon_reconcile_logs") +@router.callback_query(F.data == 'admin_mon_reconcile_logs') @admin_required async def receipts_reconcile_logs_refresh_callback(callback: CallbackQuery): """Обновить сверку по логам.""" await _do_reconcile_logs(callback) -@router.callback_query(F.data == "admin_mon_reconcile_logs_details") +@router.callback_query(F.data == 'admin_mon_reconcile_logs_details') @admin_required async def receipts_reconcile_logs_details_callback(callback: CallbackQuery): """Детальный список платежей без чеков.""" try: - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - from pathlib import Path import re + from pathlib import Path - await callback.answer("🔄 Загружаю детали...", show_alert=False) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + await callback.answer('🔄 Загружаю детали...', show_alert=False) # Путь к логам (logs/current/) log_file_path = Path(settings.LOG_FILE).resolve() log_dir = log_file_path.parent - current_dir = log_dir / "current" + current_dir = log_dir / 'current' payments_log = current_dir / settings.LOG_PAYMENTS_FILE if not payments_log.exists(): - await callback.answer("❌ Файл логов не найден", show_alert=True) + await callback.answer('❌ Файл логов не найден', show_alert=True) return payment_pattern = re.compile( - r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽" - ) - receipt_pattern = re.compile( - r"Чек NaloGO создан для платежа ([a-f0-9-]+)" + r'(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽' ) + receipt_pattern = re.compile(r'Чек NaloGO создан для платежа ([a-f0-9-]+)') payments = {} receipts = set() - with open(payments_log, "r", encoding="utf-8") as f: + with open(payments_log, encoding='utf-8') as f: for line in f: match = payment_pattern.search(line) if match: date_str, time_str, payment_id, user_id, amount = match.groups() payments[payment_id] = { - "date": date_str, - "time": time_str, - "user_id": user_id, - "amount": float(amount) + 'date': date_str, + 'time': time_str, + 'user_id': user_id, + 'amount': float(amount), } continue @@ -1596,103 +1678,98 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery): missing = [] for payment_id, data in payments.items(): if payment_id not in receipts: - missing.append({"payment_id": payment_id, **data}) + missing.append({'payment_id': payment_id, **data}) # Сортируем по дате (новые сверху) - missing.sort(key=lambda x: (x["date"], x["time"]), reverse=True) + missing.sort(key=lambda x: (x['date'], x['time']), reverse=True) if not missing: - text = "✅ Все платежи имеют чеки!" + text = '✅ Все платежи имеют чеки!' else: - text = f"📄 Платежи без чеков ({len(missing)} шт.)\n\n" + text = f'📄 Платежи без чеков ({len(missing)} шт.)\n\n' for p in missing[:20]: text += ( - f"• {p['date']} {p['time']}\n" - f" User: {p['user_id']} | {p['amount']:.0f}₽\n" - f" {p['payment_id'][:18]}...\n\n" + f'• {p["date"]} {p["time"]}\n' + f' User: {p["user_id"]} | {p["amount"]:.0f}₽\n' + f' {p["payment_id"][:18]}...\n\n' ) if len(missing) > 20: - text += f"...и ещё {len(missing) - 20} платежей" + text += f'...и ещё {len(missing) - 20} платежей' - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_reconcile_logs")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_mon_reconcile_logs')], + ] + ) try: - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except TelegramBadRequest: pass except TelegramBadRequest: pass except Exception as e: - logger.error(f"Ошибка детализации: {e}", exc_info=True) - await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) + logger.error(f'Ошибка детализации: {e}', exc_info=True) + await callback.answer(f'❌ Ошибка: {e!s}', show_alert=True) def get_monitoring_logs_keyboard(current_page: int, total_pages: int): - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + keyboard = [] - + if total_pages > 1: nav_row = [] - + if current_page > 1: - nav_row.append(InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_mon_logs_page_{current_page - 1}" - )) - - nav_row.append(InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page" - )) - + nav_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'admin_mon_logs_page_{current_page - 1}')) + + nav_row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page')) + if current_page < total_pages: - nav_row.append(InlineKeyboardButton( - text="➡️", - callback_data=f"admin_mon_logs_page_{current_page + 1}" - )) - + nav_row.append(InlineKeyboardButton(text='➡️', callback_data=f'admin_mon_logs_page_{current_page + 1}')) + keyboard.append(nav_row) - - keyboard.extend([ + + keyboard.extend( [ - InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs") - ], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] - ]) - + [ + InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_mon_logs'), + InlineKeyboardButton(text='🗑️ Очистить', callback_data='admin_mon_clear_logs'), + ], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')], + ] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_monitoring_logs_back_keyboard(): - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"), - InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters") - ], - [ - InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs") - ], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")] - ]) + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_mon_logs'), + InlineKeyboardButton(text='🔍 Фильтры', callback_data='admin_mon_logs_filters'), + ], + [InlineKeyboardButton(text='🗑️ Очистить логи', callback_data='admin_mon_clear_logs')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')], + ] + ) -@router.message(Command("monitoring")) +@router.message(Command('monitoring')) @admin_required async def monitoring_command(message: Message): try: async with AsyncSessionLocal() as db: status = await monitoring_service.get_monitoring_status(db) - running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен" + running_status = '🟢 Работает' if status['is_running'] else '🔴 Остановлен' text = f""" 🔍 Быстрый статус мониторинга @@ -1704,11 +1781,11 @@ async def monitoring_command(message: Message): Для подробного управления используйте админ-панель. """ - await message.answer(text, parse_mode="HTML") + await message.answer(text, parse_mode='HTML') except Exception as e: - logger.error(f"Ошибка команды /monitoring: {e}") - await message.answer(f"❌ Ошибка: {str(e)}") + logger.error(f'Ошибка команды /monitoring: {e}') + await message.answer(f'❌ Ошибка: {e!s}') @router.message(AdminStates.editing_notification_value) @@ -1716,72 +1793,72 @@ async def process_notification_value_input(message: Message, state: FSMContext): data = await state.get_data() if not data: await state.clear() - await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + await message.answer('ℹ️ Контекст утерян, попробуйте снова из меню настроек.') return - raw_value = (message.text or "").strip() + raw_value = (message.text or '').strip() try: value = int(raw_value) except (TypeError, ValueError): - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + language = data.get('settings_language') or message.from_user.language_code or settings.DEFAULT_LANGUAGE texts = get_texts(language) - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + await message.answer(texts.get('NOTIFICATION_VALUE_INVALID', '❌ Введите целое число.')) return - key = data.get("notification_setting_key") - field = data.get("notification_setting_field") - language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + key = data.get('notification_setting_key') + field = data.get('notification_setting_field') + language = data.get('settings_language') or message.from_user.language_code or settings.DEFAULT_LANGUAGE texts = get_texts(language) # Добавляем дополнительные проверки диапазона значений - if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"): + if (key == 'expired_second_wave' and field == 'percent') or (key == 'expired_third_wave' and field == 'percent'): if value < 0 or value > 100: - await message.answer("❌ Процент скидки должен быть от 0 до 100.") + await message.answer('❌ Процент скидки должен быть от 0 до 100.') return - elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"): + elif (key == 'expired_second_wave' and field == 'hours') or (key == 'expired_third_wave' and field == 'hours'): if value < 1 or value > 168: # Максимум 168 часов (7 дней) - await message.answer("❌ Количество часов должно быть от 1 до 168.") + await message.answer('❌ Количество часов должно быть от 1 до 168.') return - elif key == "expired_third_wave" and field == "trigger": + elif key == 'expired_third_wave' and field == 'trigger': if value < 2: # Минимум 2 дня - await message.answer("❌ Количество дней должно быть не менее 2.") + await message.answer('❌ Количество дней должно быть не менее 2.') return success = False - if key == "expired_second_wave" and field == "percent": + if key == 'expired_second_wave' and field == 'percent': success = NotificationSettingsService.set_second_wave_discount_percent(value) - elif key == "expired_second_wave" and field == "hours": + elif key == 'expired_second_wave' and field == 'hours': success = NotificationSettingsService.set_second_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "percent": + elif key == 'expired_third_wave' and field == 'percent': success = NotificationSettingsService.set_third_wave_discount_percent(value) - elif key == "expired_third_wave" and field == "hours": + elif key == 'expired_third_wave' and field == 'hours': success = NotificationSettingsService.set_third_wave_valid_hours(value) - elif key == "expired_third_wave" and field == "trigger": + elif key == 'expired_third_wave' and field == 'trigger': success = NotificationSettingsService.set_third_wave_trigger_days(value) if not success: - await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + await message.answer(texts.get('NOTIFICATION_VALUE_INVALID', '❌ Некорректное значение, попробуйте снова.')) return back_keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.get("BACK", "⬅️ Назад"), - callback_data="admin_mon_notify_settings", + text=texts.get('BACK', '⬅️ Назад'), + callback_data='admin_mon_notify_settings', ) ] ] ) await message.answer( - texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."), + texts.get('NOTIFICATION_VALUE_UPDATED', '✅ Настройки обновлены.'), reply_markup=back_keyboard, ) - chat_id = data.get("settings_message_chat") - message_id = data.get("settings_message_id") - business_connection_id = data.get("settings_business_connection_id") + chat_id = data.get('settings_message_chat') + message_id = data.get('settings_message_id') + business_connection_id = data.get('settings_business_connection_id') if chat_id and message_id: await _render_notification_settings_for_state( message.bot, @@ -1796,8 +1873,9 @@ async def process_notification_value_input(message: Message, state: FSMContext): # ============== Настройки мониторинга трафика ============== + def _format_traffic_toggle(enabled: bool) -> str: - return "🟢 Вкл" if enabled else "🔴 Выкл" + return '🟢 Вкл' if enabled else '🔴 Выкл' def _build_traffic_settings_keyboard() -> InlineKeyboardMarkup: @@ -1811,37 +1889,44 @@ def _build_traffic_settings_keyboard() -> InlineKeyboardMarkup: daily_threshold = settings.TRAFFIC_DAILY_THRESHOLD_GB cooldown = settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=f"{_format_traffic_toggle(fast_enabled)} Быстрая проверка", - callback_data="admin_traffic_toggle_fast" - )], - [InlineKeyboardButton( - text=f"⏱ Интервал: {fast_interval} мин", - callback_data="admin_traffic_edit_fast_interval" - )], - [InlineKeyboardButton( - text=f"📊 Порог дельты: {fast_threshold} ГБ", - callback_data="admin_traffic_edit_fast_threshold" - )], - [InlineKeyboardButton( - text=f"{_format_traffic_toggle(daily_enabled)} Суточная проверка", - callback_data="admin_traffic_toggle_daily" - )], - [InlineKeyboardButton( - text=f"🕐 Время проверки: {daily_time}", - callback_data="admin_traffic_edit_daily_time" - )], - [InlineKeyboardButton( - text=f"📈 Суточный порог: {daily_threshold} ГБ", - callback_data="admin_traffic_edit_daily_threshold" - )], - [InlineKeyboardButton( - text=f"⏳ Кулдаун: {cooldown} мин", - callback_data="admin_traffic_edit_cooldown" - )], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")], - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=f'{_format_traffic_toggle(fast_enabled)} Быстрая проверка', + callback_data='admin_traffic_toggle_fast', + ) + ], + [ + InlineKeyboardButton( + text=f'⏱ Интервал: {fast_interval} мин', callback_data='admin_traffic_edit_fast_interval' + ) + ], + [ + InlineKeyboardButton( + text=f'📊 Порог дельты: {fast_threshold} ГБ', callback_data='admin_traffic_edit_fast_threshold' + ) + ], + [ + InlineKeyboardButton( + text=f'{_format_traffic_toggle(daily_enabled)} Суточная проверка', + callback_data='admin_traffic_toggle_daily', + ) + ], + [ + InlineKeyboardButton( + text=f'🕐 Время проверки: {daily_time}', callback_data='admin_traffic_edit_daily_time' + ) + ], + [ + InlineKeyboardButton( + text=f'📈 Суточный порог: {daily_threshold} ГБ', callback_data='admin_traffic_edit_daily_threshold' + ) + ], + [InlineKeyboardButton(text=f'⏳ Кулдаун: {cooldown} мин', callback_data='admin_traffic_edit_cooldown')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_monitoring')], + ] + ) def _build_traffic_settings_text() -> str: @@ -1853,15 +1938,15 @@ def _build_traffic_settings_text() -> str: daily_status = _format_traffic_toggle(daily_enabled) text = ( - "⚙️ Настройки мониторинга трафика\n\n" - f"Быстрая проверка: {fast_status}\n" - f"• Интервал: {settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES} мин\n" - f"• Порог дельты: {settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB} ГБ\n\n" - f"Суточная проверка: {daily_status}\n" - f"• Время: {settings.TRAFFIC_DAILY_CHECK_TIME} UTC\n" - f"• Порог: {settings.TRAFFIC_DAILY_THRESHOLD_GB} ГБ\n\n" - f"Общие:\n" - f"• Кулдаун уведомлений: {settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES} мин\n" + '⚙️ Настройки мониторинга трафика\n\n' + f'Быстрая проверка: {fast_status}\n' + f'• Интервал: {settings.TRAFFIC_FAST_CHECK_INTERVAL_MINUTES} мин\n' + f'• Порог дельты: {settings.TRAFFIC_FAST_CHECK_THRESHOLD_GB} ГБ\n\n' + f'Суточная проверка: {daily_status}\n' + f'• Время: {settings.TRAFFIC_DAILY_CHECK_TIME} UTC\n' + f'• Порог: {settings.TRAFFIC_DAILY_THRESHOLD_GB} ГБ\n\n' + f'Общие:\n' + f'• Кулдаун уведомлений: {settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES} мин\n' ) # Информация о фильтрах @@ -1870,29 +1955,29 @@ def _build_traffic_settings_text() -> str: excluded_uuids = settings.get_traffic_excluded_user_uuids() if monitored_nodes: - text += f"• Мониторим только: {len(monitored_nodes)} нод(ы)\n" + text += f'• Мониторим только: {len(monitored_nodes)} нод(ы)\n' if ignored_nodes: - text += f"• Игнорируем: {len(ignored_nodes)} нод(ы)\n" + text += f'• Игнорируем: {len(ignored_nodes)} нод(ы)\n' if excluded_uuids: - text += f"• Исключено юзеров: {len(excluded_uuids)}\n" + text += f'• Исключено юзеров: {len(excluded_uuids)}\n' return text -@router.callback_query(F.data == "admin_mon_traffic_settings") +@router.callback_query(F.data == 'admin_mon_traffic_settings') @admin_required async def admin_traffic_settings(callback: CallbackQuery): """Показывает настройки мониторинга трафика.""" try: text = _build_traffic_settings_text() keyboard = _build_traffic_settings_keyboard() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка отображения настроек трафика: {e}") - await callback.answer("❌ Ошибка загрузки настроек", show_alert=True) + logger.error(f'Ошибка отображения настроек трафика: {e}') + await callback.answer('❌ Ошибка загрузки настроек', show_alert=True) -@router.callback_query(F.data == "admin_traffic_toggle_fast") +@router.callback_query(F.data == 'admin_traffic_toggle_fast') @admin_required async def toggle_fast_check(callback: CallbackQuery): """Переключает быструю проверку трафика.""" @@ -1903,22 +1988,22 @@ async def toggle_fast_check(callback: CallbackQuery): new_value = not current async with AsyncSessionLocal() as db: - await BotConfigurationService.set_value(db, "TRAFFIC_FAST_CHECK_ENABLED", new_value) + await BotConfigurationService.set_value(db, 'TRAFFIC_FAST_CHECK_ENABLED', new_value) await db.commit() - await callback.answer("✅ Включено" if new_value else "⏸️ Отключено") + await callback.answer('✅ Включено' if new_value else '⏸️ Отключено') # Обновляем отображение text = _build_traffic_settings_text() keyboard = _build_traffic_settings_keyboard() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка переключения быстрой проверки: {e}") - await callback.answer("❌ Ошибка", show_alert=True) + logger.error(f'Ошибка переключения быстрой проверки: {e}') + await callback.answer('❌ Ошибка', show_alert=True) -@router.callback_query(F.data == "admin_traffic_toggle_daily") +@router.callback_query(F.data == 'admin_traffic_toggle_daily') @admin_required async def toggle_daily_check(callback: CallbackQuery): """Переключает суточную проверку трафика.""" @@ -1929,104 +2014,95 @@ async def toggle_daily_check(callback: CallbackQuery): new_value = not current async with AsyncSessionLocal() as db: - await BotConfigurationService.set_value(db, "TRAFFIC_DAILY_CHECK_ENABLED", new_value) + await BotConfigurationService.set_value(db, 'TRAFFIC_DAILY_CHECK_ENABLED', new_value) await db.commit() - await callback.answer("✅ Включено" if new_value else "⏸️ Отключено") + await callback.answer('✅ Включено' if new_value else '⏸️ Отключено') text = _build_traffic_settings_text() keyboard = _build_traffic_settings_keyboard() - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) except Exception as e: - logger.error(f"Ошибка переключения суточной проверки: {e}") - await callback.answer("❌ Ошибка", show_alert=True) + logger.error(f'Ошибка переключения суточной проверки: {e}') + await callback.answer('❌ Ошибка', show_alert=True) -@router.callback_query(F.data == "admin_traffic_edit_fast_interval") +@router.callback_query(F.data == 'admin_traffic_edit_fast_interval') @admin_required async def edit_fast_interval(callback: CallbackQuery, state: FSMContext): """Начинает редактирование интервала быстрой проверки.""" await state.set_state(AdminStates.editing_traffic_setting) await state.update_data( - traffic_setting_key="TRAFFIC_FAST_CHECK_INTERVAL_MINUTES", - traffic_setting_type="int", + traffic_setting_key='TRAFFIC_FAST_CHECK_INTERVAL_MINUTES', + traffic_setting_type='int', settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, ) await callback.answer() - await callback.message.answer( - "⏱ Введите интервал быстрой проверки в минутах (минимум 1):" - ) + await callback.message.answer('⏱ Введите интервал быстрой проверки в минутах (минимум 1):') -@router.callback_query(F.data == "admin_traffic_edit_fast_threshold") +@router.callback_query(F.data == 'admin_traffic_edit_fast_threshold') @admin_required async def edit_fast_threshold(callback: CallbackQuery, state: FSMContext): """Начинает редактирование порога быстрой проверки.""" await state.set_state(AdminStates.editing_traffic_setting) await state.update_data( - traffic_setting_key="TRAFFIC_FAST_CHECK_THRESHOLD_GB", - traffic_setting_type="float", + traffic_setting_key='TRAFFIC_FAST_CHECK_THRESHOLD_GB', + traffic_setting_type='float', settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, ) await callback.answer() - await callback.message.answer( - "📊 Введите порог дельты трафика в ГБ (например: 5.0):" - ) + await callback.message.answer('📊 Введите порог дельты трафика в ГБ (например: 5.0):') -@router.callback_query(F.data == "admin_traffic_edit_daily_time") +@router.callback_query(F.data == 'admin_traffic_edit_daily_time') @admin_required async def edit_daily_time(callback: CallbackQuery, state: FSMContext): """Начинает редактирование времени суточной проверки.""" await state.set_state(AdminStates.editing_traffic_setting) await state.update_data( - traffic_setting_key="TRAFFIC_DAILY_CHECK_TIME", - traffic_setting_type="time", + traffic_setting_key='TRAFFIC_DAILY_CHECK_TIME', + traffic_setting_type='time', settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, ) await callback.answer() await callback.message.answer( - "🕐 Введите время суточной проверки в формате HH:MM (UTC):\n" - "Например: 00:00, 03:00, 12:30" + '🕐 Введите время суточной проверки в формате HH:MM (UTC):\nНапример: 00:00, 03:00, 12:30' ) -@router.callback_query(F.data == "admin_traffic_edit_daily_threshold") +@router.callback_query(F.data == 'admin_traffic_edit_daily_threshold') @admin_required async def edit_daily_threshold(callback: CallbackQuery, state: FSMContext): """Начинает редактирование суточного порога.""" await state.set_state(AdminStates.editing_traffic_setting) await state.update_data( - traffic_setting_key="TRAFFIC_DAILY_THRESHOLD_GB", - traffic_setting_type="float", + traffic_setting_key='TRAFFIC_DAILY_THRESHOLD_GB', + traffic_setting_type='float', settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, ) await callback.answer() - await callback.message.answer( - "📈 Введите суточный порог трафика в ГБ (например: 50.0):" - ) + await callback.message.answer('📈 Введите суточный порог трафика в ГБ (например: 50.0):') -@router.callback_query(F.data == "admin_traffic_edit_cooldown") +@router.callback_query(F.data == 'admin_traffic_edit_cooldown') @admin_required async def edit_cooldown(callback: CallbackQuery, state: FSMContext): """Начинает редактирование кулдауна уведомлений.""" await state.set_state(AdminStates.editing_traffic_setting) await state.update_data( - traffic_setting_key="TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES", - traffic_setting_type="int", + traffic_setting_key='TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES', + traffic_setting_type='int', settings_message_chat=callback.message.chat.id, settings_message_id=callback.message.message_id, ) await callback.answer() - await callback.message.answer( - "⏳ Введите кулдаун уведомлений в минутах (минимум 1):" - ) + await callback.message.answer('⏳ Введите кулдаун уведомлений в минутах (минимум 1):') @router.message(AdminStates.editing_traffic_setting) @@ -2037,37 +2113,38 @@ async def process_traffic_setting_input(message: Message, state: FSMContext): data = await state.get_data() if not data: await state.clear() - await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + await message.answer('ℹ️ Контекст утерян, попробуйте снова из меню настроек.') return - raw_value = (message.text or "").strip() - setting_key = data.get("traffic_setting_key") - setting_type = data.get("traffic_setting_type") + raw_value = (message.text or '').strip() + setting_key = data.get('traffic_setting_key') + setting_type = data.get('traffic_setting_type') # Валидация и парсинг значения try: - if setting_type == "int": + if setting_type == 'int': value = int(raw_value) if value < 1: - raise ValueError("Значение должно быть >= 1") - elif setting_type == "float": - value = float(raw_value.replace(",", ".")) + raise ValueError('Значение должно быть >= 1') + elif setting_type == 'float': + value = float(raw_value.replace(',', '.')) if value <= 0: - raise ValueError("Значение должно быть > 0") - elif setting_type == "time": + raise ValueError('Значение должно быть > 0') + elif setting_type == 'time': # Валидация формата HH:MM import re - if not re.match(r"^\d{1,2}:\d{2}$", raw_value): - raise ValueError("Неверный формат времени. Используйте HH:MM") - parts = raw_value.split(":") + + if not re.match(r'^\d{1,2}:\d{2}$', raw_value): + raise ValueError('Неверный формат времени. Используйте HH:MM') + parts = raw_value.split(':') hours, minutes = int(parts[0]), int(parts[1]) if hours < 0 or hours > 23 or minutes < 0 or minutes > 59: - raise ValueError("Неверное время") - value = f"{hours:02d}:{minutes:02d}" + raise ValueError('Неверное время') + value = f'{hours:02d}:{minutes:02d}' else: value = raw_value except ValueError as e: - await message.answer(f"❌ {str(e)}") + await message.answer(f'❌ {e!s}') return # Сохраняем значение @@ -2078,31 +2155,27 @@ async def process_traffic_setting_input(message: Message, state: FSMContext): back_keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton(text="⬅️ К настройкам трафика", callback_data="admin_mon_traffic_settings")] + [InlineKeyboardButton(text='⬅️ К настройкам трафика', callback_data='admin_mon_traffic_settings')] ] ) - await message.answer("✅ Настройка сохранена!", reply_markup=back_keyboard) + await message.answer('✅ Настройка сохранена!', reply_markup=back_keyboard) # Обновляем исходное сообщение с настройками - chat_id = data.get("settings_message_chat") - message_id = data.get("settings_message_id") + chat_id = data.get('settings_message_chat') + message_id = data.get('settings_message_id') if chat_id and message_id: try: text = _build_traffic_settings_text() keyboard = _build_traffic_settings_keyboard() await message.bot.edit_message_text( - chat_id=chat_id, - message_id=message_id, - text=text, - parse_mode="HTML", - reply_markup=keyboard + chat_id=chat_id, message_id=message_id, text=text, parse_mode='HTML', reply_markup=keyboard ) except Exception: pass # Игнорируем если сообщение уже удалено except Exception as e: - logger.error(f"Ошибка сохранения настройки трафика: {e}") - await message.answer(f"❌ Ошибка сохранения: {str(e)}") + logger.error(f'Ошибка сохранения настройки трафика: {e}') + await message.answer(f'❌ Ошибка сохранения: {e!s}') await state.clear() diff --git a/app/handlers/admin/payments.py b/app/handlers/admin/payments.py index 04b7a1c4..b5b65c06 100644 --- a/app/handlers/admin/payments.py +++ b/app/handlers/admin/payments.py @@ -2,7 +2,6 @@ from __future__ import annotations import html import math -from typing import Optional from aiogram import Dispatcher, F, types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -13,8 +12,8 @@ from app.database.models import PaymentMethod, User from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.services.payment_verification_service import ( - PendingPayment, SUPPORTED_MANUAL_CHECK_METHODS, + PendingPayment, get_payment_record, list_recent_pending_payments, run_manual_check, @@ -30,19 +29,19 @@ def _method_display(method: PaymentMethod) -> str: if method == PaymentMethod.MULENPAY: return settings.get_mulenpay_display_name() if method == PaymentMethod.PAL24: - return "PayPalych" + return 'PayPalych' if method == PaymentMethod.WATA: - return "WATA" + return 'WATA' if method == PaymentMethod.HELEKET: - return "Heleket" + return 'Heleket' if method == PaymentMethod.YOOKASSA: - return "YooKassa" + return 'YooKassa' if method == PaymentMethod.PLATEGA: return settings.get_platega_display_name() if method == PaymentMethod.CRYPTOBOT: - return "CryptoBot" + return 'CryptoBot' if method == PaymentMethod.TELEGRAM_STARS: - return "Telegram Stars" + return 'Telegram Stars' return method.value @@ -51,101 +50,101 @@ def _status_info( *, texts, ) -> tuple[str, str]: - status = (record.status or "").lower() + status = (record.status or '').lower() if record.is_paid: - return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") + return '✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid') if record.method == PaymentMethod.PAL24: mapping = { - "new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), - "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + 'new': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'process': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'fail': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'cancel': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.MULENPAY: mapping = { - "created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), - "hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")), - "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + 'created': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'hold': ('🔒', texts.t('ADMIN_PAYMENT_STATUS_ON_HOLD', '🔒 Hold')), + 'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'cancel': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'error': ('⚠️', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.WATA: mapping = { - "opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), - "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), + 'opened': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'closed': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'declined': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'expired': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_EXPIRED', '⌛ Expired')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.PLATEGA: mapping = { - "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), - "confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), + 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'inprogress': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'confirmed': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'failed': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'cancelled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'expired': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_EXPIRED', '⌛ Expired')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.HELEKET: - if status in {"pending", "created", "waiting", "check", "processing"}: - return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending") - if status in {"paid", "paid_over"}: - return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") - if status in {"cancel", "canceled", "fail", "failed", "expired"}: - return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled") - return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") + if status in {'pending', 'created', 'waiting', 'check', 'processing'}: + return '⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending') + if status in {'paid', 'paid_over'}: + return '✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid') + if status in {'cancel', 'canceled', 'fail', 'failed', 'expired'}: + return '❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled') + return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown') if record.method == PaymentMethod.YOOKASSA: mapping = { - "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")), - "succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), + 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'waiting_for_capture': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')), + 'succeeded': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.CRYPTOBOT: mapping = { - "active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")), + 'active': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'expired': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_EXPIRED', '⌛ Expired')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) if record.method == PaymentMethod.TELEGRAM_STARS: if record.is_paid: - return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid") - return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending") + return '✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid') + return '⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending') if record.method == PaymentMethod.FREEKASSA: mapping = { - "pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")), - "success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")), - "canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")), - "error": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")), + 'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')), + 'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')), + 'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')), + 'error': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')), } - return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown"))) + return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown'))) - return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown") + return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown') def _is_checkable(record: PendingPayment) -> bool: @@ -153,23 +152,23 @@ def _is_checkable(record: PendingPayment) -> bool: return False if not record.is_recent(): return False - status = (record.status or "").lower() + status = (record.status or '').lower() if record.method == PaymentMethod.PAL24: - return status in {"new", "process"} + return status in {'new', 'process'} if record.method == PaymentMethod.MULENPAY: - return status in {"created", "processing", "hold"} + return status in {'created', 'processing', 'hold'} if record.method == PaymentMethod.WATA: - return status in {"opened", "pending", "processing", "inprogress", "in_progress"} + return status in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'} if record.method == PaymentMethod.PLATEGA: - return status in {"pending", "inprogress", "in_progress"} + return status in {'pending', 'inprogress', 'in_progress'} if record.method == PaymentMethod.HELEKET: - return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"} + return status not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'} if record.method == PaymentMethod.YOOKASSA: - return status in {"pending", "waiting_for_capture"} + return status in {'pending', 'waiting_for_capture'} if record.method == PaymentMethod.CRYPTOBOT: - return status in {"active"} + return status in {'active'} if record.method == PaymentMethod.FREEKASSA: - return status in {"pending", ""} + return status in {'pending', ''} return False @@ -191,16 +190,16 @@ def _build_list_keyboard( for record in records: number = _record_display_number(record) - details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}") + details_template = texts.t('ADMIN_PAYMENTS_ITEM_DETAILS', '📄 #{number}') try: button_text = details_template.format(number=number) except Exception: # pragma: no cover - fallback for broken localization - button_text = f"📄 {number}" + button_text = f'📄 {number}' buttons.append( [ InlineKeyboardButton( text=button_text, - callback_data=f"admin_payment_{record.method.value}_{record.local_id}", + callback_data=f'admin_payment_{record.method.value}_{record.local_id}', ) ] ) @@ -210,29 +209,29 @@ def _build_list_keyboard( if page > 1: navigation_row.append( InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_payments_page_{page - 1}", + text='⬅️', + callback_data=f'admin_payments_page_{page - 1}', ) ) navigation_row.append( InlineKeyboardButton( - text=f"{page}/{total_pages}", - callback_data="admin_payments_page_current", + text=f'{page}/{total_pages}', + callback_data='admin_payments_page_current', ) ) if page < total_pages: navigation_row.append( InlineKeyboardButton( - text="➡️", - callback_data=f"admin_payments_page_{page + 1}", + text='➡️', + callback_data=f'admin_payments_page_{page + 1}', ) ) buttons.append(navigation_row) - buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -246,26 +245,23 @@ def _build_detail_keyboard( rows: list[list[InlineKeyboardButton]] = [] payment = record.payment - payment_url = getattr(payment, "payment_url", None) + payment_url = getattr(payment, 'payment_url', None) if record.method == PaymentMethod.PAL24: payment_url = payment.link_url or payment.link_page_url or payment_url elif record.method == PaymentMethod.WATA: payment_url = payment.url or payment_url elif record.method == PaymentMethod.YOOKASSA: - payment_url = getattr(payment, "confirmation_url", None) or payment_url + payment_url = getattr(payment, 'confirmation_url', None) or payment_url elif record.method == PaymentMethod.CRYPTOBOT: payment_url = ( - payment.bot_invoice_url - or payment.mini_app_invoice_url - or payment.web_app_invoice_url - or payment_url + payment.bot_invoice_url or payment.mini_app_invoice_url or payment.web_app_invoice_url or payment_url ) if payment_url: rows.append( [ InlineKeyboardButton( - text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"), + text=texts.t('ADMIN_PAYMENT_OPEN_LINK', '🔗 Open link'), url=payment_url, ) ] @@ -275,20 +271,20 @@ def _build_detail_keyboard( rows.append( [ InlineKeyboardButton( - text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"), - callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}", + text=texts.t('ADMIN_PAYMENT_CHECK_BUTTON', '🔁 Check status'), + callback_data=f'admin_payment_check_{record.method.value}_{record.local_id}', ) ] ) - rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")]) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_payments')]) return InlineKeyboardMarkup(inline_keyboard=rows) def _format_user_line(user: User) -> str: username = format_username(user.username, user.telegram_id, user.full_name) - user_id_display = user.telegram_id or user.email or f"#{user.id}" - return f"👤 {html.escape(username)} ({user_id_display})" + user_id_display = user.telegram_id or user.email or f'#{user.id}' + return f'👤 {html.escape(username)} ({user_id_display})' def _build_record_lines( @@ -300,30 +296,28 @@ def _build_record_lines( ) -> list[str]: amount = settings.format_price(record.amount_kopeks) if record.method == PaymentMethod.CRYPTOBOT: - crypto_amount = getattr(record.payment, "amount", None) - crypto_asset = getattr(record.payment, "asset", None) + crypto_amount = getattr(record.payment, 'amount', None) + crypto_asset = getattr(record.payment, 'asset', None) if crypto_amount and crypto_asset: - amount = f"{crypto_amount} {crypto_asset}" + amount = f'{crypto_amount} {crypto_asset}' method_name = _method_display(record.method) emoji, status_text = _status_info(record, texts=texts) created = format_datetime(record.created_at) age = format_time_ago(record.created_at, language) - identifier = ( - html.escape(str(record.identifier)) if record.identifier else "" - ) + identifier = html.escape(str(record.identifier)) if record.identifier else '' display_number = html.escape(_record_display_number(record)) lines = [ - f"{index}. {html.escape(method_name)} — {amount}", - f" {emoji} {status_text}", - f" 🕒 {created} ({age})", + f'{index}. {html.escape(method_name)} — {amount}', + f' {emoji} {status_text}', + f' 🕒 {created} ({age})', _format_user_line(record.user), ] if identifier: - lines.append(f" 🆔 ID: {identifier}") + lines.append(f' 🆔 ID: {identifier}') else: - lines.append(f" 🆔 ID: {display_number}") + lines.append(f' 🆔 ID: {display_number}') return lines @@ -333,130 +327,120 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str) emoji, status_text = _status_info(record, texts=texts) amount = settings.format_price(record.amount_kopeks) if record.method == PaymentMethod.CRYPTOBOT: - crypto_amount = getattr(record.payment, "amount", None) - crypto_asset = getattr(record.payment, "asset", None) + crypto_amount = getattr(record.payment, 'amount', None) + crypto_asset = getattr(record.payment, 'asset', None) if crypto_amount and crypto_asset: - amount = f"{crypto_amount} {crypto_asset}" + amount = f'{crypto_amount} {crypto_asset}' created = format_datetime(record.created_at) age = format_time_ago(record.created_at, language) raw_identifier = record.identifier if record.identifier else record.local_id - identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—" + identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else '—' lines = [ - texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 Payment details"), - "", - f"{html.escape(method_name)}", - f"{emoji} {status_text}", - "", - f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}", - f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})", - f"🆔 ID: {identifier}", + texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 Payment details'), + '', + f'{html.escape(method_name)}', + f'{emoji} {status_text}', + '', + f'💰 {texts.t("ADMIN_PAYMENT_AMOUNT", "Amount")}: {amount}', + f'🕒 {texts.t("ADMIN_PAYMENT_CREATED", "Created")}: {created} ({age})', + f'🆔 ID: {identifier}', _format_user_line(record.user), ] if record.expires_at: expires_at = format_datetime(record.expires_at) - lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}") + lines.append(f'⏳ {texts.t("ADMIN_PAYMENT_EXPIRES", "Expires")}: {expires_at}') payment = record.payment if record.method == PaymentMethod.PAL24: - if getattr(payment, "payment_status", None): + if getattr(payment, 'payment_status', None): lines.append( - f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: " - f"{html.escape(str(payment.payment_status))}" + f'💳 {texts.t("ADMIN_PAYMENT_GATEWAY_STATUS", "Gateway status")}: ' + f'{html.escape(str(payment.payment_status))}' ) - if getattr(payment, "payment_method", None): + if getattr(payment, 'payment_method', None): lines.append( - f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: " - f"{html.escape(str(payment.payment_method))}" + f'🏦 {texts.t("ADMIN_PAYMENT_GATEWAY_METHOD", "Method")}: {html.escape(str(payment.payment_method))}' ) - if getattr(payment, "balance_amount", None): + if getattr(payment, 'balance_amount', None): lines.append( - f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: " - f"{html.escape(str(payment.balance_amount))}" + f'💱 {texts.t("ADMIN_PAYMENT_GATEWAY_AMOUNT", "Gateway amount")}: ' + f'{html.escape(str(payment.balance_amount))}' ) - if getattr(payment, "payer_account", None): + if getattr(payment, 'payer_account', None): lines.append( - f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: " - f"{html.escape(str(payment.payer_account))}" + f'👛 {texts.t("ADMIN_PAYMENT_GATEWAY_ACCOUNT", "Payer account")}: ' + f'{html.escape(str(payment.payer_account))}' ) if record.method == PaymentMethod.MULENPAY: - if getattr(payment, "mulen_payment_id", None): + if getattr(payment, 'mulen_payment_id', None): lines.append( - f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " - f"{html.escape(str(payment.mulen_payment_id))}" + f'🧾 {texts.t("ADMIN_PAYMENT_GATEWAY_ID", "Gateway ID")}: {html.escape(str(payment.mulen_payment_id))}' ) if record.method == PaymentMethod.WATA: - if getattr(payment, "order_id", None): + if getattr(payment, 'order_id', None): lines.append( - f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " - f"{html.escape(str(payment.order_id))}" - ) - if getattr(payment, "terminal_public_id", None): - lines.append( - f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}" + f'🧾 {texts.t("ADMIN_PAYMENT_GATEWAY_ID", "Gateway ID")}: {html.escape(str(payment.order_id))}' ) + if getattr(payment, 'terminal_public_id', None): + lines.append(f'🏦 Terminal: {html.escape(str(payment.terminal_public_id))}') if record.method == PaymentMethod.HELEKET: - if getattr(payment, "order_id", None): + if getattr(payment, 'order_id', None): lines.append( - f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " - f"{html.escape(str(payment.order_id))}" + f'🧾 {texts.t("ADMIN_PAYMENT_GATEWAY_ID", "Gateway ID")}: {html.escape(str(payment.order_id))}' ) - if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None): + if getattr(payment, 'payer_amount', None) and getattr(payment, 'payer_currency', None): lines.append( - f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: " - f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}" + f'🪙 {texts.t("ADMIN_PAYMENT_PAYER_AMOUNT", "Paid amount")}: ' + f'{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}' ) if record.method == PaymentMethod.YOOKASSA: - if getattr(payment, "payment_method_type", None): + if getattr(payment, 'payment_method_type', None): lines.append( - f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: " - f"{html.escape(str(payment.payment_method_type))}" + f'💳 {texts.t("ADMIN_PAYMENT_GATEWAY_METHOD", "Method")}: ' + f'{html.escape(str(payment.payment_method_type))}' ) - if getattr(payment, "confirmation_url", None): - lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")) + if getattr(payment, 'confirmation_url', None): + lines.append(texts.t('ADMIN_PAYMENT_HAS_LINK', '🔗 Payment link is available above.')) if record.method == PaymentMethod.CRYPTOBOT: - if getattr(payment, "amount", None) and getattr(payment, "asset", None): + if getattr(payment, 'amount', None) and getattr(payment, 'asset', None): lines.append( - f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: " - f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}" + f'🪙 {texts.t("ADMIN_PAYMENT_CRYPTO_AMOUNT", "Crypto amount")}: ' + f'{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}' ) - if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None): + if getattr(payment, 'bot_invoice_url', None) or getattr(payment, 'mini_app_invoice_url', None): + lines.append(texts.t('ADMIN_PAYMENT_HAS_LINK', '🔗 Payment link is available above.')) + if getattr(payment, 'status', None): lines.append( - texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.") - ) - if getattr(payment, "status", None): - lines.append( - f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: " - f"{html.escape(str(payment.status))}" + f'📊 {texts.t("ADMIN_PAYMENT_GATEWAY_STATUS", "Gateway status")}: {html.escape(str(payment.status))}' ) if record.method == PaymentMethod.TELEGRAM_STARS: - description = getattr(payment, "description", "") or "" + description = getattr(payment, 'description', '') or '' if description: - lines.append(f"📝 {html.escape(description)}") - if getattr(payment, "external_id", None): + lines.append(f'📝 {html.escape(description)}') + if getattr(payment, 'external_id', None): lines.append( - f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: " - f"{html.escape(str(payment.external_id))}" + f'🧾 {texts.t("ADMIN_PAYMENT_GATEWAY_ID", "Gateway ID")}: {html.escape(str(payment.external_id))}' ) if _is_checkable(record): - lines.append("") - lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check.")) + lines.append('') + lines.append(texts.t('ADMIN_PAYMENT_CHECK_HINT', 'ℹ️ You can trigger a manual status check.')) - return "\n".join(lines) + return '\n'.join(lines) -def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]: +def _parse_method_and_id(payload: str, *, prefix: str) -> tuple[PaymentMethod, int] | None: suffix = payload[len(prefix) :] try: - method_str, identifier = suffix.rsplit("_", 1) + method_str, identifier = suffix.rsplit('_', 1) method = PaymentMethod(method_str) payment_id = int(identifier) return method, payment_id @@ -474,43 +458,41 @@ async def show_payments_overview( texts = get_texts(db_user.language) page = 1 - if callback.data.startswith("admin_payments_page_"): + if callback.data.startswith('admin_payments_page_'): try: - page = int(callback.data.split("_")[-1]) + page = int(callback.data.split('_')[-1]) except ValueError: page = 1 records = await list_recent_pending_payments(db) total = len(records) total_pages = max(1, math.ceil(total / PAGE_SIZE)) - if page < 1: - page = 1 - if page > total_pages: - page = total_pages + page = max(page, 1) + page = min(page, total_pages) start_index = (page - 1) * PAGE_SIZE page_records = records[start_index : start_index + PAGE_SIZE] - header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 Top-up verification") + header = texts.t('ADMIN_PAYMENTS_TITLE', '💳 Top-up verification') description = texts.t( - "ADMIN_PAYMENTS_DESCRIPTION", - "Pending invoices created during the last 24 hours.", + 'ADMIN_PAYMENTS_DESCRIPTION', + 'Pending invoices created during the last 24 hours.', ) notice = texts.t( - "ADMIN_PAYMENTS_NOTICE", - "Only invoices younger than 24 hours and waiting for payment can be checked.", + 'ADMIN_PAYMENTS_NOTICE', + 'Only invoices younger than 24 hours and waiting for payment can be checked.', ) - lines = [header, "", description] + lines = [header, '', description] if page_records: for idx, record in enumerate(page_records, start=start_index + 1): lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language)) - lines.append("") + lines.append('') lines.append(notice) else: - empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.") - lines.append("") + empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.') + lines.append('') lines.append(empty_text) keyboard = _build_list_keyboard( @@ -521,8 +503,8 @@ async def show_payments_overview( ) await callback.message.edit_text( - "\n".join(line for line in lines if line is not None), - parse_mode="HTML", + '\n'.join(line for line in lines if line is not None), + parse_mode='HTML', reply_markup=keyboard, ) await callback.answer() @@ -536,7 +518,7 @@ async def _render_payment_details( texts = get_texts(db_user.language) text = _build_payment_details_text(record, texts=texts, language=db_user.language) keyboard = _build_detail_keyboard(record, language=db_user.language) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=keyboard) @admin_required @@ -546,15 +528,15 @@ async def show_payment_details( db_user: User, db: AsyncSession, ) -> None: - parsed = _parse_method_and_id(callback.data, prefix="admin_payment_") + parsed = _parse_method_and_id(callback.data, prefix='admin_payment_') if not parsed: - await callback.answer("❌ Invalid payment reference", show_alert=True) + await callback.answer('❌ Invalid payment reference', show_alert=True) return method, payment_id = parsed record = await get_payment_record(db, method, payment_id) if not record: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return await _render_payment_details(callback, db_user, record) @@ -568,9 +550,9 @@ async def manual_check_payment( db_user: User, db: AsyncSession, ) -> None: - parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_") + parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_') if not parsed: - await callback.answer("❌ Invalid payment reference", show_alert=True) + await callback.answer('❌ Invalid payment reference', show_alert=True) return method, payment_id = parsed @@ -578,12 +560,12 @@ async def manual_check_payment( texts = get_texts(db_user.language) if not record: - await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True) + await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True) return if not _is_checkable(record): await callback.answer( - texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."), + texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'), show_alert=True, ) return @@ -593,7 +575,7 @@ async def manual_check_payment( if not updated: await callback.answer( - texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."), + texts.t('ADMIN_PAYMENT_CHECK_FAILED', 'Failed to refresh the payment status.'), show_alert=True, ) return @@ -603,23 +585,23 @@ async def manual_check_payment( if updated.status != record.status or updated.is_paid != record.is_paid: emoji, status_text = _status_info(updated, texts=texts) message = texts.t( - "ADMIN_PAYMENT_CHECK_SUCCESS", - "Status updated: {status}", - ).format(status=f"{emoji} {status_text}") + 'ADMIN_PAYMENT_CHECK_SUCCESS', + 'Status updated: {status}', + ).format(status=f'{emoji} {status_text}') else: message = texts.t( - "ADMIN_PAYMENT_CHECK_NO_CHANGES", - "Status is unchanged after the check.", + 'ADMIN_PAYMENT_CHECK_NO_CHANGES', + 'Status is unchanged after the check.', ) await callback.answer(message, show_alert=True) def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_")) + dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_')) dp.callback_query.register( show_payment_details, - F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"), + F.data.startswith('admin_payment_') & ~F.data.startswith('admin_payment_check_'), ) - dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_")) - dp.callback_query.register(show_payments_overview, F.data == "admin_payments") + dp.callback_query.register(show_payments_overview, F.data.startswith('admin_payments_page_')) + dp.callback_query.register(show_payments_overview, F.data == 'admin_payments') diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index 97352ec1..51497909 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -1,7 +1,6 @@ import html import logging -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from typing import Optional +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest @@ -31,6 +30,7 @@ from app.services.poll_service import send_poll_to_users from app.utils.decorators import admin_required, error_handler from app.utils.validators import get_html_help_text, validate_html_tags + logger = logging.getLogger(__name__) @@ -38,18 +38,18 @@ def _safe_format_price(amount_kopeks: int) -> str: try: return settings.format_price(amount_kopeks) except Exception as error: # pragma: no cover - defensive logging - logger.error("Не удалось отформатировать сумму %s: %s", amount_kopeks, error) - return f"{amount_kopeks / 100:.2f} ₽" + logger.error('Не удалось отформатировать сумму %s: %s', amount_kopeks, error) + return f'{amount_kopeks / 100:.2f} ₽' async def _safe_delete_message(message: types.Message) -> None: try: await message.delete() except TelegramBadRequest as error: - if "message to delete not found" in str(error).lower(): - logger.debug("Сообщение уже удалено: %s", error) + if 'message to delete not found' in str(error).lower(): + logger.debug('Сообщение уже удалено: %s', error) else: - logger.warning("Не удалось удалить сообщение %s: %s", message.message_id, error) + logger.warning('Не удалось удалить сообщение %s: %s', message.message_id, error) async def _edit_creation_message( @@ -58,10 +58,10 @@ async def _edit_creation_message( text: str, *, reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", + parse_mode: str | None = 'HTML', ) -> bool: - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") + chat_id = state_data.get('form_chat_id') + message_id = state_data.get('form_message_id') if not chat_id or not message_id: return False @@ -77,21 +77,17 @@ async def _edit_creation_message( return True except TelegramBadRequest as error: error_text = str(error).lower() - if "message is not modified" in error_text: + if 'message is not modified' in error_text: return True - log_method = ( - logger.debug - if "there is no text in the message to edit" in error_text - else logger.warning - ) + log_method = logger.debug if 'there is no text in the message to edit' in error_text else logger.warning log_method( - "Не удалось обновить сообщение создания опроса %s: %s", + 'Не удалось обновить сообщение создания опроса %s: %s', message_id, error, ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s", + 'Непредвиденная ошибка при обновлении сообщения создания опроса %s: %s', message_id, error, ) @@ -104,28 +100,28 @@ async def _send_creation_message( text: str, *, reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", + parse_mode: str | None = 'HTML', ) -> types.Message: state_data = await state.get_data() - chat_id = state_data.get("form_chat_id") - message_id = state_data.get("form_message_id") + chat_id = state_data.get('form_chat_id') + message_id = state_data.get('form_message_id') if chat_id and message_id: try: await message.bot.delete_message(chat_id, message_id) except TelegramBadRequest as error: error_text = str(error).lower() - if "message to delete not found" in error_text: - logger.debug("Сообщение уже удалено: %s", error) + if 'message to delete not found' in error_text: + logger.debug('Сообщение уже удалено: %s', error) else: logger.warning( - "Не удалось удалить сообщение создания опроса %s: %s", + 'Не удалось удалить сообщение создания опроса %s: %s', message_id, error, ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Непредвиденная ошибка при удалении сообщения создания опроса %s: %s", + 'Непредвиденная ошибка при удалении сообщения создания опроса %s: %s', message_id, error, ) @@ -153,106 +149,106 @@ def _render_creation_progress( status_message: str | None = None, error_message: str | None = None, ) -> str: - lines: list[str] = ["🗳️ Создание опроса"] + lines: list[str] = ['🗳️ Создание опроса'] title_prompt = texts.t( - "ADMIN_POLLS_CREATION_TITLE_PROMPT", - "Введите заголовок опроса:", + 'ADMIN_POLLS_CREATION_TITLE_PROMPT', + 'Введите заголовок опроса:', ) - lines.append("") + lines.append('') lines.append(title_prompt) - title = data.get("title") + title = data.get('title') if title: - lines.append(f"• {html.escape(title)}") + lines.append(f'• {html.escape(title)}') - if next_step == "title": + if next_step == 'title': if error_message: - lines.append("") + lines.append('') lines.append(error_message) - return "\n".join(lines) + return '\n'.join(lines) description_prompt = texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT", - "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.", + 'ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT', + 'Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.', ) - lines.append("") + lines.append('') lines.append(description_prompt) - if "description" in data: - description = data.get("description") + if 'description' in data: + description = data.get('description') if description: - lines.append(f"• {description}") + lines.append(f'• {description}') else: lines.append( - "• " + '• ' + texts.t( - "ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED", - "Описание пропущено.", + 'ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED', + 'Описание пропущено.', ) ) else: - lines.append("") + lines.append('') lines.append(get_html_help_text()) - if next_step == "description": + if next_step == 'description': if error_message: - lines.append("") + lines.append('') lines.append(error_message) - return "\n".join(lines) + return '\n'.join(lines) reward_prompt = texts.t( - "ADMIN_POLLS_CREATION_REWARD_PROMPT", - "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.", + 'ADMIN_POLLS_CREATION_REWARD_PROMPT', + 'Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.', ) - lines.append("") + lines.append('') lines.append(reward_prompt) - if "reward_enabled" in data: - if data.get("reward_enabled"): - amount = data.get("reward_amount_kopeks", 0) - lines.append(f"• {_safe_format_price(amount)}") + if 'reward_enabled' in data: + if data.get('reward_enabled'): + amount = data.get('reward_amount_kopeks', 0) + lines.append(f'• {_safe_format_price(amount)}') else: - lines.append(texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена")) + lines.append(texts.t('ADMIN_POLLS_REWARD_DISABLED', 'Награда отключена')) - if next_step == "reward": + if next_step == 'reward': if error_message: - lines.append("") + lines.append('') lines.append(error_message) - return "\n".join(lines) + return '\n'.join(lines) question_prompt = texts.t( - "ADMIN_POLLS_CREATION_QUESTION_PROMPT", + 'ADMIN_POLLS_CREATION_QUESTION_PROMPT', ( - "Введите вопрос и варианты ответов.\n" - "Каждая строка — отдельный вариант.\n" - "Первая строка — текст вопроса.\n" - "Отправьте /done, когда вопросы будут добавлены." + 'Введите вопрос и варианты ответов.\n' + 'Каждая строка — отдельный вариант.\n' + 'Первая строка — текст вопроса.\n' + 'Отправьте /done, когда вопросы будут добавлены.' ), ) - lines.append("") + lines.append('') lines.append(question_prompt) - questions = data.get("questions", []) + questions = data.get('questions', []) if questions: - lines.append("") + lines.append('') for idx, question in enumerate(questions, start=1): - lines.append(f"{idx}. {html.escape(question['text'])}") - for option in question["options"]: - lines.append(f" • {html.escape(option)}") + lines.append(f'{idx}. {html.escape(question["text"])}') + for option in question['options']: + lines.append(f' • {html.escape(option)}') if status_message: - lines.append("") + lines.append('') lines.append(status_message) if error_message: - lines.append("") + lines.append('') lines.append(error_message) - return "\n".join(lines) + return '\n'.join(lines) class PollCreationStates(StatesGroup): @@ -270,8 +266,8 @@ def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeybo keyboard.append( [ types.InlineKeyboardButton( - text=f"🗳️ {poll.title[:40]}", - callback_data=f"poll_view:{poll.id}", + text=f'🗳️ {poll.title[:40]}', + callback_data=f'poll_view:{poll.id}', ) ] ) @@ -279,8 +275,8 @@ def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeybo keyboard.append( [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CREATE", "➕ Создать опрос"), - callback_data="poll_create", + text=texts.t('ADMIN_POLLS_CREATE', '➕ Создать опрос'), + callback_data='poll_create', ) ] ) @@ -288,7 +284,7 @@ def _build_polls_keyboard(polls: list[Poll], language: str) -> types.InlineKeybo [ types.InlineKeyboardButton( text=texts.BACK, - callback_data="admin_submenu_communications", + callback_data='admin_submenu_communications', ) ] ) @@ -300,10 +296,10 @@ def _format_reward_text(poll: Poll, language: str) -> str: texts = get_texts(language) if poll.reward_enabled and poll.reward_amount_kopeks > 0: return texts.t( - "ADMIN_POLLS_REWARD_ENABLED", - "Награда: {amount}", + 'ADMIN_POLLS_REWARD_ENABLED', + 'Награда: {amount}', ).format(amount=settings.format_price(poll.reward_amount_kopeks)) - return texts.t("ADMIN_POLLS_REWARD_DISABLED", "Награда отключена") + return texts.t('ADMIN_POLLS_REWARD_DISABLED', 'Награда отключена') def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKeyboardMarkup: @@ -312,23 +308,23 @@ def _build_poll_details_keyboard(poll_id: int, language: str) -> types.InlineKey inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND", "📤 Отправить"), - callback_data=f"poll_send:{poll_id}", + text=texts.t('ADMIN_POLLS_SEND', '📤 Отправить'), + callback_data=f'poll_send:{poll_id}', ) ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_STATS", "📊 Статистика"), - callback_data=f"poll_stats:{poll_id}", + text=texts.t('ADMIN_POLLS_STATS', '📊 Статистика'), + callback_data=f'poll_stats:{poll_id}', ) ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"poll_delete:{poll_id}", + text=texts.t('ADMIN_POLLS_DELETE', '🗑️ Удалить'), + callback_data=f'poll_delete:{poll_id}', ) ], - [types.InlineKeyboardButton(text=texts.t("ADMIN_POLLS_BACK", "⬅️ К списку"), callback_data="admin_polls")], + [types.InlineKeyboardButton(text=texts.t('ADMIN_POLLS_BACK', '⬅️ К списку'), callback_data='admin_polls')], ] ) @@ -339,54 +335,54 @@ def _build_target_keyboard(poll_id: int, language: str) -> types.InlineKeyboardM inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data=f"poll_target:{poll_id}:all", + text=texts.t('ADMIN_BROADCAST_TARGET_ALL', '👥 Всем'), + callback_data=f'poll_target:{poll_id}:all', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data=f"poll_target:{poll_id}:active", + text=texts.t('ADMIN_BROADCAST_TARGET_ACTIVE', '📱 С подпиской'), + callback_data=f'poll_target:{poll_id}:active', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data=f"poll_target:{poll_id}:trial", + text=texts.t('ADMIN_BROADCAST_TARGET_TRIAL', '🎁 Триал'), + callback_data=f'poll_target:{poll_id}:trial', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data=f"poll_target:{poll_id}:no", + text=texts.t('ADMIN_BROADCAST_TARGET_NO_SUB', '❌ Без подписки'), + callback_data=f'poll_target:{poll_id}:no', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data=f"poll_target:{poll_id}:expiring", + text=texts.t('ADMIN_BROADCAST_TARGET_EXPIRING', '⏰ Истекающие'), + callback_data=f'poll_target:{poll_id}:expiring', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data=f"poll_target:{poll_id}:expired", + text=texts.t('ADMIN_BROADCAST_TARGET_EXPIRED', '🔚 Истекшие'), + callback_data=f'poll_target:{poll_id}:expired', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data=f"poll_target:{poll_id}:active_zero", + text=texts.t('ADMIN_BROADCAST_TARGET_ACTIVE_ZERO', '🧊 Активна 0 ГБ'), + callback_data=f'poll_target:{poll_id}:active_zero', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data=f"poll_target:{poll_id}:trial_zero", + text=texts.t('ADMIN_BROADCAST_TARGET_TRIAL_ZERO', '🥶 Триал 0 ГБ'), + callback_data=f'poll_target:{poll_id}:trial_zero', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_CUSTOM_TARGET", "⚙️ По критериям"), - callback_data=f"poll_custom_menu:{poll_id}", + text=texts.t('ADMIN_POLLS_CUSTOM_TARGET', '⚙️ По критериям'), + callback_data=f'poll_custom_menu:{poll_id}', ) ], [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"poll_view:{poll_id}", + callback_data=f'poll_view:{poll_id}', ) ], ] @@ -399,45 +395,45 @@ def _build_custom_target_keyboard(poll_id: int, language: str) -> types.InlineKe inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_TODAY", "📅 Сегодня"), - callback_data=f"poll_custom_target:{poll_id}:today", + text=texts.t('ADMIN_CRITERIA_TODAY', '📅 Сегодня'), + callback_data=f'poll_custom_target:{poll_id}:today', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_WEEK", "📅 За неделю"), - callback_data=f"poll_custom_target:{poll_id}:week", + text=texts.t('ADMIN_CRITERIA_WEEK', '📅 За неделю'), + callback_data=f'poll_custom_target:{poll_id}:week', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_MONTH", "📅 За месяц"), - callback_data=f"poll_custom_target:{poll_id}:month", + text=texts.t('ADMIN_CRITERIA_MONTH', '📅 За месяц'), + callback_data=f'poll_custom_target:{poll_id}:month', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), - callback_data=f"poll_custom_target:{poll_id}:active_today", + text=texts.t('ADMIN_CRITERIA_ACTIVE_TODAY', '⚡ Активные сегодня'), + callback_data=f'poll_custom_target:{poll_id}:active_today', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), - callback_data=f"poll_custom_target:{poll_id}:inactive_week", + text=texts.t('ADMIN_CRITERIA_INACTIVE_WEEK', '💤 Неактивные 7+ дней'), + callback_data=f'poll_custom_target:{poll_id}:inactive_week', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), - callback_data=f"poll_custom_target:{poll_id}:inactive_month", + text=texts.t('ADMIN_CRITERIA_INACTIVE_MONTH', '💤 Неактивные 30+ дней'), + callback_data=f'poll_custom_target:{poll_id}:inactive_month', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), - callback_data=f"poll_custom_target:{poll_id}:referrals", + text=texts.t('ADMIN_CRITERIA_REFERRALS', '🤝 Через рефералов'), + callback_data=f'poll_custom_target:{poll_id}:referrals', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), - callback_data=f"poll_custom_target:{poll_id}:direct", + text=texts.t('ADMIN_CRITERIA_DIRECT', '🎯 Прямая регистрация'), + callback_data=f'poll_custom_target:{poll_id}:direct', ), ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"poll_send:{poll_id}")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data=f'poll_send:{poll_id}')], ] ) @@ -448,14 +444,14 @@ def _build_send_confirmation_keyboard(poll_id: int, target: str, language: str) inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_SEND_CONFIRM_BUTTON", "✅ Отправить"), - callback_data=f"poll_send_confirm:{poll_id}:{target}", + text=texts.t('ADMIN_POLLS_SEND_CONFIRM_BUTTON', '✅ Отправить'), + callback_data=f'poll_send_confirm:{poll_id}:{target}', ) ], [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"poll_send:{poll_id}", + callback_data=f'poll_send:{poll_id}', ) ], ] @@ -468,22 +464,22 @@ async def show_polls_panel(callback: types.CallbackQuery, db_user: User, db: Asy polls = await list_polls(db) texts = get_texts(db_user.language) - lines = [texts.t("ADMIN_POLLS_LIST_TITLE", "🗳️ Опросы"), ""] + lines = [texts.t('ADMIN_POLLS_LIST_TITLE', '🗳️ Опросы'), ''] if not polls: - lines.append(texts.t("ADMIN_POLLS_LIST_EMPTY", "Опросов пока нет.")) + lines.append(texts.t('ADMIN_POLLS_LIST_EMPTY', 'Опросов пока нет.')) else: for poll in polls[:10]: reward = _format_reward_text(poll, db_user.language) lines.append( - f"• {html.escape(poll.title)} — " - f"{texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))}\n" - f" {reward}" + f'• {html.escape(poll.title)} — ' + f'{texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format(count=len(poll.questions))}\n' + f' {reward}' ) await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=_build_polls_keyboard(polls, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -502,12 +498,12 @@ async def start_poll_creation( await _safe_delete_message(callback.message) await state.update_data(questions=[]) - form_text = _render_creation_progress(texts, await state.get_data(), "title") + form_text = _render_creation_progress(texts, await state.get_data(), 'title') await _send_creation_message( callback.message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -523,11 +519,11 @@ async def process_poll_title( texts = get_texts(db_user.language) state_data = await state.get_data() - if message.text == "/cancel": + if message.text == '/cancel': await _safe_delete_message(message) cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", + 'ADMIN_POLLS_CREATION_CANCELLED', + '❌ Создание опроса отменено.', ) keyboard = get_admin_communications_submenu_keyboard(db_user.language) updated = await _edit_creation_message( @@ -535,7 +531,7 @@ async def process_poll_title( state_data, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) if not updated: await _send_creation_message( @@ -543,27 +539,27 @@ async def process_poll_title( state, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return - title = (message.text or "").strip() + title = (message.text or '').strip() await _safe_delete_message(message) if not title: error_text = texts.t( - "ADMIN_POLLS_CREATION_TITLE_EMPTY", - "❌ Заголовок не может быть пустым. Попробуйте снова.", + 'ADMIN_POLLS_CREATION_TITLE_EMPTY', + '❌ Заголовок не может быть пустым. Попробуйте снова.', ) - form_text = _render_creation_progress(texts, state_data, "title", error_message=error_text) + form_text = _render_creation_progress(texts, state_data, 'title', error_message=error_text) updated = await _edit_creation_message(message.bot, state_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) return @@ -571,14 +567,14 @@ async def process_poll_title( await state.set_state(PollCreationStates.waiting_for_description) new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "description") + form_text = _render_creation_progress(texts, new_data, 'description') updated = await _edit_creation_message(message.bot, new_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) @@ -593,11 +589,11 @@ async def process_poll_description( texts = get_texts(db_user.language) state_data = await state.get_data() - if message.text == "/cancel": + if message.text == '/cancel': await _safe_delete_message(message) cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", + 'ADMIN_POLLS_CREATION_CANCELLED', + '❌ Создание опроса отменено.', ) keyboard = get_admin_communications_submenu_keyboard(db_user.language) updated = await _edit_creation_message( @@ -605,7 +601,7 @@ async def process_poll_description( state_data, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) if not updated: await _send_creation_message( @@ -613,33 +609,33 @@ async def process_poll_description( state, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return - description: Optional[str] - message_text = message.text or "" + description: str | None + message_text = message.text or '' await _safe_delete_message(message) - if message_text == "/skip": + if message_text == '/skip': description = None else: description = message_text.strip() is_valid, error_message = validate_html_tags(description) if not is_valid: error_text = texts.t( - "ADMIN_POLLS_CREATION_INVALID_HTML", - "❌ Ошибка в HTML: {error}", + 'ADMIN_POLLS_CREATION_INVALID_HTML', + '❌ Ошибка в HTML: {error}', ).format(error=error_message) - form_text = _render_creation_progress(texts, state_data, "description", error_message=error_text) + form_text = _render_creation_progress(texts, state_data, 'description', error_message=error_text) updated = await _edit_creation_message(message.bot, state_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) return @@ -647,19 +643,19 @@ async def process_poll_description( await state.set_state(PollCreationStates.waiting_for_reward) new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "reward") + form_text = _render_creation_progress(texts, new_data, 'reward') updated = await _edit_creation_message(message.bot, new_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) def _parse_reward_amount(message_text: str) -> int | None: - normalized = message_text.replace(" ", "").replace(",", ".") + normalized = message_text.replace(' ', '').replace(',', '.') try: value = Decimal(normalized) except InvalidOperation: @@ -668,7 +664,7 @@ def _parse_reward_amount(message_text: str) -> int | None: if value < 0: value = Decimal(0) - kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + kopeks = int((value * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP)) return max(0, kopeks) @@ -683,11 +679,11 @@ async def process_poll_reward( texts = get_texts(db_user.language) state_data = await state.get_data() - if message.text == "/cancel": + if message.text == '/cancel': await _safe_delete_message(message) cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", + 'ADMIN_POLLS_CREATION_CANCELLED', + '❌ Создание опроса отменено.', ) keyboard = get_admin_communications_submenu_keyboard(db_user.language) updated = await _edit_creation_message( @@ -695,7 +691,7 @@ async def process_poll_reward( state_data, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) if not updated: await _send_creation_message( @@ -703,26 +699,26 @@ async def process_poll_reward( state, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return - reward_kopeks = _parse_reward_amount(message.text or "") + reward_kopeks = _parse_reward_amount(message.text or '') await _safe_delete_message(message) if reward_kopeks is None: error_text = texts.t( - "ADMIN_POLLS_CREATION_REWARD_INVALID", - "❌ Некорректная сумма. Попробуйте ещё раз.", + 'ADMIN_POLLS_CREATION_REWARD_INVALID', + '❌ Некорректная сумма. Попробуйте ещё раз.', ) - form_text = _render_creation_progress(texts, state_data, "reward", error_message=error_text) + form_text = _render_creation_progress(texts, state_data, 'reward', error_message=error_text) updated = await _edit_creation_message(message.bot, state_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) return @@ -734,14 +730,14 @@ async def process_poll_reward( await state.set_state(PollCreationStates.waiting_for_questions) new_data = await state.get_data() - form_text = _render_creation_progress(texts, new_data, "questions") + form_text = _render_creation_progress(texts, new_data, 'questions') updated = await _edit_creation_message(message.bot, new_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) @@ -756,11 +752,11 @@ async def process_poll_question( texts = get_texts(db_user.language) state_data = await state.get_data() - if message.text == "/cancel": + if message.text == '/cancel': await _safe_delete_message(message) cancel_text = texts.t( - "ADMIN_POLLS_CREATION_CANCELLED", - "❌ Создание опроса отменено.", + 'ADMIN_POLLS_CREATION_CANCELLED', + '❌ Создание опроса отменено.', ) keyboard = get_admin_communications_submenu_keyboard(db_user.language) updated = await _edit_creation_message( @@ -768,7 +764,7 @@ async def process_poll_question( state_data, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) if not updated: await _send_creation_message( @@ -776,35 +772,35 @@ async def process_poll_question( state, cancel_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return - if message.text == "/done": + if message.text == '/done': await _safe_delete_message(message) data = await state.get_data() - questions = data.get("questions", []) + questions = data.get('questions', []) if not questions: error_text = texts.t( - "ADMIN_POLLS_CREATION_NEEDS_QUESTION", - "❌ Добавьте хотя бы один вопрос.", + 'ADMIN_POLLS_CREATION_NEEDS_QUESTION', + '❌ Добавьте хотя бы один вопрос.', ) - form_text = _render_creation_progress(texts, data, "questions", error_message=error_text) + form_text = _render_creation_progress(texts, data, 'questions', error_message=error_text) updated = await _edit_creation_message(message.bot, data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) return - title = data.get("title") - description = data.get("description") - reward_enabled = data.get("reward_enabled", False) - reward_amount = data.get("reward_amount_kopeks", 0) + title = data.get('title') + description = data.get('description') + reward_enabled = data.get('reward_enabled', False) + reward_amount = data.get('reward_amount_kopeks', 0) form_data = data.copy() @@ -820,12 +816,8 @@ async def process_poll_question( reward_text = _format_reward_text(poll, db_user.language) result_text = texts.t( - "ADMIN_POLLS_CREATION_FINISHED", - ( - "✅ Опрос «{title}» создан!\n" - "Вопросов: {count}\n" - "{reward}" - ), + 'ADMIN_POLLS_CREATION_FINISHED', + ('✅ Опрос «{title}» создан!\nВопросов: {count}\n{reward}'), ).format( title=html.escape(poll.title), count=len(poll.questions), @@ -838,7 +830,7 @@ async def process_poll_question( form_data, result_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) if not updated: await _send_creation_message( @@ -846,46 +838,46 @@ async def process_poll_question( state, result_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() return - lines = [line.strip() for line in (message.text or "").splitlines() if line.strip()] + lines = [line.strip() for line in (message.text or '').splitlines() if line.strip()] await _safe_delete_message(message) if len(lines) < 3: error_text = texts.t( - "ADMIN_POLLS_CREATION_MIN_OPTIONS", - "❌ Нужен вопрос и минимум два варианта ответа.", + 'ADMIN_POLLS_CREATION_MIN_OPTIONS', + '❌ Нужен вопрос и минимум два варианта ответа.', ) - form_text = _render_creation_progress(texts, state_data, "questions", error_message=error_text) + form_text = _render_creation_progress(texts, state_data, 'questions', error_message=error_text) updated = await _edit_creation_message(message.bot, state_data, form_text) if not updated: await _send_creation_message( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) return question_text = lines[0] options = lines[1:] data = await state.get_data() - questions = data.get("questions", []) - questions.append({"text": question_text, "options": options}) + questions = data.get('questions', []) + questions.append({'text': question_text, 'options': options}) await state.update_data(questions=questions) new_data = await state.get_data() status_message = texts.t( - "ADMIN_POLLS_CREATION_ADDED_QUESTION", - "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.", + 'ADMIN_POLLS_CREATION_ADDED_QUESTION', + 'Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.', ).format(question=html.escape(question_text)) form_text = _render_creation_progress( texts, new_data, - "questions", + 'questions', status_message=status_message, ) updated = await _edit_creation_message(message.bot, new_data, form_text) @@ -894,36 +886,30 @@ async def process_poll_question( message, state, form_text, - parse_mode="HTML", + parse_mode='HTML', ) async def _render_poll_details(poll: Poll, language: str) -> str: texts = get_texts(language) - lines = [f"🗳️ {html.escape(poll.title)}"] + lines = [f'🗳️ {html.escape(poll.title)}'] if poll.description: lines.append(poll.description) lines.append(_format_reward_text(poll, language)) - lines.append( - texts.t("ADMIN_POLLS_QUESTIONS_COUNT", "Вопросов: {count}").format( - count=len(poll.questions) - ) - ) + lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))) if poll.questions: - lines.append("") - lines.append(texts.t("ADMIN_POLLS_QUESTION_LIST_HEADER", "Вопросы:")) + lines.append('') + lines.append(texts.t('ADMIN_POLLS_QUESTION_LIST_HEADER', 'Вопросы:')) for idx, question in enumerate(sorted(poll.questions, key=lambda q: q.order), start=1): - lines.append(f"{idx}. {html.escape(question.text)}") + lines.append(f'{idx}. {html.escape(question.text)}') for option in sorted(question.options, key=lambda o: o.order): lines.append( - texts.t("ADMIN_POLLS_OPTION_BULLET", " • {option}").format( - option=html.escape(option.text) - ) + texts.t('ADMIN_POLLS_OPTION_BULLET', ' • {option}').format(option=html.escape(option.text)) ) - return "\n".join(lines) + return '\n'.join(lines) @admin_required @@ -933,17 +919,17 @@ async def show_poll_details( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return text = await _render_poll_details(poll, db_user.language) await callback.message.edit_text( text, reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -955,17 +941,17 @@ async def start_poll_send( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_POLLS_SEND_CHOOSE_TARGET", "🎯 Выберите аудиторию для отправки опроса:"), + texts.t('ADMIN_POLLS_SEND_CHOOSE_TARGET', '🎯 Выберите аудиторию для отправки опроса:'), reply_markup=_build_target_keyboard(poll.id, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -977,11 +963,11 @@ async def show_custom_target_menu( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) await callback.message.edit_text( get_texts(db_user.language).t( - "ADMIN_POLLS_CUSTOM_PROMPT", - "Выберите дополнительный критерий аудитории:", + 'ADMIN_POLLS_CUSTOM_PROMPT', + 'Выберите дополнительный критерий аудитории:', ), reply_markup=_build_custom_target_keyboard(poll_id, db_user.language), ) @@ -998,20 +984,20 @@ async def _show_send_confirmation( ): poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return audience_name = get_target_display_name(target) texts = get_texts(db_user.language) confirmation_text = texts.t( - "ADMIN_POLLS_SEND_CONFIRM", - "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}", + 'ADMIN_POLLS_SEND_CONFIRM', + '📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}', ).format(title=poll.title, audience=audience_name, count=user_count) await callback.message.edit_text( confirmation_text, reply_markup=_build_send_confirmation_keyboard(poll_id, target, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1023,8 +1009,8 @@ async def select_poll_target( db_user: User, db: AsyncSession, ): - _, payload = callback.data.split(":", 1) - poll_id_str, target = payload.split(":", 1) + _, payload = callback.data.split(':', 1) + poll_id_str, target = payload.split(':', 1) poll_id = int(poll_id_str) user_count = await get_target_users_count(db, target) @@ -1038,12 +1024,12 @@ async def select_custom_poll_target( db_user: User, db: AsyncSession, ): - _, payload = callback.data.split(":", 1) - poll_id_str, criteria = payload.split(":", 1) + _, payload = callback.data.split(':', 1) + poll_id_str, criteria = payload.split(':', 1) poll_id = int(poll_id_str) user_count = await get_custom_users_count(db, criteria) - await _show_send_confirmation(callback, db_user, db, poll_id, f"custom_{criteria}", user_count) + await _show_send_confirmation(callback, db_user, db, poll_id, f'custom_{criteria}', user_count) @admin_required @@ -1053,40 +1039,40 @@ async def confirm_poll_send( db_user: User, db: AsyncSession, ): - _, payload = callback.data.split(":", 1) - poll_id_str, target = payload.split(":", 1) + _, payload = callback.data.split(':', 1) + poll_id_str, target = payload.split(':', 1) poll_id = int(poll_id_str) poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return poll_id_value = poll.id - if target.startswith("custom_"): - users = await get_custom_users(db, target.replace("custom_", "")) + if target.startswith('custom_'): + users = await get_custom_users(db, target.replace('custom_', '')) else: users = await get_target_users(db, target) user_language = db_user.language texts = get_texts(user_language) await callback.message.edit_text( - texts.t("ADMIN_POLLS_SENDING", "📤 Запускаю отправку опроса..."), - parse_mode="HTML", + texts.t('ADMIN_POLLS_SENDING', '📤 Запускаю отправку опроса...'), + parse_mode='HTML', ) result = await send_poll_to_users(callback.bot, db, poll, users) result_text = texts.t( - "ADMIN_POLLS_SEND_RESULT", - "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}", + 'ADMIN_POLLS_SEND_RESULT', + '📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}', ).format(**result) await callback.message.edit_text( result_text, reply_markup=_build_poll_details_keyboard(poll_id_value, user_language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1098,43 +1084,43 @@ async def show_poll_stats( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return stats = await get_poll_statistics(db, poll_id) texts = get_texts(db_user.language) - reward_sum = settings.format_price(stats["reward_sum_kopeks"]) - lines = [texts.t("ADMIN_POLLS_STATS_HEADER", "📊 Статистика опроса"), ""] + reward_sum = settings.format_price(stats['reward_sum_kopeks']) + lines = [texts.t('ADMIN_POLLS_STATS_HEADER', '📊 Статистика опроса'), ''] lines.append( texts.t( - "ADMIN_POLLS_STATS_OVERVIEW", - "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}", + 'ADMIN_POLLS_STATS_OVERVIEW', + 'Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}', ).format( - total=stats["total_responses"], - completed=stats["completed_responses"], + total=stats['total_responses'], + completed=stats['completed_responses'], reward=reward_sum, ) ) - for question in stats["questions"]: - lines.append("") - lines.append(f"{html.escape(question['text'])}") - for option in question["options"]: + for question in stats['questions']: + lines.append('') + lines.append(f'{html.escape(question["text"])}') + for option in question['options']: lines.append( texts.t( - "ADMIN_POLLS_STATS_OPTION_LINE", - "• {option}: {count}", - ).format(option=html.escape(option["text"]), count=option["count"]) + 'ADMIN_POLLS_STATS_OPTION_LINE', + '• {option}: {count}', + ).format(option=html.escape(option['text']), count=option['count']) ) await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=_build_poll_details_keyboard(poll.id, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1146,35 +1132,35 @@ async def confirm_poll_delete( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) poll = await get_poll_by_id(db, poll_id) if not poll: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "ADMIN_POLLS_CONFIRM_DELETE", - "Вы уверены, что хотите удалить опрос «{title}»?", + 'ADMIN_POLLS_CONFIRM_DELETE', + 'Вы уверены, что хотите удалить опрос «{title}»?', ).format(title=poll.title), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_POLLS_DELETE", "🗑️ Удалить"), - callback_data=f"poll_delete_confirm:{poll_id}", + text=texts.t('ADMIN_POLLS_DELETE', '🗑️ Удалить'), + callback_data=f'poll_delete_confirm:{poll_id}', ) ], [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"poll_view:{poll_id}", + callback_data=f'poll_view:{poll_id}', ) ], ] ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1186,34 +1172,34 @@ async def delete_poll_handler( db_user: User, db: AsyncSession, ): - poll_id = int(callback.data.split(":")[1]) + poll_id = int(callback.data.split(':')[1]) success = await delete_poll(db, poll_id) texts = get_texts(db_user.language) if success: await callback.message.edit_text( - texts.t("ADMIN_POLLS_DELETED", "🗑️ Опрос удалён."), + texts.t('ADMIN_POLLS_DELETED', '🗑️ Опрос удалён.'), reply_markup=_build_polls_keyboard(await list_polls(db), db_user.language), ) else: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return await callback.answer() def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_polls_panel, F.data == "admin_polls") - dp.callback_query.register(start_poll_creation, F.data == "poll_create") - dp.callback_query.register(show_poll_details, F.data.startswith("poll_view:")) - dp.callback_query.register(start_poll_send, F.data.startswith("poll_send:")) - dp.callback_query.register(show_custom_target_menu, F.data.startswith("poll_custom_menu:")) - dp.callback_query.register(select_poll_target, F.data.startswith("poll_target:")) - dp.callback_query.register(select_custom_poll_target, F.data.startswith("poll_custom_target:")) - dp.callback_query.register(confirm_poll_send, F.data.startswith("poll_send_confirm:")) - dp.callback_query.register(show_poll_stats, F.data.startswith("poll_stats:")) - dp.callback_query.register(confirm_poll_delete, F.data.startswith("poll_delete:")) - dp.callback_query.register(delete_poll_handler, F.data.startswith("poll_delete_confirm:")) + dp.callback_query.register(show_polls_panel, F.data == 'admin_polls') + dp.callback_query.register(start_poll_creation, F.data == 'poll_create') + dp.callback_query.register(show_poll_details, F.data.startswith('poll_view:')) + dp.callback_query.register(start_poll_send, F.data.startswith('poll_send:')) + dp.callback_query.register(show_custom_target_menu, F.data.startswith('poll_custom_menu:')) + dp.callback_query.register(select_poll_target, F.data.startswith('poll_target:')) + dp.callback_query.register(select_custom_poll_target, F.data.startswith('poll_custom_target:')) + dp.callback_query.register(confirm_poll_send, F.data.startswith('poll_send_confirm:')) + dp.callback_query.register(show_poll_stats, F.data.startswith('poll_stats:')) + dp.callback_query.register(confirm_poll_delete, F.data.startswith('poll_delete:')) + dp.callback_query.register(delete_poll_handler, F.data.startswith('poll_delete_confirm:')) dp.message.register(process_poll_title, PollCreationStates.waiting_for_title) dp.message.register(process_poll_description, PollCreationStates.waiting_for_description) diff --git a/app/handlers/admin/pricing.py b/app/handlers/admin/pricing.py index ddd60526..f9f95462 100644 --- a/app/handlers/admin/pricing.py +++ b/app/handlers/admin/pricing.py @@ -1,11 +1,12 @@ import logging +from collections.abc import Iterable from dataclasses import dataclass -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from typing import Iterable, List, Tuple, Dict, Any +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +from typing import Any from aiogram import Bot, Dispatcher, F, types -from aiogram.fsm.context import FSMContext from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,29 +16,28 @@ from app.services.system_settings_service import bot_configuration_service from app.states import PricingStates from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) -PriceItem = Tuple[str, str, int] +PriceItem = tuple[str, str, int] -TRAFFIC_PACKAGE_FIELDS: Tuple[Tuple[int, str], ...] = ( - (5, "PRICE_TRAFFIC_5GB"), - (10, "PRICE_TRAFFIC_10GB"), - (25, "PRICE_TRAFFIC_25GB"), - (50, "PRICE_TRAFFIC_50GB"), - (100, "PRICE_TRAFFIC_100GB"), - (250, "PRICE_TRAFFIC_250GB"), - (500, "PRICE_TRAFFIC_500GB"), - (1000, "PRICE_TRAFFIC_1000GB"), - (0, "PRICE_TRAFFIC_UNLIMITED"), +TRAFFIC_PACKAGE_FIELDS: tuple[tuple[int, str], ...] = ( + (5, 'PRICE_TRAFFIC_5GB'), + (10, 'PRICE_TRAFFIC_10GB'), + (25, 'PRICE_TRAFFIC_25GB'), + (50, 'PRICE_TRAFFIC_50GB'), + (100, 'PRICE_TRAFFIC_100GB'), + (250, 'PRICE_TRAFFIC_250GB'), + (500, 'PRICE_TRAFFIC_500GB'), + (1000, 'PRICE_TRAFFIC_1000GB'), + (0, 'PRICE_TRAFFIC_UNLIMITED'), ) -TRAFFIC_PACKAGE_FIELD_MAP: Dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS} -TRAFFIC_PACKAGE_ORDER: Tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS) -TRAFFIC_PACKAGE_ORDER_INDEX: Dict[int, int] = { - gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER) -} +TRAFFIC_PACKAGE_FIELD_MAP: dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS} +TRAFFIC_PACKAGE_ORDER: tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS) +TRAFFIC_PACKAGE_ORDER_INDEX: dict[int, int] = {gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER)} @dataclass(slots=True) @@ -47,7 +47,7 @@ class ChoiceOption: label_en: str | None = None def label(self, lang_code: str) -> str: - if lang_code == "ru": + if lang_code == 'ru': return self.label_ru return self.label_en or self.label_ru @@ -61,180 +61,176 @@ class SettingEntry: action: str # "input", "toggle", "price", "choice" description_ru: str | None = None description_en: str | None = None - choices: Tuple[ChoiceOption, ...] | None = None + choices: tuple[ChoiceOption, ...] | None = None def label(self, lang_code: str) -> str: - if lang_code == "ru": + if lang_code == 'ru': return self.label_ru return self.label_en or self.label_ru def description(self, lang_code: str) -> str | None: - if lang_code == "ru": + if lang_code == 'ru': return self.description_ru return self.description_en or self.description_ru -TRIAL_ENTRIES: Tuple[SettingEntry, ...] = ( +TRIAL_ENTRIES: tuple[SettingEntry, ...] = ( SettingEntry( - key="TRIAL_DURATION_DAYS", - section="trial", - label_ru="⏳ Длительность (дни)", - label_en="⏳ Duration (days)", - action="input", + key='TRIAL_DURATION_DAYS', + section='trial', + label_ru='⏳ Длительность (дни)', + label_en='⏳ Duration (days)', + action='input', ), SettingEntry( - key="TRIAL_TRAFFIC_LIMIT_GB", - section="trial", - label_ru="📦 Лимит трафика (ГБ)", - label_en="📦 Traffic limit (GB)", - action="input", + key='TRIAL_TRAFFIC_LIMIT_GB', + section='trial', + label_ru='📦 Лимит трафика (ГБ)', + label_en='📦 Traffic limit (GB)', + action='input', ), SettingEntry( - key="TRIAL_DEVICE_LIMIT", - section="trial", - label_ru="📱 Лимит устройств", - label_en="📱 Device limit", - action="input", + key='TRIAL_DEVICE_LIMIT', + section='trial', + label_ru='📱 Лимит устройств', + label_en='📱 Device limit', + action='input', ), SettingEntry( - key="TRIAL_PAYMENT_ENABLED", - section="trial", - label_ru="💳 Платная активация", - label_en="💳 Paid activation", - action="toggle", - description_ru="Если включено — за активацию триала будет списываться указанная сумма.", - description_en="When enabled, the configured amount is charged during trial activation.", + key='TRIAL_PAYMENT_ENABLED', + section='trial', + label_ru='💳 Платная активация', + label_en='💳 Paid activation', + action='toggle', + description_ru='Если включено — за активацию триала будет списываться указанная сумма.', + description_en='When enabled, the configured amount is charged during trial activation.', ), SettingEntry( - key="TRIAL_ACTIVATION_PRICE", - section="trial", - label_ru="💰 Стоимость активации", - label_en="💰 Activation price", - action="price", - description_ru="Указывается в копейках. 0 — бесплатная активация.", - description_en="Amount in kopeks. 0 — free activation.", + key='TRIAL_ACTIVATION_PRICE', + section='trial', + label_ru='💰 Стоимость активации', + label_en='💰 Activation price', + action='price', + description_ru='Указывается в копейках. 0 — бесплатная активация.', + description_en='Amount in kopeks. 0 — free activation.', ), SettingEntry( - key="TRIAL_ADD_REMAINING_DAYS_TO_PAID", - section="trial", - label_ru="➕ Добавлять оставшиеся дни к платной подписке", - label_en="➕ Add remaining trial days to paid plan", - action="toggle", - description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.", - description_en="When enabled, remaining trial days are added to paid subscription duration.", + key='TRIAL_ADD_REMAINING_DAYS_TO_PAID', + section='trial', + label_ru='➕ Добавлять оставшиеся дни к платной подписке', + label_en='➕ Add remaining trial days to paid plan', + action='toggle', + description_ru='Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.', + description_en='When enabled, remaining trial days are added to paid subscription duration.', ), ) -CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = ( +CORE_PRICING_ENTRIES: tuple[SettingEntry, ...] = ( SettingEntry( - key="BASE_SUBSCRIPTION_PRICE", - section="core", - label_ru="💳 Базовая стоимость подписки", - label_en="💳 Base subscription price", - action="price", + key='BASE_SUBSCRIPTION_PRICE', + section='core', + label_ru='💳 Базовая стоимость подписки', + label_en='💳 Base subscription price', + action='price', ), SettingEntry( - key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED", - section="core", - label_ru="🎟️ Базовые скидки для групп", - label_en="🎟️ Base group discounts", - action="toggle", - description_ru="Включает применение базовых скидок для групповых промо-периодов.", - description_en="Enables base discounts for promo group periods.", + key='BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED', + section='core', + label_ru='🎟️ Базовые скидки для групп', + label_en='🎟️ Base group discounts', + action='toggle', + description_ru='Включает применение базовых скидок для групповых промо-периодов.', + description_en='Enables base discounts for promo group periods.', ), SettingEntry( - key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS", - section="core", - label_ru="🔖 Скидки по периодам", - label_en="🔖 Period discounts", - action="input", - description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).", - description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).", + key='BASE_PROMO_GROUP_PERIOD_DISCOUNTS', + section='core', + label_ru='🔖 Скидки по периодам', + label_en='🔖 Period discounts', + action='input', + description_ru='Формат: список пар дней и скидки через запятую (например 30:10,60:20).', + description_en='Format: comma-separated day/discount pairs (e.g. 30:10,60:20).', ), SettingEntry( - key="DEFAULT_DEVICE_LIMIT", - section="core", - label_ru="📱 Устройств по умолчанию", - label_en="📱 Default device limit", - action="input", + key='DEFAULT_DEVICE_LIMIT', + section='core', + label_ru='📱 Устройств по умолчанию', + label_en='📱 Default device limit', + action='input', ), SettingEntry( - key="DEFAULT_TRAFFIC_LIMIT_GB", - section="core", - label_ru="📦 Трафик по умолчанию (ГБ)", - label_en="📦 Default traffic (GB)", - action="input", + key='DEFAULT_TRAFFIC_LIMIT_GB', + section='core', + label_ru='📦 Трафик по умолчанию (ГБ)', + label_en='📦 Default traffic (GB)', + action='input', ), SettingEntry( - key="MAX_DEVICES_LIMIT", - section="core", - label_ru="📈 Максимум устройств", - label_en="📈 Maximum devices", - action="input", + key='MAX_DEVICES_LIMIT', + section='core', + label_ru='📈 Максимум устройств', + label_en='📈 Maximum devices', + action='input', ), SettingEntry( - key="RESET_TRAFFIC_ON_PAYMENT", - section="core", - label_ru="🔄 Сбрасывать трафик при оплате", - label_en="🔄 Reset traffic on payment", - action="toggle", + key='RESET_TRAFFIC_ON_PAYMENT', + section='core', + label_ru='🔄 Сбрасывать трафик при оплате', + label_en='🔄 Reset traffic on payment', + action='toggle', ), SettingEntry( - key="DEFAULT_TRAFFIC_RESET_STRATEGY", - section="core", - label_ru="🗓 Стратегия сброса трафика", - label_en="🗓 Traffic reset strategy", - action="input", - description_ru="Доступные значения: DAY, WEEK, MONTH, NEVER.", - description_en="Available values: DAY, WEEK, MONTH, NEVER.", + key='DEFAULT_TRAFFIC_RESET_STRATEGY', + section='core', + label_ru='🗓 Стратегия сброса трафика', + label_en='🗓 Traffic reset strategy', + action='input', + description_ru='Доступные значения: DAY, WEEK, MONTH, NEVER.', + description_en='Available values: DAY, WEEK, MONTH, NEVER.', ), SettingEntry( - key="TRAFFIC_SELECTION_MODE", - section="core", - label_ru="⚙️ Режим выбора трафика", - label_en="⚙️ Traffic selection mode", - action="choice", + key='TRAFFIC_SELECTION_MODE', + section='core', + label_ru='⚙️ Режим выбора трафика', + label_en='⚙️ Traffic selection mode', + action='choice', choices=( - ChoiceOption("selectable", "Выбор пакетов", "Selectable"), - ChoiceOption("fixed", "Фиксированный лимит", "Fixed limit"), - ChoiceOption("fixed_with_topup", "Фикс. лимит + докупка", "Fixed + topup"), + ChoiceOption('selectable', 'Выбор пакетов', 'Selectable'), + ChoiceOption('fixed', 'Фиксированный лимит', 'Fixed limit'), + ChoiceOption('fixed_with_topup', 'Фикс. лимит + докупка', 'Fixed + topup'), ), - description_ru="Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.", - description_en="Defines whether users pick packages or use a fixed limit.", + description_ru='Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.', + description_en='Defines whether users pick packages or use a fixed limit.', ), SettingEntry( - key="FIXED_TRAFFIC_LIMIT_GB", - section="core", - label_ru="📏 Фиксированный лимит трафика (ГБ)", - label_en="📏 Fixed traffic limit (GB)", - action="input", - description_ru="Используется только в режиме фиксированного трафика. 0 = безлимит.", - description_en="Used only in fixed traffic mode. 0 = unlimited.", + key='FIXED_TRAFFIC_LIMIT_GB', + section='core', + label_ru='📏 Фиксированный лимит трафика (ГБ)', + label_en='📏 Fixed traffic limit (GB)', + action='input', + description_ru='Используется только в режиме фиксированного трафика. 0 = безлимит.', + description_en='Used only in fixed traffic mode. 0 = unlimited.', ), ) -SETTING_ENTRIES_BY_SECTION: Dict[str, Tuple[SettingEntry, ...]] = { - "trial": TRIAL_ENTRIES, - "core": CORE_PRICING_ENTRIES, +SETTING_ENTRIES_BY_SECTION: dict[str, tuple[SettingEntry, ...]] = { + 'trial': TRIAL_ENTRIES, + 'core': CORE_PRICING_ENTRIES, } -SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = { +SETTING_ENTRY_BY_KEY: dict[str, SettingEntry] = { entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries } -SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple( +SETTING_ENTRIES: tuple[SettingEntry, ...] = tuple( entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries ) -SETTING_KEY_TO_TOKEN: Dict[str, str] = { - entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES) -} +SETTING_KEY_TO_TOKEN: dict[str, str] = {entry.key: f's{index}' for index, entry in enumerate(SETTING_ENTRIES)} -SETTING_TOKEN_TO_KEY: Dict[str, str] = { - token: key for key, token in SETTING_KEY_TO_TOKEN.items() -} +SETTING_TOKEN_TO_KEY: dict[str, str] = {token: key for key, token in SETTING_KEY_TO_TOKEN.items()} def _encode_setting_callback_key(key: str) -> str: @@ -245,24 +241,24 @@ def _decode_setting_callback_key(raw: str) -> str: return SETTING_TOKEN_TO_KEY.get(raw, raw) -def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]: - order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"]) +def _traffic_package_sort_key(package: dict[str, Any]) -> tuple[int, int]: + order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package['gb']) if order_index is not None: return (0, order_index) - return (1, package["gb"]) + return (1, package['gb']) -def _collect_traffic_packages() -> List[Dict[str, Any]]: +def _collect_traffic_packages() -> list[dict[str, Any]]: raw_packages = settings.get_traffic_packages() - packages_map: Dict[int, Dict[str, Any]] = {} + packages_map: dict[int, dict[str, Any]] = {} for package in raw_packages: - gb = int(package.get("gb", 0)) + gb = int(package.get('gb', 0)) packages_map[gb] = { - "gb": gb, - "price": int(package.get("price") or 0), - "enabled": bool(package.get("enabled", True)), - "field": TRAFFIC_PACKAGE_FIELD_MAP.get(gb), + 'gb': gb, + 'price': int(package.get('price') or 0), + 'enabled': bool(package.get('enabled', True)), + 'field': TRAFFIC_PACKAGE_FIELD_MAP.get(gb), } for gb, field in TRAFFIC_PACKAGE_FIELDS: @@ -271,13 +267,13 @@ def _collect_traffic_packages() -> List[Dict[str, Any]]: price = getattr(settings, field) existing = packages_map.get(gb) - enabled = existing["enabled"] if existing is not None else True + enabled = existing['enabled'] if existing is not None else True packages_map[gb] = { - "gb": gb, - "price": int(price), - "enabled": enabled, - "field": field, + 'gb': gb, + 'price': int(price), + 'enabled': enabled, + 'field': field, } packages = list(packages_map.values()) @@ -285,67 +281,67 @@ def _collect_traffic_packages() -> List[Dict[str, Any]]: return packages -def _serialize_traffic_packages(packages: Iterable[Dict[str, Any]]) -> str: +def _serialize_traffic_packages(packages: Iterable[dict[str, Any]]) -> str: parts = [] for package in packages: - enabled_flag = "true" if package.get("enabled") else "false" - parts.append(f"{int(package['gb'])}:{int(package['price'])}:{enabled_flag}") - return ",".join(parts) + enabled_flag = 'true' if package.get('enabled') else 'false' + parts.append(f'{int(package["gb"])}:{int(package["price"])}:{enabled_flag}') + return ','.join(parts) async def _save_traffic_packages( db: AsyncSession, - packages: Iterable[Dict[str, Any]], + packages: Iterable[dict[str, Any]], *, skip_if_same: bool = False, ) -> bool: new_value = _serialize_traffic_packages(packages) - current_value = bot_configuration_service.get_current_value("TRAFFIC_PACKAGES_CONFIG") or "" + current_value = bot_configuration_service.get_current_value('TRAFFIC_PACKAGES_CONFIG') or '' if skip_if_same and current_value == new_value: return False - await bot_configuration_service.set_value(db, "TRAFFIC_PACKAGES_CONFIG", new_value) + await bot_configuration_service.set_value(db, 'TRAFFIC_PACKAGES_CONFIG', new_value) await db.commit() return True def _language_code(language: str | None) -> str: - return (language or "ru").split("-")[0].lower() + return (language or 'ru').split('-')[0].lower() def _format_period_label(days: int, lang_code: str, short: bool = False) -> str: if short: - suffix = "д" if lang_code == "ru" else "d" - return f"{days}{suffix}" - if lang_code == "ru": - return f"{days} дней" + suffix = 'д' if lang_code == 'ru' else 'd' + return f'{days}{suffix}' + if lang_code == 'ru': + return f'{days} дней' if days == 1: - return "1 day" - return f"{days}-day plan" + return '1 day' + return f'{days}-day plan' def _format_traffic_label(gb: int, lang_code: str, short: bool = False) -> str: if gb == 0: - return "∞" if short else ("Безлимит" if lang_code == "ru" else "Unlimited") - unit = "ГБ" if lang_code == "ru" else "GB" + return '∞' if short else ('Безлимит' if lang_code == 'ru' else 'Unlimited') + unit = 'ГБ' if lang_code == 'ru' else 'GB' if short: - return f"{gb}{unit}" if lang_code == "ru" else f"{gb}{unit}" - return f"{gb} {unit}" + return f'{gb}{unit}' if lang_code == 'ru' else f'{gb}{unit}' + return f'{gb} {unit}' def _format_trial_summary(lang_code: str) -> str: duration = settings.TRIAL_DURATION_DAYS traffic = settings.TRIAL_TRAFFIC_LIMIT_GB devices = settings.TRIAL_DEVICE_LIMIT - price_note = "" + price_note = '' if settings.is_trial_paid_activation_enabled(): - price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}" + price_note = f', 💳 {settings.format_price(settings.get_trial_activation_price())}' traffic_label = _format_traffic_label(traffic, lang_code, short=True) - devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱" - days_suffix = "д" if lang_code == "ru" else "d" - return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}" + devices_label = f'{devices}📱' if lang_code == 'ru' else f'{devices}📱' + days_suffix = 'д' if lang_code == 'ru' else 'd' + return f'{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}' def _format_core_summary(lang_code: str) -> str: @@ -353,262 +349,257 @@ def _format_core_summary(lang_code: str) -> str: device_limit = settings.DEFAULT_DEVICE_LIMIT traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB mode = settings.TRAFFIC_SELECTION_MODE.lower() - if mode == "fixed": - traffic_mode = "⚙️ fixed" - elif mode == "fixed_with_topup": - traffic_mode = "⚙️ fixed+topup" + if mode == 'fixed': + traffic_mode = '⚙️ fixed' + elif mode == 'fixed_with_topup': + traffic_mode = '⚙️ fixed+topup' else: - traffic_mode = "⚙️ selectable" + traffic_mode = '⚙️ selectable' traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True) - return f"{base_price}, {device_limit}📱, {traffic_label}, {traffic_mode}" + return f'{base_price}, {device_limit}📱, {traffic_label}, {traffic_mode}' -def _get_period_items(lang_code: str) -> List[PriceItem]: +def _get_period_items(lang_code: str) -> list[PriceItem]: from app.config import PERIOD_PRICES - items: List[PriceItem] = [] + + items: list[PriceItem] = [] for days in settings.get_available_subscription_periods(): - key = f"PRICE_{days}_DAYS" + key = f'PRICE_{days}_DAYS' price = PERIOD_PRICES.get(days, 0) items.append((key, _format_period_label(days, lang_code), price)) return items -def _get_traffic_items(lang_code: str) -> List[PriceItem]: +def _get_traffic_items(lang_code: str) -> list[PriceItem]: packages = _collect_traffic_packages() - items: List[PriceItem] = [] + items: list[PriceItem] = [] for package in packages: - field = package.get("field") + field = package.get('field') if not field: continue - label = _format_traffic_label(package["gb"], lang_code) - icon = "✅" if package["enabled"] else "⚪️" - items.append((field, f"{icon} {label}", int(package["price"]))) + label = _format_traffic_label(package['gb'], lang_code) + icon = '✅' if package['enabled'] else '⚪️' + items.append((field, f'{icon} {label}', int(package['price']))) return items -def _get_extra_items(lang_code: str) -> List[PriceItem]: - items: List[PriceItem] = [] +def _get_extra_items(lang_code: str) -> list[PriceItem]: + items: list[PriceItem] = [] - if hasattr(settings, "PRICE_PER_DEVICE"): - label = "Дополнительное устройство" if lang_code == "ru" else "Extra device" - items.append(("PRICE_PER_DEVICE", label, settings.PRICE_PER_DEVICE)) + if hasattr(settings, 'PRICE_PER_DEVICE'): + label = 'Дополнительное устройство' if lang_code == 'ru' else 'Extra device' + items.append(('PRICE_PER_DEVICE', label, settings.PRICE_PER_DEVICE)) return items def _build_period_summary(items: Iterable[PriceItem], lang_code: str, fallback: str) -> str: - parts: List[str] = [] + parts: list[str] = [] for key, label, price in items: try: - days = int(key.replace("PRICE_", "").replace("_DAYS", "")) + days = int(key.replace('PRICE_', '').replace('_DAYS', '')) except ValueError: days = None if days is not None: - suffix = "д" if lang_code == "ru" else "d" - short_label = f"{days}{suffix}" + suffix = 'д' if lang_code == 'ru' else 'd' + short_label = f'{days}{suffix}' else: short_label = label - parts.append(f"{short_label}: {settings.format_price(price)}") + parts.append(f'{short_label}: {settings.format_price(price)}') - return ", ".join(parts) if parts else fallback + return ', '.join(parts) if parts else fallback def _build_traffic_summary(lang_code: str, fallback: str) -> str: packages = _collect_traffic_packages() - enabled_packages = [package for package in packages if package["enabled"]] + enabled_packages = [package for package in packages if package['enabled']] if not enabled_packages: return fallback - parts: List[str] = [] + parts: list[str] = [] for package in enabled_packages: - short_label = _format_traffic_label(package["gb"], lang_code, short=True) - parts.append(f"{short_label}: {settings.format_price(int(package['price']))}") + short_label = _format_traffic_label(package['gb'], lang_code, short=True) + parts.append(f'{short_label}: {settings.format_price(int(package["price"]))}') - return ", ".join(parts) if parts else fallback + return ', '.join(parts) if parts else fallback def _build_period_options_summary(lang_code: str) -> str: - suffix = "д" if lang_code == "ru" else "d" - available = ", ".join(f"{days}{suffix}" for days in settings.get_available_subscription_periods()) - renewal = ", ".join(f"{days}{suffix}" for days in settings.get_available_renewal_periods()) - if lang_code == "ru": - return f"Подписки: {available or '—'} | Продления: {renewal or '—'}" - return f"Subscriptions: {available or '-'} | Renewals: {renewal or '-'}" + suffix = 'д' if lang_code == 'ru' else 'd' + available = ', '.join(f'{days}{suffix}' for days in settings.get_available_subscription_periods()) + renewal = ', '.join(f'{days}{suffix}' for days in settings.get_available_renewal_periods()) + if lang_code == 'ru': + return f'Подписки: {available or "—"} | Продления: {renewal or "—"}' + return f'Subscriptions: {available or "-"} | Renewals: {renewal or "-"}' def _build_extra_summary(items: Iterable[PriceItem], fallback: str) -> str: - parts = [f"{label}: {settings.format_price(price)}" for key, label, price in items] - return ", ".join(parts) if parts else fallback + parts = [f'{label}: {settings.format_price(price)}' for key, label, price in items] + return ', '.join(parts) if parts else fallback def _build_settings_section( section: str, language: str, -) -> Tuple[str, types.InlineKeyboardMarkup]: +) -> tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) entries = SETTING_ENTRIES_BY_SECTION.get(section, ()) - if section == "trial": - title = texts.t("ADMIN_PRICING_SECTION_TRIAL_TITLE", "🎁 Пробный период") - elif section == "core": - title = texts.t("ADMIN_PRICING_SECTION_CORE_TITLE", "⚙️ Настройки тарифов") + if section == 'trial': + title = texts.t('ADMIN_PRICING_SECTION_TRIAL_TITLE', '🎁 Пробный период') + elif section == 'core': + title = texts.t('ADMIN_PRICING_SECTION_CORE_TITLE', '⚙️ Настройки тарифов') else: - title = texts.t("ADMIN_PRICING_SECTION_SETTINGS_GENERIC", "⚙️ Настройки") + title = texts.t('ADMIN_PRICING_SECTION_SETTINGS_GENERIC', '⚙️ Настройки') - lines: List[str] = [title, ""] - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + lines: list[str] = [title, ''] + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] if entries: lines.append( texts.t( - "ADMIN_PRICING_SECTION_CURRENT", - "Текущие значения:", + 'ADMIN_PRICING_SECTION_CURRENT', + 'Текущие значения:', ) ) - lines.append("") + lines.append('') for entry in entries: label = entry.label(lang_code) value = bot_configuration_service.get_current_value(entry.key) formatted = bot_configuration_service.format_value_human(entry.key, value) - if entry.action == "toggle": - state_icon = "✅" if bool(value) else "⚪️" - lines.append(f"{state_icon} {label} — {formatted}") + if entry.action == 'toggle': + state_icon = '✅' if bool(value) else '⚪️' + lines.append(f'{state_icon} {label} — {formatted}') button_text = texts.t( - "ADMIN_PRICING_SETTING_TOGGLE_STATEFUL", - "{icon} {label}", + 'ADMIN_PRICING_SETTING_TOGGLE_STATEFUL', + '{icon} {label}', ).format(icon=state_icon, label=label) keyboard_rows.append( [ types.InlineKeyboardButton( text=button_text, - callback_data=( - f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}" - ), + callback_data=(f'admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}'), ) ] ) - elif entry.action == "choice" and entry.choices: - lines.append(f"• {label}: {formatted}") - buttons: List[types.InlineKeyboardButton] = [] + elif entry.action == 'choice' and entry.choices: + lines.append(f'• {label}: {formatted}') + buttons: list[types.InlineKeyboardButton] = [] for option in entry.choices: is_active = value == option.value - icon = "✅" if is_active else "⚪️" + icon = '✅' if is_active else '⚪️' buttons.append( types.InlineKeyboardButton( - text=f"{icon} {option.label(lang_code)}", + text=f'{icon} {option.label(lang_code)}', callback_data=( - f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}" + f'admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}' ), ) ) for i in range(0, len(buttons), 2): keyboard_rows.append(buttons[i : i + 2]) else: - lines.append(f"• {label}: {formatted}") + lines.append(f'• {label}: {formatted}') button_text = texts.t( - "ADMIN_PRICING_SETTING_EDIT_WITH_VALUE", - "✏️ {label} • {value}", + 'ADMIN_PRICING_SETTING_EDIT_WITH_VALUE', + '✏️ {label} • {value}', ).format(label=label, value=formatted) keyboard_rows.append( [ types.InlineKeyboardButton( text=button_text, - callback_data=( - f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}" - ), + callback_data=(f'admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}'), ) ] ) description = entry.description(lang_code) if description: - lines.append(f"{description}") - lines.append("") + lines.append(f'{description}') + lines.append('') if entries: - lines.append(texts.t("ADMIN_PRICING_SECTION_PROMPT", "Выберите что изменить:")) + lines.append(texts.t('ADMIN_PRICING_SECTION_PROMPT', 'Выберите что изменить:')) else: - lines.append(texts.t("ADMIN_PRICING_SECTION_EMPTY", "Нет параметров для изменения.")) + lines.append(texts.t('ADMIN_PRICING_SECTION_EMPTY', 'Нет параметров для изменения.')) - keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")]) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_pricing')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - return "\n".join(lines).strip(), keyboard + return '\n'.join(lines).strip(), keyboard -def _build_traffic_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: +def _build_traffic_options_section(language: str) -> tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) packages = _collect_traffic_packages() title = texts.t( - "ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE", - "🚦 Отображение пакетов трафика", + 'ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE', + '🚦 Отображение пакетов трафика', ) - lines: List[str] = [title, ""] + lines: list[str] = [title, ''] enabled_labels = [ - _format_traffic_label(package["gb"], lang_code, short=True) - for package in packages - if package["enabled"] + _format_traffic_label(package['gb'], lang_code, short=True) for package in packages if package['enabled'] ] if enabled_labels: lines.append( texts.t( - "ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE", - "Активные пакеты: {items}", - ).format(items=", ".join(enabled_labels)) + 'ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE', + 'Активные пакеты: {items}', + ).format(items=', '.join(enabled_labels)) ) else: lines.append( texts.t( - "ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE", - "Активных пакетов нет.", + 'ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE', + 'Активных пакетов нет.', ) ) - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT", - "Нажмите на пакет, чтобы включить или выключить его отображение.", + 'ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT', + 'Нажмите на пакет, чтобы включить или выключить его отображение.', ) ) - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] - buttons: List[types.InlineKeyboardButton] = [] + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] + buttons: list[types.InlineKeyboardButton] = [] for package in packages: - icon = "✅" if package["enabled"] else "⚪️" - label = _format_traffic_label(package["gb"], lang_code, short=True) + icon = '✅' if package['enabled'] else '⚪️' + label = _format_traffic_label(package['gb'], lang_code, short=True) buttons.append( types.InlineKeyboardButton( - text=f"{icon} {label}", - callback_data=f"admin_pricing_toggle_traffic:{package['gb']}", + text=f'{icon} {label}', + callback_data=f'admin_pricing_toggle_traffic:{package["gb"]}', ) ) for i in range(0, len(buttons), 3): keyboard_rows.append(buttons[i : i + 3]) - keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")]) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_pricing')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - return "\n".join(lines), keyboard + return '\n'.join(lines), keyboard -def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: +def _build_period_options_section(language: str) -> tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) - suffix = "д" if lang_code == "ru" else "d" + suffix = 'д' if lang_code == 'ru' else 'd' # Используем методы без фильтрации по ценам для админки available_subscription = set(settings.get_configured_subscription_periods()) @@ -617,41 +608,41 @@ def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeybo subscription_options = (14, 30, 60, 90, 180, 360) renewal_options = (30, 60, 90, 180, 360) - title = texts.t("ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE", "🗓 Доступные периоды") - lines: List[str] = [title, ""] + title = texts.t('ADMIN_PRICING_SECTION_PERIOD_OPTIONS_TITLE', '🗓 Доступные периоды') + lines: list[str] = [title, ''] - sub_list = ", ".join(f"{days}{suffix}" for days in sorted(available_subscription)) or "—" - renew_list = ", ".join(f"{days}{suffix}" for days in sorted(available_renewal)) or "—" + sub_list = ', '.join(f'{days}{suffix}' for days in sorted(available_subscription)) or '—' + renew_list = ', '.join(f'{days}{suffix}' for days in sorted(available_renewal)) or '—' lines.append( texts.t( - "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB", - "Активные периоды подписки: {items}", + 'ADMIN_PRICING_SECTION_PERIOD_OPTIONS_SUB', + 'Активные периоды подписки: {items}', ).format(items=sub_list) ) lines.append( texts.t( - "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW", - "Активные периоды продления: {items}", + 'ADMIN_PRICING_SECTION_PERIOD_OPTIONS_RENEW', + 'Активные периоды продления: {items}', ).format(items=renew_list) ) - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT", - "Нажмите на период, чтобы включить или выключить его отображение.", + 'ADMIN_PRICING_SECTION_PERIOD_OPTIONS_PROMPT', + 'Нажмите на период, чтобы включить или выключить его отображение.', ) ) - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] sub_buttons = [] for days in subscription_options: - icon = "✅" if days in available_subscription else "⚪️" + icon = '✅' if days in available_subscription else '⚪️' sub_buttons.append( types.InlineKeyboardButton( - text=f"{icon} {days}{suffix}", - callback_data=f"admin_pricing_toggle_period:subscription:{days}", + text=f'{icon} {days}{suffix}', + callback_data=f'admin_pricing_toggle_period:subscription:{days}', ) ) for i in range(0, len(sub_buttons), 3): @@ -659,30 +650,30 @@ def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeybo renew_buttons = [] for days in renewal_options: - icon = "✅" if days in available_renewal else "⚪️" + icon = '✅' if days in available_renewal else '⚪️' renew_buttons.append( types.InlineKeyboardButton( - text=f"{icon} {days}{suffix}", - callback_data=f"admin_pricing_toggle_period:renewal:{days}", + text=f'{icon} {days}{suffix}', + callback_data=f'admin_pricing_toggle_period:renewal:{days}', ) ) for i in range(0, len(renew_buttons), 3): keyboard_rows.append(renew_buttons[i : i + 3]) - keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")]) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_pricing')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - return "\n".join(lines), keyboard + return '\n'.join(lines), keyboard -def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: +def _build_overview(language: str) -> tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) period_items = _get_period_items(lang_code) - traffic_items = _get_traffic_items(lang_code) + _get_traffic_items(lang_code) extra_items = _get_extra_items(lang_code) - fallback = texts.t("ADMIN_PRICING_SUMMARY_EMPTY", "—") + fallback = texts.t('ADMIN_PRICING_SUMMARY_EMPTY', '—') summary_periods = _build_period_summary(period_items, lang_code, fallback) summary_traffic = _build_traffic_summary(lang_code, fallback) summary_extra = _build_extra_summary(extra_items, fallback) @@ -691,20 +682,20 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: summary_period_options = _build_period_options_summary(lang_code) lines = [ - f"💰 {texts.t('ADMIN_PRICING_MENU_TITLE', 'Управление ценами')}", + f'💰 {texts.t("ADMIN_PRICING_MENU_TITLE", "Управление ценами")}', texts.t( 'ADMIN_PRICING_MENU_DESCRIPTION', 'Быстрый доступ к настройкам тарифов, периодов и пакетов.', ), - "", - f"{texts.t('ADMIN_PRICING_MENU_SUMMARY', 'Краткая сводка')}", - f"🎁 {texts.t('ADMIN_PRICING_MENU_SUMMARY_TRIAL', 'Триал: {summary}').format(summary=summary_trial)}", - f"⚙️ {texts.t('ADMIN_PRICING_MENU_SUMMARY_CORE', 'Базовые лимиты: {summary}').format(summary=summary_core)}", - f"🗓 {texts.t('ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS', 'Доступные периоды: {summary}').format(summary=summary_period_options)}", - f"💵 {texts.t('ADMIN_PRICING_MENU_SUMMARY_PERIODS', 'Стоимость периодов: {summary}').format(summary=summary_periods)}", - f"📦 {texts.t('ADMIN_PRICING_MENU_SUMMARY_TRAFFIC', 'Пакеты трафика: {summary}').format(summary=summary_traffic)}", - f"➕ {texts.t('ADMIN_PRICING_MENU_SUMMARY_EXTRA', 'Дополнительно: {summary}').format(summary=summary_extra)}", - "", + '', + f'{texts.t("ADMIN_PRICING_MENU_SUMMARY", "Краткая сводка")}', + f'🎁 {texts.t("ADMIN_PRICING_MENU_SUMMARY_TRIAL", "Триал: {summary}").format(summary=summary_trial)}', + f'⚙️ {texts.t("ADMIN_PRICING_MENU_SUMMARY_CORE", "Базовые лимиты: {summary}").format(summary=summary_core)}', + f'🗓 {texts.t("ADMIN_PRICING_MENU_SUMMARY_PERIOD_OPTIONS", "Доступные периоды: {summary}").format(summary=summary_period_options)}', + f'💵 {texts.t("ADMIN_PRICING_MENU_SUMMARY_PERIODS", "Стоимость периодов: {summary}").format(summary=summary_periods)}', + f'📦 {texts.t("ADMIN_PRICING_MENU_SUMMARY_TRAFFIC", "Пакеты трафика: {summary}").format(summary=summary_traffic)}', + f'➕ {texts.t("ADMIN_PRICING_MENU_SUMMARY_EXTRA", "Дополнительно: {summary}").format(summary=summary_extra)}', + '', texts.t('ADMIN_PRICING_MENU_PROMPT', 'Выберите раздел для редактирования:'), ] @@ -712,110 +703,108 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]: inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_TRIAL", "🎁 Пробный период"), - callback_data="admin_pricing_section:trial", + text=texts.t('ADMIN_PRICING_BUTTON_TRIAL', '🎁 Пробный период'), + callback_data='admin_pricing_section:trial', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_CORE", "⚙️ Настройки тарифов"), - callback_data="admin_pricing_section:core", + text=texts.t('ADMIN_PRICING_BUTTON_CORE', '⚙️ Настройки тарифов'), + callback_data='admin_pricing_section:core', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_PERIOD_OPTIONS", "🗓 Доступные периоды"), - callback_data="admin_pricing_section:period_options", + text=texts.t('ADMIN_PRICING_BUTTON_PERIOD_OPTIONS', '🗓 Доступные периоды'), + callback_data='admin_pricing_section:period_options', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_PERIODS", "💵 Стоимость периодов"), - callback_data="admin_pricing_section:periods", + text=texts.t('ADMIN_PRICING_BUTTON_PERIODS', '💵 Стоимость периодов'), + callback_data='admin_pricing_section:periods', ), ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_TRAFFIC", "📦 Пакеты трафика"), - callback_data="admin_pricing_section:traffic", + text=texts.t('ADMIN_PRICING_BUTTON_TRAFFIC', '📦 Пакеты трафика'), + callback_data='admin_pricing_section:traffic', ), types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS", - "🚦 Отображение пакетов", + 'ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS', + '🚦 Отображение пакетов', ), - callback_data="admin_pricing_section:traffic_options", + callback_data='admin_pricing_section:traffic_options', ), types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_BUTTON_EXTRA", "➕ Дополнительно"), - callback_data="admin_pricing_section:extra", + text=texts.t('ADMIN_PRICING_BUTTON_EXTRA', '➕ Дополнительно'), + callback_data='admin_pricing_section:extra', ), ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], ] ) - return "\n".join(lines), keyboard + return '\n'.join(lines), keyboard def _build_section( section: str, language: str, -) -> Tuple[str, types.InlineKeyboardMarkup]: +) -> tuple[str, types.InlineKeyboardMarkup]: texts = get_texts(language) lang_code = _language_code(language) - if section == "periods": + if section == 'periods': items = _get_period_items(lang_code) - title = texts.t("ADMIN_PRICING_SECTION_PERIODS_TITLE", "🗓 Периоды подписки") - elif section == "traffic": + title = texts.t('ADMIN_PRICING_SECTION_PERIODS_TITLE', '🗓 Периоды подписки') + elif section == 'traffic': items = _get_traffic_items(lang_code) - title = texts.t("ADMIN_PRICING_SECTION_TRAFFIC_TITLE", "📦 Пакеты трафика") - elif section == "extra": + title = texts.t('ADMIN_PRICING_SECTION_TRAFFIC_TITLE', '📦 Пакеты трафика') + elif section == 'extra': items = _get_extra_items(lang_code) - title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции") - elif section == "traffic_options": + title = texts.t('ADMIN_PRICING_SECTION_EXTRA_TITLE', '➕ Дополнительные опции') + elif section == 'traffic_options': return _build_traffic_options_section(language) elif section in SETTING_ENTRIES_BY_SECTION: return _build_settings_section(section, language) - elif section == "period_options": + elif section == 'period_options': return _build_period_options_section(language) else: items = _get_extra_items(lang_code) - title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции") + title = texts.t('ADMIN_PRICING_SECTION_EXTRA_TITLE', '➕ Дополнительные опции') - lines = [title, ""] + lines = [title, ''] if items: for key, label, price in items: - lines.append(f"• {label} — {settings.format_price(price)}") - lines.append("") - lines.append(texts.t("ADMIN_PRICING_SECTION_PROMPT", "Выберите что изменить:")) + lines.append(f'• {label} — {settings.format_price(price)}') + lines.append('') + lines.append(texts.t('ADMIN_PRICING_SECTION_PROMPT', 'Выберите что изменить:')) else: - lines.append(texts.t("ADMIN_PRICING_SECTION_EMPTY", "Нет доступных значений.")) + lines.append(texts.t('ADMIN_PRICING_SECTION_EMPTY', 'Нет доступных значений.')) - keyboard_rows: List[List[types.InlineKeyboardButton]] = [] + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] for key, label, price in items: keyboard_rows.append( [ types.InlineKeyboardButton( - text=f"{label} • {settings.format_price(price)}", - callback_data=f"admin_pricing_edit:{section}:{key}", + text=f'{label} • {settings.format_price(price)}', + callback_data=f'admin_pricing_edit:{section}:{key}', ) ] ) - keyboard_rows.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_pricing')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - return "\n".join(lines), keyboard + return '\n'.join(lines), keyboard def _build_price_prompt(texts: Any, label: str, current_price: str) -> str: lines = [ - f"💰 {texts.t('ADMIN_PRICING_EDIT_TITLE', 'Изменение цены')}", - "", - f"{texts.t('ADMIN_PRICING_EDIT_TARGET', 'Текущий тариф')}: {label}", - f"{texts.t('ADMIN_PRICING_EDIT_CURRENT', 'Текущее значение')}: {current_price}", - "", + f'💰 {texts.t("ADMIN_PRICING_EDIT_TITLE", "Изменение цены")}', + '', + f'{texts.t("ADMIN_PRICING_EDIT_TARGET", "Текущий тариф")}: {label}', + f'{texts.t("ADMIN_PRICING_EDIT_CURRENT", "Текущее значение")}: {current_price}', + '', texts.t( 'ADMIN_PRICING_EDIT_PROMPT', 'Введите новую стоимость в рублях (например 990 или 990.50). Для бесплатного тарифа укажите 0.', @@ -825,7 +814,7 @@ def _build_price_prompt(texts: Any, label: str, current_price: str) -> str: 'Напишите «Отмена», чтобы вернуться без изменений.', ), ] - return "\n".join(lines) + return '\n'.join(lines) async def _render_message( @@ -834,10 +823,10 @@ async def _render_message( keyboard: types.InlineKeyboardMarkup, ) -> None: try: - await message.edit_text(text, reply_markup=keyboard, parse_mode="HTML") + await message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') except TelegramBadRequest as error: # message changed elsewhere - logger.debug("Failed to edit pricing message: %s", error) - await message.answer(text, reply_markup=keyboard, parse_mode="HTML") + logger.debug('Failed to edit pricing message: %s', error) + await message.answer(text, reply_markup=keyboard, parse_mode='HTML') async def _render_message_by_id( @@ -853,28 +842,28 @@ async def _render_message_by_id( chat_id=chat_id, message_id=message_id, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except TelegramBadRequest as error: - logger.debug("Failed to edit pricing message by id: %s", error) - await bot.send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML") + logger.debug('Failed to edit pricing message by id: %s', error) + await bot.send_message(chat_id, text, reply_markup=keyboard, parse_mode='HTML') def _parse_price_input(text: str) -> int: - normalized = text.replace("₽", "").replace("р", "").replace("RUB", "") - normalized = normalized.replace(" ", "").replace(",", ".").strip() + normalized = text.replace('₽', '').replace('р', '').replace('RUB', '') + normalized = normalized.replace(' ', '').replace(',', '.').strip() if not normalized: - raise ValueError("empty") + raise ValueError('empty') try: value = Decimal(normalized) except InvalidOperation as error: - raise ValueError("invalid") from error + raise ValueError('invalid') from error if value < 0: - raise ValueError("negative") + raise ValueError('negative') - kopeks = int((value * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + kopeks = int((value * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP)) return kopeks @@ -885,16 +874,16 @@ def _resolve_label(section: str, key: str, language: str) -> str: if entry is not None: return entry.label(lang_code) - if section == "periods" and key.startswith("PRICE_") and key.endswith("_DAYS"): + if section == 'periods' and key.startswith('PRICE_') and key.endswith('_DAYS'): try: - days = int(key.replace("PRICE_", "").replace("_DAYS", "")) + days = int(key.replace('PRICE_', '').replace('_DAYS', '')) except ValueError: days = None if days is not None: return _format_period_label(days, lang_code) - if section == "traffic" and key.startswith("PRICE_TRAFFIC_"): - if key.endswith("UNLIMITED"): + if section == 'traffic' and key.startswith('PRICE_TRAFFIC_'): + if key.endswith('UNLIMITED'): return _format_traffic_label(0, lang_code) digits = ''.join(ch for ch in key if ch.isdigit()) try: @@ -904,8 +893,8 @@ def _resolve_label(section: str, key: str, language: str) -> str: if gb is not None: return _format_traffic_label(gb, lang_code) - if key == "PRICE_PER_DEVICE": - return "Дополнительное устройство" if lang_code == "ru" else "Extra device" + if key == 'PRICE_PER_DEVICE': + return 'Дополнительное устройство' if lang_code == 'ru' else 'Extra device' return key @@ -932,7 +921,7 @@ async def show_pricing_section( db: AsyncSession, state: FSMContext, ) -> None: - section = callback.data.split(":", 1)[1] + section = callback.data.split(':', 1)[1] text, keyboard = _build_section(section, db_user.language) await _render_message(callback.message, text, keyboard) await state.clear() @@ -947,7 +936,7 @@ async def start_price_edit( db: AsyncSession, state: FSMContext, ) -> None: - _, section, key = callback.data.split(":", 2) + _, section, key = callback.data.split(':', 2) texts = get_texts(db_user.language) label = _resolve_label(section, key, db_user.language) @@ -955,7 +944,7 @@ async def start_price_edit( pricing_key=key, pricing_section=section, pricing_message_id=callback.message.message_id, - pricing_mode="price", + pricing_mode='price', ) await state.set_state(PricingStates.waiting_for_value) @@ -966,8 +955,8 @@ async def start_price_edit( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_EDIT_CANCEL", "❌ Отмена"), - callback_data=f"admin_pricing_section:{section}", + text=texts.t('ADMIN_PRICING_EDIT_CANCEL', '❌ Отмена'), + callback_data=f'admin_pricing_section:{section}', ) ] ] @@ -986,7 +975,7 @@ async def start_setting_edit( state: FSMContext, ) -> None: try: - _, section, raw_key = callback.data.split(":", 2) + _, section, raw_key = callback.data.split(':', 2) except ValueError: await callback.answer() return @@ -1000,7 +989,7 @@ async def start_setting_edit( formatted_current = bot_configuration_service.format_value_human(key, current_value) guidance = bot_configuration_service.get_setting_guidance(key) - mode = "price" if entry and entry.action == "price" else "setting" + mode = 'price' if entry and entry.action == 'price' else 'setting' await state.update_data( pricing_key=key, @@ -1011,39 +1000,37 @@ async def start_setting_edit( ) await state.set_state(PricingStates.waiting_for_value) - if mode == "price": + if mode == 'price': prompt = _build_price_prompt( texts, label, settings.format_price(int(current_value or 0)), ) else: - description = guidance.get("description") or "" - format_hint = guidance.get("format") or "" - example = guidance.get("example") or "—" - warning = guidance.get("warning") or "" + description = guidance.get('description') or '' + format_hint = guidance.get('format') or '' + example = guidance.get('example') or '—' + warning = guidance.get('warning') or '' prompt_parts = [ - f"⚙️ {texts.t('ADMIN_PRICING_SETTING_EDIT_TITLE', 'Настройка параметра')}", - "", - f"{texts.t('ADMIN_PRICING_SETTING_PARAMETER', 'Параметр')}: {label}", - f"{texts.t('ADMIN_PRICING_SETTING_CURRENT', 'Текущее значение')}: {formatted_current}", + f'⚙️ {texts.t("ADMIN_PRICING_SETTING_EDIT_TITLE", "Настройка параметра")}', + '', + f'{texts.t("ADMIN_PRICING_SETTING_PARAMETER", "Параметр")}: {label}', + f'{texts.t("ADMIN_PRICING_SETTING_CURRENT", "Текущее значение")}: {formatted_current}', ] if description: - prompt_parts.extend(["", description]) + prompt_parts.extend(['', description]) prompt_parts.extend( [ - "", - f"ℹ️ {texts.t('ADMIN_PRICING_SETTING_FORMAT', 'Формат ввода')}: {format_hint}", - f"📌 {texts.t('ADMIN_PRICING_SETTING_EXAMPLE', 'Пример')}: {example}", + '', + f'ℹ️ {texts.t("ADMIN_PRICING_SETTING_FORMAT", "Формат ввода")}: {format_hint}', + f'📌 {texts.t("ADMIN_PRICING_SETTING_EXAMPLE", "Пример")}: {example}', ] ) if warning: - prompt_parts.append( - f"⚠️ {texts.t('ADMIN_PRICING_SETTING_WARNING', 'Важно')}: {warning}" - ) + prompt_parts.append(f'⚠️ {texts.t("ADMIN_PRICING_SETTING_WARNING", "Важно")}: {warning}') prompt_parts.extend( [ - "", + '', texts.t( 'ADMIN_PRICING_SETTING_PROMPT', 'Отправьте новое значение или напишите «Отмена». Для очистки используйте none.', @@ -1054,14 +1041,14 @@ async def start_setting_edit( ), ] ) - prompt = "\n".join(prompt_parts) + prompt = '\n'.join(prompt_parts) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRICING_EDIT_CANCEL", "❌ Отмена"), - callback_data=f"admin_pricing_section:{section}", + text=texts.t('ADMIN_PRICING_EDIT_CANCEL', '❌ Отмена'), + callback_data=f'admin_pricing_section:{section}', ) ] ] @@ -1078,21 +1065,21 @@ async def process_pricing_input( db: AsyncSession, ) -> None: data = await state.get_data() - key = data.get("pricing_key") - section = data.get("pricing_section", "periods") - message_id = data.get("pricing_message_id") - mode = data.get("pricing_mode", "price") - stored_label = data.get("pricing_label") + key = data.get('pricing_key') + section = data.get('pricing_section', 'periods') + message_id = data.get('pricing_message_id') + mode = data.get('pricing_mode', 'price') + stored_label = data.get('pricing_label') texts = get_texts(db_user.language) if not key: - await message.answer(texts.t("ADMIN_PRICING_EDIT_EXPIRED", "Сессия редактирования истекла.")) + await message.answer(texts.t('ADMIN_PRICING_EDIT_EXPIRED', 'Сессия редактирования истекла.')) await state.clear() return - raw_value = message.text or "" - if raw_value.strip().lower() in {"cancel", "отмена"}: + raw_value = message.text or '' + if raw_value.strip().lower() in {'cancel', 'отмена'}: await state.clear() section_text, section_keyboard = _build_section(section, db_user.language) if message_id: @@ -1103,17 +1090,17 @@ async def process_pricing_input( section_text, section_keyboard, ) - await message.answer(texts.t("ADMIN_PRICING_EDIT_CANCELLED", "Изменения отменены.")) + await message.answer(texts.t('ADMIN_PRICING_EDIT_CANCELLED', 'Изменения отменены.')) return - if mode == "price": + if mode == 'price': try: new_value = _parse_price_input(raw_value) except ValueError: await message.answer( texts.t( - "ADMIN_PRICING_EDIT_INVALID", - "Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).", + 'ADMIN_PRICING_EDIT_INVALID', + 'Не удалось распознать цену. Укажите число в рублях (например 990 или 990.50).', ) ) return @@ -1122,8 +1109,8 @@ async def process_pricing_input( new_value = bot_configuration_service.parse_user_value(key, raw_value) except ValueError as error: error_text = str(error) or texts.t( - "ADMIN_PRICING_SETTING_INVALID", - "Не удалось обновить параметр. Проверьте формат значения.", + 'ADMIN_PRICING_SETTING_INVALID', + 'Не удалось обновить параметр. Проверьте формат значения.', ) await message.answer(error_text) return @@ -1131,13 +1118,13 @@ async def process_pricing_input( await bot_configuration_service.set_value(db, key, new_value) await db.commit() - if key.startswith("PRICE_TRAFFIC_"): + if key.startswith('PRICE_TRAFFIC_'): packages = _collect_traffic_packages() await _save_traffic_packages(db, packages, skip_if_same=True) section_text, section_keyboard = _build_section(section, db_user.language) - if mode == "price": + if mode == 'price': if message_id: await _render_message_by_id( message.bot, @@ -1149,22 +1136,21 @@ async def process_pricing_input( try: await message.delete() except TelegramBadRequest as error: - logger.debug("Failed to delete pricing input message: %s", error) + logger.debug('Failed to delete pricing input message: %s', error) await state.clear() return - else: - entry = SETTING_ENTRY_BY_KEY.get(key) - lang_code = _language_code(db_user.language) - label = entry.label(lang_code) if entry else (stored_label or key) - formatted_value = bot_configuration_service.format_value_human( - key, bot_configuration_service.get_current_value(key) - ) - await message.answer( - texts.t( - "ADMIN_PRICING_SETTING_SUCCESS", - "Параметр {label} обновлен: {value}", - ).format(label=label, value=formatted_value) - ) + entry = SETTING_ENTRY_BY_KEY.get(key) + lang_code = _language_code(db_user.language) + label = entry.label(lang_code) if entry else (stored_label or key) + formatted_value = bot_configuration_service.format_value_human( + key, bot_configuration_service.get_current_value(key) + ) + await message.answer( + texts.t( + 'ADMIN_PRICING_SETTING_SUCCESS', + 'Параметр {label} обновлен: {value}', + ).format(label=label, value=formatted_value) + ) await state.clear() @@ -1188,14 +1174,14 @@ async def toggle_setting( state: FSMContext, ) -> None: try: - _, section, raw_key = callback.data.split(":", 2) + _, section, raw_key = callback.data.split(':', 2) except ValueError: await callback.answer() return key = _decode_setting_callback_key(raw_key) entry = SETTING_ENTRY_BY_KEY.get(key) - if not entry or entry.action != "toggle": + if not entry or entry.action != 'toggle': await callback.answer() return @@ -1220,14 +1206,14 @@ async def select_setting_choice( state: FSMContext, ) -> None: try: - _, section, raw_key, value_raw = callback.data.split(":", 3) + _, section, raw_key, value_raw = callback.data.split(':', 3) except ValueError: await callback.answer() return key = _decode_setting_callback_key(raw_key) entry = SETTING_ENTRY_BY_KEY.get(key) - if not entry or entry.action != "choice" or not entry.choices: + if not entry or entry.action != 'choice' or not entry.choices: await callback.answer() return @@ -1246,8 +1232,8 @@ async def select_setting_choice( if current_value == target_option.value: await callback.answer( texts.t( - "ADMIN_PRICING_CHOICE_ALREADY", - "Это значение уже активно.", + 'ADMIN_PRICING_CHOICE_ALREADY', + 'Это значение уже активно.', ) ) return @@ -1258,8 +1244,8 @@ async def select_setting_choice( lang_code = _language_code(db_user.language) await callback.answer( texts.t( - "ADMIN_PRICING_CHOICE_UPDATED", - "Выбрано: {label}", + 'ADMIN_PRICING_CHOICE_UPDATED', + 'Выбрано: {label}', ).format(label=target_option.label(lang_code)) ) @@ -1276,7 +1262,7 @@ async def toggle_traffic_package( state: FSMContext, ) -> None: try: - _, gb_raw = callback.data.split(":", 1) + _, gb_raw = callback.data.split(':', 1) gb_value = int(gb_raw) except (ValueError, TypeError): await callback.answer() @@ -1285,32 +1271,32 @@ async def toggle_traffic_package( texts = get_texts(db_user.language) packages = _collect_traffic_packages() - target_index = next((index for index, pkg in enumerate(packages) if pkg["gb"] == gb_value), None) + target_index = next((index for index, pkg in enumerate(packages) if pkg['gb'] == gb_value), None) if target_index is None: await callback.answer() return - enabled_count = sum(1 for pkg in packages if pkg["enabled"]) + enabled_count = sum(1 for pkg in packages if pkg['enabled']) target_package = packages[target_index] - if target_package["enabled"] and enabled_count <= 1: + if target_package['enabled'] and enabled_count <= 1: await callback.answer( texts.t( - "ADMIN_PRICING_TRAFFIC_PACKAGE_MIN", - "Должен оставаться хотя бы один пакет.", + 'ADMIN_PRICING_TRAFFIC_PACKAGE_MIN', + 'Должен оставаться хотя бы один пакет.', ), show_alert=True, ) return - target_package["enabled"] = not target_package["enabled"] + target_package['enabled'] = not target_package['enabled'] await _save_traffic_packages(db, packages) status_text = ( - texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED", "Пакет включен.") - if target_package["enabled"] - else texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED", "Пакет отключен.") + texts.t('ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED', 'Пакет включен.') + if target_package['enabled'] + else texts.t('ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED', 'Пакет отключен.') ) await callback.answer(status_text) @@ -1327,7 +1313,7 @@ async def toggle_period_option( state: FSMContext, ) -> None: try: - _, target, value_raw = callback.data.split(":", 2) + _, target, value_raw = callback.data.split(':', 2) days = int(value_raw) except (ValueError, TypeError): await callback.answer() @@ -1335,16 +1321,16 @@ async def toggle_period_option( texts = get_texts(db_user.language) - if target == "subscription": + if target == 'subscription': # Используем метод без фильтрации по ценам для админки current = set(settings.get_configured_subscription_periods()) options = {14, 30, 60, 90, 180, 360} - setting_key = "AVAILABLE_SUBSCRIPTION_PERIODS" - elif target == "renewal": + setting_key = 'AVAILABLE_SUBSCRIPTION_PERIODS' + elif target == 'renewal': # Используем метод без фильтрации по ценам для админки current = set(settings.get_configured_renewal_periods()) options = {30, 60, 90, 180, 360} - setting_key = "AVAILABLE_RENEWAL_PERIODS" + setting_key = 'AVAILABLE_RENEWAL_PERIODS' else: await callback.answer() return @@ -1357,19 +1343,19 @@ async def toggle_period_option( if len(current) == 1: await callback.answer( texts.t( - "ADMIN_PRICING_PERIOD_MIN", - "Должен оставаться хотя бы один период.", + 'ADMIN_PRICING_PERIOD_MIN', + 'Должен оставаться хотя бы один период.', ), show_alert=True, ) return current.remove(days) - action_text = texts.t("ADMIN_PRICING_PERIOD_DISABLED", "Период отключен.") + action_text = texts.t('ADMIN_PRICING_PERIOD_DISABLED', 'Период отключен.') else: current.add(days) - action_text = texts.t("ADMIN_PRICING_PERIOD_ENABLED", "Период включен.") + action_text = texts.t('ADMIN_PRICING_PERIOD_ENABLED', 'Период включен.') - new_value = ",".join(str(item) for item in sorted(current)) + new_value = ','.join(str(item) for item in sorted(current)) await bot_configuration_service.set_value(db, setting_key, new_value) await db.commit() @@ -1382,35 +1368,35 @@ async def toggle_period_option( def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_pricing_menu, - F.data.in_({"admin_pricing", "admin_subs_pricing"}), + F.data.in_({'admin_pricing', 'admin_subs_pricing'}), ) dp.callback_query.register( show_pricing_section, - F.data.startswith("admin_pricing_section:"), + F.data.startswith('admin_pricing_section:'), ) dp.callback_query.register( start_price_edit, - F.data.startswith("admin_pricing_edit:"), + F.data.startswith('admin_pricing_edit:'), ) dp.callback_query.register( start_setting_edit, - F.data.startswith("admin_pricing_setting:"), + F.data.startswith('admin_pricing_setting:'), ) dp.callback_query.register( toggle_setting, - F.data.startswith("admin_pricing_toggle:"), + F.data.startswith('admin_pricing_toggle:'), ) dp.callback_query.register( select_setting_choice, - F.data.startswith("admin_pricing_choice:"), + F.data.startswith('admin_pricing_choice:'), ) dp.callback_query.register( toggle_traffic_package, - F.data.startswith("admin_pricing_toggle_traffic:"), + F.data.startswith('admin_pricing_toggle_traffic:'), ) dp.callback_query.register( toggle_period_option, - F.data.startswith("admin_pricing_toggle_period:"), + F.data.startswith('admin_pricing_toggle_period:'), ) dp.message.register( process_pricing_input, diff --git a/app/handlers/admin/privacy_policy.py b/app/handlers/admin/privacy_policy.py index 5f6a8b9f..92d9bd44 100644 --- a/app/handlers/admin/privacy_policy.py +++ b/app/handlers/admin/privacy_policy.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -11,18 +11,19 @@ from app.localization.texts import get_texts from app.services.privacy_policy_service import PrivacyPolicyService from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -from app.utils.validators import validate_html_tags, get_html_help_text +from app.utils.validators import get_html_help_text, validate_html_tags + logger = logging.getLogger(__name__) def _format_timestamp(value: datetime | None) -> str: if not value: - return "" + return '' try: - return value.strftime("%d.%m.%Y %H:%M") + return value.strftime('%d.%m.%Y %H:%M') except Exception: - return "" + return '' async def _build_overview( @@ -40,63 +41,60 @@ async def _build_overview( has_content = bool(policy and policy.content and policy.content.strip()) description = texts.t( - "ADMIN_PRIVACY_POLICY_DESCRIPTION", - "Политика конфиденциальности отображается в разделе «Инфо».", + 'ADMIN_PRIVACY_POLICY_DESCRIPTION', + 'Политика конфиденциальности отображается в разделе «Инфо».', ) status_text = texts.t( - "ADMIN_PRIVACY_POLICY_STATUS_DISABLED", - "⚠️ Показ политики выключен или текст отсутствует.", + 'ADMIN_PRIVACY_POLICY_STATUS_DISABLED', + '⚠️ Показ политики выключен или текст отсутствует.', ) if policy and policy.is_enabled and has_content: status_text = texts.t( - "ADMIN_PRIVACY_POLICY_STATUS_ENABLED", - "✅ Политика активна и показывается пользователям.", + 'ADMIN_PRIVACY_POLICY_STATUS_ENABLED', + '✅ Политика активна и показывается пользователям.', ) elif policy and policy.is_enabled: status_text = texts.t( - "ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY", - "⚠️ Политика включена, но текст пуст — пользователи её не увидят.", + 'ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY', + '⚠️ Политика включена, но текст пуст — пользователи её не увидят.', ) - updated_at = _format_timestamp(getattr(policy, "updated_at", None)) - updated_block = "" + updated_at = _format_timestamp(getattr(policy, 'updated_at', None)) + updated_block = '' if updated_at: updated_block = texts.t( - "ADMIN_PRIVACY_POLICY_UPDATED_AT", - "Последнее обновление: {timestamp}", + 'ADMIN_PRIVACY_POLICY_UPDATED_AT', + 'Последнее обновление: {timestamp}', ).format(timestamp=updated_at) preview_block = texts.t( - "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY", - "Текст ещё не задан.", + 'ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY', + 'Текст ещё не задан.', ) if has_content: preview_title = texts.t( - "ADMIN_PRIVACY_POLICY_PREVIEW_TITLE", - "Превью текста:", + 'ADMIN_PRIVACY_POLICY_PREVIEW_TITLE', + 'Превью текста:', ) preview_raw = policy.content.strip() preview_trimmed = preview_raw[:400] if len(preview_raw) > 400: - preview_trimmed += "..." - preview_block = ( - f"{preview_title}\n" - f"{html.escape(preview_trimmed)}" - ) + preview_trimmed += '...' + preview_block = f'{preview_title}\n{html.escape(preview_trimmed)}' language_block = texts.t( - "ADMIN_PRIVACY_POLICY_LANGUAGE", - "Язык: {lang}", + 'ADMIN_PRIVACY_POLICY_LANGUAGE', + 'Язык: {lang}', ).format(lang=normalized_language) header = texts.t( - "ADMIN_PRIVACY_POLICY_HEADER", - "🛡️ Политика конфиденциальности", + 'ADMIN_PRIVACY_POLICY_HEADER', + '🛡️ Политика конфиденциальности', ) actions_prompt = texts.t( - "ADMIN_PRIVACY_POLICY_ACTION_PROMPT", - "Выберите действие:", + 'ADMIN_PRIVACY_POLICY_ACTION_PROMPT', + 'Выберите действие:', ) message_parts = [ @@ -112,64 +110,74 @@ async def _build_overview( message_parts.append(preview_block) message_parts.append(actions_prompt) - overview_text = "\n\n".join(part for part in message_parts if part) + overview_text = '\n\n'.join(part for part in message_parts if part) buttons: list[list[types.InlineKeyboardButton]] = [] - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PRIVACY_POLICY_EDIT_BUTTON", - "✏️ Изменить текст", - ), - callback_data="admin_privacy_policy_edit", - ) - ]) - - if has_content: - buttons.append([ + buttons.append( + [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_VIEW_BUTTON", - "👀 Просмотреть текущий текст", + 'ADMIN_PRIVACY_POLICY_EDIT_BUTTON', + '✏️ Изменить текст', ), - callback_data="admin_privacy_policy_view", + callback_data='admin_privacy_policy_edit', ) - ]) + ] + ) + + if has_content: + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PRIVACY_POLICY_VIEW_BUTTON', + '👀 Просмотреть текущий текст', + ), + callback_data='admin_privacy_policy_view', + ) + ] + ) toggle_text = texts.t( - "ADMIN_PRIVACY_POLICY_ENABLE_BUTTON", - "✅ Включить показ", + 'ADMIN_PRIVACY_POLICY_ENABLE_BUTTON', + '✅ Включить показ', ) if policy and policy.is_enabled: toggle_text = texts.t( - "ADMIN_PRIVACY_POLICY_DISABLE_BUTTON", - "🚫 Отключить показ", + 'ADMIN_PRIVACY_POLICY_DISABLE_BUTTON', + '🚫 Отключить показ', ) - buttons.append([ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_privacy_policy_toggle", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data='admin_privacy_policy_toggle', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PRIVACY_POLICY_HTML_HELP", - "ℹ️ HTML помощь", - ), - callback_data="admin_privacy_policy_help", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PRIVACY_POLICY_HTML_HELP', + 'ℹ️ HTML помощь', + ), + callback_data='admin_privacy_policy_help', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_settings", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data='admin_submenu_settings', + ) + ] + ) return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy @@ -200,14 +208,14 @@ async def toggle_privacy_policy( texts = get_texts(db_user.language) updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language) logger.info( - "Админ %s переключил показ политики конфиденциальности: %s", + 'Админ %s переключил показ политики конфиденциальности: %s', db_user.telegram_id, - "enabled" if updated_policy.is_enabled else "disabled", + 'enabled' if updated_policy.is_enabled else 'disabled', ) status_message = ( - texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена") + texts.t('ADMIN_PRIVACY_POLICY_ENABLED', '✅ Политика включена') if updated_policy.is_enabled - else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена") + else texts.t('ADMIN_PRIVACY_POLICY_DISABLED', '🚫 Политика отключена') ) overview_text, markup, _ = await _build_overview(db_user, db) @@ -234,32 +242,32 @@ async def start_edit_privacy_policy( fallback=False, ) - current_preview = "" + current_preview = '' if policy and policy.content: preview = policy.content.strip()[:400] if len(policy.content.strip()) > 400: - preview += "..." + preview += '...' current_preview = ( texts.t( - "ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW", - "Текущий текст (превью):", + 'ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW', + 'Текущий текст (превью):', ) - + f"\n{html.escape(preview)}\n\n" + + f'\n{html.escape(preview)}\n\n' ) prompt = texts.t( - "ADMIN_PRIVACY_POLICY_EDIT_PROMPT", - "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.", + 'ADMIN_PRIVACY_POLICY_EDIT_PROMPT', + 'Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.', ) hint = texts.t( - "ADMIN_PRIVACY_POLICY_EDIT_HINT", - "Используйте /html_help для справки по тегам.", + 'ADMIN_PRIVACY_POLICY_EDIT_HINT', + 'Используйте /html_help для справки по тегам.', ) message_text = ( - f"📝 {texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}\n\n" - f"{current_preview}{prompt}\n\n{hint}" + f'📝 {texts.t("ADMIN_PRIVACY_POLICY_EDIT_TITLE", "Редактирование политики")}\n\n' + f'{current_preview}{prompt}\n\n{hint}' ) keyboard = types.InlineKeyboardMarkup( @@ -267,16 +275,16 @@ async def start_edit_privacy_policy( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_HTML_HELP", - "ℹ️ HTML помощь", + 'ADMIN_PRIVACY_POLICY_HTML_HELP', + 'ℹ️ HTML помощь', ), - callback_data="admin_privacy_policy_help", + callback_data='admin_privacy_policy_help', ) ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"), - callback_data="admin_privacy_policy_cancel", + text=texts.t('ADMIN_PRIVACY_POLICY_CANCEL', '❌ Отмена'), + callback_data='admin_privacy_policy_cancel', ) ], ] @@ -313,13 +321,13 @@ async def process_privacy_policy_edit( db: AsyncSession, ): texts = get_texts(db_user.language) - new_text = message.text or "" + new_text = message.text or '' if len(new_text) > 4000: await message.answer( texts.t( - "ADMIN_PRIVACY_POLICY_TOO_LONG", - "❌ Текст политики слишком длинный. Максимум 4000 символов.", + 'ADMIN_PRIVACY_POLICY_TOO_LONG', + '❌ Текст политики слишком длинный. Максимум 4000 символов.', ) ) return @@ -328,23 +336,23 @@ async def process_privacy_policy_edit( if not is_valid: await message.answer( texts.t( - "ADMIN_PRIVACY_POLICY_HTML_ERROR", - "❌ Ошибка в HTML: {error}", + 'ADMIN_PRIVACY_POLICY_HTML_ERROR', + '❌ Ошибка в HTML: {error}', ).format(error=error_message) ) return await PrivacyPolicyService.save_policy(db, db_user.language, new_text) logger.info( - "Админ %s обновил текст политики конфиденциальности (%d символов)", + 'Админ %s обновил текст политики конфиденциальности (%d символов)', db_user.telegram_id, len(new_text), ) await state.clear() success_text = texts.t( - "ADMIN_PRIVACY_POLICY_SAVED", - "✅ Политика конфиденциальности обновлена.", + 'ADMIN_PRIVACY_POLICY_SAVED', + '✅ Политика конфиденциальности обновлена.', ) reply_markup = types.InlineKeyboardMarkup( @@ -352,10 +360,10 @@ async def process_privacy_policy_edit( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_BACK_BUTTON", - "⬅️ К настройкам политики", + 'ADMIN_PRIVACY_POLICY_BACK_BUTTON', + '⬅️ К настройкам политики', ), - callback_data="admin_privacy_policy", + callback_data='admin_privacy_policy', ) ] ] @@ -381,8 +389,8 @@ async def view_privacy_policy( if not policy or not policy.content or not policy.content.strip(): await callback.answer( texts.t( - "ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT", - "Текст политики пока не задан.", + 'ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT', + 'Текст политики пока не задан.', ), show_alert=True, ) @@ -392,19 +400,19 @@ async def view_privacy_policy( truncated = False max_length = 3800 if len(content) > max_length: - content = content[: max_length - 3] + "..." + content = content[: max_length - 3] + '...' truncated = True header = texts.t( - "ADMIN_PRIVACY_POLICY_VIEW_TITLE", - "👀 Текущий текст политики", + 'ADMIN_PRIVACY_POLICY_VIEW_TITLE', + '👀 Текущий текст политики', ) - note = "" + note = '' if truncated: note = texts.t( - "ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED", - "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + 'ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED', + '\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.', ) keyboard = types.InlineKeyboardMarkup( @@ -412,26 +420,26 @@ async def view_privacy_policy( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_BACK_BUTTON", - "⬅️ К настройкам политики", + 'ADMIN_PRIVACY_POLICY_BACK_BUTTON', + '⬅️ К настройкам политики', ), - callback_data="admin_privacy_policy", + callback_data='admin_privacy_policy', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_EDIT_BUTTON", - "✏️ Изменить текст", + 'ADMIN_PRIVACY_POLICY_EDIT_BUTTON', + '✏️ Изменить текст', ), - callback_data="admin_privacy_policy_edit", + callback_data='admin_privacy_policy_edit', ) ], ] ) await callback.message.edit_text( - f"{header}\n\n{content}{note}", + f'{header}\n\n{content}{note}', reply_markup=keyboard, ) await callback.answer() @@ -453,25 +461,29 @@ async def show_privacy_policy_html_help( buttons: list[list[types.InlineKeyboardButton]] = [] if current_state == AdminStates.editing_privacy_policy.state: - buttons.append([ + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT', + '⬅️ Назад к редактированию', + ), + callback_data='admin_privacy_policy_edit', + ) + ] + ) + + buttons.append( + [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT", - "⬅️ Назад к редактированию", + 'ADMIN_PRIVACY_POLICY_BACK_BUTTON', + '⬅️ К настройкам политики', ), - callback_data="admin_privacy_policy_edit", + callback_data='admin_privacy_policy', ) - ]) - - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PRIVACY_POLICY_BACK_BUTTON", - "⬅️ К настройкам политики", - ), - callback_data="admin_privacy_policy", - ) - ]) + ] + ) await callback.message.edit_text( help_text, @@ -483,27 +495,27 @@ async def show_privacy_policy_html_help( def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_privacy_policy_management, - F.data == "admin_privacy_policy", + F.data == 'admin_privacy_policy', ) dp.callback_query.register( toggle_privacy_policy, - F.data == "admin_privacy_policy_toggle", + F.data == 'admin_privacy_policy_toggle', ) dp.callback_query.register( start_edit_privacy_policy, - F.data == "admin_privacy_policy_edit", + F.data == 'admin_privacy_policy_edit', ) dp.callback_query.register( cancel_edit_privacy_policy, - F.data == "admin_privacy_policy_cancel", + F.data == 'admin_privacy_policy_cancel', ) dp.callback_query.register( view_privacy_policy, - F.data == "admin_privacy_policy_view", + F.data == 'admin_privacy_policy_view', ) dp.callback_query.register( show_privacy_policy_html_help, - F.data == "admin_privacy_policy_help", + F.data == 'admin_privacy_policy_help', ) dp.message.register( diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 486c9e53..dd86b067 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,86 +1,87 @@ import logging -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP -from typing import Dict, Optional, Tuple +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.promo_group import ( - get_promo_groups_with_counts, - get_promo_group_by_id, - create_promo_group, - update_promo_group, - delete_promo_group, - get_promo_group_members, count_promo_group_members, + create_promo_group, + delete_promo_group, + get_promo_group_by_id, + get_promo_group_members, + get_promo_groups_with_counts, + update_promo_group, ) from app.database.models import PromoGroup -from app.localization.texts import get_texts -from app.states import AdminStates -from app.utils.decorators import admin_required, error_handler from app.keyboards.admin import ( get_admin_pagination_keyboard, get_confirmation_keyboard, ) +from app.localization.texts import get_texts +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler from app.utils.pricing_utils import format_period_description + + logger = logging.getLogger(__name__) def _format_discount_lines(texts, group) -> list[str]: return [ texts.t( - "ADMIN_PROMO_GROUP_DISCOUNTS_HEADER", - "💸 Скидки промогруппы:", + 'ADMIN_PROMO_GROUP_DISCOUNTS_HEADER', + '💸 Скидки промогруппы:', ), texts.t( - "ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS", - "• Серверы: {percent}%", + 'ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS', + '• Серверы: {percent}%', ).format(percent=group.server_discount_percent), texts.t( - "ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC", - "• Трафик: {percent}%", + 'ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC', + '• Трафик: {percent}%', ).format(percent=group.traffic_discount_percent), texts.t( - "ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES", - "• Устройства: {percent}%", + '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) + enabled = getattr(group, 'apply_discounts_to_addons', True) if enabled: return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED", - "🧩 Скидки на доп. услуги: включены", + 'ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED', + '🧩 Скидки на доп. услуги: включены', ) return texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED", - "🧩 Скидки на доп. услуги: отключены", + 'ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED', + '🧩 Скидки на доп. услуги: отключены', ) def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str: - enabled = getattr(group, "apply_discounts_to_addons", True) + enabled = getattr(group, 'apply_discounts_to_addons', True) if enabled: return texts.t( - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE", - "🧩 Отключить скидки на доп. услуги", + 'ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE', + '🧩 Отключить скидки на доп. услуги', ) return texts.t( - "ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE", - "🧩 Включить скидки на доп. услуги", + 'ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE', + '🧩 Включить скидки на доп. услуги', ) -def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: +def _normalize_periods_dict(raw: dict | None) -> dict[int, int]: if not raw or not isinstance(raw, dict): return {} - normalized: Dict[int, int] = {} + normalized: dict[int, int] = {} for key, value in raw.items(): try: @@ -94,8 +95,8 @@ def _normalize_periods_dict(raw: Optional[Dict]) -> Dict[int, int]: return normalized -def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: - discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) +def _collect_period_discounts(group: PromoGroup) -> dict[int, int]: + discounts = _normalize_periods_dict(getattr(group, 'period_discounts', None)) if discounts: return dict(sorted(discounts.items())) @@ -118,8 +119,8 @@ def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> l return [] header = texts.t( - "ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER", - "⏳ Скидки по периодам:", + 'ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER', + '⏳ Скидки по периодам:', ) lines = [header] @@ -127,7 +128,7 @@ def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> l for period_days, percent in discounts.items(): period_display = format_period_description(period_days, language) lines.append( - texts.t("PROMO_GROUP_PERIOD_DISCOUNT_ITEM", "{period} — {percent}%").format( + texts.t('PROMO_GROUP_PERIOD_DISCOUNT_ITEM', '{period} — {percent}%').format( period=period_display, percent=percent, ) @@ -136,35 +137,32 @@ def _format_period_discounts_lines(texts, group: PromoGroup, language: str) -> l return lines -def _format_period_discounts_value(discounts: Dict[int, int]) -> str: +def _format_period_discounts_value(discounts: dict[int, int]) -> str: if not discounts: - return "0" + return '0' - return ", ".join( - f"{period}:{percent}" - for period, percent in sorted(discounts.items()) - ) + return ', '.join(f'{period}:{percent}' for period, percent in sorted(discounts.items())) -def _parse_period_discounts_input(value: str) -> Dict[int, int]: - cleaned = (value or "").strip() +def _parse_period_discounts_input(value: str) -> dict[int, int]: + cleaned = (value or '').strip() - if not cleaned or cleaned in {"0", "-"}: + if not cleaned or cleaned in {'0', '-'}: return {} - cleaned = cleaned.replace(";", ",").replace("\n", ",") - parts = [part.strip() for part in cleaned.split(",") if part.strip()] + cleaned = cleaned.replace(';', ',').replace('\n', ',') + parts = [part.strip() for part in cleaned.split(',') if part.strip()] if not parts: return {} - discounts: Dict[int, int] = {} + discounts: dict[int, int] = {} for part in parts: - if ":" not in part: + if ':' not in part: raise ValueError - period_raw, percent_raw = part.split(":", 1) + period_raw, percent_raw = part.split(':', 1) period = int(period_raw.strip()) percent = int(percent_raw.strip()) @@ -183,10 +181,10 @@ async def _prompt_for_period_discounts( prompt_key: str, default_text: str, *, - current_value: Optional[str] = None, + current_value: str | None = None, ): data = await state.get_data() - texts = get_texts(data.get("language", "ru")) + texts = get_texts(data.get('language', 'ru')) prompt_text = texts.t(prompt_key, default_text) if current_value is not None: @@ -200,61 +198,59 @@ async def _prompt_for_period_discounts( def _format_rubles(amount_kopeks: int) -> str: if amount_kopeks <= 0: - return "0" + return '0' rubles = Decimal(amount_kopeks) / Decimal(100) if rubles == rubles.to_integral_value(): - formatted = f"{rubles:,.0f}" + formatted = f'{rubles:,.0f}' else: - formatted = f"{rubles:,.2f}" + formatted = f'{rubles:,.2f}' - return formatted.replace(",", " ") + return formatted.replace(',', ' ') def _format_priority_line(texts, group: PromoGroup) -> str: - priority = getattr(group, "priority", 0) + priority = getattr(group, 'priority', 0) return texts.t( - "ADMIN_PROMO_GROUP_PRIORITY_LINE", - "🎯 Приоритет: {priority}", + 'ADMIN_PROMO_GROUP_PRIORITY_LINE', + '🎯 Приоритет: {priority}', ).format(priority=priority) def _format_auto_assign_line(texts, group: PromoGroup) -> str: - threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0 + threshold = getattr(group, 'auto_assign_total_spent_kopeks', 0) or 0 if threshold <= 0: return texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED", - "Автовыдача по суммарным тратам: отключена", + 'ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED', + 'Автовыдача по суммарным тратам: отключена', ) amount = _format_rubles(threshold) return texts.t( - "ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE", - "Автовыдача по суммарным тратам: от {amount} ₽", + 'ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE', + 'Автовыдача по суммарным тратам: от {amount} ₽', ).format(amount=amount) -def _format_auto_assign_value(value_kopeks: Optional[int]) -> str: +def _format_auto_assign_value(value_kopeks: int | None) -> str: if not value_kopeks or value_kopeks <= 0: - return "0" + return '0' rubles = Decimal(value_kopeks) / Decimal(100) quantized = ( - rubles.quantize(Decimal("1")) - if rubles == rubles.to_integral_value() - else rubles.quantize(Decimal("0.01")) + rubles.quantize(Decimal(1)) if rubles == rubles.to_integral_value() else rubles.quantize(Decimal('0.01')) ) return str(quantized) def _parse_auto_assign_threshold_input(value: str) -> int: - cleaned = (value or "").strip() + cleaned = (value or '').strip() - if not cleaned or cleaned in {"0", "-", "off", "нет"}: + if not cleaned or cleaned in {'0', '-', 'off', 'нет'}: return 0 - normalized = cleaned.replace(" ", "").replace(",", ".") + normalized = cleaned.replace(' ', '').replace(',', '.') try: amount = Decimal(normalized) @@ -264,7 +260,7 @@ def _parse_auto_assign_threshold_input(value: str) -> int: if amount < 0: raise ValueError - kopeks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + kopeks = int((amount * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP)) return max(0, kopeks) @@ -274,10 +270,10 @@ async def _prompt_for_auto_assign_threshold( prompt_key: str, default_text: str, *, - current_value: Optional[str] = None, + current_value: str | None = None, ): data = await state.get_data() - texts = get_texts(data.get("language", "ru")) + texts = get_texts(data.get('language', 'ru')) prompt_text = texts.t(prompt_key, default_text) if current_value is not None: @@ -293,10 +289,10 @@ def _build_edit_menu_content( texts, group: PromoGroup, language: str, -) -> Tuple[str, types.InlineKeyboardMarkup]: +) -> tuple[str, types.InlineKeyboardMarkup]: header = texts.t( - "ADMIN_PROMO_GROUP_EDIT_MENU_TITLE", - "✏️ Настройки промогруппы «{name}»", + 'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE', + '✏️ Настройки промогруппы «{name}»', ).format(name=group.name) lines = [header] @@ -310,87 +306,87 @@ def _build_edit_menu_content( lines.append( texts.t( - "ADMIN_PROMO_GROUP_EDIT_MENU_HINT", - "Выберите параметр для изменения:", + 'ADMIN_PROMO_GROUP_EDIT_MENU_HINT', + 'Выберите параметр для изменения:', ) ) - text = "\n".join(line for line in lines if line) + text = '\n'.join(line for line in lines if line) keyboard_rows = [ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_NAME", - "✏️ Изменить название", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_NAME', + '✏️ Изменить название', ), - callback_data=f"promo_group_edit_field_{group.id}_name", + callback_data=f'promo_group_edit_field_{group.id}_name', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY", - "🎯 Приоритет", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY', + '🎯 Приоритет', ), - callback_data=f"promo_group_edit_field_{group.id}_priority", + callback_data=f'promo_group_edit_field_{group.id}_priority', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC", - "🌐 Скидка на трафик", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC', + '🌐 Скидка на трафик', ), - callback_data=f"promo_group_edit_field_{group.id}_traffic", + callback_data=f'promo_group_edit_field_{group.id}_traffic', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS", - "🖥 Скидка на серверы", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS', + '🖥 Скидка на серверы', ), - callback_data=f"promo_group_edit_field_{group.id}_servers", + callback_data=f'promo_group_edit_field_{group.id}_servers', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES", - "📱 Скидка на устройства", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES', + '📱 Скидка на устройства', ), - callback_data=f"promo_group_edit_field_{group.id}_devices", + callback_data=f'promo_group_edit_field_{group.id}_devices', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS", - "⏳ Скидки по периодам", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS', + '⏳ Скидки по периодам', ), - callback_data=f"promo_group_edit_field_{group.id}_periods", + 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}", + callback_data=f'promo_group_toggle_addons_{group.id}', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN", - "🤖 Автовыдача по тратам", + 'ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN', + '🤖 Автовыдача по тратам', ), - callback_data=f"promo_group_edit_field_{group.id}_auto", + callback_data=f'promo_group_edit_field_{group.id}_auto', ) ], [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"promo_group_manage_{group.id}", + callback_data=f'promo_group_manage_{group.id}', ) ], ] @@ -405,7 +401,7 @@ def _get_edit_prompt_keyboard(group_id: int, texts) -> types.InlineKeyboardMarku [ types.InlineKeyboardButton( text=texts.BACK, - callback_data=f"promo_group_edit_{group_id}", + callback_data=f'promo_group_edit_{group_id}', ) ] ] @@ -417,21 +413,21 @@ async def _send_edit_menu_after_update( texts, group: PromoGroup, language: str, - success_message: Optional[str] = None, + success_message: str | None = None, ): 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) + text = '\n\n'.join(parts) - from_user = getattr(message, "from_user", None) + from_user = getattr(message, 'from_user', None) - if getattr(from_user, "is_bot", False): + if getattr(from_user, 'is_bot', False): try: await message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) return except TelegramBadRequest: @@ -440,7 +436,7 @@ async def _send_edit_menu_after_update( await message.answer( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -455,60 +451,54 @@ async def show_promo_groups_menu( groups = await get_promo_groups_with_counts(db) total_members = sum(count for _, count in groups) - header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 Промогруппы") + header = texts.t('ADMIN_PROMO_GROUPS_TITLE', '💳 Промогруппы') if groups: summary = texts.t( - "ADMIN_PROMO_GROUPS_SUMMARY", - "Всего групп: {count}\nВсего участников: {members}", + 'ADMIN_PROMO_GROUPS_SUMMARY', + 'Всего групп: {count}\nВсего участников: {members}', ).format(count=len(groups), members=total_members) - lines = [header, "", summary, ""] + lines = [header, '', summary, ''] keyboard_rows = [] for group, member_count in groups: - default_suffix = ( - texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)") - if group.is_default - else "" - ) + default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else '' group_lines = [ - f"{'⭐' if group.is_default else '🎯'} {group.name}{default_suffix}", + f'{"⭐" if group.is_default else "🎯"} {group.name}{default_suffix}', ] 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}", + 'ADMIN_PROMO_GROUPS_MEMBERS_COUNT', + 'Участников: {count}', ).format(count=member_count) ) period_lines = _format_period_discounts_lines(texts, group, db_user.language) group_lines.extend(period_lines) - group_lines.append("") + group_lines.append('') lines.extend(group_lines) - keyboard_rows.append([ - types.InlineKeyboardButton( - text=f"{'⭐' if group.is_default else '🎯'} {group.name}", - callback_data=f"promo_group_manage_{group.id}", - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=f'{"⭐" if group.is_default else "🎯"} {group.name}', + callback_data=f'promo_group_manage_{group.id}', + ) + ] + ) else: - lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")] + lines = [header, '', texts.t('ADMIN_PROMO_GROUPS_EMPTY', 'Промогруппы не найдены.')] keyboard_rows = [] - keyboard_rows.append( - [types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")] - ) - keyboard_rows.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text='➕ Создать', callback_data='admin_promo_group_create')]) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_promo')]) await callback.message.edit_text( - "\n".join(line for line in lines if line is not None), + '\n'.join(line for line in lines if line is not None), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -516,11 +506,11 @@ async def show_promo_groups_menu( async def _get_group_or_alert( callback: types.CallbackQuery, db: AsyncSession, -) -> Optional[PromoGroup]: - group_id = int(callback.data.split("_")[-1]) +) -> PromoGroup | None: + group_id = int(callback.data.split('_')[-1]) group = await get_promo_group_by_id(db, group_id) if not group: - await callback.answer("❌ Промогруппа не найдена", show_alert=True) + await callback.answer('❌ Промогруппа не найдена', show_alert=True) return None return group @@ -539,24 +529,20 @@ async def show_promo_group_details( texts = get_texts(db_user.language) member_count = await count_promo_group_members(db, group.id) - default_note = ( - texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.") - if group.is_default - else "" - ) + default_note = texts.t('ADMIN_PROMO_GROUP_DETAILS_DEFAULT', 'Это базовая группа.') if group.is_default else '' lines = [ texts.t( - "ADMIN_PROMO_GROUP_DETAILS_TITLE", - "💳 Промогруппа: {name}", + 'ADMIN_PROMO_GROUP_DETAILS_TITLE', + '💳 Промогруппа: {name}', ).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}", + 'ADMIN_PROMO_GROUP_DETAILS_MEMBERS', + 'Участников: {count}', ).format(count=member_count) ) @@ -566,15 +552,15 @@ async def show_promo_group_details( if default_note: lines.append(default_note) - text = "\n".join(line for line in lines if line) + text = '\n'.join(line for line in lines if line) keyboard_rows = [] if member_count > 0: keyboard_rows.append( [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"), - callback_data=f"promo_group_members_{group.id}_page_1", + text=texts.t('ADMIN_PROMO_GROUP_MEMBERS_BUTTON', '👥 Участники'), + callback_data=f'promo_group_members_{group.id}_page_1', ) ] ) @@ -582,8 +568,8 @@ async def show_promo_group_details( keyboard_rows.append( [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"), - callback_data=f"promo_group_edit_{group.id}", + text=texts.t('ADMIN_PROMO_GROUP_EDIT_BUTTON', '✏️ Изменить'), + callback_data=f'promo_group_edit_{group.id}', ) ] ) @@ -592,20 +578,18 @@ async def show_promo_group_details( keyboard_rows.append( [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"), - callback_data=f"promo_group_delete_{group.id}", + text=texts.t('ADMIN_PROMO_GROUP_DELETE_BUTTON', '🗑️ Удалить'), + callback_data=f'promo_group_delete_{group.id}', ) ] ) - keyboard_rows.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]) await callback.message.edit_text( text.strip(), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -624,7 +608,7 @@ async def _prompt_for_discount( default_text: str, ): data = await state.get_data() - texts = get_texts(data.get("language", "ru")) + texts = get_texts(data.get('language', 'ru')) await message.answer(texts.t(prompt_key, default_text)) @@ -640,11 +624,9 @@ async def start_create_promo_group( await state.set_state(AdminStates.creating_promo_group_name) await state.update_data(language=db_user.language) await callback.message.edit_text( - texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"), + texts.t('ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT', 'Введите название новой промогруппы:'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] - ] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]] ), ) await callback.answer() @@ -653,23 +635,23 @@ async def start_create_promo_group( async def process_create_group_name(message: types.Message, state: FSMContext): name = message.text.strip() if not name: - texts = get_texts((await state.get_data()).get("language", "ru")) - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) + texts = get_texts((await state.get_data()).get('language', 'ru')) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_NAME', 'Название не может быть пустым.')) return await state.update_data(new_group_name=name) await state.set_state(AdminStates.creating_promo_group_priority) - texts = get_texts((await state.get_data()).get("language", "ru")) + texts = get_texts((await state.get_data()).get('language', 'ru')) await message.answer( texts.t( - "ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT", - "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):", + 'ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT', + 'Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):', ) ) async def process_create_group_priority(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) + texts = get_texts((await state.get_data()).get('language', 'ru')) try: priority = int(message.text) if priority < 0: @@ -677,8 +659,8 @@ async def process_create_group_priority(message: types.Message, state: FSMContex except (ValueError, TypeError): await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_PRIORITY", - "❌ Приоритет должен быть неотрицательным целым числом", + 'ADMIN_PROMO_GROUP_INVALID_PRIORITY', + '❌ Приоритет должен быть неотрицательным целым числом', ) ) return @@ -688,17 +670,17 @@ async def process_create_group_priority(message: types.Message, state: FSMContex await _prompt_for_discount( message, state, - "ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT", - "Введите скидку на трафик (0-100):", + 'ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT', + 'Введите скидку на трафик (0-100):', ) async def process_create_group_traffic(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) + texts = get_texts((await state.get_data()).get('language', 'ru')) try: value = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return await state.update_data(new_group_traffic=value) @@ -706,17 +688,17 @@ async def process_create_group_traffic(message: types.Message, state: FSMContext await _prompt_for_discount( message, state, - "ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT", - "Введите скидку на серверы (0-100):", + 'ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT', + 'Введите скидку на серверы (0-100):', ) async def process_create_group_servers(message: types.Message, state: FSMContext): - texts = get_texts((await state.get_data()).get("language", "ru")) + texts = get_texts((await state.get_data()).get('language', 'ru')) try: value = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return await state.update_data(new_group_servers=value) @@ -724,8 +706,8 @@ async def process_create_group_servers(message: types.Message, state: FSMContext await _prompt_for_discount( message, state, - "ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT", - "Введите скидку на устройства (0-100):", + 'ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT', + 'Введите скидку на устройства (0-100):', ) @@ -738,12 +720,12 @@ async def process_create_group_devices( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: devices_discount = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return await state.update_data(new_group_devices=devices_discount) @@ -752,8 +734,8 @@ async def process_create_group_devices( await _prompt_for_period_discounts( message, state, - "ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT", - "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.", + 'ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT', + 'Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.', ) @@ -766,15 +748,15 @@ async def process_create_group_period_discounts( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: period_discounts = _parse_period_discounts_input(message.text) except ValueError: await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", - "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + 'ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS', + 'Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.', ) ) return @@ -785,8 +767,8 @@ async def process_create_group_period_discounts( await _prompt_for_auto_assign_threshold( message, state, - "ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT", - "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.", + 'ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT', + 'Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.', ) @@ -799,15 +781,15 @@ async def process_create_group_auto_assign( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) except ValueError: await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", - "Введите неотрицательное число в рублях или 0 для отключения.", + 'ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN', + 'Введите неотрицательное число в рублях или 0 для отключения.', ) ) return @@ -815,34 +797,32 @@ async def process_create_group_auto_assign( try: group = await create_promo_group( db, - data["new_group_name"], - priority=data.get("new_group_priority", 0), - traffic_discount_percent=data["new_group_traffic"], - server_discount_percent=data["new_group_servers"], - device_discount_percent=data["new_group_devices"], - period_discounts=data.get("new_group_period_discounts"), + data['new_group_name'], + priority=data.get('new_group_priority', 0), + traffic_discount_percent=data['new_group_traffic'], + server_discount_percent=data['new_group_servers'], + device_discount_percent=data['new_group_devices'], + period_discounts=data.get('new_group_period_discounts'), auto_assign_total_spent_kopeks=auto_assign_kopeks, ) except Exception as e: - logger.error(f"Не удалось создать промогруппу: {e}") + logger.error(f'Не удалось создать промогруппу: {e}') await message.answer(texts.ERROR) await state.clear() return await state.clear() await message.answer( - texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format( - name=group.name - ), + texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON", - "↩️ К промогруппам", + 'ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON', + '↩️ К промогруппам', ), - callback_data="admin_promo_groups", + callback_data='admin_promo_groups', ) ] ] @@ -870,7 +850,7 @@ async def start_edit_promo_group( await callback.message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -883,9 +863,9 @@ async def prompt_edit_promo_group_field( state: FSMContext, db: AsyncSession, ): - parts = callback.data.split("_") + parts = callback.data.split('_') if len(parts) < 6: - await callback.answer("❌ Неверная команда", show_alert=True) + await callback.answer('❌ Неверная команда', show_alert=True) return group_id = int(parts[4]) @@ -893,7 +873,7 @@ async def prompt_edit_promo_group_field( group = await get_promo_group_by_id(db, group_id) if not group: - await callback.answer("❌ Промогруппа не найдена", show_alert=True) + await callback.answer('❌ Промогруппа не найдена', show_alert=True) return await state.update_data(edit_group_id=group.id, language=db_user.language) @@ -901,51 +881,51 @@ async def prompt_edit_promo_group_field( texts = get_texts(db_user.language) reply_markup = _get_edit_prompt_keyboard(group.id, texts) - if field == "name": + if field == 'name': await state.set_state(AdminStates.editing_promo_group_name) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT", - "Введите новое название промогруппы (текущее: {name}):", + 'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT', + 'Введите новое название промогруппы (текущее: {name}):', ).format(name=group.name) - elif field == "priority": + elif field == 'priority': await state.set_state(AdminStates.editing_promo_group_priority) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT", - "Введите новый приоритет (текущий: {current}):", - ).format(current=getattr(group, "priority", 0)) - elif field == "traffic": + 'ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT', + 'Введите новый приоритет (текущий: {current}):', + ).format(current=getattr(group, 'priority', 0)) + elif field == 'traffic': await state.set_state(AdminStates.editing_promo_group_traffic_discount) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT", - "Введите новую скидку на трафик (текущее значение: {current}%):", + 'ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT', + 'Введите новую скидку на трафик (текущее значение: {current}%):', ).format(current=group.traffic_discount_percent) - elif field == "servers": + elif field == 'servers': await state.set_state(AdminStates.editing_promo_group_server_discount) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT", - "Введите новую скидку на серверы (текущее значение: {current}%):", + 'ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT', + 'Введите новую скидку на серверы (текущее значение: {current}%):', ).format(current=group.server_discount_percent) - elif field == "devices": + elif field == 'devices': await state.set_state(AdminStates.editing_promo_group_device_discount) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT", - "Введите новую скидку на устройства (текущее значение: {current}%):", + 'ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT', + 'Введите новую скидку на устройства (текущее значение: {current}%):', ).format(current=group.device_discount_percent) - elif field == "periods": + elif field == 'periods': await state.set_state(AdminStates.editing_promo_group_period_discount) - current_discounts = _normalize_periods_dict(getattr(group, "period_discounts", None)) + current_discounts = _normalize_periods_dict(getattr(group, 'period_discounts', None)) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT", - "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.", + 'ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT', + 'Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.', ).format(current=_format_period_discounts_value(current_discounts)) - elif field == "auto": + elif field == 'auto': await state.set_state(AdminStates.editing_promo_group_auto_assign) prompt = texts.t( - "ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT", - "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.", + 'ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT', + 'Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.', ).format(current=_format_auto_assign_value(group.auto_assign_total_spent_kopeks)) else: - await callback.answer("❌ Неизвестный параметр", show_alert=True) + await callback.answer('❌ Неизвестный параметр', show_alert=True) return await callback.message.edit_text(prompt, reply_markup=reply_markup) @@ -961,16 +941,16 @@ async def process_edit_group_name( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) name = message.text.strip() if not name: - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_NAME', 'Название не может быть пустым.')) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -981,8 +961,8 @@ async def process_edit_group_name( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -995,7 +975,7 @@ async def process_edit_group_priority( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: priority = int(message.text) @@ -1004,15 +984,15 @@ async def process_edit_group_priority( except (ValueError, TypeError): await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_PRIORITY", - "❌ Приоритет должен быть неотрицательным целым числом", + 'ADMIN_PROMO_GROUP_INVALID_PRIORITY', + '❌ Приоритет должен быть неотрицательным целым числом', ) ) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1023,8 +1003,8 @@ async def process_edit_group_priority( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1037,17 +1017,17 @@ async def process_edit_group_traffic( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: value = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1058,8 +1038,8 @@ async def process_edit_group_traffic( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1072,17 +1052,17 @@ async def process_edit_group_servers( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: value = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1093,8 +1073,8 @@ async def process_edit_group_servers( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1107,17 +1087,17 @@ async def process_edit_group_devices( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: devices_discount = _validate_percent(message.text) except (ValueError, TypeError): - await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100.")) + await message.answer(texts.t('ADMIN_PROMO_GROUP_INVALID_PERCENT', 'Введите число от 0 до 100.')) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1128,8 +1108,8 @@ async def process_edit_group_devices( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1142,22 +1122,22 @@ async def process_edit_group_period_discounts( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: period_discounts = _parse_period_discounts_input(message.text) except ValueError: await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS", - "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.", + 'ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS', + 'Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.', ) ) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1168,8 +1148,8 @@ async def process_edit_group_period_discounts( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1182,22 +1162,22 @@ async def process_edit_group_auto_assign( db: AsyncSession, ): data = await state.get_data() - texts = get_texts(data.get("language", db_user.language)) + texts = get_texts(data.get('language', db_user.language)) try: auto_assign_kopeks = _parse_auto_assign_threshold_input(message.text) except ValueError: await message.answer( texts.t( - "ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN", - "Введите неотрицательное число в рублях или 0 для отключения.", + 'ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN', + 'Введите неотрицательное число в рублях или 0 для отключения.', ) ) return - group = await get_promo_group_by_id(db, data.get("edit_group_id")) + group = await get_promo_group_by_id(db, data.get('edit_group_id')) if not group: - await message.answer("❌ Промогруппа не найдена") + await message.answer('❌ Промогруппа не найдена') await state.clear() return @@ -1212,8 +1192,8 @@ async def process_edit_group_auto_assign( message, texts, group, - data.get("language", db_user.language), - texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name), + data.get('language', db_user.language), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), ) @@ -1224,7 +1204,7 @@ async def show_promo_group_members( db_user, db: AsyncSession, ): - parts = callback.data.split("_") + parts = callback.data.split('_') group_id = int(parts[3]) page = int(parts[-1]) limit = 10 @@ -1232,7 +1212,7 @@ async def show_promo_group_members( group = await get_promo_group_by_id(db, group_id) if not group: - await callback.answer("❌ Промогруппа не найдена", show_alert=True) + await callback.answer('❌ Промогруппа не найдена', show_alert=True) return texts = get_texts(db_user.language) @@ -1241,46 +1221,42 @@ async def show_promo_group_members( total_pages = max(1, (total_members + limit - 1) // limit) title = texts.t( - "ADMIN_PROMO_GROUP_MEMBERS_TITLE", - "👥 Участники группы {name}", + 'ADMIN_PROMO_GROUP_MEMBERS_TITLE', + '👥 Участники группы {name}', ).format(name=group.name) if not members: - body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.") + body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.') else: lines = [] for index, user in enumerate(members, start=offset + 1): - username = f"@{user.username}" if user.username else "—" + username = f'@{user.username}' if user.username else '—' if user.telegram_id: user_link = f'{user.full_name}' tg_display = str(user.telegram_id) else: - user_link = f"{user.full_name}" - tg_display = user.email or f"#{user.id}" - lines.append( - f"{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})" - ) - body = "\n".join(lines) + user_link = f'{user.full_name}' + tg_display = user.email or f'#{user.id}' + lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})') + body = '\n'.join(lines) keyboard = [] if total_pages > 1: pagination = get_admin_pagination_keyboard( page, total_pages, - f"promo_group_members_{group_id}", - f"promo_group_manage_{group_id}", + f'promo_group_members_{group_id}', + f'promo_group_manage_{group_id}', db_user.language, ) keyboard.extend(pagination.inline_keyboard) - keyboard.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")] - ) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_group_manage_{group_id}')]) await callback.message.edit_text( - f"{title}\n\n{body}", + f'{title}\n\n{body}', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1300,21 +1276,21 @@ async def request_delete_promo_group( if group.is_default: await callback.answer( - texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."), + texts.t('ADMIN_PROMO_GROUP_DELETE_FORBIDDEN', 'Базовую промогруппу нельзя удалить.'), show_alert=True, ) return confirm_text = texts.t( - "ADMIN_PROMO_GROUP_DELETE_CONFIRM", - "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.", + 'ADMIN_PROMO_GROUP_DELETE_CONFIRM', + 'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.', ).format(name=group.name) await callback.message.edit_text( confirm_text, reply_markup=get_confirmation_keyboard( - confirm_action=f"promo_group_delete_confirm_{group.id}", - cancel_action=f"promo_group_manage_{group.id}", + confirm_action=f'promo_group_delete_confirm_{group.id}', + cancel_action=f'promo_group_manage_{group.id}', language=db_user.language, ), ) @@ -1337,17 +1313,15 @@ async def delete_promo_group_confirmed( success = await delete_promo_group(db, group) if not success: await callback.answer( - texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."), + texts.t('ADMIN_PROMO_GROUP_DELETE_FORBIDDEN', 'Базовую промогруппу нельзя удалить.'), show_alert=True, ) return await callback.message.edit_text( - texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")] - ] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]] ), ) await callback.answer() @@ -1366,7 +1340,7 @@ async def toggle_promo_group_addon_discounts( texts = get_texts(db_user.language) - new_value = not getattr(group, "apply_discounts_to_addons", True) + new_value = not getattr(group, 'apply_discounts_to_addons', True) group = await update_promo_group( db, @@ -1375,11 +1349,11 @@ async def toggle_promo_group_addon_discounts( ) status_text = texts.t( - "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED" + 'ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED' if new_value - else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED", - "🧩 Скидки на докупку доп. услуг {status}.", - ).format(status="включены" if new_value else "отключены") + else 'ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED', + '🧩 Скидки на докупку доп. услуг {status}.', + ).format(status='включены' if new_value else 'отключены') await _send_edit_menu_after_update( callback.message, @@ -1393,33 +1367,32 @@ async def toggle_promo_group_addon_discounts( 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_")) - dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create") + 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_')) + dp.callback_query.register(start_create_promo_group, F.data == 'admin_promo_group_create') dp.callback_query.register( prompt_edit_promo_group_field, - F.data.startswith("promo_group_edit_field_"), + F.data.startswith('promo_group_edit_field_'), ) dp.callback_query.register( toggle_promo_group_addon_discounts, - F.data.startswith("promo_group_toggle_addons_"), + F.data.startswith('promo_group_toggle_addons_'), ) dp.callback_query.register( start_edit_promo_group, - F.data.regexp(r"^promo_group_edit_\d+$"), + F.data.regexp(r'^promo_group_edit_\d+$'), ) dp.callback_query.register( request_delete_promo_group, - F.data.startswith("promo_group_delete_") - & ~F.data.startswith("promo_group_delete_confirm_"), + F.data.startswith('promo_group_delete_') & ~F.data.startswith('promo_group_delete_confirm_'), ) dp.callback_query.register( delete_promo_group_confirmed, - F.data.startswith("promo_group_delete_confirm_"), + F.data.startswith('promo_group_delete_confirm_'), ) dp.callback_query.register( show_promo_group_members, - F.data.regexp(r"^promo_group_members_\d+_page_\d+$"), + F.data.regexp(r'^promo_group_members_\d+_page_\d+$'), ) dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name) diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index a7b8a588..33c187b7 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -4,8 +4,8 @@ import asyncio import html import logging import re +from collections.abc import Sequence from datetime import datetime -from typing import Dict, List, Optional, Sequence, Set, Tuple from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError @@ -17,18 +17,18 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.database.crud.discount_offer import list_discount_offers, upsert_discount_offer -from app.database.crud.server_squad import ( - get_all_server_squads, - get_server_squad_by_id, - get_server_squad_by_uuid, -) +from app.database.crud.promo_offer_log import list_promo_offer_logs from app.database.crud.promo_offer_template import ( ensure_default_templates, get_promo_offer_template_by_id, list_promo_offer_templates, update_promo_offer_template, ) -from app.database.crud.promo_offer_log import list_promo_offer_logs +from app.database.crud.server_squad import ( + get_all_server_squads, + get_server_squad_by_id, + get_server_squad_by_uuid, +) from app.database.crud.user import get_user_by_id, get_users_for_promo_segment from app.database.models import ( DiscountOffer, @@ -43,9 +43,10 @@ from app.localization.texts import get_texts from app.services.user_service import UserService from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -from app.utils.subscription_utils import get_display_subscription_link from app.utils.formatters import format_datetime, format_duration from app.utils.miniapp_buttons import build_miniapp_or_callback_button +from app.utils.subscription_utils import get_display_subscription_link + logger = logging.getLogger(__name__) @@ -59,26 +60,26 @@ async def _safe_delete_message(message: Message) -> None: try: await message.delete() except TelegramBadRequest as exc: - if "message to delete not found" not in str(exc).lower(): - logger.debug("Не удалось удалить сообщение администратора: %s", exc) + if 'message to delete not found' not in str(exc).lower(): + logger.debug('Не удалось удалить сообщение администратора: %s', exc) except TelegramForbiddenError: - logger.debug("Недостаточно прав для удаления сообщения администратора") + logger.debug('Недостаточно прав для удаления сообщения администратора') async def _safe_delete_message_by_id(bot, chat_id: int, message_id: int) -> None: try: await bot.delete_message(chat_id, message_id) except TelegramBadRequest as exc: - if "message to delete not found" not in str(exc).lower(): + if 'message to delete not found' not in str(exc).lower(): logger.debug( - "Не удалось удалить сообщение администратора (%s, %s): %s", + 'Не удалось удалить сообщение администратора (%s, %s): %s', chat_id, message_id, exc, ) except TelegramForbiddenError: logger.debug( - "Недостаточно прав для удаления сообщения администратора (%s, %s)", + 'Недостаточно прав для удаления сообщения администратора (%s, %s)', chat_id, message_id, ) @@ -86,9 +87,9 @@ async def _safe_delete_message_by_id(bot, chat_id: int, message_id: int) -> None async def _clear_promo_offer_search_prompt(state: FSMContext, bot) -> None: data = await state.get_data() - prompt_info = data.get("promo_offer_user_search_prompt") or {} - chat_id = prompt_info.get("chat_id") - message_id = prompt_info.get("message_id") + prompt_info = data.get('promo_offer_user_search_prompt') or {} + chat_id = prompt_info.get('chat_id') + message_id = prompt_info.get('message_id') if chat_id and message_id: await _safe_delete_message_by_id(bot, chat_id, message_id) @@ -98,83 +99,84 @@ async def _clear_promo_offer_search_prompt(state: FSMContext, bot) -> None: ACTION_LABEL_KEYS = { - "claimed": "ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED", - "consumed": "ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED", - "disabled": "ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED", + 'claimed': 'ADMIN_PROMO_OFFER_LOGS_ACTION_CLAIMED', + 'consumed': 'ADMIN_PROMO_OFFER_LOGS_ACTION_CONSUMED', + 'disabled': 'ADMIN_PROMO_OFFER_LOGS_ACTION_DISABLED', } REASON_LABEL_KEYS = { - "manual_charge": "ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL", - "autopay_consumed": "ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY", - "offer_expired": "ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED", - "test_access_expired": "ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED", + 'manual_charge': 'ADMIN_PROMO_OFFER_LOGS_REASON_MANUAL', + 'autopay_consumed': 'ADMIN_PROMO_OFFER_LOGS_REASON_AUTOPAY', + 'offer_expired': 'ADMIN_PROMO_OFFER_LOGS_REASON_EXPIRED', + 'test_access_expired': 'ADMIN_PROMO_OFFER_LOGS_REASON_TEST_EXPIRED', } OFFER_TYPE_CONFIG = { - "test_access": { - "icon": "🧪", - "label_key": "ADMIN_PROMO_OFFER_TEST_ACCESS", - "default_label": "Тестовые сервера", - "allowed_segments": [ - ("paid_active", "🟢 Активные платные"), - ("trial_active", "🎁 Активные триалы"), + 'test_access': { + 'icon': '🧪', + 'label_key': 'ADMIN_PROMO_OFFER_TEST_ACCESS', + 'default_label': 'Тестовые сервера', + 'allowed_segments': [ + ('paid_active', '🟢 Активные платные'), + ('trial_active', '🎁 Активные триалы'), ], - "effect_type": "test_access", + 'effect_type': 'test_access', }, - "extend_discount": { - "icon": "💎", - "label_key": "ADMIN_PROMO_OFFER_EXTEND", - "default_label": "Скидка на продление", - "allowed_segments": [ - ("paid_active", "🟢 Активные платные"), + 'extend_discount': { + 'icon': '💎', + 'label_key': 'ADMIN_PROMO_OFFER_EXTEND', + 'default_label': 'Скидка на продление', + 'allowed_segments': [ + ('paid_active', '🟢 Активные платные'), ], - "effect_type": "percent_discount", + 'effect_type': 'percent_discount', }, - "purchase_discount": { - "icon": "🎯", - "label_key": "ADMIN_PROMO_OFFER_PURCHASE", - "default_label": "Скидка на покупку", - "allowed_segments": [ - ("paid_expired", "🔴 Истёкшие платные"), - ("trial_expired", "🥶 Истёкшие триалы"), - ("trial_active", "🎁 Активные триалы"), + 'purchase_discount': { + 'icon': '🎯', + 'label_key': 'ADMIN_PROMO_OFFER_PURCHASE', + 'default_label': 'Скидка на покупку', + 'allowed_segments': [ + ('paid_expired', '🔴 Истёкшие платные'), + ('trial_expired', '🥶 Истёкшие триалы'), + ('trial_active', '🎁 Активные триалы'), ], - "effect_type": "percent_discount", + 'effect_type': 'percent_discount', }, } + def _render_template_text( template: PromoOfferTemplate, language: str, *, - server_name: Optional[str] = None, + server_name: str | None = None, ) -> str: replacements = { - "discount_percent": template.discount_percent, - "valid_hours": template.valid_hours, - "test_duration_hours": template.test_duration_hours or 0, - "active_discount_hours": template.active_discount_hours or template.valid_hours, + 'discount_percent': template.discount_percent, + 'valid_hours': template.valid_hours, + 'test_duration_hours': template.test_duration_hours or 0, + 'active_discount_hours': template.active_discount_hours or template.valid_hours, } if server_name is not None: - replacements.setdefault("server_name", server_name) + replacements.setdefault('server_name', server_name) else: # Prevent KeyError if template expects server_name - replacements.setdefault("server_name", "???") + replacements.setdefault('server_name', '???') try: return template.message_text.format(**replacements) except Exception: # pragma: no cover - fallback for invalid placeholders - logger.warning("Не удалось форматировать текст промо-предложения %s", template.id) + logger.warning('Не удалось форматировать текст промо-предложения %s', template.id) return template.message_text async def _resolve_template_squad( db: AsyncSession, template: PromoOfferTemplate, -) -> Tuple[Optional[str], Optional[str]]: - if template.offer_type != "test_access": +) -> tuple[str | None, str | None]: + if template.offer_type != 'test_access': return None, None squads = template.test_squad_uuids or [] @@ -189,74 +191,90 @@ async def _resolve_template_squad( def _build_templates_keyboard(templates: Sequence[PromoOfferTemplate], language: str) -> InlineKeyboardMarkup: texts = get_texts(language) - rows: List[List[InlineKeyboardButton]] = [] + rows: list[list[InlineKeyboardButton]] = [] for template in templates: config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) - icon = config.get("icon", "📨") - label = texts.t(config.get("label_key", ""), config.get("default_label", template.offer_type)) - rows.append([ - InlineKeyboardButton( - text=f"{icon} {label}", - callback_data=f"promo_offer_{template.id}", - ) - ]) - rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_LOGS", "📜 Лог операций"), - callback_data="promo_offer_logs_page_1", + icon = config.get('icon', '📨') + label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type)) + rows.append( + [ + InlineKeyboardButton( + text=f'{icon} {label}', + callback_data=f'promo_offer_{template.id}', + ) + ] ) - ]) - rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")]) + rows.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_PROMO_OFFER_LOGS', '📜 Лог операций'), + callback_data='promo_offer_logs_page_1', + ) + ] + ) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_communications')]) return InlineKeyboardMarkup(inline_keyboard=rows) def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup: texts = get_texts(language) - config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) - rows: List[List[InlineKeyboardButton]] = [] + OFFER_TYPE_CONFIG.get(template.offer_type, {}) + rows: list[list[InlineKeyboardButton]] = [] - rows.append([ - InlineKeyboardButton(text="✏️ Текст", callback_data=f"promo_offer_edit_message_{template.id}"), - InlineKeyboardButton(text="🪄 Кнопка", callback_data=f"promo_offer_edit_button_{template.id}"), - ]) - rows.append([ - InlineKeyboardButton(text="⏱️ Срок", callback_data=f"promo_offer_edit_valid_{template.id}"), - ]) + rows.append( + [ + InlineKeyboardButton(text='✏️ Текст', callback_data=f'promo_offer_edit_message_{template.id}'), + InlineKeyboardButton(text='🪄 Кнопка', callback_data=f'promo_offer_edit_button_{template.id}'), + ] + ) + rows.append( + [ + InlineKeyboardButton(text='⏱️ Срок', callback_data=f'promo_offer_edit_valid_{template.id}'), + ] + ) - if template.offer_type != "test_access": - rows[-1].append(InlineKeyboardButton(text="📉 %", callback_data=f"promo_offer_edit_discount_{template.id}")) - rows.append([ - InlineKeyboardButton(text="⌛ Активна", callback_data=f"promo_offer_edit_active_{template.id}"), - ]) + if template.offer_type != 'test_access': + rows[-1].append(InlineKeyboardButton(text='📉 %', callback_data=f'promo_offer_edit_discount_{template.id}')) + rows.append( + [ + InlineKeyboardButton(text='⌛ Активна', callback_data=f'promo_offer_edit_active_{template.id}'), + ] + ) else: - rows.append([ - InlineKeyboardButton(text="⏳ Длительность", callback_data=f"promo_offer_edit_duration_{template.id}"), - InlineKeyboardButton(text="🌍 Сквады", callback_data=f"promo_offer_edit_squads_{template.id}"), - ]) + rows.append( + [ + InlineKeyboardButton(text='⏳ Длительность', callback_data=f'promo_offer_edit_duration_{template.id}'), + InlineKeyboardButton(text='🌍 Сквады', callback_data=f'promo_offer_edit_squads_{template.id}'), + ] + ) - rows.append([ - InlineKeyboardButton(text="📬 Отправить", callback_data=f"promo_offer_send_menu_{template.id}"), - ]) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_offers"), - ]) + rows.append( + [ + InlineKeyboardButton(text='📬 Отправить', callback_data=f'promo_offer_send_menu_{template.id}'), + ] + ) + rows.append( + [ + InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_offers'), + ] + ) return InlineKeyboardMarkup(inline_keyboard=rows) def _format_offer_remaining(offer, texts) -> str: if not offer.expires_at: - return texts.t("ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY", "без срока") + return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока') remaining_seconds = int((offer.expires_at - datetime.utcnow()).total_seconds()) if remaining_seconds <= 0: - return texts.t("ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED", "истекло") + return texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT_EXPIRED', 'истекло') return format_duration(remaining_seconds) -def _extract_offer_active_hours(offer, template: Optional[PromoOfferTemplate]) -> Optional[int]: +def _extract_offer_active_hours(offer, template: PromoOfferTemplate | None) -> int | None: extra = offer.extra_data or {} - active_hours = extra.get("active_discount_hours") + active_hours = extra.get('active_discount_hours') if active_hours: try: return int(active_hours) @@ -266,16 +284,16 @@ def _extract_offer_active_hours(offer, template: Optional[PromoOfferTemplate]) - if template and template.active_discount_hours: return template.active_discount_hours - if template and template.offer_type == "test_access" and template.test_duration_hours: + if template and template.offer_type == 'test_access' and template.test_duration_hours: return template.test_duration_hours return None -_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") +_TEMPLATE_ID_PATTERN = re.compile(r'promo_template_(?P\d+)$') -def _extract_template_id_from_notification(notification_type: Optional[str]) -> Optional[int]: +def _extract_template_id_from_notification(notification_type: str | None) -> int | None: if not notification_type: return None @@ -284,7 +302,7 @@ def _extract_template_id_from_notification(notification_type: Optional[str]) -> return None try: - return int(match.group("template_id")) + return int(match.group('template_id')) except (TypeError, ValueError): return None @@ -294,142 +312,132 @@ def _format_promo_offer_log_entry( index: int, texts, ) -> str: - timestamp = entry.created_at.strftime("%d.%m.%Y %H:%M") if entry.created_at else "-" - action_key = ACTION_LABEL_KEYS.get(entry.action, "") + timestamp = entry.created_at.strftime('%d.%m.%Y %H:%M') if entry.created_at else '-' + action_key = ACTION_LABEL_KEYS.get(entry.action, '') action_label = texts.get(action_key, entry.action.title()) - lines = [f"{index}. {timestamp} — {action_label}"] + lines = [f'{index}. {timestamp} — {action_label}'] user = entry.user if user: if user.username: - username = f"@{user.username}" + username = f'@{user.username}' elif user.telegram_id: - username = f"ID{user.telegram_id}" + username = f'ID{user.telegram_id}' elif user.email: - username = f"📧{user.email}" + username = f'📧{user.email}' else: - username = f"User#{user.id}" - label = f"{username} (#{user.id})" + username = f'User#{user.id}' + label = f'{username} (#{user.id})' elif entry.user_id: - label = f"ID{entry.user_id}" + label = f'ID{entry.user_id}' else: - label = texts.get("ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER", "Неизвестный пользователь") + label = texts.get('ADMIN_PROMO_OFFER_LOGS_UNKNOWN_USER', 'Неизвестный пользователь') - lines.append(texts.get("ADMIN_PROMO_OFFER_LOGS_USER", "👤 {user}").format(user=html.escape(label))) + lines.append(texts.get('ADMIN_PROMO_OFFER_LOGS_USER', '👤 {user}').format(user=html.escape(label))) if entry.percent: - lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_PERCENT", "📉 Скидка: {percent}%").format( - percent=entry.percent - ) - ) + lines.append(texts.get('ADMIN_PROMO_OFFER_LOGS_PERCENT', '📉 Скидка: {percent}%').format(percent=entry.percent)) - effect_type = (entry.effect_type or "").lower() + effect_type = (entry.effect_type or '').lower() if effect_type: - if effect_type == "test_access": - effect_label = texts.get("ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST", "🧪 Тестовый доступ") + if effect_type == 'test_access': + effect_label = texts.get('ADMIN_PROMO_OFFER_LOGS_EFFECT_TEST', '🧪 Тестовый доступ') else: - effect_label = texts.get("ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT", "💸 Скидка") + effect_label = texts.get('ADMIN_PROMO_OFFER_LOGS_EFFECT_DISCOUNT', '💸 Скидка') lines.append(effect_label) if entry.source: lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_SOURCE", "🏷 Источник: {source}").format( - source=html.escape(entry.source) - ) + texts.get('ADMIN_PROMO_OFFER_LOGS_SOURCE', '🏷 Источник: {source}').format(source=html.escape(entry.source)) ) - details: Dict[str, object] = entry.details if isinstance(entry.details, dict) else {} - reason_key = details.get("reason") + details: dict[str, object] = entry.details if isinstance(entry.details, dict) else {} + reason_key = details.get('reason') if reason_key: - reason_label = texts.get(REASON_LABEL_KEYS.get(reason_key, ""), "") + reason_label = texts.get(REASON_LABEL_KEYS.get(reason_key, ''), '') if not reason_label: reason_label = texts.get( - "ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC", - "ℹ️ Действие: {reason}", + 'ADMIN_PROMO_OFFER_LOGS_REASON_GENERIC', + 'ℹ️ Действие: {reason}', ).format(reason=html.escape(str(reason_key))) lines.append(reason_label) - description = details.get("description") + description = details.get('description') if description: lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_DESCRIPTION", "📝 {description}").format( + texts.get('ADMIN_PROMO_OFFER_LOGS_DESCRIPTION', '📝 {description}').format( description=html.escape(str(description)) ) ) - amount = details.get("amount_kopeks") + amount = details.get('amount_kopeks') if isinstance(amount, int): lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_AMOUNT", "💰 Сумма: {amount}").format( - amount=texts.format_price(amount) - ) + texts.get('ADMIN_PROMO_OFFER_LOGS_AMOUNT', '💰 Сумма: {amount}').format(amount=texts.format_price(amount)) ) - squad_uuid = details.get("squad_uuid") + squad_uuid = details.get('squad_uuid') if squad_uuid: lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_SQUAD", "🌍 Сквад: {squad}").format( - squad=html.escape(str(squad_uuid)) - ) + texts.get('ADMIN_PROMO_OFFER_LOGS_SQUAD', '🌍 Сквад: {squad}').format(squad=html.escape(str(squad_uuid))) ) - new_squads = details.get("new_squads") + new_squads = details.get('new_squads') if isinstance(new_squads, (list, tuple)): filtered = [html.escape(str(item)) for item in new_squads if item] if filtered: lines.append( - texts.get("ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS", "🌍 Новые сквады: {squads}").format( - squads=", ".join(filtered) + texts.get('ADMIN_PROMO_OFFER_LOGS_NEW_SQUADS', '🌍 Новые сквады: {squads}').format( + squads=', '.join(filtered) ) ) - return "\n".join(lines) + return '\n'.join(lines) def _build_logs_keyboard(page: int, total_pages: int, language: str) -> InlineKeyboardMarkup: texts = get_texts(language) - rows: List[List[InlineKeyboardButton]] = [] + rows: list[list[InlineKeyboardButton]] = [] if total_pages > 1: - nav_row: List[InlineKeyboardButton] = [] + nav_row: list[InlineKeyboardButton] = [] if page > 1: nav_row.append( InlineKeyboardButton( - text="⬅️", - callback_data=f"promo_offer_logs_page_{page - 1}", + text='⬅️', + callback_data=f'promo_offer_logs_page_{page - 1}', ) ) nav_row.append( InlineKeyboardButton( - text=f"{page}/{total_pages}", - callback_data=f"promo_offer_logs_page_{page}", + text=f'{page}/{total_pages}', + callback_data=f'promo_offer_logs_page_{page}', ) ) if page < total_pages: nav_row.append( InlineKeyboardButton( - text="➡️", - callback_data=f"promo_offer_logs_page_{page + 1}", + text='➡️', + callback_data=f'promo_offer_logs_page_{page + 1}', ) ) rows.append(nav_row) - rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_offers")]) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_offers')]) return InlineKeyboardMarkup(inline_keyboard=rows) def _build_send_keyboard(template: PromoOfferTemplate, language: str) -> InlineKeyboardMarkup: config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) - segments = config.get("allowed_segments", []) + segments = config.get('allowed_segments', []) texts = get_texts(language) - rows: List[List[InlineKeyboardButton]] = [] + rows: list[list[InlineKeyboardButton]] = [] for segment, label in segments: rows.append( [ InlineKeyboardButton( text=label, - callback_data=f"promo_offer_send_{template.id}_{segment}", + callback_data=f'promo_offer_send_{template.id}_{segment}', ) ] ) @@ -438,64 +446,64 @@ def _build_send_keyboard(template: PromoOfferTemplate, language: str) -> InlineK [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER", - "👤 Отправка пользователю", + 'ADMIN_PROMO_OFFER_SEND_USER', + '👤 Отправка пользователю', ), - callback_data=f"promo_offer_send_user_{template.id}_page_1", + callback_data=f'promo_offer_send_user_{template.id}_page_1', ) ] ) - rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_{template.id}")]) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template.id}')]) return InlineKeyboardMarkup(inline_keyboard=rows) def _build_user_button_label(user: User) -> str: status_emoji_map = { - UserStatus.ACTIVE.value: "✅", - UserStatus.BLOCKED.value: "🚫", - UserStatus.DELETED.value: "🗑️", + UserStatus.ACTIVE.value: '✅', + UserStatus.BLOCKED.value: '🚫', + UserStatus.DELETED.value: '🗑️', } - status_emoji = status_emoji_map.get(getattr(user, "status", None), "❓") + status_emoji = status_emoji_map.get(getattr(user, 'status', None), '❓') - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if subscription: if subscription.is_trial: - subscription_emoji = "🎁" + subscription_emoji = '🎁' elif subscription.is_active: - subscription_emoji = "💎" + subscription_emoji = '💎' else: - subscription_emoji = "⏰" + subscription_emoji = '⏰' else: - subscription_emoji = "❌" + subscription_emoji = '❌' - name = (user.full_name or user.username or "").strip() + name = (user.full_name or user.username or '').strip() if not name: if user.telegram_id: - name = f"ID {user.telegram_id}" + name = f'ID {user.telegram_id}' elif user.email: name = user.email.split('@')[0][:15] else: - name = f"User#{user.id}" + name = f'User#{user.id}' if len(name) > 20: - name = name[:17] + "..." + name = name[:17] + '...' # Build identifier: telegram_id, email, or internal id if user.telegram_id: - identifier = f"🆔 {user.telegram_id}" + identifier = f'🆔 {user.telegram_id}' elif user.email: - identifier = f"📧 {user.email[:20]}" + identifier = f'📧 {user.email[:20]}' else: - identifier = f"#{user.id}" + identifier = f'#{user.id}' parts = [status_emoji, subscription_emoji, name, identifier] - balance = getattr(user, "balance_kopeks", 0) + balance = getattr(user, 'balance_kopeks', 0) if balance: - parts.append(f"💰 {settings.format_price(balance)}") + parts.append(f'💰 {settings.format_price(balance)}') - return " ".join(parts) + return ' '.join(parts) async def _render_send_user_list( @@ -508,7 +516,7 @@ async def _render_send_user_list( db: AsyncSession, state: FSMContext, page: int = 1, - query: Optional[str] = None, + query: str | None = None, ) -> None: user_service = UserService() texts = get_texts(db_user.language) @@ -519,8 +527,8 @@ async def _render_send_user_list( else: result = await user_service.get_users_page(db, page=page, limit=limit) - total_pages = max(1, int(result.get("total_pages") or 1)) - current_page = max(1, min(total_pages, int(result.get("current_page") or page or 1))) + total_pages = max(1, int(result.get('total_pages') or 1)) + current_page = max(1, min(total_pages, int(result.get('current_page') or page or 1))) if current_page != page: if query: @@ -528,65 +536,65 @@ async def _render_send_user_list( else: result = await user_service.get_users_page(db, page=current_page, limit=limit) - users: Sequence[User] = result.get("users", []) + users: Sequence[User] = result.get('users', []) lines = [ - texts.t("ADMIN_PROMO_OFFER_SEND_USER_TITLE", "👤 Отправка пользователю"), - "", + texts.t('ADMIN_PROMO_OFFER_SEND_USER_TITLE', '👤 Отправка пользователю'), + '', texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_HINT", - "Выберите пользователя для отправки промопредложения.", + 'ADMIN_PROMO_OFFER_SEND_USER_HINT', + 'Выберите пользователя для отправки промопредложения.', ), ] if query: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY", - "🔍 Поиск: {query}", + 'ADMIN_PROMO_OFFER_SEND_USER_SEARCH_QUERY', + '🔍 Поиск: {query}', ).format(query=html.escape(query)) ) if not users: - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_EMPTY", - "Подходящие пользователи не найдены. Измените запрос поиска.", + 'ADMIN_PROMO_OFFER_SEND_USER_EMPTY', + 'Подходящие пользователи не найдены. Измените запрос поиска.', ) ) - keyboard_rows: List[List[InlineKeyboardButton]] = [] + keyboard_rows: list[list[InlineKeyboardButton]] = [] for user in users: keyboard_rows.append( [ InlineKeyboardButton( text=_build_user_button_label(user), - callback_data=f"promo_offer_send_user_select_{template_id}_{user.id}", + callback_data=f'promo_offer_send_user_select_{template_id}_{user.id}', ) ] ) if total_pages > 1: - nav_row: List[InlineKeyboardButton] = [] + nav_row: list[InlineKeyboardButton] = [] if current_page > 1: nav_row.append( InlineKeyboardButton( - text="⬅️", - callback_data=f"promo_offer_send_user_{template_id}_page_{current_page - 1}", + text='⬅️', + callback_data=f'promo_offer_send_user_{template_id}_page_{current_page - 1}', ) ) nav_row.append( InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data=f"promo_offer_send_user_{template_id}_page_{current_page}", + text=f'{current_page}/{total_pages}', + callback_data=f'promo_offer_send_user_{template_id}_page_{current_page}', ) ) if current_page < total_pages: nav_row.append( InlineKeyboardButton( - text="➡️", - callback_data=f"promo_offer_send_user_{template_id}_page_{current_page + 1}", + text='➡️', + callback_data=f'promo_offer_send_user_{template_id}_page_{current_page + 1}', ) ) keyboard_rows.append(nav_row) @@ -594,8 +602,8 @@ async def _render_send_user_list( keyboard_rows.append( [ InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_SEND_USER_SEARCH", "🔍 Поиск"), - callback_data=f"promo_offer_send_user_search_{template_id}", + text=texts.t('ADMIN_PROMO_OFFER_SEND_USER_SEARCH', '🔍 Поиск'), + callback_data=f'promo_offer_send_user_search_{template_id}', ) ] ) @@ -604,8 +612,8 @@ async def _render_send_user_list( keyboard_rows.append( [ InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_SEND_USER_RESET", "❌ Сбросить поиск"), - callback_data=f"promo_offer_send_user_reset_{template_id}", + text=texts.t('ADMIN_PROMO_OFFER_SEND_USER_RESET', '❌ Сбросить поиск'), + callback_data=f'promo_offer_send_user_reset_{template_id}', ) ] ) @@ -614,17 +622,17 @@ async def _render_send_user_list( [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS", - "↩️ К выбору категории", + 'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_SEGMENTS', + '↩️ К выбору категории', ), - callback_data=f"promo_offer_send_menu_{template_id}", + callback_data=f'promo_offer_send_menu_{template_id}', ) ] ) - keyboard_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_{template_id}")]) + keyboard_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template_id}')]) markup = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - text = "\n".join(lines) + text = '\n'.join(lines) current_message_id = message_id try: @@ -633,11 +641,11 @@ async def _render_send_user_list( chat_id=chat_id, message_id=message_id, reply_markup=markup, - parse_mode="HTML", + parse_mode='HTML', ) except TelegramBadRequest as exc: error_text = str(exc).lower() - if "message is not modified" in error_text: + if 'message is not modified' in error_text: await bot.edit_message_reply_markup( chat_id=chat_id, message_id=message_id, @@ -648,16 +656,16 @@ async def _render_send_user_list( chat_id=chat_id, text=text, reply_markup=markup, - parse_mode="HTML", + parse_mode='HTML', ) current_message_id = sent_message.message_id await state.update_data( - promo_offer_user_message={"chat_id": chat_id, "message_id": current_message_id}, + promo_offer_user_message={'chat_id': chat_id, 'message_id': current_message_id}, promo_offer_user_filter={ - "template_id": template_id, - "page": current_page, - "query": query, + 'template_id': template_id, + 'page': current_page, + 'query': query, }, ) @@ -666,28 +674,28 @@ def _describe_offer( template: PromoOfferTemplate, language: str, *, - server_name: Optional[str] = None, - server_uuid: Optional[str] = None, + server_name: str | None = None, + server_uuid: str | None = None, ) -> str: texts = get_texts(language) config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) - label = texts.t(config.get("label_key", ""), config.get("default_label", template.offer_type)) - icon = config.get("icon", "📨") + label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type)) + icon = config.get('icon', '📨') - lines = [f"{icon} {template.name}", ""] - lines.append(texts.t("ADMIN_PROMO_OFFER_TYPE", "Тип: {label}").format(label=label)) - lines.append(texts.t("ADMIN_PROMO_OFFER_VALID", "Срок действия: {hours} ч").format(hours=template.valid_hours)) + lines = [f'{icon} {template.name}', ''] + lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label)) + lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours)) - if template.offer_type != "test_access": + if template.offer_type != 'test_access': lines.append( texts.t( - "ADMIN_PROMO_OFFER_DISCOUNT", - "Доп. скидка: {percent}% (суммируется с промогруппой)", + 'ADMIN_PROMO_OFFER_DISCOUNT', + 'Доп. скидка: {percent}% (суммируется с промогруппой)', ).format(percent=template.discount_percent) ) stack_note = texts.t( - "ADMIN_PROMO_OFFER_STACKABLE_NOTE", - "Скидка применяется один раз и добавляется к промогруппе.", + 'ADMIN_PROMO_OFFER_STACKABLE_NOTE', + 'Скидка применяется один раз и добавляется к промогруппе.', ) if stack_note: lines.append(stack_note) @@ -695,40 +703,36 @@ def _describe_offer( if active_hours > 0: lines.append( texts.t( - "ADMIN_PROMO_OFFER_ACTIVE_DURATION", - "Скидка после активации действует {hours} ч.", + 'ADMIN_PROMO_OFFER_ACTIVE_DURATION', + 'Скидка после активации действует {hours} ч.', ).format(hours=active_hours) ) else: duration = template.test_duration_hours or 0 - lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration)) + lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_DURATION', 'Доступ: {hours} ч').format(hours=duration)) squads = template.test_squad_uuids or [] if server_name: - lines.append( - texts.t("ADMIN_PROMO_OFFER_TEST_SQUAD_NAME", "Сервер: {name}").format(name=server_name) - ) + lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUAD_NAME', 'Сервер: {name}').format(name=server_name)) elif squads: lines.append( - texts.t("ADMIN_PROMO_OFFER_TEST_SQUADS", "Сквады: {squads}").format( - squads=", ".join(str(item) for item in squads) + texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS', 'Сквады: {squads}').format( + squads=', '.join(str(item) for item in squads) ) ) elif server_uuid: - lines.append( - texts.t("ADMIN_PROMO_OFFER_TEST_SQUADS", "Сквады: {squads}").format(squads=server_uuid) - ) + lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS', 'Сквады: {squads}').format(squads=server_uuid)) else: - lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY", "Сквады: не указаны")) + lines.append(texts.t('ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY', 'Сквады: не указаны')) - allowed_segments = config.get("allowed_segments", []) + allowed_segments = config.get('allowed_segments', []) if allowed_segments: segment_labels = [label for _, label in allowed_segments] - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_ALLOWED", "Доступные категории:") ) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_ALLOWED', 'Доступные категории:')) lines.extend(segment_labels) - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_PREVIEW", "Предпросмотр:")) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_PREVIEW', 'Предпросмотр:')) lines.append( _render_template_text( template, @@ -737,7 +741,7 @@ def _describe_offer( ) ) - return "\n".join(lines) + return '\n'.join(lines) @admin_required @@ -746,11 +750,11 @@ async def show_promo_offers_menu(callback: CallbackQuery, db_user: User, db: Asy await ensure_default_templates(db, created_by=db_user.id) templates = await list_promo_offer_templates(db) texts = get_texts(db_user.language) - header = texts.t("ADMIN_PROMO_OFFERS_TITLE", "🎯 Промо-предложения\n\nВыберите предложение для настройки:") + header = texts.t('ADMIN_PROMO_OFFERS_TITLE', '🎯 Промо-предложения\n\nВыберите предложение для настройки:') await callback.message.edit_text( header, reply_markup=_build_templates_keyboard(templates, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -759,14 +763,14 @@ async def show_promo_offers_menu(callback: CallbackQuery, db_user: User, db: Asy @error_handler async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): - await callback.answer("❌ Неверный идентификатор", show_alert=True) + await callback.answer('❌ Неверный идентификатор', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await state.update_data(selected_promo_offer=template.id) @@ -780,7 +784,7 @@ async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: A await callback.message.edit_text( description, reply_markup=_build_offer_detail_keyboard(template, db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -789,15 +793,14 @@ async def show_promo_offer_details(callback: CallbackQuery, db_user: User, db: A @error_handler async def show_promo_offer_logs(callback: CallbackQuery, db_user: User, db: AsyncSession): try: - if "_page_" in callback.data: - page = int(callback.data.split("_page_")[-1]) + if '_page_' in callback.data: + page = int(callback.data.split('_page_')[-1]) else: page = 1 except (ValueError, AttributeError): page = 1 - if page < 1: - page = 1 + page = max(page, 1) limit = PROMO_OFFER_LOGS_PAGE_LIMIT offset = (page - 1) * limit @@ -811,31 +814,31 @@ async def show_promo_offer_logs(callback: CallbackQuery, db_user: User, db: Asyn texts = get_texts(db_user.language) header = texts.t( - "ADMIN_PROMO_OFFER_LOGS_TITLE", - "📜 Лог операций промо-предложений", + 'ADMIN_PROMO_OFFER_LOGS_TITLE', + '📜 Лог операций промо-предложений', ) if logs: message_lines = [ header, texts.get( - "ADMIN_PROMO_OFFER_LOGS_PAGINATION", - "Страница {page}/{total}", + 'ADMIN_PROMO_OFFER_LOGS_PAGINATION', + 'Страница {page}/{total}', ).format(page=page, total=total_pages), - "", + '', ] for index, entry in enumerate(logs, start=offset + 1): message_lines.append(_format_promo_offer_log_entry(entry, index, texts)) - message_lines.append("") - message_text = "\n".join(message_lines).rstrip() + message_lines.append('') + message_text = '\n'.join(message_lines).rstrip() else: - message_text = "\n".join( + message_text = '\n'.join( [ header, - "", + '', texts.get( - "ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY", - "Записей пока нет.", + 'ADMIN_PROMO_OFFER_LOGS_EMPTY_BODY', + 'Записей пока нет.', ), ] ) @@ -844,7 +847,7 @@ async def show_promo_offer_logs(callback: CallbackQuery, db_user: User, db: Asyn await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -863,47 +866,47 @@ async def _prompt_edit(callback: CallbackQuery, state: FSMContext, template_id: @admin_required @error_handler async def prompt_edit_message(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_MESSAGE", "Введите новый текст предложения:") + prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_MESSAGE', 'Введите новый текст предложения:') await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_message) @admin_required @error_handler async def prompt_edit_button(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_BUTTON", "Введите новый текст кнопки:") + prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_BUTTON', 'Введите новый текст кнопки:') await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_button) @admin_required @error_handler async def prompt_edit_valid(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_VALID", "Укажите срок действия (в часах):") + prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_VALID', 'Укажите срок действия (в часах):') await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_valid_hours) @admin_required @error_handler async def prompt_edit_discount(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_DISCOUNT", "Введите размер скидки в процентах:") + prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_DISCOUNT', 'Введите размер скидки в процентах:') await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_discount) @admin_required @error_handler async def prompt_edit_active_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) prompt = texts.t( - "ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION", - "Введите срок действия активированной скидки (в часах):", + 'ADMIN_PROMO_OFFER_PROMPT_ACTIVE_DURATION', + 'Введите срок действия активированной скидки (в часах):', ) await _prompt_edit( callback, @@ -917,19 +920,19 @@ async def prompt_edit_active_duration(callback: CallbackQuery, db_user: User, db @admin_required @error_handler async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_DURATION", "Введите длительность тестового доступа (в часах):") + prompt = texts.t('ADMIN_PROMO_OFFER_PROMPT_DURATION', 'Введите длительность тестового доступа (в часах):') await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_test_duration) @admin_required @error_handler async def prompt_edit_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await state.update_data( @@ -960,9 +963,11 @@ async def _render_squad_selection( if total_count == 0: await callback.message.edit_text( - texts.t("ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE", "❌ Доступные серверы не найдены."), + texts.t('ADMIN_PROMO_OFFER_NO_SQUADS_AVAILABLE', '❌ Доступные серверы не найдены.'), reply_markup=InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_squad_back_{template.id}")]] + inline_keyboard=[ + [InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_squad_back_{template.id}')] + ] ), ) return @@ -979,67 +984,69 @@ async def _render_squad_selection( if selected_server: selected_server_name = selected_server.display_name - header = texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE", "🌍 Выберите сквад") + header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 Выберите сквад') if selected_server_name: current = texts.t( - "ADMIN_PROMO_OFFER_SELECTED_SQUAD", - "Текущий сквад: {name}", + 'ADMIN_PROMO_OFFER_SELECTED_SQUAD', + 'Текущий сквад: {name}', ).format(name=selected_server_name) elif selected_uuid: current = texts.t( - "ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID", - "Текущий сквад: {uuid}", + 'ADMIN_PROMO_OFFER_SELECTED_SQUAD_UUID', + 'Текущий сквад: {uuid}', ).format(uuid=selected_uuid) else: current = texts.t( - "ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY", - "Текущий сквад: не выбран", + 'ADMIN_PROMO_OFFER_SELECTED_SQUAD_EMPTY', + 'Текущий сквад: не выбран', ) hint = texts.t( - "ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT", - "Выберите сервер для тестового доступа из списка ниже.", + 'ADMIN_PROMO_OFFER_SELECT_SQUAD_HINT', + 'Выберите сервер для тестового доступа из списка ниже.', ) total_pages = (total_count + SQUADS_PAGE_LIMIT - 1) // SQUADS_PAGE_LIMIT or 1 page = max(1, min(page, total_pages)) - lines = [header, "", current, "", hint] + lines = [header, '', current, '', hint] if total_pages > 1: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE", - "Страница {page}/{total}", + 'ADMIN_PROMO_OFFER_SELECT_SQUAD_PAGE', + 'Страница {page}/{total}', ).format(page=page, total=total_pages) ) - text = "\n".join(lines) + text = '\n'.join(lines) - keyboard_rows: List[List[InlineKeyboardButton]] = [] + keyboard_rows: list[list[InlineKeyboardButton]] = [] for server in squads: - emoji = "✅" if server.squad_uuid == selected_uuid else ("⚪" if server.is_available else "🔒") - label = f"{emoji} {server.display_name}" - keyboard_rows.append([ - InlineKeyboardButton( - text=label, - callback_data=f"promo_offer_select_squad_{template.id}_{server.id}_{page}", - ) - ]) + emoji = '✅' if server.squad_uuid == selected_uuid else ('⚪' if server.is_available else '🔒') + label = f'{emoji} {server.display_name}' + keyboard_rows.append( + [ + InlineKeyboardButton( + text=label, + callback_data=f'promo_offer_select_squad_{template.id}_{server.id}_{page}', + ) + ] + ) if total_pages > 1: - nav_row: List[InlineKeyboardButton] = [] + nav_row: list[InlineKeyboardButton] = [] if page > 1: nav_row.append( InlineKeyboardButton( - text="⬅️", - callback_data=f"promo_offer_squad_page_{template.id}_{page - 1}", + text='⬅️', + callback_data=f'promo_offer_squad_page_{template.id}_{page - 1}', ) ) if page < total_pages: nav_row.append( InlineKeyboardButton( - text="➡️", - callback_data=f"promo_offer_squad_page_{template.id}_{page + 1}", + text='➡️', + callback_data=f'promo_offer_squad_page_{template.id}_{page + 1}', ) ) if nav_row: @@ -1047,12 +1054,12 @@ async def _render_squad_selection( action_row = [ InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR", "🗑 Очистить"), - callback_data=f"promo_offer_clear_squad_{template.id}_{page}", + text=texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEAR', '🗑 Очистить'), + callback_data=f'promo_offer_clear_squad_{template.id}_{page}', ), InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK", "↩️ Назад"), - callback_data=f"promo_offer_squad_back_{template.id}", + text=texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_BACK', '↩️ Назад'), + callback_data=f'promo_offer_squad_back_{template.id}', ), ] keyboard_rows.append(action_row) @@ -1060,7 +1067,7 @@ async def _render_squad_selection( await callback.message.edit_text( text, reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) @@ -1080,7 +1087,7 @@ async def _render_offer_details( await callback.message.edit_text( description, reply_markup=_build_offer_detail_keyboard(template, language), - parse_mode="HTML", + parse_mode='HTML', ) @@ -1092,59 +1099,59 @@ async def _handle_edit_field( field: str, ): data = await state.get_data() - template_id = data.get("selected_promo_offer") + template_id = data.get('selected_promo_offer') if not template_id: await _safe_delete_message(message) - await message.answer("❌ Не удалось определить предложение. Повторите действие.") + await message.answer('❌ Не удалось определить предложение. Повторите действие.') await state.clear() return template = await get_promo_offer_template_by_id(db, int(template_id)) if not template: await _safe_delete_message(message) - await message.answer("❌ Предложение не найдено.") + await message.answer('❌ Предложение не найдено.') await state.clear() return value = message.text.strip() try: - if field == "message_text": + if field == 'message_text': await update_promo_offer_template(db, template, message_text=value) - elif field == "button_text": + elif field == 'button_text': await update_promo_offer_template(db, template, button_text=value) - elif field == "valid_hours": + elif field == 'valid_hours': hours = max(1, int(value)) await update_promo_offer_template(db, template, valid_hours=hours) - elif field == "discount_percent": + elif field == 'discount_percent': percent = max(0, min(100, int(value))) await update_promo_offer_template(db, template, discount_percent=percent) - elif field == "active_discount_hours": + elif field == 'active_discount_hours': hours = max(1, int(value)) await update_promo_offer_template(db, template, active_discount_hours=hours) - elif field == "test_duration_hours": + elif field == 'test_duration_hours': hours = max(1, int(value)) await update_promo_offer_template(db, template, test_duration_hours=hours) - elif field == "test_squad_uuids": - if value.lower() in {"clear", "очистить"}: - squads: List[str] = [] + elif field == 'test_squad_uuids': + if value.lower() in {'clear', 'очистить'}: + squads: list[str] = [] else: - squads = [item for item in re.split(r"[\s,]+", value) if item] + squads = [item for item in re.split(r'[\s,]+', value) if item] await update_promo_offer_template(db, template, test_squad_uuids=squads) else: - raise ValueError("Unsupported field") + raise ValueError('Unsupported field') except ValueError: await _safe_delete_message(message) - await message.answer("❌ Некорректное значение. Попробуйте снова.") + await message.answer('❌ Некорректное значение. Попробуйте снова.') return - edit_message_id = data.get("promo_edit_message_id") - edit_chat_id = data.get("promo_edit_chat_id", message.chat.id) + edit_message_id = data.get('promo_edit_message_id') + edit_chat_id = data.get('promo_edit_chat_id', message.chat.id) await state.clear() updated_template = await get_promo_offer_template_by_id(db, template.id) if not updated_template: await _safe_delete_message(message) - await message.answer("❌ Предложение не найдено после обновления.") + await message.answer('❌ Предложение не найдено после обновления.') return squad_uuid, squad_name = await _resolve_template_squad(db, updated_template) @@ -1163,21 +1170,21 @@ async def _handle_edit_field( chat_id=edit_chat_id, message_id=edit_message_id, reply_markup=reply_markup, - parse_mode="HTML", + parse_mode='HTML', ) except TelegramBadRequest as exc: error_text = str(exc).lower() - if "there is no text in the message to edit" in error_text: - logger.debug("Сообщение промо без текста, пересылаем обновлённую версию") + if 'there is no text in the message to edit' in error_text: + logger.debug('Сообщение промо без текста, пересылаем обновлённую версию') try: await message.bot.delete_message(chat_id=edit_chat_id, message_id=edit_message_id) except TelegramBadRequest: - logger.debug("Не удалось удалить сообщение промо без текста") + logger.debug('Не удалось удалить сообщение промо без текста') else: - logger.warning("Не удалось обновить сообщение редактирования промо: %s", exc) - await message.answer(description, reply_markup=reply_markup, parse_mode="HTML") + logger.warning('Не удалось обновить сообщение редактирования промо: %s', exc) + await message.answer(description, reply_markup=reply_markup, parse_mode='HTML') else: - await message.answer(description, reply_markup=reply_markup, parse_mode="HTML") + await message.answer(description, reply_markup=reply_markup, parse_mode='HTML') await _safe_delete_message(message) @@ -1185,15 +1192,13 @@ async def _handle_edit_field( @admin_required @error_handler async def show_send_segments(callback: CallbackQuery, db_user: User, db: AsyncSession): - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return - await callback.message.edit_reply_markup( - reply_markup=_build_send_keyboard(template, db_user.language) - ) + await callback.message.edit_reply_markup(reply_markup=_build_send_keyboard(template, db_user.language)) await callback.answer() @@ -1201,31 +1206,30 @@ async def show_send_segments(callback: CallbackQuery, db_user: User, db: AsyncSe @error_handler async def show_send_user_list(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - prefix = "promo_offer_send_user_" + prefix = 'promo_offer_send_user_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, page_label, page_str = payload.split("_", 2) - if page_label != "page": - raise ValueError("invalid payload") + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, page_label, page_str = payload.split('_', 2) + if page_label != 'page': + raise ValueError('invalid payload') template_id = int(template_id_str) page = int(page_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return - if page < 1: - page = 1 + page = max(page, 1) await state.set_state(AdminStates.selecting_promo_offer_user) data = await state.get_data() - filter_data = data.get("promo_offer_user_filter") or {} - query = filter_data.get("query") if filter_data.get("template_id") == template_id else None + filter_data = data.get('promo_offer_user_filter') or {} + query = filter_data.get('query') if filter_data.get('template_id') == template_id else None await _render_send_user_list( bot=callback.bot, @@ -1245,14 +1249,14 @@ async def show_send_user_list(callback: CallbackQuery, db_user: User, db: AsyncS @error_handler async def prompt_send_user_search(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await _clear_promo_offer_search_prompt(state, callback.bot) @@ -1262,14 +1266,14 @@ async def prompt_send_user_search(callback: CallbackQuery, db_user: User, db: As texts = get_texts(db_user.language) prompt_message = await callback.message.answer( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT", - "Введите имя, username или ID пользователя для поиска:", + 'ADMIN_PROMO_OFFER_SEND_USER_SEARCH_PROMPT', + 'Введите имя, username или ID пользователя для поиска:', ) ) await state.update_data( promo_offer_user_search_prompt={ - "chat_id": prompt_message.chat.id, - "message_id": prompt_message.message_id, + 'chat_id': prompt_message.chat.id, + 'message_id': prompt_message.message_id, } ) await callback.answer() @@ -1279,14 +1283,14 @@ async def prompt_send_user_search(callback: CallbackQuery, db_user: User, db: As @error_handler async def reset_send_user_search(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await _clear_promo_offer_search_prompt(state, callback.bot) @@ -1309,22 +1313,22 @@ async def reset_send_user_search(callback: CallbackQuery, db_user: User, db: Asy @error_handler async def back_to_user_list(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await _clear_promo_offer_search_prompt(state, callback.bot) data = await state.get_data() - filter_data = data.get("promo_offer_user_filter") or {} - if filter_data.get("template_id") == template_id: - page = int(filter_data.get("page") or 1) - query = filter_data.get("query") + filter_data = data.get('promo_offer_user_filter') or {} + if filter_data.get('template_id') == template_id: + page = int(filter_data.get('page') or 1) + query = filter_data.get('query') else: page = 1 query = None @@ -1352,38 +1356,38 @@ async def process_send_user_search( state: FSMContext, db: AsyncSession, ): - query = (message.text or "").strip() + query = (message.text or '').strip() if not query: - await message.answer("❌ Введите корректный запрос для поиска") + await message.answer('❌ Введите корректный запрос для поиска') return data = await state.get_data() - template_id = data.get("promo_offer_user_search_template") + template_id = data.get('promo_offer_user_search_template') if not template_id: - await message.answer("❌ Не удалось определить промопредложение") + await message.answer('❌ Не удалось определить промопредложение') await _safe_delete_message(message) return try: template_id = int(template_id) except (TypeError, ValueError): - await message.answer("❌ Некорректные данные поиска") + await message.answer('❌ Некорректные данные поиска') await _safe_delete_message(message) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await message.answer("❌ Предложение не найдено") + await message.answer('❌ Предложение не найдено') await _safe_delete_message(message) return await _clear_promo_offer_search_prompt(state, message.bot) - message_info = data.get("promo_offer_user_message") or {} - chat_id = message_info.get("chat_id") - message_id = message_info.get("message_id") + message_info = data.get('promo_offer_user_message') or {} + chat_id = message_info.get('chat_id') + message_id = message_info.get('message_id') if not chat_id or not message_id: - placeholder = await message.answer("⏳ Обновляем список пользователей...") + placeholder = await message.answer('⏳ Обновляем список пользователей...') chat_id = placeholder.chat.id message_id = placeholder.message_id @@ -1416,25 +1420,25 @@ async def show_selected_user_details( state: FSMContext, ): try: - prefix = "promo_offer_send_user_select_" + prefix = 'promo_offer_send_user_select_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, user_id_str = payload.split("_", 1) + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, user_id_str = payload.split('_', 1) template_id = int(template_id_str) user_id = int(user_id_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return texts = get_texts(db_user.language) @@ -1446,58 +1450,56 @@ async def show_selected_user_details( name = html.escape(user.full_name or user.username or str(user.telegram_id or user.id)) username = html.escape(user.username) if user.username else None - balance = getattr(user, "balance_kopeks", 0) + balance = getattr(user, 'balance_kopeks', 0) lines = [ - texts.t("ADMIN_PROMO_OFFER_SEND_USER_PROFILE", "👤 {name}").format(name=name), - texts.t("ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM", "🆔 {telegram_id}").format( - telegram_id=user.telegram_id or "—" + texts.t('ADMIN_PROMO_OFFER_SEND_USER_PROFILE', '👤 {name}').format(name=name), + texts.t('ADMIN_PROMO_OFFER_SEND_USER_TELEGRAM', '🆔 {telegram_id}').format( + telegram_id=user.telegram_id or '—' ), ] if username: - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_USERNAME", "🔗 @{username}").format(username=username)) + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_USERNAME', '🔗 @{username}').format(username=username)) status_label = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN) - lines.append( - texts.t("ADMIN_PROMO_OFFER_SEND_USER_STATUS", "Статус: {status}").format(status=status_label) - ) + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_STATUS', 'Статус: {status}').format(status=status_label)) if balance: lines.append( - texts.t("ADMIN_PROMO_OFFER_SEND_USER_BALANCE", "Баланс: {amount}").format( + texts.t('ADMIN_PROMO_OFFER_SEND_USER_BALANCE', 'Баланс: {amount}').format( amount=settings.format_price(balance) ) ) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if subscription: - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION", "💳 Подписка")) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 Подписка')) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS", - "Статус: {status}", + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS', + 'Статус: {status}', ).format(status=subscription.status_display) ) end_date_text = ( format_datetime(subscription.end_date) if subscription.end_date else texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN", - "не указано", + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN', + 'не указано', ) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END", - "Истекает: {date}", + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END', + 'Истекает: {date}', ).format(date=end_date_text) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC", - "Трафик: {used}/{limit} ГБ", + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC', + 'Трафик: {used}/{limit} ГБ', ).format( used=subscription.traffic_used_gb or 0, limit=subscription.traffic_limit_gb or 0, @@ -1507,92 +1509,92 @@ async def show_selected_user_details( if connected: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS", - "Подключено сквадов: {count}", + 'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS', + 'Подключено сквадов: {count}', ).format(count=len(connected)) ) else: - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION", "💳 Подписка отсутствует")) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_NO_SUBSCRIPTION', '💳 Подписка отсутствует')) now = datetime.utcnow() percent = 0 try: - percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): percent = 0 - expires_at = getattr(user, "promo_offer_discount_expires_at", None) + expires_at = getattr(user, 'promo_offer_discount_expires_at', None) if percent > 0 and (not expires_at or expires_at > now): discount_line = texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT", - "💸 Активная скидка: {percent}%", + 'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT', + '💸 Активная скидка: {percent}%', ).format(percent=percent) if expires_at: date_text = format_datetime(expires_at) remaining_seconds = int((expires_at - now).total_seconds()) if remaining_seconds > 0: discount_line += texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT", - " (до {date}, осталось {time})", + 'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_LEFT', + ' (до {date}, осталось {time})', ).format(date=date_text, time=format_duration(remaining_seconds)) else: discount_line += texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL", - " (до {date})", + 'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_UNTIL', + ' (до {date})', ).format(date=date_text) - source = getattr(user, "promo_offer_discount_source", None) + source = getattr(user, 'promo_offer_discount_source', None) if source: discount_line += texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE", - " — источник: {source}", + 'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_SOURCE', + ' — источник: {source}', ).format(source=html.escape(str(source))) else: discount_line = texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE", - "💸 Активная скидка отсутствует", + 'ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_DISCOUNT_NONE', + '💸 Активная скидка отсутствует', ) - lines.append("") + lines.append('') lines.append(discount_line) config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) offer_label = texts.t( - config.get("label_key", ""), - config.get("default_label", template.offer_type), + config.get('label_key', ''), + config.get('default_label', template.offer_type), ) - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER", - "📨 Выбранное предложение", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_HEADER', + '📨 Выбранное предложение', ) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE", - "Тип: {label}", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TYPE', + 'Тип: {label}', ).format(label=offer_label) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID", - "Действует: {hours} ч.", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_VALID', + 'Действует: {hours} ч.', ).format(hours=template.valid_hours) ) - if template.offer_type == "test_access": + if template.offer_type == 'test_access': duration_hours = template.test_duration_hours or 0 lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION", - "Тестовый доступ: {hours} ч.", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_TEST_DURATION', + 'Тестовый доступ: {hours} ч.', ).format(hours=duration_hours) ) else: if template.discount_percent: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT", - "Скидка: {percent}%", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_DISCOUNT', + 'Скидка: {percent}%', ).format(percent=template.discount_percent) ) @@ -1600,18 +1602,18 @@ async def show_selected_user_details( if active_hours: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION", - "После активации действует {hours} ч.", + 'ADMIN_PROMO_OFFER_SEND_USER_TEMPLATE_ACTIVE_DURATION', + 'После активации действует {hours} ч.', ).format(hours=active_hours) ) active_offers = await list_discount_offers(db, user_id=user.id, is_active=True) if active_offers: - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS", "📨 Активные предложения:")) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_ACTIVE_OFFERS', '📨 Активные предложения:')) - template_map: Dict[int, PromoOfferTemplate] = {template.id: template} - template_ids_to_load: Set[int] = set() + template_map: dict[int, PromoOfferTemplate] = {template.id: template} + template_ids_to_load: set[int] = set() for offer in active_offers: offer_template_id = _extract_template_id_from_notification(offer.notification_type) @@ -1626,64 +1628,64 @@ async def show_selected_user_details( template_map[offer_template.id] = offer_template for offer in active_offers[:5]: - parts: List[str] = [] - if offer.effect_type == "test_access": - parts.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST", "Тестовый доступ")) + parts: list[str] = [] + if offer.effect_type == 'test_access': + parts.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_TEST', 'Тестовый доступ')) if offer.discount_percent: parts.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT", - "Скидка {percent}%", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_PERCENT', + 'Скидка {percent}%', ).format(percent=offer.discount_percent) ) if offer.bonus_amount_kopeks: parts.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS", - "Бонус {amount}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_BONUS', + 'Бонус {amount}', ).format(amount=settings.format_price(offer.bonus_amount_kopeks)) ) - description = ", ".join(parts) or offer.effect_type + description = ', '.join(parts) or offer.effect_type lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER", - "• {description}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_ITEM_HEADER', + '• {description}', ).format(description=description) ) expires_text = ( format_datetime(offer.expires_at) if offer.expires_at - else texts.t("ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY", "без срока") + else texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока') ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES", - " Истекает: {expires}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_EXPIRES', + ' Истекает: {expires}', ).format(expires=expires_text) ) status_label = texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED", - "✅ Принято", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_ACCEPTED', + '✅ Принято', ) if not offer.claimed_at: status_label = texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING", - "⏳ Не принято", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS_PENDING', + '⏳ Не принято', ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS", - " Статус: {status}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATUS', + ' Статус: {status}', ).format(status=status_label) ) time_left = _format_offer_remaining(offer, texts) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT", - " Осталось: {time}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_TIME_LEFT', + ' Осталось: {time}', ).format(time=time_left) ) @@ -1696,8 +1698,8 @@ async def show_selected_user_details( if active_hours: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION", - " После активации: {duration}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_ACTIVE_DURATION', + ' После активации: {duration}', ).format(duration=format_duration(active_hours * 3600)) ) @@ -1706,37 +1708,34 @@ async def show_selected_user_details( if total_seconds > 0: lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION", - " Всего действует: {duration}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_TOTAL_DURATION', + ' Всего действует: {duration}', ).format(duration=format_duration(total_seconds)) ) - lines.append("") - if lines[-1] == "": + lines.append('') + if lines[-1] == '': lines.pop() else: - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS", - "📨 Активных предложений нет", + 'ADMIN_PROMO_OFFER_SEND_USER_NO_ACTIVE_OFFERS', + '📨 Активных предложений нет', ) ) - stats_stmt = ( - select( - func.count(DiscountOffer.id), - func.sum( - case( - (DiscountOffer.claimed_at.isnot(None), 1), - else_=0, - ) - ), - ) - .where( - DiscountOffer.user_id == user.id, - DiscountOffer.notification_type == f"promo_template_{template.id}", - ) + stats_stmt = select( + func.count(DiscountOffer.id), + func.sum( + case( + (DiscountOffer.claimed_at.isnot(None), 1), + else_=0, + ) + ), + ).where( + DiscountOffer.user_id == user.id, + DiscountOffer.notification_type == f'promo_template_{template.id}', ) stats_result = await db.execute(stats_stmt) total_offers, accepted_offers = stats_result.one() @@ -1745,35 +1744,35 @@ async def show_selected_user_details( pending_offers = max(total_offers - accepted_offers, 0) if total_offers > 0: - lines.append("") + lines.append('') lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER", - "📊 Статистика предложений", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_HEADER', + '📊 Статистика предложений', ) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL", - "Всего отправлено: {count}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_TOTAL', + 'Всего отправлено: {count}', ).format(count=total_offers) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED", - "Принято: {count}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACCEPTED', + 'Принято: {count}', ).format(count=accepted_offers) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING", - "Не принято: {count}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_PENDING', + 'Не принято: {count}', ).format(count=pending_offers) ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE", - "Активно сейчас: {count}", + 'ADMIN_PROMO_OFFER_SEND_USER_OFFER_STATS_ACTIVE', + 'Активно сейчас: {count}', ).format(count=len(active_offers)) ) @@ -1784,7 +1783,7 @@ async def show_selected_user_details( .options(selectinload(SubscriptionTemporaryAccess.offer)) .where( SubscriptionTemporaryAccess.subscription_id == subscription.id, - SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.is_active == True, SubscriptionTemporaryAccess.expires_at > now, ) .order_by(SubscriptionTemporaryAccess.expires_at.desc()) @@ -1794,19 +1793,19 @@ async def show_selected_user_details( accesses = [] if accesses: - lines.append("") - lines.append(texts.t("ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS", "🧪 Активные тестовые доступы:")) + lines.append('') + lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS', '🧪 Активные тестовые доступы:')) for entry in accesses[:5]: - squad_label = html.escape(entry.squad_uuid or "—") + squad_label = html.escape(entry.squad_uuid or '—') expires_text = ( format_datetime(entry.expires_at) if entry.expires_at - else texts.t("ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY", "без срока") + else texts.t('ADMIN_PROMO_OFFER_SEND_USER_OFFER_NO_EXPIRY', 'без срока') ) lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM", - "• {squad} (до {expires})", + 'ADMIN_PROMO_OFFER_SEND_USER_TEST_ACCESS_ITEM', + '• {squad} (до {expires})', ).format(squad=squad_label, expires=expires_text) ) @@ -1814,87 +1813,87 @@ async def show_selected_user_details( [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON", - "📬 Отправить предложение", + 'ADMIN_PROMO_OFFER_SEND_USER_SEND_BUTTON', + '📬 Отправить предложение', ), - callback_data=f"promo_offer_send_user_confirm_{template_id}_{user.id}", + callback_data=f'promo_offer_send_user_confirm_{template_id}_{user.id}', ) ], [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST", - "⬅️ К списку пользователей", + 'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST', + '⬅️ К списку пользователей', ), - callback_data=f"promo_offer_send_user_back_{template_id}", + callback_data=f'promo_offer_send_user_back_{template_id}', ) ], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_offer_{template_id}")], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'promo_offer_{template_id}')], ] await callback.message.edit_text( - "\n".join(lines), + '\n'.join(lines), reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(AdminStates.selecting_promo_offer_user) await state.update_data( promo_offer_selected_user=user.id, promo_offer_user_message={ - "chat_id": callback.message.chat.id, - "message_id": callback.message.message_id, + 'chat_id': callback.message.chat.id, + 'message_id': callback.message.message_id, }, ) await callback.answer() -def _build_connect_button_rows(user: User, texts) -> List[List[InlineKeyboardButton]]: - subscription = getattr(user, "subscription", None) +def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]: + subscription = getattr(user, 'subscription', None) if not subscription: return [] - button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться") + button_text = texts.t('CONNECT_BUTTON', '🔗 Подключиться') subscription_link = get_display_subscription_link(subscription) connect_mode = settings.CONNECT_BUTTON_MODE def _fallback_button() -> InlineKeyboardButton: - return InlineKeyboardButton(text=button_text, callback_data="subscription_connect") + return InlineKeyboardButton(text=button_text, callback_data='subscription_connect') - rows: List[List[InlineKeyboardButton]] = [] + rows: list[list[InlineKeyboardButton]] = [] - if connect_mode == "miniapp_subscription": + if connect_mode == 'miniapp_subscription': if subscription_link: - rows.append([ - InlineKeyboardButton( - text=button_text, - web_app=types.WebAppInfo(url=subscription_link), - ) - ]) + rows.append( + [ + InlineKeyboardButton( + text=button_text, + web_app=types.WebAppInfo(url=subscription_link), + ) + ] + ) else: rows.append([_fallback_button()]) - elif connect_mode == "miniapp_custom": + elif connect_mode == 'miniapp_custom': if settings.MINIAPP_CUSTOM_URL: - rows.append([ - InlineKeyboardButton( - text=button_text, - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), - ) - ]) + rows.append( + [ + InlineKeyboardButton( + text=button_text, + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), + ) + ] + ) else: rows.append([_fallback_button()]) - elif connect_mode == "link": + elif connect_mode == 'link': if subscription_link: - rows.append([ - InlineKeyboardButton(text=button_text, url=subscription_link) - ]) + rows.append([InlineKeyboardButton(text=button_text, url=subscription_link)]) else: rows.append([_fallback_button()]) - elif connect_mode == "happ_cryptolink": + elif connect_mode == 'happ_cryptolink': if subscription_link: - rows.append([ - InlineKeyboardButton(text=button_text, callback_data="open_subscription_link") - ]) + rows.append([InlineKeyboardButton(text=button_text, callback_data='open_subscription_link')]) else: rows.append([_fallback_button()]) else: @@ -1914,11 +1913,11 @@ async def _send_offer_to_users( db: AsyncSession, users: Sequence[User], *, - squad_name: Optional[str], + squad_name: str | None, effect_type: str, -) -> Tuple[int, int]: +) -> tuple[int, int]: from app.database.database import AsyncSessionLocal - + sent = 0 failed = 0 @@ -1929,7 +1928,7 @@ async def _send_offer_to_users( """Отправляет одно предложение с семафором ограничения""" # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug("Пропуск email-пользователя %s при рассылке промо", user.id) + logger.debug('Пропуск email-пользователя %s при рассылке промо', user.id) return False async with semaphore: @@ -1939,37 +1938,41 @@ async def _send_offer_to_users( offer_record = await upsert_discount_offer( new_db, user_id=user.id, - subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None, - notification_type=f"promo_template_{template.id}", + subscription_id=getattr(user, 'subscription', None).id + if getattr(user, 'subscription', None) + else None, + notification_type=f'promo_template_{template.id}', discount_percent=template.discount_percent, bonus_amount_kopeks=0, valid_hours=template.valid_hours, effect_type=effect_type, extra_data={ - "template_id": template.id, - "offer_type": template.offer_type, - "test_duration_hours": template.test_duration_hours, - "test_squad_uuids": template.test_squad_uuids, - "active_discount_hours": template.active_discount_hours, + 'template_id': template.id, + 'offer_type': template.offer_type, + 'test_duration_hours': template.test_duration_hours, + 'test_squad_uuids': template.test_squad_uuids, + 'active_discount_hours': template.active_discount_hours, }, ) user_texts = get_texts(user.language or db_user.language) - keyboard_rows: List[List[InlineKeyboardButton]] = [ + keyboard_rows: list[list[InlineKeyboardButton]] = [ [ build_miniapp_or_callback_button( text=template.button_text, - callback_data=f"claim_discount_{offer_record.id}", + callback_data=f'claim_discount_{offer_record.id}', ) ] ] - keyboard_rows.append([ - InlineKeyboardButton( - text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"), - callback_data="promo_offer_close", - ) - ]) + keyboard_rows.append( + [ + InlineKeyboardButton( + text=user_texts.t('PROMO_OFFER_CLOSE', '❌ Закрыть'), + callback_data='promo_offer_close', + ) + ] + ) keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) @@ -1982,20 +1985,20 @@ async def _send_offer_to_users( chat_id=user.telegram_id, text=message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id or user.id, exc) + logger.warning('Не удалось отправить предложение пользователю %s: %s', user.telegram_id or user.id, exc) return False except Exception as exc: # pragma: no cover - defensive logging - logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id or user.id, exc) + logger.error('Ошибка рассылки промо предложения пользователю %s: %s', user.telegram_id or user.id, exc) return False # Отправляем предложения пакетами для эффективности batch_size = 100 for i in range(0, len(users), batch_size): - batch = users[i:i + batch_size] + batch = users[i : i + batch_size] tasks = [send_single_offer(user) for user in batch] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -2018,38 +2021,38 @@ async def _send_offer_to_users( @error_handler async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: AsyncSession): try: - prefix = "promo_offer_send_" + prefix = 'promo_offer_send_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - data = callback.data[len(prefix):] - template_id_str, segment = data.split("_", 1) + raise ValueError('invalid prefix') + data = callback.data[len(prefix) :] + template_id_str, segment = data.split('_', 1) template_id = int(template_id_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) squad_uuid, squad_name = await _resolve_template_squad(db, template) - allowed_segments = {seg for seg, _ in config.get("allowed_segments", [])} + allowed_segments = {seg for seg, _ in config.get('allowed_segments', [])} if segment not in allowed_segments: - await callback.answer("⚠️ Нельзя отправить это предложение выбранной категории", show_alert=True) + await callback.answer('⚠️ Нельзя отправить это предложение выбранной категории', show_alert=True) return texts = get_texts(db_user.language) - await callback.answer(texts.t("ADMIN_PROMO_OFFER_SENDING", "Начинаем рассылку..."), show_alert=True) + await callback.answer(texts.t('ADMIN_PROMO_OFFER_SENDING', 'Начинаем рассылку...'), show_alert=True) users = await get_users_for_promo_segment(db, segment) initial_count = len(users) - if template.offer_type == "test_access" and squad_uuid: - filtered_users: List[User] = [] + if template.offer_type == 'test_access' and squad_uuid: + filtered_users: list[User] = [] for user in users: - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) connected = set(subscription.connected_squads or []) if subscription else set() if squad_uuid in connected: continue @@ -2057,11 +2060,11 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn users = filtered_users if not users: - await callback.message.answer(texts.t("ADMIN_PROMO_OFFER_NO_USERS", "Подходящих пользователей не найдено.")) + await callback.message.answer(texts.t('ADMIN_PROMO_OFFER_NO_USERS', 'Подходящих пользователей не найдено.')) return skipped = initial_count - len(users) - effect_type = config.get("effect_type", "percent_discount") + effect_type = config.get('effect_type', 'percent_discount') sent, failed = await _send_offer_to_users( callback.bot, template, @@ -2073,36 +2076,40 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn ) summary = texts.t( - "ADMIN_PROMO_OFFER_RESULT", - "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}", + 'ADMIN_PROMO_OFFER_RESULT', + '📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}', ).format(sent=sent, failed=failed) if skipped > 0: - summary += "\n" + texts.t( - "ADMIN_PROMO_OFFER_SKIPPED", - "Пропущено: {skipped} (уже есть доступ)", + summary += '\n' + texts.t( + 'ADMIN_PROMO_OFFER_SKIPPED', + 'Пропущено: {skipped} (уже есть доступ)', ).format(skipped=skipped) refreshed = await get_promo_offer_template_by_id(db, template.id) - result_keyboard_rows: List[List[InlineKeyboardButton]] = [] + result_keyboard_rows: list[list[InlineKeyboardButton]] = [] if refreshed: - result_keyboard_rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE", "↩️ К предложению"), - callback_data=f"promo_offer_{refreshed.id}", - ) - ]) - - result_keyboard_rows.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_BACK_TO_LIST", "⬅️ К промопредложениям"), - callback_data="admin_promo_offers", + result_keyboard_rows.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE', '↩️ К предложению'), + callback_data=f'promo_offer_{refreshed.id}', + ) + ] ) - ]) + + result_keyboard_rows.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_LIST', '⬅️ К промопредложениям'), + callback_data='admin_promo_offers', + ) + ] + ) await callback.message.edit_text( summary, reply_markup=InlineKeyboardMarkup(inline_keyboard=result_keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) @@ -2110,38 +2117,38 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn @error_handler async def send_offer_to_user(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - prefix = "promo_offer_send_user_confirm_" + prefix = 'promo_offer_send_user_confirm_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, user_id_str = payload.split("_", 1) + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, user_id_str = payload.split('_', 1) template_id = int(template_id_str) user_id = int(user_id_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return config = OFFER_TYPE_CONFIG.get(template.offer_type, {}) squad_uuid, squad_name = await _resolve_template_squad(db, template) - effect_type = config.get("effect_type", "percent_discount") + effect_type = config.get('effect_type', 'percent_discount') texts = get_texts(db_user.language) - await callback.answer(texts.t("ADMIN_PROMO_OFFER_SENDING", "Начинаем рассылку..."), show_alert=True) + await callback.answer(texts.t('ADMIN_PROMO_OFFER_SENDING', 'Начинаем рассылку...'), show_alert=True) - users_to_send: List[User] = [user] + users_to_send: list[User] = [user] skipped = 0 - if template.offer_type == "test_access" and squad_uuid: - subscription = getattr(user, "subscription", None) + if template.offer_type == 'test_access' and squad_uuid: + subscription = getattr(user, 'subscription', None) connected = set(subscription.connected_squads or []) if subscription else set() if squad_uuid in connected: users_to_send = [] @@ -2163,28 +2170,28 @@ async def send_offer_to_user(callback: CallbackQuery, db_user: User, db: AsyncSe display_name = html.escape(user.full_name or user.username or str(user.telegram_id or user.id)) summary_lines = [ texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE", - "📬 Отправка пользователю {name}", + 'ADMIN_PROMO_OFFER_SEND_USER_SUMMARY_TITLE', + '📬 Отправка пользователю {name}', ).format(name=display_name), texts.t( - "ADMIN_PROMO_OFFER_RESULT", - "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}", + 'ADMIN_PROMO_OFFER_RESULT', + '📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}', ).format(sent=sent, failed=failed), ] if skipped: summary_lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_SKIPPED", - "Пропущено: {skipped} (уже есть доступ)", + 'ADMIN_PROMO_OFFER_SEND_USER_SKIPPED', + 'Пропущено: {skipped} (уже есть доступ)', ).format(skipped=skipped) ) if not users_to_send and not skipped: summary_lines.append( texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT", - "Отправка не выполнена", + 'ADMIN_PROMO_OFFER_SEND_USER_EMPTY_RESULT', + 'Отправка не выполнена', ) ) @@ -2192,87 +2199,87 @@ async def send_offer_to_user(callback: CallbackQuery, db_user: User, db: AsyncSe [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE", - "👤 К профилю пользователя", + 'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_PROFILE', + '👤 К профилю пользователя', ), - callback_data=f"promo_offer_send_user_select_{template.id}_{user.id}", + callback_data=f'promo_offer_send_user_select_{template.id}_{user.id}', ) ], [ InlineKeyboardButton( text=texts.t( - "ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST", - "⬅️ К списку пользователей", + 'ADMIN_PROMO_OFFER_SEND_USER_BACK_TO_LIST', + '⬅️ К списку пользователей', ), - callback_data=f"promo_offer_send_user_back_{template.id}", + callback_data=f'promo_offer_send_user_back_{template.id}', ) ], [ InlineKeyboardButton( - text=texts.t("ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE", "↩️ К предложению"), - callback_data=f"promo_offer_{template.id}", + text=texts.t('ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE', '↩️ К предложению'), + callback_data=f'promo_offer_{template.id}', ) ], ] await callback.message.edit_text( - "\n".join(summary_lines), + '\n'.join(summary_lines), reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_rows), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(AdminStates.selecting_promo_offer_user) await state.update_data( promo_offer_selected_user=user.id, promo_offer_user_message={ - "chat_id": callback.message.chat.id, - "message_id": callback.message.message_id, + 'chat_id': callback.message.chat.id, + 'message_id': callback.message.message_id, }, ) async def process_edit_message_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "message_text") + await _handle_edit_field(message, state, db, db_user, 'message_text') async def process_edit_button_text(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "button_text") + await _handle_edit_field(message, state, db, db_user, 'button_text') async def process_edit_valid_hours(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "valid_hours") + await _handle_edit_field(message, state, db, db_user, 'valid_hours') async def process_edit_active_duration_hours(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "active_discount_hours") + await _handle_edit_field(message, state, db, db_user, 'active_discount_hours') async def process_edit_discount_percent(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "discount_percent") + await _handle_edit_field(message, state, db, db_user, 'discount_percent') async def process_edit_test_duration(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "test_duration_hours") + await _handle_edit_field(message, state, db, db_user, 'test_duration_hours') @admin_required @error_handler async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - prefix = "promo_offer_squad_page_" + prefix = 'promo_offer_squad_page_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, page_str = payload.split("_", 1) + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, page_str = payload.split('_', 1) template_id = int(template_id_str) page = int(page_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await state.update_data(selected_promo_offer=template.id) @@ -2284,29 +2291,29 @@ async def paginate_squad_selection(callback: CallbackQuery, db_user: User, db: A @error_handler async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - prefix = "promo_offer_select_squad_" + prefix = 'promo_offer_select_squad_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, server_id_str, page_str = payload.split("_", 2) + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, server_id_str, page_str = payload.split('_', 2) template_id = int(template_id_str) server_id = int(server_id_str) page = int(page_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return server = await get_server_squad_by_id(db, server_id) if not server: await callback.answer( get_texts(db_user.language).t( - "ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND", - "❌ Сервер не найден", + 'ADMIN_PROMO_OFFER_SELECT_SQUAD_NOT_FOUND', + '❌ Сервер не найден', ), show_alert=True, ) @@ -2318,7 +2325,7 @@ async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: await state.update_data(selected_promo_offer=updated.id) texts = get_texts(db_user.language) - await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED", "✅ Сквад обновлён")) + await callback.answer(texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_UPDATED', '✅ Сквад обновлён')) if updated: await _render_offer_details(callback, updated, db_user.language, db) @@ -2330,20 +2337,20 @@ async def select_squad_for_template(callback: CallbackQuery, db_user: User, db: @error_handler async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - prefix = "promo_offer_clear_squad_" + prefix = 'promo_offer_clear_squad_' if not callback.data.startswith(prefix): - raise ValueError("invalid prefix") - payload = callback.data[len(prefix):] - template_id_str, page_str = payload.split("_", 1) + raise ValueError('invalid prefix') + payload = callback.data[len(prefix) :] + template_id_str, page_str = payload.split('_', 1) template_id = int(template_id_str) page = int(page_str) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await update_promo_offer_template(db, template, test_squad_uuids=[]) @@ -2352,7 +2359,7 @@ async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: A await state.update_data(selected_promo_offer=updated.id) texts = get_texts(db_user.language) - await callback.answer(texts.t("ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED", "✅ Сквад очищен")) + await callback.answer(texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_CLEARED', '✅ Сквад очищен')) if updated: await _render_squad_selection(callback, updated, db, db_user.language, page=page) @@ -2364,14 +2371,14 @@ async def clear_squad_for_template(callback: CallbackQuery, db_user: User, db: A @error_handler async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): try: - template_id = int(callback.data.split("_")[-1]) + template_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return template = await get_promo_offer_template_by_id(db, template_id) if not template: - await callback.answer("❌ Предложение не найдено", show_alert=True) + await callback.answer('❌ Предложение не найдено', show_alert=True) return await state.update_data(selected_promo_offer=template.id) @@ -2380,28 +2387,28 @@ async def back_to_offer_from_squads(callback: CallbackQuery, db_user: User, db: def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_promo_offers_menu, F.data == "admin_promo_offers") - dp.callback_query.register(prompt_edit_message, F.data.startswith("promo_offer_edit_message_")) - dp.callback_query.register(prompt_edit_button, F.data.startswith("promo_offer_edit_button_")) - dp.callback_query.register(prompt_edit_valid, F.data.startswith("promo_offer_edit_valid_")) - dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_")) - dp.callback_query.register(prompt_edit_active_duration, F.data.startswith("promo_offer_edit_active_")) - dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_")) - dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_")) - dp.callback_query.register(paginate_squad_selection, F.data.startswith("promo_offer_squad_page_")) - dp.callback_query.register(select_squad_for_template, F.data.startswith("promo_offer_select_squad_")) - dp.callback_query.register(clear_squad_for_template, F.data.startswith("promo_offer_clear_squad_")) - dp.callback_query.register(back_to_offer_from_squads, F.data.startswith("promo_offer_squad_back_")) - dp.callback_query.register(show_send_user_list, F.data.regexp(r"^promo_offer_send_user_\d+_page_\d+$")) - dp.callback_query.register(show_selected_user_details, F.data.startswith("promo_offer_send_user_select_")) - dp.callback_query.register(prompt_send_user_search, F.data.startswith("promo_offer_send_user_search_")) - dp.callback_query.register(reset_send_user_search, F.data.startswith("promo_offer_send_user_reset_")) - dp.callback_query.register(back_to_user_list, F.data.startswith("promo_offer_send_user_back_")) - dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_")) - dp.callback_query.register(send_offer_to_user, F.data.startswith("promo_offer_send_user_confirm_")) - dp.callback_query.register(send_offer_to_segment, F.data.startswith("promo_offer_send_")) - dp.callback_query.register(show_promo_offer_logs, F.data.regexp(r"^promo_offer_logs_page_\d+$")) - dp.callback_query.register(show_promo_offer_details, F.data.startswith("promo_offer_")) + dp.callback_query.register(show_promo_offers_menu, F.data == 'admin_promo_offers') + dp.callback_query.register(prompt_edit_message, F.data.startswith('promo_offer_edit_message_')) + dp.callback_query.register(prompt_edit_button, F.data.startswith('promo_offer_edit_button_')) + dp.callback_query.register(prompt_edit_valid, F.data.startswith('promo_offer_edit_valid_')) + dp.callback_query.register(prompt_edit_discount, F.data.startswith('promo_offer_edit_discount_')) + dp.callback_query.register(prompt_edit_active_duration, F.data.startswith('promo_offer_edit_active_')) + dp.callback_query.register(prompt_edit_duration, F.data.startswith('promo_offer_edit_duration_')) + dp.callback_query.register(prompt_edit_squads, F.data.startswith('promo_offer_edit_squads_')) + dp.callback_query.register(paginate_squad_selection, F.data.startswith('promo_offer_squad_page_')) + dp.callback_query.register(select_squad_for_template, F.data.startswith('promo_offer_select_squad_')) + dp.callback_query.register(clear_squad_for_template, F.data.startswith('promo_offer_clear_squad_')) + dp.callback_query.register(back_to_offer_from_squads, F.data.startswith('promo_offer_squad_back_')) + dp.callback_query.register(show_send_user_list, F.data.regexp(r'^promo_offer_send_user_\d+_page_\d+$')) + dp.callback_query.register(show_selected_user_details, F.data.startswith('promo_offer_send_user_select_')) + dp.callback_query.register(prompt_send_user_search, F.data.startswith('promo_offer_send_user_search_')) + dp.callback_query.register(reset_send_user_search, F.data.startswith('promo_offer_send_user_reset_')) + dp.callback_query.register(back_to_user_list, F.data.startswith('promo_offer_send_user_back_')) + dp.callback_query.register(show_send_segments, F.data.startswith('promo_offer_send_menu_')) + dp.callback_query.register(send_offer_to_user, F.data.startswith('promo_offer_send_user_confirm_')) + dp.callback_query.register(send_offer_to_segment, F.data.startswith('promo_offer_send_')) + dp.callback_query.register(show_promo_offer_logs, F.data.regexp(r'^promo_offer_logs_page_\d+$')) + dp.callback_query.register(show_promo_offer_details, F.data.startswith('promo_offer_')) dp.message.register(process_edit_message_text, AdminStates.editing_promo_offer_message) dp.message.register(process_edit_button_text, AdminStates.editing_promo_offer_button) diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index 11e340c7..62390a5b 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -1,39 +1,42 @@ import logging from datetime import datetime, timedelta -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import AdminStates -from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User -from app.keyboards.admin import ( - get_admin_promocodes_keyboard, get_promocode_type_keyboard, - get_admin_pagination_keyboard, get_confirmation_keyboard -) -from app.localization.texts import get_texts -from app.database.crud.promocode import ( - get_promocodes_list, get_promocodes_count, create_promocode, - get_promocode_statistics, get_promocode_by_code, update_promocode, - delete_promocode, get_promocode_by_id -) from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts +from app.database.crud.promocode import ( + create_promocode, + delete_promocode, + get_promocode_by_code, + get_promocode_by_id, + get_promocode_statistics, + get_promocodes_count, + get_promocodes_list, + update_promocode, +) +from app.database.models import PromoCodeType, User +from app.keyboards.admin import ( + get_admin_pagination_keyboard, + get_admin_promocodes_keyboard, + get_promocode_type_keyboard, +) +from app.states import AdminStates from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_promocodes_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promocodes_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): total_codes = await get_promocodes_count(db) active_codes = await get_promocodes_count(db, is_active=True) - + text = f""" 🎫 Управление промокодами @@ -44,106 +47,88 @@ async def show_promocodes_menu( Выберите действие: """ - - await callback.message.edit_text( - text, - reply_markup=get_admin_promocodes_keyboard(db_user.language) - ) + + await callback.message.edit_text(text, reply_markup=get_admin_promocodes_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler -async def show_promocodes_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - page: int = 1 -): +async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1): limit = 10 offset = (page - 1) * limit - + promocodes = await get_promocodes_list(db, offset=offset, limit=limit) total_count = await get_promocodes_count(db) total_pages = (total_count + limit - 1) // limit - + if not promocodes: await callback.message.edit_text( - "🎫 Промокоды не найдены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] - ]) + '🎫 Промокоды не найдены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_promocodes')]] + ), ) await callback.answer() return - - text = f"🎫 Список промокодов (стр. {page}/{total_pages})\n\n" - keyboard = [] - - for promo in promocodes: - status_emoji = "✅" if promo.is_active else "❌" - type_emoji = { - "balance": "💰", - "subscription_days": "📅", - "trial_subscription": "🎁", - "promo_group": "🏷️", - "discount": "💸" - }.get(promo.type, "🎫") - text += f"{status_emoji} {type_emoji} {promo.code}\n" - text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n" + text = f'🎫 Список промокодов (стр. {page}/{total_pages})\n\n' + keyboard = [] + + for promo in promocodes: + status_emoji = '✅' if promo.is_active else '❌' + type_emoji = { + 'balance': '💰', + 'subscription_days': '📅', + 'trial_subscription': '🎁', + 'promo_group': '🏷️', + 'discount': '💸', + }.get(promo.type, '🎫') + + text += f'{status_emoji} {type_emoji} {promo.code}\n' + text += f'📊 Использований: {promo.current_uses}/{promo.max_uses}\n' if promo.type == PromoCodeType.BALANCE.value: - text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" + text += f'💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n' elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: - text += f"📅 Дней: {promo.subscription_days}\n" + text += f'📅 Дней: {promo.subscription_days}\n' elif promo.type == PromoCodeType.PROMO_GROUP.value: if promo.promo_group: - text += f"🏷️ Промогруппа: {promo.promo_group.name}\n" + text += f'🏷️ Промогруппа: {promo.promo_group.name}\n' elif promo.type == PromoCodeType.DISCOUNT.value: discount_hours = promo.subscription_days if discount_hours > 0: - text += f"💸 Скидка: {promo.balance_bonus_kopeks}% ({discount_hours} ч.)\n" + text += f'💸 Скидка: {promo.balance_bonus_kopeks}% ({discount_hours} ч.)\n' else: - text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (до покупки)\n" + text += f'💸 Скидка: {promo.balance_bonus_kopeks}% (до покупки)\n' if promo.valid_until: - text += f"⏰ До: {format_datetime(promo.valid_until)}\n" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"🎫 {promo.code}", - callback_data=f"promo_manage_{promo.id}" - ) - ]) - - text += "\n" - + text += f'⏰ До: {format_datetime(promo.valid_until)}\n' + + keyboard.append([types.InlineKeyboardButton(text=f'🎫 {promo.code}', callback_data=f'promo_manage_{promo.id}')]) + + text += '\n' + if total_pages > 1: pagination_row = get_admin_pagination_keyboard( - page, total_pages, "admin_promo_list", "admin_promocodes", db_user.language + page, total_pages, 'admin_promo_list', 'admin_promocodes', db_user.language ).inline_keyboard[0] keyboard.append(pagination_row) - - keyboard.extend([ - [types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='➕ Создать', callback_data='admin_promo_create')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_promocodes')], + ] ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_promocodes_list_page( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promocodes_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Обработчик пагинации списка промокодов.""" try: page = int(callback.data.split('_')[-1]) @@ -154,26 +139,22 @@ async def show_promocodes_list_page( @admin_required @error_handler -async def show_promocode_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promocode_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): promo_id = int(callback.data.split('_')[-1]) promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - - status_emoji = "✅" if promo.is_active else "❌" + + status_emoji = '✅' if promo.is_active else '❌' type_emoji = { - "balance": "💰", - "subscription_days": "📅", - "trial_subscription": "🎁", - "promo_group": "🏷️", - "discount": "💸" - }.get(promo.type, "🎫") + 'balance': '💰', + 'subscription_days': '📅', + 'trial_subscription': '🎁', + 'promo_group': '🏷️', + 'discount': '💸', + }.get(promo.type, '🎫') text = f""" 🎫 Управление промокодом @@ -184,172 +165,114 @@ async def show_promocode_management( """ if promo.type == PromoCodeType.BALANCE.value: - text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" + text += f'💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n' elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value: - text += f"📅 Дней: {promo.subscription_days}\n" + text += f'📅 Дней: {promo.subscription_days}\n' elif promo.type == PromoCodeType.PROMO_GROUP.value: if promo.promo_group: - text += f"🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n" + text += f'🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n' elif promo.promo_group_id: - text += f"🏷️ Промогруппа ID: {promo.promo_group_id} (не найдена)\n" + text += f'🏷️ Промогруппа ID: {promo.promo_group_id} (не найдена)\n' elif promo.type == PromoCodeType.DISCOUNT.value: discount_hours = promo.subscription_days if discount_hours > 0: - text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (срок: {discount_hours} ч.)\n" + text += f'💸 Скидка: {promo.balance_bonus_kopeks}% (срок: {discount_hours} ч.)\n' else: - text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (до первой покупки)\n" + text += f'💸 Скидка: {promo.balance_bonus_kopeks}% (до первой покупки)\n' if promo.valid_until: - text += f"⏰ Действует до: {format_datetime(promo.valid_until)}\n" + text += f'⏰ Действует до: {format_datetime(promo.valid_until)}\n' first_purchase_only = getattr(promo, 'first_purchase_only', False) - first_purchase_emoji = "✅" if first_purchase_only else "❌" - text += f"🆕 Только первая покупка: {first_purchase_emoji}\n" + first_purchase_emoji = '✅' if first_purchase_only else '❌' + text += f'🆕 Только первая покупка: {first_purchase_emoji}\n' - text += f"📅 Создан: {format_datetime(promo.created_at)}\n" + text += f'📅 Создан: {format_datetime(promo.created_at)}\n' - first_purchase_btn_text = "🆕 Первая покупка: ✅" if first_purchase_only else "🆕 Первая покупка: ❌" + first_purchase_btn_text = '🆕 Первая покупка: ✅' if first_purchase_only else '🆕 Первая покупка: ❌' keyboard = [ [ - types.InlineKeyboardButton( - text="✏️ Редактировать", - callback_data=f"promo_edit_{promo.id}" - ), - types.InlineKeyboardButton( - text="🔄 Переключить статус", - callback_data=f"promo_toggle_{promo.id}" - ) + types.InlineKeyboardButton(text='✏️ Редактировать', callback_data=f'promo_edit_{promo.id}'), + types.InlineKeyboardButton(text='🔄 Переключить статус', callback_data=f'promo_toggle_{promo.id}'), ], + [types.InlineKeyboardButton(text=first_purchase_btn_text, callback_data=f'promo_toggle_first_{promo.id}')], [ - types.InlineKeyboardButton( - text=first_purchase_btn_text, - callback_data=f"promo_toggle_first_{promo.id}" - ) + types.InlineKeyboardButton(text='📊 Статистика', callback_data=f'promo_stats_{promo.id}'), + types.InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'promo_delete_{promo.id}'), ], - [ - types.InlineKeyboardButton( - text="📊 Статистика", - callback_data=f"promo_stats_{promo.id}" - ), - types.InlineKeyboardButton( - text="🗑️ Удалить", - callback_data=f"promo_delete_{promo.id}" - ) - ], - [ - types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list") - ] + [types.InlineKeyboardButton(text='⬅️ К списку', callback_data='admin_promo_list')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def show_promocode_edit_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promocode_edit_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - + text = f""" ✏️ Редактирование промокода {promo.code} 💰 Текущие параметры: """ - + if promo.type == PromoCodeType.BALANCE.value: - text += f"• Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n" + text += f'• Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n' elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]: - text += f"• Дней: {promo.subscription_days}\n" - - text += f"• Использований: {promo.current_uses}/{promo.max_uses}\n" - + text += f'• Дней: {promo.subscription_days}\n' + + text += f'• Использований: {promo.current_uses}/{promo.max_uses}\n' + if promo.valid_until: - text += f"• До: {format_datetime(promo.valid_until)}\n" + text += f'• До: {format_datetime(promo.valid_until)}\n' else: - text += f"• Срок: бессрочно\n" - - text += f"\nВыберите параметр для изменения:" - + text += '• Срок: бессрочно\n' + + text += '\nВыберите параметр для изменения:' + keyboard = [ - [ - types.InlineKeyboardButton( - text="📅 Дата окончания", - callback_data=f"promo_edit_date_{promo.id}" - ) - ], - [ - types.InlineKeyboardButton( - text="📊 Количество использований", - callback_data=f"promo_edit_uses_{promo.id}" - ) - ] + [types.InlineKeyboardButton(text='📅 Дата окончания', callback_data=f'promo_edit_date_{promo.id}')], + [types.InlineKeyboardButton(text='📊 Количество использований', callback_data=f'promo_edit_uses_{promo.id}')], ] - + if promo.type == PromoCodeType.BALANCE.value: - keyboard.insert(1, [ - types.InlineKeyboardButton( - text="💰 Сумма бонуса", - callback_data=f"promo_edit_amount_{promo.id}" - ) - ]) + keyboard.insert( + 1, [types.InlineKeyboardButton(text='💰 Сумма бонуса', callback_data=f'promo_edit_amount_{promo.id}')] + ) elif promo.type in [PromoCodeType.SUBSCRIPTION_DAYS.value, PromoCodeType.TRIAL_SUBSCRIPTION.value]: - keyboard.insert(1, [ - types.InlineKeyboardButton( - text="📅 Количество дней", - callback_data=f"promo_edit_days_{promo.id}" - ) - ]) - - keyboard.extend([ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"promo_manage_{promo.id}" - ) - ] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + keyboard.insert( + 1, [types.InlineKeyboardButton(text='📅 Количество дней', callback_data=f'promo_edit_days_{promo.id}')] + ) + + keyboard.extend([[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'promo_manage_{promo.id}')]]) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def start_edit_promocode_date( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_edit_promocode_date(callback: types.CallbackQuery, db_user: User, state: FSMContext): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return - - await state.update_data( - editing_promo_id=promo_id, - edit_action="date" - ) - + + await state.update_data(editing_promo_id=promo_id, edit_action='date') + text = f""" 📅 Изменение даты окончания промокода @@ -361,11 +284,11 @@ async def start_edit_promocode_date( ID промокода: {promo_id} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'promo_edit_{promo_id}')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await state.set_state(AdminStates.setting_promocode_expiry) await callback.answer() @@ -373,22 +296,15 @@ ID промокода: {promo_id} @admin_required @error_handler -async def start_edit_promocode_amount( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_edit_promocode_amount(callback: types.CallbackQuery, db_user: User, state: FSMContext): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return - - await state.update_data( - editing_promo_id=promo_id, - edit_action="amount" - ) - + + await state.update_data(editing_promo_id=promo_id, edit_action='amount') + text = f""" 💰 Изменение суммы бонуса промокода @@ -397,34 +313,28 @@ async def start_edit_promocode_amount( ID промокода: {promo_id} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'promo_edit_{promo_id}')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await state.set_state(AdminStates.setting_promocode_value) await callback.answer() + @admin_required @error_handler -async def start_edit_promocode_days( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_edit_promocode_days(callback: types.CallbackQuery, db_user: User, state: FSMContext): # ИСПРАВЛЕНИЕ: берем последний элемент как ID try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return - - await state.update_data( - editing_promo_id=promo_id, - edit_action="days" - ) - + + await state.update_data(editing_promo_id=promo_id, edit_action='days') + text = f""" 📅 Изменение количества дней подписки @@ -433,11 +343,11 @@ async def start_edit_promocode_days( ID промокода: {promo_id} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'promo_edit_{promo_id}')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await state.set_state(AdminStates.setting_promocode_value) await callback.answer() @@ -445,22 +355,15 @@ ID промокода: {promo_id} @admin_required @error_handler -async def start_edit_promocode_uses( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_edit_promocode_uses(callback: types.CallbackQuery, db_user: User, state: FSMContext): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return - - await state.update_data( - editing_promo_id=promo_id, - edit_action="uses" - ) - + + await state.update_data(editing_promo_id=promo_id, edit_action='uses') + text = f""" 📊 Изменение максимального количества использований @@ -472,11 +375,11 @@ async def start_edit_promocode_uses( ID промокода: {promo_id} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"promo_edit_{promo_id}")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'promo_edit_{promo_id}')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await state.set_state(AdminStates.setting_promocode_uses) await callback.answer() @@ -484,166 +387,131 @@ ID промокода: {promo_id} @admin_required @error_handler -async def start_promocode_creation( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_promocode_creation(callback: types.CallbackQuery, db_user: User, state: FSMContext): await callback.message.edit_text( - "🎫 Создание промокода\n\n" - "Выберите тип промокода:", - reply_markup=get_promocode_type_keyboard(db_user.language) + '🎫 Создание промокода\n\nВыберите тип промокода:', + reply_markup=get_promocode_type_keyboard(db_user.language), ) await callback.answer() @admin_required @error_handler -async def select_promocode_type( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def select_promocode_type(callback: types.CallbackQuery, db_user: User, state: FSMContext): promo_type = callback.data.split('_')[-1] type_names = { - "balance": "💰 Пополнение баланса", - "days": "📅 Дни подписки", - "trial": "🎁 Тестовая подписка", - "group": "🏷️ Промогруппа", - "discount": "💸 Одноразовая скидка" + 'balance': '💰 Пополнение баланса', + 'days': '📅 Дни подписки', + 'trial': '🎁 Тестовая подписка', + 'group': '🏷️ Промогруппа', + 'discount': '💸 Одноразовая скидка', } await state.update_data(promocode_type=promo_type) - + await callback.message.edit_text( - f"🎫 Создание промокода\n\n" - f"Тип: {type_names.get(promo_type, promo_type)}\n\n" - f"Введите код промокода (только латинские буквы и цифры):", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")] - ]) + f'🎫 Создание промокода\n\n' + f'Тип: {type_names.get(promo_type, promo_type)}\n\n' + f'Введите код промокода (только латинские буквы и цифры):', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_promocodes')]] + ), ) - + await state.set_state(AdminStates.creating_promocode) await callback.answer() @admin_required @error_handler -async def process_promocode_code( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_promocode_code(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): code = message.text.strip().upper() - + if not code.isalnum() or len(code) < 3 or len(code) > 20: - await message.answer("❌ Код должен содержать только латинские буквы и цифры (3-20 символов)") + await message.answer('❌ Код должен содержать только латинские буквы и цифры (3-20 символов)') return - + existing = await get_promocode_by_code(db, code) if existing: - await message.answer("❌ Промокод с таким кодом уже существует") + await message.answer('❌ Промокод с таким кодом уже существует') return - + await state.update_data(promocode_code=code) - + data = await state.get_data() promo_type = data.get('promocode_type') - - if promo_type == "balance": - await message.answer( - f"💰 Промокод: {code}\n\n" - f"Введите сумму пополнения баланса (в рублях):" - ) + + if promo_type == 'balance': + await message.answer(f'💰 Промокод: {code}\n\nВведите сумму пополнения баланса (в рублях):') await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "days": - await message.answer( - f"📅 Промокод: {code}\n\n" - f"Введите количество дней подписки:" - ) + elif promo_type == 'days': + await message.answer(f'📅 Промокод: {code}\n\nВведите количество дней подписки:') await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "trial": - await message.answer( - f"🎁 Промокод: {code}\n\n" - f"Введите количество дней тестовой подписки:" - ) + elif promo_type == 'trial': + await message.answer(f'🎁 Промокод: {code}\n\nВведите количество дней тестовой подписки:') await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "discount": - await message.answer( - f"💸 Промокод: {code}\n\n" - f"Введите процент скидки (1-100):" - ) + elif promo_type == 'discount': + await message.answer(f'💸 Промокод: {code}\n\nВведите процент скидки (1-100):') await state.set_state(AdminStates.setting_promocode_value) - elif promo_type == "group": + elif promo_type == 'group': # Show promo group selection groups_with_counts = await get_promo_groups_with_counts(db, limit=50) if not groups_with_counts: await message.answer( - "❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")] - ]) + '❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_promocodes')]] + ), ) await state.clear() return keyboard = [] - text = f"🏷️ Промокод: {code}\n\nВыберите промогруппу для назначения:\n\n" + text = f'🏷️ Промокод: {code}\n\nВыберите промогруппу для назначения:\n\n' for promo_group, user_count in groups_with_counts: - text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n" - keyboard.append([ - types.InlineKeyboardButton( - text=f"{promo_group.name} (↑{promo_group.priority})", - callback_data=f"promo_select_group_{promo_group.id}" - ) - ]) + text += f'• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n' + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{promo_group.name} (↑{promo_group.priority})', + callback_data=f'promo_select_group_{promo_group.id}', + ) + ] + ) - keyboard.append([ - types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes") - ]) + keyboard.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_promocodes')]) - await message.answer( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await message.answer(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await state.set_state(AdminStates.selecting_promo_group) @admin_required @error_handler async def process_promo_group_selection( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession + callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession ): """Handle promo group selection for promocode""" try: promo_group_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True) + await callback.answer('❌ Ошибка получения ID промогруппы', show_alert=True) return promo_group = await get_promo_group_by_id(db, promo_group_id) if not promo_group: - await callback.answer("❌ Промогруппа не найдена", show_alert=True) + await callback.answer('❌ Промогруппа не найдена', show_alert=True) return - await state.update_data( - promo_group_id=promo_group_id, - promo_group_name=promo_group.name - ) + await state.update_data(promo_group_id=promo_group_id, promo_group_name=promo_group.name) await callback.message.edit_text( - f"🏷️ Промокод для промогруппы\n\n" - f"Промогруппа: {promo_group.name}\n" - f"Приоритет: {promo_group.priority}\n\n" - f"📊 Введите количество использований промокода (или 0 для безлимита):" + f'🏷️ Промокод для промогруппы\n\n' + f'Промогруппа: {promo_group.name}\n' + f'Приоритет: {promo_group.priority}\n\n' + f'📊 Введите количество использований промокода (или 0 для безлимита):' ) await state.set_state(AdminStates.setting_promocode_uses) @@ -652,198 +520,179 @@ async def process_promo_group_selection( @admin_required @error_handler -async def process_promocode_value( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_promocode_value(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - + if data.get('editing_promo_id'): await handle_edit_value(message, db_user, state, db) return - + try: value = int(message.text.strip()) - + promo_type = data.get('promocode_type') - - if promo_type == "balance" and (value < 1 or value > 10000): - await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") + + if promo_type == 'balance' and (value < 1 or value > 10000): + await message.answer('❌ Сумма должна быть от 1 до 10,000 рублей') return - elif promo_type in ["days", "trial"] and (value < 1 or value > 3650): - await message.answer("❌ Количество дней должно быть от 1 до 3650") + if promo_type in ['days', 'trial'] and (value < 1 or value > 3650): + await message.answer('❌ Количество дней должно быть от 1 до 3650') return - elif promo_type == "discount" and (value < 1 or value > 100): - await message.answer("❌ Процент скидки должен быть от 1 до 100") + if promo_type == 'discount' and (value < 1 or value > 100): + await message.answer('❌ Процент скидки должен быть от 1 до 100') return - + await state.update_data(promocode_value=value) - - await message.answer( - f"📊 Введите количество использований промокода (или 0 для безлимита):" - ) + + await message.answer('📊 Введите количество использований промокода (или 0 для безлимита):') await state.set_state(AdminStates.setting_promocode_uses) - + except ValueError: - await message.answer("❌ Введите корректное число") + await message.answer('❌ Введите корректное число') -async def handle_edit_value( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def handle_edit_value(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() promo_id = data.get('editing_promo_id') edit_action = data.get('edit_action') promo = await get_promocode_by_id(db, promo_id) if not promo: - await message.answer("❌ Промокод не найден") + await message.answer('❌ Промокод не найден') await state.clear() return - + try: value = int(message.text.strip()) - - if edit_action == "amount": + + if edit_action == 'amount': if value < 1 or value > 10000: - await message.answer("❌ Сумма должна быть от 1 до 10,000 рублей") + await message.answer('❌ Сумма должна быть от 1 до 10,000 рублей') return - + await update_promocode(db, promo, balance_bonus_kopeks=value * 100) await message.answer( - f"✅ Сумма бонуса изменена на {value}₽", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] - ]) + f'✅ Сумма бонуса изменена на {value}₽', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🎫 К промокоду', callback_data=f'promo_manage_{promo_id}')] + ] + ), ) - - elif edit_action == "days": + + elif edit_action == 'days': if value < 1 or value > 3650: - await message.answer("❌ Количество дней должно быть от 1 до 3650") + await message.answer('❌ Количество дней должно быть от 1 до 3650') return - + await update_promocode(db, promo, subscription_days=value) await message.answer( - f"✅ Количество дней изменено на {value}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] - ]) + f'✅ Количество дней изменено на {value}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🎫 К промокоду', callback_data=f'promo_manage_{promo_id}')] + ] + ), ) - + await state.clear() - logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}") - + logger.info( + f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: {edit_action} = {value}' + ) + except ValueError: - await message.answer("❌ Введите корректное число") + await message.answer('❌ Введите корректное число') @admin_required @error_handler -async def process_promocode_uses( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_promocode_uses(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - + if data.get('editing_promo_id'): await handle_edit_uses(message, db_user, state, db) return - + try: max_uses = int(message.text.strip()) - + if max_uses < 0 or max_uses > 100000: - await message.answer("❌ Количество использований должно быть от 0 до 100,000") + await message.answer('❌ Количество использований должно быть от 0 до 100,000') return - + if max_uses == 0: max_uses = 999999 - + await state.update_data(promocode_max_uses=max_uses) - - await message.answer( - f"⏰ Введите срок действия промокода в днях (или 0 для бессрочного):" - ) + + await message.answer('⏰ Введите срок действия промокода в днях (или 0 для бессрочного):') await state.set_state(AdminStates.setting_promocode_expiry) - + except ValueError: - await message.answer("❌ Введите корректное число") + await message.answer('❌ Введите корректное число') -async def handle_edit_uses( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def handle_edit_uses(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() promo_id = data.get('editing_promo_id') promo = await get_promocode_by_id(db, promo_id) if not promo: - await message.answer("❌ Промокод не найден") + await message.answer('❌ Промокод не найден') await state.clear() return - + try: max_uses = int(message.text.strip()) - + if max_uses < 0 or max_uses > 100000: - await message.answer("❌ Количество использований должно быть от 0 до 100,000") + await message.answer('❌ Количество использований должно быть от 0 до 100,000') return - + if max_uses == 0: max_uses = 999999 - + if max_uses < promo.current_uses: await message.answer( - f"❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})" + f'❌ Новый лимит ({max_uses}) не может быть меньше текущих использований ({promo.current_uses})' ) return - + await update_promocode(db, promo, max_uses=max_uses) - - uses_text = "безлимитное" if max_uses == 999999 else str(max_uses) + + uses_text = 'безлимитное' if max_uses == 999999 else str(max_uses) await message.answer( - f"✅ Максимальное количество использований изменено на {uses_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] - ]) + f'✅ Максимальное количество использований изменено на {uses_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🎫 К промокоду', callback_data=f'promo_manage_{promo_id}')] + ] + ), ) - + await state.clear() - logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}") - + logger.info( + f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: max_uses = {max_uses}' + ) + except ValueError: - await message.answer("❌ Введите корректное число") + await message.answer('❌ Введите корректное число') @admin_required @error_handler -async def process_promocode_expiry( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_promocode_expiry(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - + if data.get('editing_promo_id'): await handle_edit_expiry(message, db_user, state, db) return - + try: expiry_days = int(message.text.strip()) - + if expiry_days < 0 or expiry_days > 3650: - await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") + await message.answer('❌ Срок действия должен быть от 0 до 3650 дней') return code = data.get('promocode_code') @@ -854,12 +703,12 @@ async def process_promocode_expiry( promo_group_name = data.get('promo_group_name') # Для DISCOUNT типа нужно дополнительно спросить срок действия скидки в часах - if promo_type == "discount": + if promo_type == 'discount': await state.update_data(promocode_expiry_days=expiry_days) await message.answer( - f"⏰ Промокод: {code}\n\n" - f"Введите срок действия скидки в часах (0-8760):\n" - f"0 = бессрочно до первой покупки" + f'⏰ Промокод: {code}\n\n' + f'Введите срок действия скидки в часах (0-8760):\n' + f'0 = бессрочно до первой покупки' ) await state.set_state(AdminStates.setting_discount_hours) return @@ -869,29 +718,29 @@ async def process_promocode_expiry( valid_until = datetime.utcnow() + timedelta(days=expiry_days) type_map = { - "balance": PromoCodeType.BALANCE, - "days": PromoCodeType.SUBSCRIPTION_DAYS, - "trial": PromoCodeType.TRIAL_SUBSCRIPTION, - "group": PromoCodeType.PROMO_GROUP + 'balance': PromoCodeType.BALANCE, + 'days': PromoCodeType.SUBSCRIPTION_DAYS, + 'trial': PromoCodeType.TRIAL_SUBSCRIPTION, + 'group': PromoCodeType.PROMO_GROUP, } promocode = await create_promocode( db=db, code=code, type=type_map[promo_type], - balance_bonus_kopeks=value * 100 if promo_type == "balance" else 0, - subscription_days=value if promo_type in ["days", "trial"] else 0, + balance_bonus_kopeks=value * 100 if promo_type == 'balance' else 0, + subscription_days=value if promo_type in ['days', 'trial'] else 0, max_uses=max_uses, valid_until=valid_until, created_by=db_user.id, - promo_group_id=promo_group_id if promo_type == "group" else None + promo_group_id=promo_group_id if promo_type == 'group' else None, ) - + type_names = { - "balance": "Пополнение баланса", - "days": "Дни подписки", - "trial": "Тестовая подписка", - "group": "Промогруппа" + 'balance': 'Пополнение баланса', + 'days': 'Дни подписки', + 'trial': 'Тестовая подписка', + 'group': 'Промогруппа', } summary_text = f""" @@ -901,40 +750,35 @@ async def process_promocode_expiry( 📝 Тип: {type_names.get(promo_type)} """ - if promo_type == "balance": - summary_text += f"💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n" - elif promo_type in ["days", "trial"]: - summary_text += f"📅 Дней: {promocode.subscription_days}\n" - elif promo_type == "group" and promo_group_name: - summary_text += f"🏷️ Промогруппа: {promo_group_name}\n" + if promo_type == 'balance': + summary_text += f'💰 Сумма: {settings.format_price(promocode.balance_bonus_kopeks)}\n' + elif promo_type in ['days', 'trial']: + summary_text += f'📅 Дней: {promocode.subscription_days}\n' + elif promo_type == 'group' and promo_group_name: + summary_text += f'🏷️ Промогруппа: {promo_group_name}\n' + + summary_text += f'📊 Использований: {promocode.max_uses}\n' - summary_text += f"📊 Использований: {promocode.max_uses}\n" - if promocode.valid_until: - summary_text += f"⏰ Действует до: {format_datetime(promocode.valid_until)}\n" - + summary_text += f'⏰ Действует до: {format_datetime(promocode.valid_until)}\n' + await message.answer( summary_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🎫 К промокодам', callback_data='admin_promocodes')]] + ), ) - + await state.clear() - logger.info(f"Создан промокод {code} администратором {db_user.telegram_id}") - + logger.info(f'Создан промокод {code} администратором {db_user.telegram_id}') + except ValueError: - await message.answer("❌ Введите корректное число дней") + await message.answer('❌ Введите корректное число дней') @admin_required @error_handler -async def process_discount_hours( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_discount_hours(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): """Обработчик ввода срока действия скидки в часах для DISCOUNT промокода.""" data = await state.get_data() @@ -942,7 +786,7 @@ async def process_discount_hours( discount_hours = int(message.text.strip()) if discount_hours < 0 or discount_hours > 8760: - await message.answer("❌ Срок действия скидки должен быть от 0 до 8760 часов") + await message.answer('❌ Срок действия скидки должен быть от 0 до 8760 часов') return code = data.get('promocode_code') @@ -966,7 +810,7 @@ async def process_discount_hours( max_uses=max_uses, valid_until=valid_until, created_by=db_user.id, - promo_group_id=None + promo_group_id=None, ) summary_text = f""" @@ -978,118 +822,111 @@ async def process_discount_hours( """ if discount_hours > 0: - summary_text += f"⏰ Срок скидки: {discount_hours} ч.\n" + summary_text += f'⏰ Срок скидки: {discount_hours} ч.\n' else: - summary_text += f"⏰ Срок скидки: до первой покупки\n" + summary_text += '⏰ Срок скидки: до первой покупки\n' - summary_text += f"📊 Использований: {promocode.max_uses}\n" + summary_text += f'📊 Использований: {promocode.max_uses}\n' if promocode.valid_until: - summary_text += f"⏳ Промокод действует до: {format_datetime(promocode.valid_until)}\n" + summary_text += f'⏳ Промокод действует до: {format_datetime(promocode.valid_until)}\n' await message.answer( summary_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='🎫 К промокодам', callback_data='admin_promocodes')]] + ), ) await state.clear() - logger.info(f"Создан DISCOUNT промокод {code} ({value}%, {discount_hours}ч) администратором {db_user.telegram_id}") + logger.info( + f'Создан DISCOUNT промокод {code} ({value}%, {discount_hours}ч) администратором {db_user.telegram_id}' + ) except ValueError: - await message.answer("❌ Введите корректное число часов") + await message.answer('❌ Введите корректное число часов') -async def handle_edit_expiry( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def handle_edit_expiry(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() promo_id = data.get('editing_promo_id') promo = await get_promocode_by_id(db, promo_id) if not promo: - await message.answer("❌ Промокод не найден") + await message.answer('❌ Промокод не найден') await state.clear() return - + try: expiry_days = int(message.text.strip()) - + if expiry_days < 0 or expiry_days > 3650: - await message.answer("❌ Срок действия должен быть от 0 до 3650 дней") + await message.answer('❌ Срок действия должен быть от 0 до 3650 дней') return - + valid_until = None if expiry_days > 0: valid_until = datetime.utcnow() + timedelta(days=expiry_days) - + await update_promocode(db, promo, valid_until=valid_until) - + if valid_until: - expiry_text = f"до {format_datetime(valid_until)}" + expiry_text = f'до {format_datetime(valid_until)}' else: - expiry_text = "бессрочно" - + expiry_text = 'бессрочно' + await message.answer( - f"✅ Срок действия промокода изменен: {expiry_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🎫 К промокоду", callback_data=f"promo_manage_{promo_id}")] - ]) + f'✅ Срок действия промокода изменен: {expiry_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🎫 К промокоду', callback_data=f'promo_manage_{promo_id}')] + ] + ), ) - + await state.clear() - logger.info(f"Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней") - + logger.info( + f'Промокод {promo.code} отредактирован администратором {db_user.telegram_id}: expiry = {expiry_days} дней' + ) + except ValueError: - await message.answer("❌ Введите корректное число дней") + await message.answer('❌ Введите корректное число дней') @admin_required @error_handler -async def toggle_promocode_status( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_promocode_status(callback: types.CallbackQuery, db_user: User, db: AsyncSession): promo_id = int(callback.data.split('_')[-1]) promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - + new_status = not promo.is_active await update_promocode(db, promo, is_active=new_status) - - status_text = "активирован" if new_status else "деактивирован" - await callback.answer(f"✅ Промокод {status_text}", show_alert=True) + + status_text = 'активирован' if new_status else 'деактивирован' + await callback.answer(f'✅ Промокод {status_text}', show_alert=True) await show_promocode_management(callback, db_user, db) @admin_required @error_handler -async def toggle_promocode_first_purchase( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_promocode_first_purchase(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Переключает режим 'только для первой покупки'.""" promo_id = int(callback.data.split('_')[-1]) promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return new_status = not getattr(promo, 'first_purchase_only', False) await update_promocode(db, promo, first_purchase_only=new_status) - status_text = "включён" if new_status else "выключен" + status_text = 'включён' if new_status else 'выключен' await callback.answer(f"✅ Режим 'первая покупка' {status_text}", show_alert=True) await show_promocode_management(callback, db_user, db) @@ -1097,22 +934,18 @@ async def toggle_promocode_first_purchase( @admin_required @error_handler -async def confirm_delete_promocode( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_delete_promocode(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - + text = f""" ⚠️ Подтверждение удаления @@ -1126,67 +959,56 @@ async def confirm_delete_promocode( ID: {promo_id} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="✅ Да, удалить", - callback_data=f"promo_delete_confirm_{promo.id}" - ), - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"promo_manage_{promo.id}" - ) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='✅ Да, удалить', callback_data=f'promo_delete_confirm_{promo.id}'), + types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'promo_manage_{promo.id}'), + ] ] - ]) - + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() + @admin_required @error_handler -async def delete_promocode_confirmed( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def delete_promocode_confirmed(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: promo_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Ошибка получения ID промокода", show_alert=True) + await callback.answer('❌ Ошибка получения ID промокода', show_alert=True) return promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - + code = promo.code success = await delete_promocode(db, promo) - + if success: - await callback.answer(f"✅ Промокод {code} удален", show_alert=True) + await callback.answer(f'✅ Промокод {code} удален', show_alert=True) await show_promocodes_list(callback, db_user, db) else: - await callback.answer("❌ Ошибка удаления промокода", show_alert=True) + await callback.answer('❌ Ошибка удаления промокода', show_alert=True) @admin_required @error_handler -async def show_promocode_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): promo_id = int(callback.data.split('_')[-1]) promo = await get_promocode_by_id(db, promo_id) if not promo: - await callback.answer("❌ Промокод не найден", show_alert=True) + await callback.answer('❌ Промокод не найден', show_alert=True) return - + stats = await get_promocode_statistics(db, promo_id) - + text = f""" 📊 Статистика промокода {promo.code} @@ -1197,46 +1019,38 @@ async def show_promocode_stats( 📅 Последние использования: """ - + if stats['recent_uses']: for use in stats['recent_uses'][:5]: use_date = format_datetime(use.used_at) - + if hasattr(use, 'user_username') and use.user_username: - user_display = f"@{use.user_username}" + user_display = f'@{use.user_username}' elif hasattr(use, 'user_full_name') and use.user_full_name: user_display = use.user_full_name elif hasattr(use, 'user_telegram_id'): - user_display = f"ID{use.user_telegram_id}" + user_display = f'ID{use.user_telegram_id}' else: - user_display = f"ID{use.user_id}" - - text += f"- {use_date} | {user_display}\n" + user_display = f'ID{use.user_id}' + + text += f'- {use_date} | {user_display}\n' else: - text += "- Пока не было использований\n" - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"promo_manage_{promo.id}" - ) - ] - ]) - + text += '- Пока не было использований\n' + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'promo_manage_{promo.id}')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() + @admin_required @error_handler -async def show_general_promocode_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_general_promocode_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): total_codes = await get_promocodes_count(db) active_codes = await get_promocodes_count(db, is_active=True) - + text = f""" 📊 Общая статистика промокодов @@ -1247,49 +1061,44 @@ async def show_general_promocode_stats( Для детальной статистики выберите конкретный промокод из списка. """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promo_list") - ], - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes") + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🎫 К промокодам', callback_data='admin_promo_list')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_promocodes')], ] - ]) - + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() + def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes") - dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list") - dp.callback_query.register(show_promocodes_list_page, F.data.startswith("admin_promo_list_page_")) - dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create") - dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_")) - dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_")) - - dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_")) - dp.callback_query.register(toggle_promocode_first_purchase, F.data.startswith("promo_toggle_first_")) - dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_")) - dp.callback_query.register(show_promocode_stats, F.data.startswith("promo_stats_")) - - dp.callback_query.register(start_edit_promocode_date, F.data.startswith("promo_edit_date_")) - dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_")) - dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_")) - dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_")) - dp.callback_query.register(show_general_promocode_stats, F.data == "admin_promo_general_stats") - - dp.callback_query.register( - show_promocode_edit_menu, - F.data.regexp(r"^promo_edit_\d+$") - ) - - dp.callback_query.register(delete_promocode_confirmed, F.data.startswith("promo_delete_confirm_")) - dp.callback_query.register(confirm_delete_promocode, F.data.startswith("promo_delete_")) - + dp.callback_query.register(show_promocodes_menu, F.data == 'admin_promocodes') + dp.callback_query.register(show_promocodes_list, F.data == 'admin_promo_list') + dp.callback_query.register(show_promocodes_list_page, F.data.startswith('admin_promo_list_page_')) + dp.callback_query.register(start_promocode_creation, F.data == 'admin_promo_create') + dp.callback_query.register(select_promocode_type, F.data.startswith('promo_type_')) + dp.callback_query.register(process_promo_group_selection, F.data.startswith('promo_select_group_')) + + dp.callback_query.register(show_promocode_management, F.data.startswith('promo_manage_')) + dp.callback_query.register(toggle_promocode_first_purchase, F.data.startswith('promo_toggle_first_')) + dp.callback_query.register(toggle_promocode_status, F.data.startswith('promo_toggle_')) + dp.callback_query.register(show_promocode_stats, F.data.startswith('promo_stats_')) + + dp.callback_query.register(start_edit_promocode_date, F.data.startswith('promo_edit_date_')) + dp.callback_query.register(start_edit_promocode_amount, F.data.startswith('promo_edit_amount_')) + dp.callback_query.register(start_edit_promocode_days, F.data.startswith('promo_edit_days_')) + dp.callback_query.register(start_edit_promocode_uses, F.data.startswith('promo_edit_uses_')) + dp.callback_query.register(show_general_promocode_stats, F.data == 'admin_promo_general_stats') + + dp.callback_query.register(show_promocode_edit_menu, F.data.regexp(r'^promo_edit_\d+$')) + + dp.callback_query.register(delete_promocode_confirmed, F.data.startswith('promo_delete_confirm_')) + dp.callback_query.register(confirm_delete_promocode, F.data.startswith('promo_delete_')) + dp.message.register(process_promocode_code, AdminStates.creating_promocode) dp.message.register(process_promocode_value, AdminStates.setting_promocode_value) dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses) dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry) dp.message.register(process_discount_hours, AdminStates.setting_discount_hours) - diff --git a/app/handlers/admin/public_offer.py b/app/handlers/admin/public_offer.py index 141e7de5..31043965 100644 --- a/app/handlers/admin/public_offer.py +++ b/app/handlers/admin/public_offer.py @@ -2,7 +2,7 @@ import html import logging from datetime import datetime -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -11,18 +11,19 @@ from app.localization.texts import get_texts from app.services.public_offer_service import PublicOfferService from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -from app.utils.validators import validate_html_tags, get_html_help_text +from app.utils.validators import get_html_help_text, validate_html_tags + logger = logging.getLogger(__name__) def _format_timestamp(value: datetime | None) -> str: if not value: - return "" + return '' try: - return value.strftime("%d.%m.%Y %H:%M") + return value.strftime('%d.%m.%Y %H:%M') except Exception: - return "" + return '' async def _build_overview( @@ -40,63 +41,60 @@ async def _build_overview( has_content = bool(offer and offer.content and offer.content.strip()) description = texts.t( - "ADMIN_PUBLIC_OFFER_DESCRIPTION", - "Публичная оферта отображается в разделе «Инфо».", + 'ADMIN_PUBLIC_OFFER_DESCRIPTION', + 'Публичная оферта отображается в разделе «Инфо».', ) status_text = texts.t( - "ADMIN_PUBLIC_OFFER_STATUS_DISABLED", - "⚠️ Показ оферты выключен или текст отсутствует.", + 'ADMIN_PUBLIC_OFFER_STATUS_DISABLED', + '⚠️ Показ оферты выключен или текст отсутствует.', ) if offer and offer.is_enabled and has_content: status_text = texts.t( - "ADMIN_PUBLIC_OFFER_STATUS_ENABLED", - "✅ Оферта активна и показывается пользователям.", + 'ADMIN_PUBLIC_OFFER_STATUS_ENABLED', + '✅ Оферта активна и показывается пользователям.', ) elif offer and offer.is_enabled: status_text = texts.t( - "ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY", - "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.", + 'ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY', + '⚠️ Оферта включена, но текст пуст — пользователи её не увидят.', ) - updated_at = _format_timestamp(getattr(offer, "updated_at", None)) - updated_block = "" + updated_at = _format_timestamp(getattr(offer, 'updated_at', None)) + updated_block = '' if updated_at: updated_block = texts.t( - "ADMIN_PUBLIC_OFFER_UPDATED_AT", - "Последнее обновление: {timestamp}", + 'ADMIN_PUBLIC_OFFER_UPDATED_AT', + 'Последнее обновление: {timestamp}', ).format(timestamp=updated_at) preview_block = texts.t( - "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY", - "Текст ещё не задан.", + 'ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY', + 'Текст ещё не задан.', ) if has_content: preview_title = texts.t( - "ADMIN_PUBLIC_OFFER_PREVIEW_TITLE", - "Превью текста:", + 'ADMIN_PUBLIC_OFFER_PREVIEW_TITLE', + 'Превью текста:', ) preview_raw = offer.content.strip() preview_trimmed = preview_raw[:400] if len(preview_raw) > 400: - preview_trimmed += "..." - preview_block = ( - f"{preview_title}\n" - f"{html.escape(preview_trimmed)}" - ) + preview_trimmed += '...' + preview_block = f'{preview_title}\n{html.escape(preview_trimmed)}' language_block = texts.t( - "ADMIN_PUBLIC_OFFER_LANGUAGE", - "Язык: {lang}", + 'ADMIN_PUBLIC_OFFER_LANGUAGE', + 'Язык: {lang}', ).format(lang=normalized_language) header = texts.t( - "ADMIN_PUBLIC_OFFER_HEADER", - "📄 Публичная оферта", + 'ADMIN_PUBLIC_OFFER_HEADER', + '📄 Публичная оферта', ) actions_prompt = texts.t( - "ADMIN_PUBLIC_OFFER_ACTION_PROMPT", - "Выберите действие:", + 'ADMIN_PUBLIC_OFFER_ACTION_PROMPT', + 'Выберите действие:', ) message_parts = [ @@ -112,64 +110,74 @@ async def _build_overview( message_parts.append(preview_block) message_parts.append(actions_prompt) - overview_text = "\n\n".join(part for part in message_parts if part) + overview_text = '\n\n'.join(part for part in message_parts if part) buttons: list[list[types.InlineKeyboardButton]] = [] - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PUBLIC_OFFER_EDIT_BUTTON", - "✏️ Изменить текст", - ), - callback_data="admin_public_offer_edit", - ) - ]) - - if has_content: - buttons.append([ + buttons.append( + [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_VIEW_BUTTON", - "👀 Просмотреть текущий текст", + 'ADMIN_PUBLIC_OFFER_EDIT_BUTTON', + '✏️ Изменить текст', ), - callback_data="admin_public_offer_view", + callback_data='admin_public_offer_edit', ) - ]) + ] + ) + + if has_content: + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PUBLIC_OFFER_VIEW_BUTTON', + '👀 Просмотреть текущий текст', + ), + callback_data='admin_public_offer_view', + ) + ] + ) toggle_text = texts.t( - "ADMIN_PUBLIC_OFFER_ENABLE_BUTTON", - "✅ Включить показ", + 'ADMIN_PUBLIC_OFFER_ENABLE_BUTTON', + '✅ Включить показ', ) if offer and offer.is_enabled: toggle_text = texts.t( - "ADMIN_PUBLIC_OFFER_DISABLE_BUTTON", - "🚫 Отключить показ", + 'ADMIN_PUBLIC_OFFER_DISABLE_BUTTON', + '🚫 Отключить показ', ) - buttons.append([ - types.InlineKeyboardButton( - text=toggle_text, - callback_data="admin_public_offer_toggle", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=toggle_text, + callback_data='admin_public_offer_toggle', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PUBLIC_OFFER_HTML_HELP", - "ℹ️ HTML помощь", - ), - callback_data="admin_public_offer_help", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PUBLIC_OFFER_HTML_HELP', + 'ℹ️ HTML помощь', + ), + callback_data='admin_public_offer_help', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_settings", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.BACK, + callback_data='admin_submenu_settings', + ) + ] + ) return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer @@ -200,14 +208,14 @@ async def toggle_public_offer( texts = get_texts(db_user.language) updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language) logger.info( - "Админ %s переключил показ публичной оферты: %s", + 'Админ %s переключил показ публичной оферты: %s', db_user.telegram_id, - "enabled" if updated_offer.is_enabled else "disabled", + 'enabled' if updated_offer.is_enabled else 'disabled', ) status_message = ( - texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена") + texts.t('ADMIN_PUBLIC_OFFER_ENABLED', '✅ Оферта включена') if updated_offer.is_enabled - else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена") + else texts.t('ADMIN_PUBLIC_OFFER_DISABLED', '🚫 Оферта отключена') ) overview_text, markup, _ = await _build_overview(db_user, db) @@ -234,32 +242,32 @@ async def start_edit_public_offer( fallback=False, ) - current_preview = "" + current_preview = '' if offer and offer.content: preview = offer.content.strip()[:400] if len(offer.content.strip()) > 400: - preview += "..." + preview += '...' current_preview = ( texts.t( - "ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW", - "Текущий текст (превью):", + 'ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW', + 'Текущий текст (превью):', ) - + f"\n{html.escape(preview)}\n\n" + + f'\n{html.escape(preview)}\n\n' ) prompt = texts.t( - "ADMIN_PUBLIC_OFFER_EDIT_PROMPT", - "Отправьте новый текст публичной оферты. Допускается HTML-разметка.", + 'ADMIN_PUBLIC_OFFER_EDIT_PROMPT', + 'Отправьте новый текст публичной оферты. Допускается HTML-разметка.', ) hint = texts.t( - "ADMIN_PUBLIC_OFFER_EDIT_HINT", - "Используйте /html_help для справки по тегам.", + 'ADMIN_PUBLIC_OFFER_EDIT_HINT', + 'Используйте /html_help для справки по тегам.', ) message_text = ( - f"📝 {texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}\n\n" - f"{current_preview}{prompt}\n\n{hint}" + f'📝 {texts.t("ADMIN_PUBLIC_OFFER_EDIT_TITLE", "Редактирование оферты")}\n\n' + f'{current_preview}{prompt}\n\n{hint}' ) keyboard = types.InlineKeyboardMarkup( @@ -267,16 +275,16 @@ async def start_edit_public_offer( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_HTML_HELP", - "ℹ️ HTML помощь", + 'ADMIN_PUBLIC_OFFER_HTML_HELP', + 'ℹ️ HTML помощь', ), - callback_data="admin_public_offer_help", + callback_data='admin_public_offer_help', ) ], [ types.InlineKeyboardButton( - text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"), - callback_data="admin_public_offer_cancel", + text=texts.t('ADMIN_PUBLIC_OFFER_CANCEL', '❌ Отмена'), + callback_data='admin_public_offer_cancel', ) ], ] @@ -303,8 +311,8 @@ async def cancel_edit_public_offer( ) await callback.answer( get_texts(db_user.language).t( - "ADMIN_PUBLIC_OFFER_EDIT_CANCELLED", - "Редактирование оферты отменено.", + 'ADMIN_PUBLIC_OFFER_EDIT_CANCELLED', + 'Редактирование оферты отменено.', ) ) @@ -318,13 +326,13 @@ async def process_public_offer_edit( db: AsyncSession, ): texts = get_texts(db_user.language) - new_text = message.text or "" + new_text = message.text or '' if len(new_text) > 4000: await message.answer( texts.t( - "ADMIN_PUBLIC_OFFER_TOO_LONG", - "❌ Текст оферты слишком длинный. Максимум 4000 символов.", + 'ADMIN_PUBLIC_OFFER_TOO_LONG', + '❌ Текст оферты слишком длинный. Максимум 4000 символов.', ) ) return @@ -333,23 +341,23 @@ async def process_public_offer_edit( if not is_valid: await message.answer( texts.t( - "ADMIN_PUBLIC_OFFER_HTML_ERROR", - "❌ Ошибка в HTML: {error}", + 'ADMIN_PUBLIC_OFFER_HTML_ERROR', + '❌ Ошибка в HTML: {error}', ).format(error=error_message) ) return await PublicOfferService.save_offer(db, db_user.language, new_text) logger.info( - "Админ %s обновил текст публичной оферты (%d символов)", + 'Админ %s обновил текст публичной оферты (%d символов)', db_user.telegram_id, len(new_text), ) await state.clear() success_text = texts.t( - "ADMIN_PUBLIC_OFFER_SAVED", - "✅ Публичная оферта обновлена.", + 'ADMIN_PUBLIC_OFFER_SAVED', + '✅ Публичная оферта обновлена.', ) reply_markup = types.InlineKeyboardMarkup( @@ -357,10 +365,10 @@ async def process_public_offer_edit( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_BACK_BUTTON", - "⬅️ К настройкам оферты", + 'ADMIN_PUBLIC_OFFER_BACK_BUTTON', + '⬅️ К настройкам оферты', ), - callback_data="admin_public_offer", + callback_data='admin_public_offer', ) ] ] @@ -386,8 +394,8 @@ async def view_public_offer( if not offer or not offer.content or not offer.content.strip(): await callback.answer( texts.t( - "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT", - "Текст оферты пока не задан.", + 'ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT', + 'Текст оферты пока не задан.', ), show_alert=True, ) @@ -403,8 +411,8 @@ async def view_public_offer( if not pages: await callback.answer( texts.t( - "ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT", - "Текст оферты пока не задан.", + 'ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT', + 'Текст оферты пока не задан.', ), show_alert=True, ) @@ -414,15 +422,15 @@ async def view_public_offer( truncated = len(pages) > 1 header = texts.t( - "ADMIN_PUBLIC_OFFER_VIEW_TITLE", - "👀 Текущий текст оферты", + 'ADMIN_PUBLIC_OFFER_VIEW_TITLE', + '👀 Текущий текст оферты', ) - note = "" + note = '' if truncated: note = texts.t( - "ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED", - "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.", + 'ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED', + '\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.', ) keyboard = types.InlineKeyboardMarkup( @@ -430,26 +438,26 @@ async def view_public_offer( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_BACK_BUTTON", - "⬅️ К настройкам оферты", + 'ADMIN_PUBLIC_OFFER_BACK_BUTTON', + '⬅️ К настройкам оферты', ), - callback_data="admin_public_offer", + callback_data='admin_public_offer', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_EDIT_BUTTON", - "✏️ Изменить текст", + 'ADMIN_PUBLIC_OFFER_EDIT_BUTTON', + '✏️ Изменить текст', ), - callback_data="admin_public_offer_edit", + callback_data='admin_public_offer_edit', ) ], ] ) await callback.message.edit_text( - f"{header}\n\n{preview}{note}", + f'{header}\n\n{preview}{note}', reply_markup=keyboard, ) await callback.answer() @@ -471,25 +479,29 @@ async def show_public_offer_html_help( buttons: list[list[types.InlineKeyboardButton]] = [] if current_state == AdminStates.editing_public_offer.state: - buttons.append([ + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t( + 'ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT', + '⬅️ Назад к редактированию', + ), + callback_data='admin_public_offer_edit', + ) + ] + ) + + buttons.append( + [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT", - "⬅️ Назад к редактированию", + 'ADMIN_PUBLIC_OFFER_BACK_BUTTON', + '⬅️ К настройкам оферты', ), - callback_data="admin_public_offer_edit", + callback_data='admin_public_offer', ) - ]) - - buttons.append([ - types.InlineKeyboardButton( - text=texts.t( - "ADMIN_PUBLIC_OFFER_BACK_BUTTON", - "⬅️ К настройкам оферты", - ), - callback_data="admin_public_offer", - ) - ]) + ] + ) await callback.message.edit_text( help_text, @@ -501,27 +513,27 @@ async def show_public_offer_html_help( def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_public_offer_management, - F.data == "admin_public_offer", + F.data == 'admin_public_offer', ) dp.callback_query.register( toggle_public_offer, - F.data == "admin_public_offer_toggle", + F.data == 'admin_public_offer_toggle', ) dp.callback_query.register( start_edit_public_offer, - F.data == "admin_public_offer_edit", + F.data == 'admin_public_offer_edit', ) dp.callback_query.register( cancel_edit_public_offer, - F.data == "admin_public_offer_cancel", + F.data == 'admin_public_offer_cancel', ) dp.callback_query.register( view_public_offer, - F.data == "admin_public_offer_view", + F.data == 'admin_public_offer_view', ) dp.callback_query.register( show_public_offer_html_help, - F.data == "admin_public_offer_help", + F.data == 'admin_public_offer_help', ) dp.message.register( diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index 1e5c883a..1f5ab589 100644 --- a/app/handlers/admin/referrals.py +++ b/app/handlers/admin/referrals.py @@ -1,43 +1,40 @@ +import datetime import json import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -import datetime +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus, ReferralEarning -from app.localization.texts import get_texts from app.database.crud.referral import ( get_referral_statistics, get_top_referrers_by_period, - get_user_referral_stats, ) from app.database.crud.user import get_user_by_id, get_user_by_telegram_id +from app.database.models import ReferralEarning, User, WithdrawalRequest, WithdrawalRequestStatus +from app.localization.texts import get_texts from app.services.referral_withdrawal_service import referral_withdrawal_service from app.states import AdminStates from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_referral_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: stats = await get_referral_statistics(db) - + avg_per_referrer = 0 if stats.get('active_referrers', 0) > 0: avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers'] - - current_time = datetime.datetime.now().strftime("%H:%M:%S") - + + current_time = datetime.datetime.now().strftime('%H:%M:%S') + text = f""" 🤝 Реферальная статистика @@ -56,21 +53,21 @@ async def show_referral_statistics( Топ-5 рефереров: """ - + top_referrers = stats.get('top_referrers', []) if top_referrers: for i, referrer in enumerate(top_referrers[:5], 1): earned = referrer.get('total_earned_kopeks', 0) count = referrer.get('referrals_count', 0) user_id = referrer.get('user_id', 'N/A') - + if count > 0: - text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n" + text += f'{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n' else: - logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе") + logger.warning(f'Реферер {user_id} имеет {count} рефералов, но есть в топе') else: - text += "Нет данных\n" - + text += 'Нет данных\n' + text += f""" Настройки реферальной системы: @@ -82,42 +79,41 @@ async def show_referral_statistics( 🕐 Обновлено: {current_time} """ - + keyboard_rows = [ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")], - [types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")], + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')], + [types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')], ] # Кнопка заявок на вывод (если функция включена) if settings.is_referral_withdrawal_enabled(): - keyboard_rows.append([ - types.InlineKeyboardButton( - text="💸 Заявки на вывод", - callback_data="admin_withdrawal_requests" - ) - ]) + keyboard_rows.append( + [types.InlineKeyboardButton(text='💸 Заявки на вывод', callback_data='admin_withdrawal_requests')] + ) - keyboard_rows.extend([ - [types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] - ]) + keyboard_rows.extend( + [ + [types.InlineKeyboardButton(text='⚙️ Настройки', callback_data='admin_referrals_settings')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_panel')], + ] + ) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - + try: await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer("Обновлено") + await callback.answer('Обновлено') except Exception as edit_error: - if "message is not modified" in str(edit_error): - await callback.answer("Данные актуальны") + if 'message is not modified' in str(edit_error): + await callback.answer('Данные актуальны') else: - logger.error(f"Ошибка редактирования сообщения: {edit_error}") - await callback.answer("Ошибка обновления") - + logger.error(f'Ошибка редактирования сообщения: {edit_error}') + await callback.answer('Ошибка обновления') + except Exception as e: - logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True) - - current_time = datetime.datetime.now().strftime("%H:%M:%S") + logger.error(f'Ошибка в show_referral_statistics: {e}', exc_info=True) + + current_time = datetime.datetime.now().strftime('%H:%M:%S') text = f""" 🤝 Реферальная статистика @@ -131,103 +127,82 @@ async def show_referral_statistics( 🕐 Время: {current_time} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Повторить', callback_data='admin_referrals')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_panel')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) except: pass - await callback.answer("Произошла ошибка при загрузке статистики") + await callback.answer('Произошла ошибка при загрузке статистики') def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup: """Создаёт клавиатуру для выбора периода и сортировки.""" - period_week = "✅ Неделя" if period == "week" else "Неделя" - period_month = "✅ Месяц" if period == "month" else "Месяц" - sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку" - sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым" + period_week = '✅ Неделя' if period == 'week' else 'Неделя' + period_month = '✅ Месяц' if period == 'month' else 'Месяц' + sort_earnings = '✅ По заработку' if sort_by == 'earnings' else 'По заработку' + sort_invited = '✅ По приглашённым' if sort_by == 'invited' else 'По приглашённым' - return types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=period_week, - callback_data=f"admin_top_ref:week:{sort_by}" - ), - types.InlineKeyboardButton( - text=period_month, - callback_data=f"admin_top_ref:month:{sort_by}" - ), - ], - [ - types.InlineKeyboardButton( - text=sort_earnings, - callback_data=f"admin_top_ref:{period}:earnings" - ), - types.InlineKeyboardButton( - text=sort_invited, - callback_data=f"admin_top_ref:{period}:invited" - ), - ], - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")], - [types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")] - ]) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text=period_week, callback_data=f'admin_top_ref:week:{sort_by}'), + types.InlineKeyboardButton(text=period_month, callback_data=f'admin_top_ref:month:{sort_by}'), + ], + [ + types.InlineKeyboardButton(text=sort_earnings, callback_data=f'admin_top_ref:{period}:earnings'), + types.InlineKeyboardButton(text=sort_invited, callback_data=f'admin_top_ref:{period}:invited'), + ], + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_top_ref:{period}:{sort_by}')], + [types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')], + ] + ) @admin_required @error_handler -async def show_top_referrers( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_top_referrers(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает топ рефереров (по умолчанию: неделя, по заработку).""" - await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings") + await _show_top_referrers_filtered(callback, db, period='week', sort_by='earnings') @admin_required @error_handler -async def show_top_referrers_filtered( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_top_referrers_filtered(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Обрабатывает выбор периода и сортировки.""" # Парсим callback_data: admin_top_ref:period:sort_by - parts = callback.data.split(":") + parts = callback.data.split(':') if len(parts) != 3: - await callback.answer("Ошибка параметров") + await callback.answer('Ошибка параметров') return period = parts[1] # week или month sort_by = parts[2] # earnings или invited - if period not in ("week", "month"): - period = "week" - if sort_by not in ("earnings", "invited"): - sort_by = "earnings" + if period not in ('week', 'month'): + period = 'week' + if sort_by not in ('earnings', 'invited'): + sort_by = 'earnings' await _show_top_referrers_filtered(callback, db, period, sort_by) -async def _show_top_referrers_filtered( - callback: types.CallbackQuery, - db: AsyncSession, - period: str, - sort_by: str -): +async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncSession, period: str, sort_by: str): """Внутренняя функция отображения топа с фильтрами.""" try: top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by) - period_text = "за неделю" if period == "week" else "за месяц" - sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым" + period_text = 'за неделю' if period == 'week' else 'за месяц' + sort_text = 'по заработку' if sort_by == 'earnings' else 'по приглашённым' - text = f"🏆 Топ рефереров {period_text}\n" - text += f"Сортировка: {sort_text}\n\n" + text = f'🏆 Топ рефереров {period_text}\n' + text += f'Сортировка: {sort_text}\n\n' if top_referrers: for i, referrer in enumerate(top_referrers[:20], 1): @@ -238,32 +213,32 @@ async def _show_top_referrers_filtered( telegram_id = referrer.get('telegram_id') user_email = referrer.get('email', '') user_id = referrer.get('user_id', '') - id_display = telegram_id or user_email or f"#{user_id}" if user_id else "N/A" + id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A' if username: - display_text = f"@{username} (ID{id_display})" - elif display_name and display_name != f"ID{id_display}": - display_text = f"{display_name} (ID{id_display})" + display_text = f'@{username} (ID{id_display})' + elif display_name and display_name != f'ID{id_display}': + display_text = f'{display_name} (ID{id_display})' else: - display_text = f"ID{id_display}" + display_text = f'ID{id_display}' - emoji = "" + emoji = '' if i == 1: - emoji = "🥇 " + emoji = '🥇 ' elif i == 2: - emoji = "🥈 " + emoji = '🥈 ' elif i == 3: - emoji = "🥉 " + emoji = '🥉 ' # Выделяем основную метрику в зависимости от сортировки - if sort_by == "invited": - text += f"{emoji}{i}. {display_text}\n" - text += f" 👥 {count} приглашённых | 💰 {settings.format_price(earned)}\n\n" + if sort_by == 'invited': + text += f'{emoji}{i}. {display_text}\n' + text += f' 👥 {count} приглашённых | 💰 {settings.format_price(earned)}\n\n' else: - text += f"{emoji}{i}. {display_text}\n" - text += f" 💰 {settings.format_price(earned)} | 👥 {count} приглашённых\n\n" + text += f'{emoji}{i}. {display_text}\n' + text += f' 💰 {settings.format_price(earned)} | 👥 {count} приглашённых\n\n' else: - text += "Нет данных за выбранный период\n" + text += 'Нет данных за выбранный период\n' keyboard = _get_top_keyboard(period, sort_by) @@ -271,23 +246,19 @@ async def _show_top_referrers_filtered( await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() except Exception as edit_error: - if "message is not modified" in str(edit_error): - await callback.answer("Данные актуальны") + if 'message is not modified' in str(edit_error): + await callback.answer('Данные актуальны') else: raise except Exception as e: - logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True) - await callback.answer("Ошибка загрузки топа рефереров") + logger.error(f'Ошибка в show_top_referrers_filtered: {e}', exc_info=True) + await callback.answer('Ошибка загрузки топа рефереров') @admin_required @error_handler -async def show_referral_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_referral_settings(callback: types.CallbackQuery, db_user: User, db: AsyncSession): text = f""" ⚙️ Настройки реферальной системы @@ -305,121 +276,98 @@ async def show_referral_settings( 💡 Для изменения настроек отредактируйте файл .env и перезапустите бота """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')]] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() @admin_required @error_handler -async def show_pending_withdrawal_requests( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает список ожидающих заявок на вывод.""" requests = await referral_withdrawal_service.get_pending_requests(db) if not requests: - text = "📋 Заявки на вывод\n\nНет ожидающих заявок." + text = '📋 Заявки на вывод\n\nНет ожидающих заявок.' keyboard_rows = [] # Кнопка тестового начисления (только в тестовом режиме) if settings.REFERRAL_WITHDRAWAL_TEST_MODE: - keyboard_rows.append([ - types.InlineKeyboardButton( - text="🧪 Тестовое начисление", - callback_data="admin_test_referral_earning" - ) - ]) - keyboard_rows.append([ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals") - ]) + keyboard_rows.append( + [types.InlineKeyboardButton(text='🧪 Тестовое начисление', callback_data='admin_test_referral_earning')] + ) + keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referrals')]) - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)) await callback.answer() return - text = f"📋 Заявки на вывод ({len(requests)})\n\n" + text = f'📋 Заявки на вывод ({len(requests)})\n\n' for req in requests[:10]: user = await get_user_by_id(db, req.user_id) - user_name = user.full_name if user else "Неизвестно" - user_tg_id = user.telegram_id if user else "N/A" + user_name = user.full_name if user else 'Неизвестно' + user_tg_id = user.telegram_id if user else 'N/A' - risk_emoji = "🟢" if req.risk_score < 30 else "🟡" if req.risk_score < 50 else "🟠" if req.risk_score < 70 else "🔴" + risk_emoji = ( + '🟢' if req.risk_score < 30 else '🟡' if req.risk_score < 50 else '🟠' if req.risk_score < 70 else '🔴' + ) - text += f"#{req.id} — {user_name} (ID{user_tg_id})\n" - text += f"💰 {req.amount_kopeks / 100:.0f}₽ | {risk_emoji} Риск: {req.risk_score}/100\n" - text += f"📅 {req.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + text += f'#{req.id} — {user_name} (ID{user_tg_id})\n' + text += f'💰 {req.amount_kopeks / 100:.0f}₽ | {risk_emoji} Риск: {req.risk_score}/100\n' + text += f'📅 {req.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' keyboard_rows = [] for req in requests[:5]: - keyboard_rows.append([ - types.InlineKeyboardButton( - text=f"#{req.id} — {req.amount_kopeks / 100:.0f}₽", - callback_data=f"admin_withdrawal_view_{req.id}" - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=f'#{req.id} — {req.amount_kopeks / 100:.0f}₽', callback_data=f'admin_withdrawal_view_{req.id}' + ) + ] + ) # Кнопка тестового начисления (только в тестовом режиме) if settings.REFERRAL_WITHDRAWAL_TEST_MODE: - keyboard_rows.append([ - types.InlineKeyboardButton( - text="🧪 Тестовое начисление", - callback_data="admin_test_referral_earning" - ) - ]) + keyboard_rows.append( + [types.InlineKeyboardButton(text='🧪 Тестовое начисление', callback_data='admin_test_referral_earning')] + ) - keyboard_rows.append([ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_referrals") - ]) + keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referrals')]) - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)) await callback.answer() @admin_required @error_handler -async def view_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает детали заявки на вывод.""" - request_id = int(callback.data.split("_")[-1]) + request_id = int(callback.data.split('_')[-1]) - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request: - await callback.answer("Заявка не найдена", show_alert=True) + await callback.answer('Заявка не найдена', show_alert=True) return user = await get_user_by_id(db, request.user_id) - user_name = user.full_name if user else "Неизвестно" - user_tg_id = (user.telegram_id or user.email or f"#{user.id}") if user else "N/A" + user_name = user.full_name if user else 'Неизвестно' + user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A' analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} status_text = { - WithdrawalRequestStatus.PENDING.value: "⏳ Ожидает", - WithdrawalRequestStatus.APPROVED.value: "✅ Одобрена", - WithdrawalRequestStatus.REJECTED.value: "❌ Отклонена", - WithdrawalRequestStatus.COMPLETED.value: "✅ Выполнена", - WithdrawalRequestStatus.CANCELLED.value: "🚫 Отменена", + WithdrawalRequestStatus.PENDING.value: '⏳ Ожидает', + WithdrawalRequestStatus.APPROVED.value: '✅ Одобрена', + WithdrawalRequestStatus.REJECTED.value: '❌ Отклонена', + WithdrawalRequestStatus.COMPLETED.value: '✅ Выполнена', + WithdrawalRequestStatus.CANCELLED.value: '🚫 Отменена', }.get(request.status, request.status) text = f""" @@ -441,65 +389,46 @@ async def view_withdrawal_request( keyboard = [] if request.status == WithdrawalRequestStatus.PENDING.value: - keyboard.append([ - types.InlineKeyboardButton( - text="✅ Одобрить", - callback_data=f"admin_withdrawal_approve_{request.id}" - ), - types.InlineKeyboardButton( - text="❌ Отклонить", - callback_data=f"admin_withdrawal_reject_{request.id}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton(text='✅ Одобрить', callback_data=f'admin_withdrawal_approve_{request.id}'), + types.InlineKeyboardButton(text='❌ Отклонить', callback_data=f'admin_withdrawal_reject_{request.id}'), + ] + ) if request.status == WithdrawalRequestStatus.APPROVED.value: - keyboard.append([ - types.InlineKeyboardButton( - text="✅ Деньги переведены", - callback_data=f"admin_withdrawal_complete_{request.id}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text='✅ Деньги переведены', callback_data=f'admin_withdrawal_complete_{request.id}' + ) + ] + ) if user: - keyboard.append([ - types.InlineKeyboardButton( - text="👤 Профиль пользователя", - callback_data=f"admin_user_manage_{user.id}" - ) - ]) - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_withdrawal_requests") - ]) + keyboard.append( + [types.InlineKeyboardButton(text='👤 Профиль пользователя', callback_data=f'admin_user_manage_{user.id}')] + ) + keyboard.append([types.InlineKeyboardButton(text='⬅️ К списку', callback_data='admin_withdrawal_requests')]) - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def approve_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def approve_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Одобряет заявку на вывод.""" - request_id = int(callback.data.split("_")[-1]) + request_id = int(callback.data.split('_')[-1]) - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request: - await callback.answer("Заявка не найдена", show_alert=True) + await callback.answer('Заявка не найдена', show_alert=True) return - success, error = await referral_withdrawal_service.approve_request( - db, request_id, db_user.id - ) + success, error = await referral_withdrawal_service.approve_request(db, request_id, db_user.id) if success: # Уведомляем пользователя (только если есть telegram_id) @@ -510,46 +439,38 @@ async def approve_withdrawal_request( await callback.bot.send_message( user.telegram_id, texts.t( - "REFERRAL_WITHDRAWAL_APPROVED", - "✅ Заявка на вывод #{id} одобрена!\n\n" - "Сумма: {amount}\n" - "Средства списаны с баланса.\n\n" - "Ожидайте перевод на указанные реквизиты." - ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + 'REFERRAL_WITHDRAWAL_APPROVED', + '✅ Заявка на вывод #{id} одобрена!\n\n' + 'Сумма: {amount}\n' + 'Средства списаны с баланса.\n\n' + 'Ожидайте перевод на указанные реквизиты.', + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f"Ошибка отправки уведомления пользователю: {e}") + logger.error(f'Ошибка отправки уведомления пользователю: {e}') - await callback.answer("✅ Заявка одобрена, средства списаны с баланса") + await callback.answer('✅ Заявка одобрена, средства списаны с баланса') # Обновляем отображение await view_withdrawal_request(callback, db_user, db) else: - await callback.answer(f"❌ {error}", show_alert=True) + await callback.answer(f'❌ {error}', show_alert=True) @admin_required @error_handler -async def reject_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def reject_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Отклоняет заявку на вывод.""" - request_id = int(callback.data.split("_")[-1]) + request_id = int(callback.data.split('_')[-1]) - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request: - await callback.answer("Заявка не найдена", show_alert=True) + await callback.answer('Заявка не найдена', show_alert=True) return - success = await referral_withdrawal_service.reject_request( - db, request_id, db_user.id, "Отклонено администратором" - ) + success = await referral_withdrawal_service.reject_request(db, request_id, db_user.id, 'Отклонено администратором') if success: # Уведомляем пользователя (только если есть telegram_id) @@ -560,45 +481,37 @@ async def reject_withdrawal_request( await callback.bot.send_message( user.telegram_id, texts.t( - "REFERRAL_WITHDRAWAL_REJECTED", - "❌ Заявка на вывод #{id} отклонена\n\n" - "Сумма: {amount}\n\n" - "Если у вас есть вопросы, обратитесь в поддержку." - ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + 'REFERRAL_WITHDRAWAL_REJECTED', + '❌ Заявка на вывод #{id} отклонена\n\n' + 'Сумма: {amount}\n\n' + 'Если у вас есть вопросы, обратитесь в поддержку.', + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f"Ошибка отправки уведомления пользователю: {e}") + logger.error(f'Ошибка отправки уведомления пользователю: {e}') - await callback.answer("❌ Заявка отклонена") + await callback.answer('❌ Заявка отклонена') # Обновляем отображение await view_withdrawal_request(callback, db_user, db) else: - await callback.answer("❌ Ошибка отклонения", show_alert=True) + await callback.answer('❌ Ошибка отклонения', show_alert=True) @admin_required @error_handler -async def complete_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def complete_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Отмечает заявку как выполненную (деньги переведены).""" - request_id = int(callback.data.split("_")[-1]) + request_id = int(callback.data.split('_')[-1]) - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request: - await callback.answer("Заявка не найдена", show_alert=True) + await callback.answer('Заявка не найдена', show_alert=True) return - success = await referral_withdrawal_service.complete_request( - db, request_id, db_user.id, "Перевод выполнен" - ) + success = await referral_withdrawal_service.complete_request(db, request_id, db_user.id, 'Перевод выполнен') if success: # Уведомляем пользователя (только если есть telegram_id) @@ -609,34 +522,31 @@ async def complete_withdrawal_request( await callback.bot.send_message( user.telegram_id, texts.t( - "REFERRAL_WITHDRAWAL_COMPLETED", - "💸 Выплата по заявке #{id} выполнена!\n\n" - "Сумма: {amount}\n\n" - "Деньги отправлены на указанные реквизиты." - ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)) + 'REFERRAL_WITHDRAWAL_COMPLETED', + '💸 Выплата по заявке #{id} выполнена!\n\n' + 'Сумма: {amount}\n\n' + 'Деньги отправлены на указанные реквизиты.', + ).format(id=request.id, amount=texts.format_price(request.amount_kopeks)), ) except Exception as e: - logger.error(f"Ошибка отправки уведомления пользователю: {e}") + logger.error(f'Ошибка отправки уведомления пользователю: {e}') - await callback.answer("✅ Заявка выполнена") + await callback.answer('✅ Заявка выполнена') # Обновляем отображение await view_withdrawal_request(callback, db_user, db) else: - await callback.answer("❌ Ошибка выполнения", show_alert=True) + await callback.answer('❌ Ошибка выполнения', show_alert=True) @admin_required @error_handler async def start_test_referral_earning( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): """Начинает процесс тестового начисления реферального дохода.""" if not settings.REFERRAL_WITHDRAWAL_TEST_MODE: - await callback.answer("Тестовый режим отключён", show_alert=True) + await callback.answer('Тестовый режим отключён', show_alert=True) return await state.set_state(AdminStates.test_referral_earning_input) @@ -654,9 +564,9 @@ async def start_test_referral_earning( ⚠️ Это создаст реальную запись ReferralEarning, как будто пользователь заработал с реферала. """ - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_withdrawal_requests")] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_withdrawal_requests')]] + ) await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() @@ -664,15 +574,10 @@ async def start_test_referral_earning( @admin_required @error_handler -async def process_test_referral_earning( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_test_referral_earning(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): """Обрабатывает ввод тестового начисления.""" if not settings.REFERRAL_WITHDRAWAL_TEST_MODE: - await message.answer("❌ Тестовый режим отключён") + await message.answer('❌ Тестовый режим отключён') await state.clear() return @@ -681,35 +586,33 @@ async def process_test_referral_earning( if len(parts) != 2: await message.answer( - "❌ Неверный формат. Введите: telegram_id сумма\n\n" - "Например: 123456789 500" + '❌ Неверный формат. Введите: telegram_id сумма\n\nНапример: 123456789 500' ) return try: target_telegram_id = int(parts[0]) - amount_rubles = float(parts[1].replace(",", ".")) + amount_rubles = float(parts[1].replace(',', '.')) amount_kopeks = int(amount_rubles * 100) if amount_kopeks <= 0: - await message.answer("❌ Сумма должна быть положительной") + await message.answer('❌ Сумма должна быть положительной') return if amount_kopeks > 10000000: # Лимит 100 000₽ - await message.answer("❌ Максимальная сумма тестового начисления: 100 000₽") + await message.answer('❌ Максимальная сумма тестового начисления: 100 000₽') return except ValueError: await message.answer( - "❌ Неверный формат чисел. Введите: telegram_id сумма\n\n" - "Например: 123456789 500" + '❌ Неверный формат чисел. Введите: telegram_id сумма\n\nНапример: 123456789 500' ) return # Ищем целевого пользователя target_user = await get_user_by_telegram_id(db, target_telegram_id) if not target_user: - await message.answer(f"❌ Пользователь с ID {target_telegram_id} не найден в базе") + await message.answer(f'❌ Пользователь с ID {target_telegram_id} не найден в базе') return # Создаём тестовое начисление @@ -717,7 +620,7 @@ async def process_test_referral_earning( user_id=target_user.id, referral_id=target_user.id, # Сам на себя (тестовое) amount_kopeks=amount_kopeks, - reason="test_earning" + reason='test_earning', ) db.add(earning) @@ -728,37 +631,38 @@ async def process_test_referral_earning( await state.clear() await message.answer( - f"✅ Тестовое начисление создано!\n\n" - f"👤 Пользователь: {target_user.full_name or 'Без имени'}\n" - f"🆔 ID: {target_telegram_id}\n" - f"💰 Сумма: {amount_rubles:.0f}₽\n" - f"💳 Новый баланс: {target_user.balance_kopeks / 100:.0f}₽\n\n" - f"Начисление добавлено как реферальный доход.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📋 К заявкам", callback_data="admin_withdrawal_requests")], - [types.InlineKeyboardButton(text="👤 Профиль", callback_data=f"admin_user_manage_{target_user.id}")] - ]) + f'✅ Тестовое начисление создано!\n\n' + f'👤 Пользователь: {target_user.full_name or "Без имени"}\n' + f'🆔 ID: {target_telegram_id}\n' + f'💰 Сумма: {amount_rubles:.0f}₽\n' + f'💳 Новый баланс: {target_user.balance_kopeks / 100:.0f}₽\n\n' + f'Начисление добавлено как реферальный доход.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📋 К заявкам', callback_data='admin_withdrawal_requests')], + [types.InlineKeyboardButton(text='👤 Профиль', callback_data=f'admin_user_manage_{target_user.id}')], + ] + ), ) logger.info( - f"Тестовое начисление: админ {db_user.telegram_id} начислил {amount_rubles}₽ " - f"пользователю {target_telegram_id}" + f'Тестовое начисление: админ {db_user.telegram_id} начислил {amount_rubles}₽ пользователю {target_telegram_id}' ) def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals") - dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top") - dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:")) - dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings") + dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals') + dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top') + dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:')) + dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings') # Хендлеры заявок на вывод - dp.callback_query.register(show_pending_withdrawal_requests, F.data == "admin_withdrawal_requests") - dp.callback_query.register(view_withdrawal_request, F.data.startswith("admin_withdrawal_view_")) - dp.callback_query.register(approve_withdrawal_request, F.data.startswith("admin_withdrawal_approve_")) - dp.callback_query.register(reject_withdrawal_request, F.data.startswith("admin_withdrawal_reject_")) - dp.callback_query.register(complete_withdrawal_request, F.data.startswith("admin_withdrawal_complete_")) + dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests') + dp.callback_query.register(view_withdrawal_request, F.data.startswith('admin_withdrawal_view_')) + dp.callback_query.register(approve_withdrawal_request, F.data.startswith('admin_withdrawal_approve_')) + dp.callback_query.register(reject_withdrawal_request, F.data.startswith('admin_withdrawal_reject_')) + dp.callback_query.register(complete_withdrawal_request, F.data.startswith('admin_withdrawal_complete_')) # Тестовое начисление - dp.callback_query.register(start_test_referral_earning, F.data == "admin_test_referral_earning") + dp.callback_query.register(start_test_referral_earning, F.data == 'admin_test_referral_earning') dp.message.register(process_test_referral_earning, AdminStates.test_referral_earning_input) diff --git a/app/handlers/admin/remnawave.py b/app/handlers/admin/remnawave.py index c0c17fbf..868ce62e 100644 --- a/app/handlers/admin/remnawave.py +++ b/app/handlers/admin/remnawave.py @@ -1,40 +1,42 @@ import logging import math from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext -from app.states import ( - RemnaWaveSyncStates, - SquadRenameStates, - SquadCreateStates, - SquadMigrationStates, -) from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User from app.database.crud.server_squad import ( count_active_users_for_squad, get_all_server_squads, get_server_squad_by_uuid, ) +from app.database.models import User from app.keyboards.admin import ( - get_admin_remnawave_keyboard, get_sync_options_keyboard, - get_node_management_keyboard, get_confirmation_keyboard, - get_squad_management_keyboard, get_squad_edit_keyboard + get_admin_remnawave_keyboard, + get_node_management_keyboard, + get_squad_edit_keyboard, + get_squad_management_keyboard, ) from app.localization.texts import get_texts -from app.services.remnawave_service import RemnaWaveService, RemnaWaveConfigurationError +from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService from app.services.remnawave_sync_service import ( RemnaWaveAutoSyncStatus, remnawave_sync_service, ) from app.services.system_settings_service import bot_configuration_service +from app.states import ( + RemnaWaveSyncStates, + SquadCreateStates, + SquadMigrationStates, + SquadRenameStates, +) from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_bytes, format_datetime + logger = logging.getLogger(__name__) squad_inbound_selections = {} @@ -45,97 +47,75 @@ MIGRATION_PAGE_SIZE = 8 def _format_duration(seconds: float) -> str: if seconds < 1: - return "менее 1с" + return 'менее 1с' minutes, sec = divmod(int(seconds), 60) if minutes: if sec: - return f"{minutes} мин {sec} с" - return f"{minutes} мин" - return f"{sec} с" + return f'{minutes} мин {sec} с' + return f'{minutes} мин' + return f'{sec} с' -def _format_user_stats(stats: Optional[Dict[str, Any]]) -> str: +def _format_user_stats(stats: dict[str, Any] | None) -> str: if not stats: - return "—" + return '—' - created = stats.get("created", 0) - updated = stats.get("updated", 0) - deleted = stats.get("deleted", stats.get("deactivated", 0)) - errors = stats.get("errors", 0) + created = stats.get('created', 0) + updated = stats.get('updated', 0) + deleted = stats.get('deleted', stats.get('deactivated', 0)) + errors = stats.get('errors', 0) - return ( - f"• Создано: {created}\n" - f"• Обновлено: {updated}\n" - f"• Деактивировано: {deleted}\n" - f"• Ошибок: {errors}" - ) + return f'• Создано: {created}\n• Обновлено: {updated}\n• Деактивировано: {deleted}\n• Ошибок: {errors}' -def _format_server_stats(stats: Optional[Dict[str, Any]]) -> str: +def _format_server_stats(stats: dict[str, Any] | None) -> str: if not stats: - return "—" + return '—' - created = stats.get("created", 0) - updated = stats.get("updated", 0) - removed = stats.get("removed", 0) - total = stats.get("total", 0) + created = stats.get('created', 0) + updated = stats.get('updated', 0) + removed = stats.get('removed', 0) + total = stats.get('total', 0) - return ( - f"• Создано: {created}\n" - f"• Обновлено: {updated}\n" - f"• Удалено: {removed}\n" - f"• Всего в панели: {total}" - ) + return f'• Создано: {created}\n• Обновлено: {updated}\n• Удалено: {removed}\n• Всего в панели: {total}' def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.InlineKeyboardMarkup]: - times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—" - next_run_text = format_datetime(status.next_run) if status.next_run else "—" + times_text = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—' + next_run_text = format_datetime(status.next_run) if status.next_run else '—' if status.last_run_finished_at: finished_text = format_datetime(status.last_run_finished_at) - started_text = ( - format_datetime(status.last_run_started_at) - if status.last_run_started_at - else "—" - ) - duration = ( - status.last_run_finished_at - status.last_run_started_at - if status.last_run_started_at - else None - ) - duration_text = f" ({_format_duration(duration.total_seconds())})" if duration else "" + started_text = format_datetime(status.last_run_started_at) if status.last_run_started_at else '—' + duration = status.last_run_finished_at - status.last_run_started_at if status.last_run_started_at else None + duration_text = f' ({_format_duration(duration.total_seconds())})' if duration else '' reason_map = { - "manual": "вручную", - "auto": "по расписанию", - "immediate": "при включении", + 'manual': 'вручную', + 'auto': 'по расписанию', + 'immediate': 'при включении', } - reason_text = reason_map.get(status.last_run_reason or "", "—") - result_icon = "✅" if status.last_run_success else "❌" - result_label = "успешно" if status.last_run_success else "с ошибками" - error_block = ( - f"\n⚠️ Ошибка: {status.last_run_error}" - if status.last_run_error - else "" - ) + reason_text = reason_map.get(status.last_run_reason or '', '—') + result_icon = '✅' if status.last_run_success else '❌' + result_label = 'успешно' if status.last_run_success else 'с ошибками' + error_block = f'\n⚠️ Ошибка: {status.last_run_error}' if status.last_run_error else '' last_run_text = ( - f"{result_icon} {result_label}\n" - f"• Старт: {started_text}\n" - f"• Завершено: {finished_text}{duration_text}\n" - f"• Причина запуска: {reason_text}{error_block}" + f'{result_icon} {result_label}\n' + f'• Старт: {started_text}\n' + f'• Завершено: {finished_text}{duration_text}\n' + f'• Причина запуска: {reason_text}{error_block}' ) elif status.last_run_started_at: last_run_text = ( - "⏳ Синхронизация началась, но еще не завершилась" + '⏳ Синхронизация началась, но еще не завершилась' if status.is_running - else f"ℹ️ Последний запуск: {format_datetime(status.last_run_started_at)}" + else f'ℹ️ Последний запуск: {format_datetime(status.last_run_started_at)}' ) else: - last_run_text = "—" + last_run_text = '—' - running_text = "⏳ Выполняется сейчас" if status.is_running else "Ожидание" - toggle_text = "❌ Отключить" if status.enabled else "✅ Включить" + running_text = '⏳ Выполняется сейчас' if status.is_running else 'Ожидание' + toggle_text = '❌ Отключить' if status.enabled else '✅ Включить' text = f"""🔄 Автосинхронизация RemnaWave @@ -158,26 +138,26 @@ def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.I inline_keyboard=[ [ types.InlineKeyboardButton( - text="🔁 Запустить сейчас", - callback_data="remnawave_auto_sync_run", + text='🔁 Запустить сейчас', + callback_data='remnawave_auto_sync_run', ) ], [ types.InlineKeyboardButton( text=toggle_text, - callback_data="remnawave_auto_sync_toggle", + callback_data='remnawave_auto_sync_toggle', ) ], [ types.InlineKeyboardButton( - text="🕒 Изменить расписание", - callback_data="remnawave_auto_sync_times", + text='🕒 Изменить расписание', + callback_data='remnawave_auto_sync_times', ) ], [ types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_rw_sync", + text='⬅️ Назад', + callback_data='admin_rw_sync', ) ], ] @@ -188,13 +168,13 @@ def _build_auto_sync_view(status: RemnaWaveAutoSyncStatus) -> tuple[str, types.I def _format_migration_server_label(texts, server) -> str: status = ( - texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE", "✅ Доступен") - if getattr(server, "is_available", True) - else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE", "🚫 Недоступен") + texts.t('ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE', '✅ Доступен') + if getattr(server, 'is_available', True) + else texts.t('ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE', '🚫 Недоступен') ) return texts.t( - "ADMIN_SQUAD_MIGRATION_SERVER_LABEL", - "{name} — 👥 {users} ({status})", + 'ADMIN_SQUAD_MIGRATION_SERVER_LABEL', + '{name} — 👥 {users} ({status})', ).format(name=server.display_name, users=server.current_users, status=status) @@ -207,13 +187,13 @@ def _build_migration_keyboard( *, exclude_uuid: str = None, ): - prefix = "admin_migration_source" if stage == "source" else "admin_migration_target" + prefix = 'admin_migration_source' if stage == 'source' else 'admin_migration_target' rows = [] has_items = False button_template = texts.t( - "ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON", - "🌍 {name} — 👥 {users} ({status})", + 'ADMIN_SQUAD_MIGRATION_SQUAD_BUTTON', + '🌍 {name} — 👥 {users} ({status})', ) for squad in squads: @@ -222,9 +202,9 @@ def _build_migration_keyboard( has_items = True status = ( - texts.t("ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT", "✅") - if getattr(squad, "is_available", True) - else texts.t("ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT", "🚫") + texts.t('ADMIN_SQUAD_MIGRATION_STATUS_AVAILABLE_SHORT', '✅') + if getattr(squad, 'is_available', True) + else texts.t('ADMIN_SQUAD_MIGRATION_STATUS_UNAVAILABLE_SHORT', '🚫') ) rows.append( [ @@ -234,7 +214,7 @@ def _build_migration_keyboard( users=squad.current_users, status=status, ), - callback_data=f"{prefix}_{squad.squad_uuid}", + callback_data=f'{prefix}_{squad.squad_uuid}', ) ] ) @@ -244,24 +224,24 @@ def _build_migration_keyboard( if page > 1: nav_buttons.append( types.InlineKeyboardButton( - text="⬅️", - callback_data=f"{prefix}_page_{page - 1}", + text='⬅️', + callback_data=f'{prefix}_page_{page - 1}', ) ) nav_buttons.append( types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_PAGE", - "Стр. {page}/{pages}", + 'ADMIN_SQUAD_MIGRATION_PAGE', + 'Стр. {page}/{pages}', ).format(page=page, pages=total_pages), - callback_data="admin_migration_page_info", + callback_data='admin_migration_page_info', ) ) if page < total_pages: nav_buttons.append( types.InlineKeyboardButton( - text="➡️", - callback_data=f"{prefix}_page_{page + 1}", + text='➡️', + callback_data=f'{prefix}_page_{page + 1}', ) ) rows.append(nav_buttons) @@ -270,7 +250,7 @@ def _build_migration_keyboard( [ types.InlineKeyboardButton( text=texts.CANCEL, - callback_data="admin_migration_cancel", + callback_data='admin_migration_cancel', ) ] ) @@ -289,8 +269,7 @@ async def _fetch_migration_page( ) total_pages = max(1, math.ceil(total / MIGRATION_PAGE_SIZE)) - if page < 1: - page = 1 + page = max(page, 1) if page > total_pages: page = total_pages squads, total = await get_all_server_squads( @@ -321,25 +300,22 @@ async def show_squad_migration_menu( squads, page, total_pages, - "source", + 'source', ) message = ( - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") - + "\n\n" + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов') + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE", - "Выберите сквад, из которого нужно переехать:", + 'ADMIN_SQUAD_MIGRATION_SELECT_SOURCE', + 'Выберите сквад, из которого нужно переехать:', ) ) if not has_items: - message += ( - "\n\n" - + texts.t( - "ADMIN_SQUAD_MIGRATION_NO_OPTIONS", - "Нет доступных сквадов. Добавьте новые или отмените операцию.", - ) + message += '\n\n' + texts.t( + 'ADMIN_SQUAD_MIGRATION_NO_OPTIONS', + 'Нет доступных сквадов. Добавьте новые или отмените операцию.', ) await state.set_state(SquadMigrationStates.selecting_source) @@ -365,7 +341,7 @@ async def paginate_migration_source( return try: - page = int(callback.data.split("_page_")[-1]) + page = int(callback.data.split('_page_')[-1]) except (ValueError, IndexError): await callback.answer() return @@ -377,25 +353,22 @@ async def paginate_migration_source( squads, page, total_pages, - "source", + 'source', ) message = ( - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") - + "\n\n" + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов') + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECT_SOURCE", - "Выберите сквад, из которого нужно переехать:", + 'ADMIN_SQUAD_MIGRATION_SELECT_SOURCE', + 'Выберите сквад, из которого нужно переехать:', ) ) if not has_items: - message += ( - "\n\n" - + texts.t( - "ADMIN_SQUAD_MIGRATION_NO_OPTIONS", - "Нет доступных сквадов. Добавьте новые или отмените операцию.", - ) + message += '\n\n' + texts.t( + 'ADMIN_SQUAD_MIGRATION_NO_OPTIONS', + 'Нет доступных сквадов. Добавьте новые или отмените операцию.', ) await callback.message.edit_text( @@ -418,11 +391,11 @@ async def handle_migration_source_selection( await callback.answer() return - if "_page_" in callback.data: + if '_page_' in callback.data: await callback.answer() return - source_uuid = callback.data.replace("admin_migration_source_", "", 1) + source_uuid = callback.data.replace('admin_migration_source_', '', 1) texts = get_texts(db_user.language) server = await get_server_squad_by_uuid(db, source_uuid) @@ -430,8 +403,8 @@ async def handle_migration_source_selection( if not server: await callback.answer( texts.t( - "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND", - "Сквад не найден или недоступен.", + 'ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND', + 'Сквад не найден или недоступен.', ), show_alert=True, ) @@ -448,31 +421,28 @@ async def handle_migration_source_selection( squads, page, total_pages, - "target", + 'target', exclude_uuid=server.squad_uuid, ) message = ( - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") - + "\n\n" + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов') + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", - "Источник: {source}", + 'ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE', + 'Источник: {source}', ).format(source=_format_migration_server_label(texts, server)) - + "\n\n" + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", - "Выберите сквад, в который нужно переехать:", + 'ADMIN_SQUAD_MIGRATION_SELECT_TARGET', + 'Выберите сквад, в который нужно переехать:', ) ) if not has_items: - message += ( - "\n\n" - + texts.t( - "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", - "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", - ) + message += '\n\n' + texts.t( + 'ADMIN_SQUAD_MIGRATION_TARGET_EMPTY', + 'Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.', ) await state.set_state(SquadMigrationStates.selecting_target) @@ -498,13 +468,13 @@ async def paginate_migration_target( return try: - page = int(callback.data.split("_page_")[-1]) + page = int(callback.data.split('_page_')[-1]) except (ValueError, IndexError): await callback.answer() return data = await state.get_data() - source_uuid = data.get("source_uuid") + source_uuid = data.get('source_uuid') if not source_uuid: await callback.answer() return @@ -517,33 +487,30 @@ async def paginate_migration_target( squads, page, total_pages, - "target", + 'target', exclude_uuid=source_uuid, ) - source_display = data.get("source_display") or source_uuid + source_display = data.get('source_display') or source_uuid message = ( - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") - + "\n\n" + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов') + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", - "Источник: {source}", + 'ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE', + 'Источник: {source}', ).format(source=source_display) - + "\n\n" + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", - "Выберите сквад, в который нужно переехать:", + 'ADMIN_SQUAD_MIGRATION_SELECT_TARGET', + 'Выберите сквад, в который нужно переехать:', ) ) if not has_items: - message += ( - "\n\n" - + texts.t( - "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", - "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", - ) + message += '\n\n' + texts.t( + 'ADMIN_SQUAD_MIGRATION_TARGET_EMPTY', + 'Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.', ) await callback.message.edit_text( @@ -567,26 +534,26 @@ async def handle_migration_target_selection( await callback.answer() return - if "_page_" in callback.data: + if '_page_' in callback.data: await callback.answer() return data = await state.get_data() - source_uuid = data.get("source_uuid") + source_uuid = data.get('source_uuid') if not source_uuid: await callback.answer() return - target_uuid = callback.data.replace("admin_migration_target_", "", 1) + target_uuid = callback.data.replace('admin_migration_target_', '', 1) texts = get_texts(db_user.language) if target_uuid == source_uuid: await callback.answer( texts.t( - "ADMIN_SQUAD_MIGRATION_SAME_SQUAD", - "Нельзя выбрать тот же сквад.", + 'ADMIN_SQUAD_MIGRATION_SAME_SQUAD', + 'Нельзя выбрать тот же сквад.', ), show_alert=True, ) @@ -596,14 +563,14 @@ async def handle_migration_target_selection( if not target_server: await callback.answer( texts.t( - "ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND", - "Сквад не найден или недоступен.", + 'ADMIN_SQUAD_MIGRATION_SQUAD_NOT_FOUND', + 'Сквад не найден или недоступен.', ), show_alert=True, ) return - source_display = data.get("source_display") or source_uuid + source_display = data.get('source_display') or source_uuid users_to_move = await count_active_users_for_squad(db, source_uuid) @@ -616,28 +583,28 @@ async def handle_migration_target_selection( await state.set_state(SquadMigrationStates.confirming) message_lines = [ - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов"), - "", + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов'), + '', texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS", - "Проверьте параметры переезда:", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_DETAILS', + 'Проверьте параметры переезда:', ), texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", - "• Из: {source}", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE', + '• Из: {source}', ).format(source=source_display), texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", - "• В: {target}", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET', + '• В: {target}', ).format(target=_format_migration_server_label(texts, target_server)), texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT", - "• Пользователей к переносу: {count}", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_COUNT', + '• Пользователей к переносу: {count}', ).format(count=users_to_move), - "", + '', texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT", - "Подтвердите выполнение операции.", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_PROMPT', + 'Подтвердите выполнение операции.', ), ] @@ -646,32 +613,32 @@ async def handle_migration_target_selection( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON", - "✅ Подтвердить", + 'ADMIN_SQUAD_MIGRATION_CONFIRM_BUTTON', + '✅ Подтвердить', ), - callback_data="admin_migration_confirm", + callback_data='admin_migration_confirm', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_CHANGE_TARGET", - "🔄 Изменить сервер назначения", + 'ADMIN_SQUAD_MIGRATION_CHANGE_TARGET', + '🔄 Изменить сервер назначения', ), - callback_data="admin_migration_change_target", + callback_data='admin_migration_change_target', ) ], [ types.InlineKeyboardButton( text=texts.CANCEL, - callback_data="admin_migration_cancel", + callback_data='admin_migration_cancel', ) ], ] ) await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, disable_web_page_preview=True, ) @@ -687,7 +654,7 @@ async def change_migration_target( state: FSMContext, ): data = await state.get_data() - source_uuid = data.get("source_uuid") + source_uuid = data.get('source_uuid') if not source_uuid: await callback.answer() @@ -702,33 +669,30 @@ async def change_migration_target( squads, page, total_pages, - "target", + 'target', exclude_uuid=source_uuid, ) - source_display = data.get("source_display") or source_uuid + source_display = data.get('source_display') or source_uuid message = ( - texts.t("ADMIN_SQUAD_MIGRATION_TITLE", "🚚 Переезд сквадов") - + "\n\n" + texts.t('ADMIN_SQUAD_MIGRATION_TITLE', '🚚 Переезд сквадов') + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE", - "Источник: {source}", + 'ADMIN_SQUAD_MIGRATION_SELECTED_SOURCE', + 'Источник: {source}', ).format(source=source_display) - + "\n\n" + + '\n\n' + texts.t( - "ADMIN_SQUAD_MIGRATION_SELECT_TARGET", - "Выберите сквад, в который нужно переехать:", + 'ADMIN_SQUAD_MIGRATION_SELECT_TARGET', + 'Выберите сквад, в который нужно переехать:', ) ) if not has_items: - message += ( - "\n\n" - + texts.t( - "ADMIN_SQUAD_MIGRATION_TARGET_EMPTY", - "Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.", - ) + message += '\n\n' + texts.t( + 'ADMIN_SQUAD_MIGRATION_TARGET_EMPTY', + 'Нет других сквадов для переезда. Отмените операцию или создайте новые сквады.', ) await callback.message.edit_text( @@ -753,8 +717,8 @@ async def confirm_squad_migration( return data = await state.get_data() - source_uuid = data.get("source_uuid") - target_uuid = data.get("target_uuid") + source_uuid = data.get('source_uuid') + target_uuid = data.get('target_uuid') if not source_uuid or not target_uuid: await callback.answer() @@ -763,7 +727,7 @@ async def confirm_squad_migration( texts = get_texts(db_user.language) remnawave_service = RemnaWaveService() - await callback.answer(texts.t("ADMIN_SQUAD_MIGRATION_IN_PROGRESS", "Запускаю переезд...")) + await callback.answer(texts.t('ADMIN_SQUAD_MIGRATION_IN_PROGRESS', 'Запускаю переезд...')) try: result = await remnawave_service.migrate_squad_users( @@ -773,18 +737,18 @@ async def confirm_squad_migration( ) except RemnaWaveConfigurationError as error: message = texts.t( - "ADMIN_SQUAD_MIGRATION_API_ERROR", - "❌ RemnaWave API не настроен: {error}", + 'ADMIN_SQUAD_MIGRATION_API_ERROR', + '❌ RemnaWave API не настроен: {error}', ).format(error=str(error)) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", - "⬅️ В Remnawave", + 'ADMIN_SQUAD_MIGRATION_BACK_BUTTON', + '⬅️ В Remnawave', ), - callback_data="admin_remnawave", + callback_data='admin_remnawave', ) ] ] @@ -793,34 +757,34 @@ async def confirm_squad_migration( await state.clear() return - source_display = data.get("source_display") or source_uuid - target_display = data.get("target_display") or target_uuid + source_display = data.get('source_display') or source_uuid + target_display = data.get('target_display') or target_uuid - if not result.get("success"): - error_message = result.get("message") or "" - error_code = result.get("error") or "unexpected" + if not result.get('success'): + error_message = result.get('message') or '' + error_code = result.get('error') or 'unexpected' message = texts.t( - "ADMIN_SQUAD_MIGRATION_ERROR", - "❌ Не удалось выполнить переезд (код: {code}). {details}", + 'ADMIN_SQUAD_MIGRATION_ERROR', + '❌ Не удалось выполнить переезд (код: {code}). {details}', ).format(code=error_code, details=error_message) reply_markup = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", - "⬅️ В Remnawave", + 'ADMIN_SQUAD_MIGRATION_BACK_BUTTON', + '⬅️ В Remnawave', ), - callback_data="admin_remnawave", + callback_data='admin_remnawave', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_NEW_BUTTON", - "🔁 Новый переезд", + 'ADMIN_SQUAD_MIGRATION_NEW_BUTTON', + '🔁 Новый переезд', ), - callback_data="admin_rw_migration", + callback_data='admin_rw_migration', ) ], ] @@ -830,40 +794,36 @@ async def confirm_squad_migration( return message_lines = [ - texts.t("ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE", "✅ Переезд завершен"), - "", - texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE", "• Из: {source}").format( - source=source_display - ), - texts.t("ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET", "• В: {target}").format( - target=target_display - ), - "", + texts.t('ADMIN_SQUAD_MIGRATION_SUCCESS_TITLE', '✅ Переезд завершен'), + '', + texts.t('ADMIN_SQUAD_MIGRATION_CONFIRM_SOURCE', '• Из: {source}').format(source=source_display), + texts.t('ADMIN_SQUAD_MIGRATION_CONFIRM_TARGET', '• В: {target}').format(target=target_display), + '', texts.t( - "ADMIN_SQUAD_MIGRATION_RESULT_TOTAL", - "Найдено подписок: {count}", - ).format(count=result.get("total", 0)), + 'ADMIN_SQUAD_MIGRATION_RESULT_TOTAL', + 'Найдено подписок: {count}', + ).format(count=result.get('total', 0)), texts.t( - "ADMIN_SQUAD_MIGRATION_RESULT_UPDATED", - "Перенесено: {count}", - ).format(count=result.get("updated", 0)), + 'ADMIN_SQUAD_MIGRATION_RESULT_UPDATED', + 'Перенесено: {count}', + ).format(count=result.get('updated', 0)), ] - panel_updated = result.get("panel_updated", 0) - panel_failed = result.get("panel_failed", 0) + panel_updated = result.get('panel_updated', 0) + panel_failed = result.get('panel_failed', 0) if panel_updated: message_lines.append( texts.t( - "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED", - "Обновлено в панели: {count}", + 'ADMIN_SQUAD_MIGRATION_RESULT_PANEL_UPDATED', + 'Обновлено в панели: {count}', ).format(count=panel_updated) ) if panel_failed: message_lines.append( texts.t( - "ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED", - "Не удалось обновить в панели: {count}", + 'ADMIN_SQUAD_MIGRATION_RESULT_PANEL_FAILED', + 'Не удалось обновить в панели: {count}', ).format(count=panel_failed) ) @@ -872,26 +832,26 @@ async def confirm_squad_migration( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_NEW_BUTTON", - "🔁 Новый переезд", + 'ADMIN_SQUAD_MIGRATION_NEW_BUTTON', + '🔁 Новый переезд', ), - callback_data="admin_rw_migration", + callback_data='admin_rw_migration', ) ], [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", - "⬅️ В Remnawave", + 'ADMIN_SQUAD_MIGRATION_BACK_BUTTON', + '⬅️ В Remnawave', ), - callback_data="admin_remnawave", + callback_data='admin_remnawave', ) ], ] ) await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=reply_markup, disable_web_page_preview=True, ) @@ -910,8 +870,8 @@ async def cancel_squad_migration( await state.clear() message = texts.t( - "ADMIN_SQUAD_MIGRATION_CANCELLED", - "❌ Переезд отменен.", + 'ADMIN_SQUAD_MIGRATION_CANCELLED', + '❌ Переезд отменен.', ) reply_markup = types.InlineKeyboardMarkup( @@ -919,10 +879,10 @@ async def cancel_squad_migration( [ types.InlineKeyboardButton( text=texts.t( - "ADMIN_SQUAD_MIGRATION_BACK_BUTTON", - "⬅️ В Remnawave", + 'ADMIN_SQUAD_MIGRATION_BACK_BUTTON', + '⬅️ В Remnawave', ), - callback_data="admin_remnawave", + callback_data='admin_remnawave', ) ] ] @@ -942,113 +902,95 @@ async def handle_migration_page_info( ): texts = get_texts(db_user.language) await callback.answer( - texts.t("ADMIN_SQUAD_MIGRATION_PAGE_HINT", "Это текущая страница."), + texts.t('ADMIN_SQUAD_MIGRATION_PAGE_HINT', 'Это текущая страница.'), show_alert=False, ) + @admin_required @error_handler -async def show_remnawave_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - remnawave_service = RemnaWaveService() - connection_test = await remnawave_service.test_api_connection() +async def show_remnawave_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + remnawave_service = RemnaWaveService() + connection_test = await remnawave_service.test_api_connection() - status = connection_test.get("status") - if status == "connected": - status_emoji = "✅" - elif status == "not_configured": - status_emoji = "ℹ️" - else: - status_emoji = "❌" + 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 "—" + api_url_display = settings.REMNAWAVE_API_URL or '—' - text = f""" + text = f""" 🖥️ Управление Remnawave -📡 Соединение: {status_emoji} {connection_test.get("message", "Нет данных")} +📡 Соединение: {status_emoji} {connection_test.get('message', 'Нет данных')} 🌐 URL: {api_url_display} Выберите действие: """ - - await callback.message.edit_text( - text, - reply_markup=get_admin_remnawave_keyboard(db_user.language) - ) - await callback.answer() + + await callback.message.edit_text(text, reply_markup=get_admin_remnawave_keyboard(db_user.language)) + await callback.answer() @admin_required @error_handler -async def show_system_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - from datetime import datetime, timedelta - - remnawave_service = RemnaWaveService() - stats = await remnawave_service.get_system_statistics() - - if "error" in stats: - await callback.message.edit_text( - f"❌ Ошибка получения статистики: {stats['error']}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - ) - await callback.answer() - return - - system = stats.get("system", {}) - users_by_status = stats.get("users_by_status", {}) - server_info = stats.get("server_info", {}) - bandwidth = stats.get("bandwidth", {}) - traffic_periods = stats.get("traffic_periods", {}) - nodes_realtime = stats.get("nodes_realtime", []) - nodes_weekly = stats.get("nodes_weekly", []) - - memory_total = server_info.get('memory_total', 1) - memory_used_percent = (server_info.get('memory_used', 0) / memory_total * 100) if memory_total > 0 else 0 - - uptime_seconds = server_info.get('uptime_seconds', 0) - uptime_days = int(uptime_seconds // 86400) - uptime_hours = int((uptime_seconds % 86400) // 3600) - uptime_str = f"{uptime_days}д {uptime_hours}ч" - - users_status_text = "" - for status, count in users_by_status.items(): - status_emoji = { - 'ACTIVE': '✅', - 'DISABLED': '❌', - 'LIMITED': '⚠️', - 'EXPIRED': '⏰' - }.get(status, '❓') - users_status_text += f" {status_emoji} {status}: {count}\n" - - top_nodes_text = "" - for i, node in enumerate(nodes_weekly[:3], 1): - top_nodes_text += f" {i}. {node['name']}: {format_bytes(node['total_bytes'])}\n" - - realtime_nodes_text = "" - for node in nodes_realtime[:3]: - node_total = node.get('downloadBytes', 0) + node.get('uploadBytes', 0) - if node_total > 0: - realtime_nodes_text += f" 📡 {node.get('nodeName', 'Unknown')}: {format_bytes(node_total)}\n" - - def format_traffic_change(difference_str): - if not difference_str or difference_str == '0': - return "" - elif difference_str.startswith('-'): - return f" (🔻 {difference_str[1:]})" - else: - return f" (🔺 {difference_str})" - - text = f""" +async def show_system_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + remnawave_service = RemnaWaveService() + stats = await remnawave_service.get_system_statistics() + + if 'error' in stats: + await callback.message.edit_text( + f'❌ Ошибка получения статистики: {stats["error"]}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]] + ), + ) + await callback.answer() + return + + system = stats.get('system', {}) + users_by_status = stats.get('users_by_status', {}) + server_info = stats.get('server_info', {}) + bandwidth = stats.get('bandwidth', {}) + traffic_periods = stats.get('traffic_periods', {}) + nodes_realtime = stats.get('nodes_realtime', []) + nodes_weekly = stats.get('nodes_weekly', []) + + memory_total = server_info.get('memory_total', 1) + memory_used_percent = (server_info.get('memory_used', 0) / memory_total * 100) if memory_total > 0 else 0 + + uptime_seconds = server_info.get('uptime_seconds', 0) + uptime_days = int(uptime_seconds // 86400) + uptime_hours = int((uptime_seconds % 86400) // 3600) + uptime_str = f'{uptime_days}д {uptime_hours}ч' + + users_status_text = '' + for status, count in users_by_status.items(): + status_emoji = {'ACTIVE': '✅', 'DISABLED': '❌', 'LIMITED': '⚠️', 'EXPIRED': '⏰'}.get(status, '❓') + users_status_text += f' {status_emoji} {status}: {count}\n' + + top_nodes_text = '' + for i, node in enumerate(nodes_weekly[:3], 1): + top_nodes_text += f' {i}. {node["name"]}: {format_bytes(node["total_bytes"])}\n' + + realtime_nodes_text = '' + for node in nodes_realtime[:3]: + node_total = node.get('downloadBytes', 0) + node.get('uploadBytes', 0) + if node_total > 0: + realtime_nodes_text += f' 📡 {node.get("nodeName", "Unknown")}: {format_bytes(node_total)}\n' + + def format_traffic_change(difference_str): + if not difference_str or difference_str == '0': + return '' + if difference_str.startswith('-'): + return f' (🔻 {difference_str[1:]})' + return f' (🔺 {difference_str})' + + text = f""" 📊 Детальная статистика Remnawave 🖥️ Сервер: @@ -1068,17 +1010,17 @@ async def show_system_stats( 🌐 Ноды ({system.get('nodes_online', 0)} онлайн):""" - if realtime_nodes_text: - text += f""" + if realtime_nodes_text: + text += f""" Реалтайм активность: {realtime_nodes_text}""" - - if top_nodes_text: - text += f""" + + if top_nodes_text: + text += f""" Топ нод за неделю: {top_nodes_text}""" - - text += f""" + + text += f""" 📈 Общий трафик пользователей: {format_bytes(system.get('total_user_traffic', 0))} @@ -1090,86 +1032,79 @@ async def show_system_stats( - Год: {format_bytes(traffic_periods.get('current_year', {}).get('current', 0))}{format_traffic_change(traffic_periods.get('current_year', {}).get('difference', ''))} """ - if bandwidth.get('realtime_total', 0) > 0: - text += f""" + if bandwidth.get('realtime_total', 0) > 0: + text += f""" ⚡ Реалтайм трафик: - Скачивание: {format_bytes(bandwidth.get('realtime_download', 0))} - Загрузка: {format_bytes(bandwidth.get('realtime_upload', 0))} - Итого: {format_bytes(bandwidth.get('realtime_total', 0))} """ - - text += f""" + + text += f""" 🕒 Обновлено: {format_datetime(stats.get('last_updated', datetime.now()))} """ - - keyboard = [ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_rw_system")], - [types.InlineKeyboardButton(text="📈 Ноды", callback_data="admin_rw_nodes"), - types.InlineKeyboardButton(text="👥 Синхронизация", callback_data="admin_rw_sync")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + keyboard = [ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_system')], + [ + types.InlineKeyboardButton(text='📈 Ноды', callback_data='admin_rw_nodes'), + types.InlineKeyboardButton(text='👥 Синхронизация', callback_data='admin_rw_sync'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + await callback.answer() + @admin_required @error_handler -async def show_traffic_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - from datetime import datetime, timedelta - +async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): remnawave_service = RemnaWaveService() - + try: async with remnawave_service.get_api_client() as api: bandwidth_stats = await api.get_bandwidth_stats() - + realtime_usage = await api.get_nodes_realtime_usage() - + nodes_stats = await api.get_nodes_statistics() - + except Exception as e: await callback.message.edit_text( - f"❌ Ошибка получения статистики трафика: {str(e)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) + f'❌ Ошибка получения статистики трафика: {e!s}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]] + ), ) await callback.answer() return - + def parse_bandwidth(bandwidth_str): return remnawave_service._parse_bandwidth_string(bandwidth_str) - + total_realtime_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) total_realtime_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) total_realtime = total_realtime_download + total_realtime_upload - + total_download_speed = sum(node.get('downloadSpeedBps', 0) for node in realtime_usage) total_upload_speed = sum(node.get('uploadSpeedBps', 0) for node in realtime_usage) - + periods = { 'last_2_days': bandwidth_stats.get('bandwidthLastTwoDays', {}), 'last_7_days': bandwidth_stats.get('bandwidthLastSevenDays', {}), 'last_30_days': bandwidth_stats.get('bandwidthLast30Days', {}), 'current_month': bandwidth_stats.get('bandwidthCalendarMonth', {}), - 'current_year': bandwidth_stats.get('bandwidthCurrentYear', {}) + 'current_year': bandwidth_stats.get('bandwidthCurrentYear', {}), } - + def format_change(diff_str): if not diff_str or diff_str == '0': - return "" - elif diff_str.startswith('-'): - return f" 🔻 {diff_str[1:]}" - else: - return f" 🔺 {diff_str}" - + return '' + if diff_str.startswith('-'): + return f' 🔻 {diff_str[1:]}' + return f' 🔺 {diff_str}' + text = f""" 📊 Статистика трафика Remnawave @@ -1210,136 +1145,109 @@ async def show_traffic_stats( - Предыдущий: {format_bytes(parse_bandwidth(periods['current_year'].get('previous', '0')))} - Изменение:{format_change(periods['current_year'].get('difference', ''))} """ - + if realtime_usage: - text += "\n🌐 Трафик по нодам (реалтайм):\n" + text += '\n🌐 Трафик по нодам (реалтайм):\n' for node in sorted(realtime_usage, key=lambda x: x.get('totalBytes', 0), reverse=True): node_total = node.get('totalBytes', 0) if node_total > 0: - text += f"- {node.get('nodeName', 'Unknown')}: {format_bytes(node_total)}\n" - + text += f'- {node.get("nodeName", "Unknown")}: {format_bytes(node_total)}\n' + if nodes_stats.get('lastSevenDays'): - text += "\n📊 Топ нод за 7 дней:\n" - + text += '\n📊 Топ нод за 7 дней:\n' + nodes_weekly = {} for day_data in nodes_stats['lastSevenDays']: node_name = day_data['nodeName'] if node_name not in nodes_weekly: nodes_weekly[node_name] = 0 nodes_weekly[node_name] += int(day_data['totalBytes']) - + sorted_nodes = sorted(nodes_weekly.items(), key=lambda x: x[1], reverse=True) for i, (node_name, total_bytes) in enumerate(sorted_nodes[:5], 1): - text += f"{i}. {node_name}: {format_bytes(total_bytes)}\n" - - text += f"\n🕒 Обновлено: {format_datetime(datetime.now())}" - + text += f'{i}. {node_name}: {format_bytes(total_bytes)}\n' + + text += f'\n🕒 Обновлено: {format_datetime(datetime.now())}' + keyboard = [ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_rw_traffic")], - [types.InlineKeyboardButton(text="📈 Ноды", callback_data="admin_rw_nodes"), - types.InlineKeyboardButton(text="📊 Система", callback_data="admin_rw_system")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_rw_traffic')], + [ + types.InlineKeyboardButton(text='📈 Ноды', callback_data='admin_rw_nodes'), + types.InlineKeyboardButton(text='📊 Система', callback_data='admin_rw_system'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_nodes_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - remnawave_service = RemnaWaveService() - nodes = await remnawave_service.get_all_nodes() - - if not nodes: - await callback.message.edit_text( - "🖥️ Ноды не найдены или ошибка подключения", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - ) - await callback.answer() - return - - text = "🖥️ Управление нодами\n\n" - keyboard = [] - - for node in nodes: - status_emoji = "🟢" if node["is_node_online"] else "🔴" - connection_emoji = "📡" if node["is_connected"] else "📵" - - text += f"{status_emoji} {connection_emoji} {node['name']}\n" - text += f"🌍 {node['country_code']} • {node['address']}\n" - text += f"👥 Онлайн: {node['users_online'] or 0}\n\n" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"⚙️ {node['name']}", - callback_data=f"admin_node_manage_{node['uuid']}" - ) - ]) - - keyboard.extend([ - [types.InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="admin_restart_all_nodes")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() +async def show_nodes_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + remnawave_service = RemnaWaveService() + nodes = await remnawave_service.get_all_nodes() + + if not nodes: + await callback.message.edit_text( + '🖥️ Ноды не найдены или ошибка подключения', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')]] + ), + ) + await callback.answer() + return + + text = '🖥️ Управление нодами\n\n' + keyboard = [] + + for node in nodes: + status_emoji = '🟢' if node['is_node_online'] else '🔴' + connection_emoji = '📡' if node['is_connected'] else '📵' + + text += f'{status_emoji} {connection_emoji} {node["name"]}\n' + text += f'🌍 {node["country_code"]} • {node["address"]}\n' + text += f'👥 Онлайн: {node["users_online"] or 0}\n\n' + + keyboard.append( + [types.InlineKeyboardButton(text=f'⚙️ {node["name"]}', callback_data=f'admin_node_manage_{node["uuid"]}')] + ) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='🔄 Перезагрузить все', callback_data='admin_restart_all_nodes')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] + ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + await callback.answer() @admin_required @error_handler -async def show_node_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - node_uuid = callback.data.split('_')[-1] - - remnawave_service = RemnaWaveService() - node = await remnawave_service.get_node_details(node_uuid) - - if not node: - await callback.answer("❌ Нода не найдена", show_alert=True) - return - - status_emoji = "🟢" if node["is_node_online"] else "🔴" - xray_emoji = "✅" if node["is_xray_running"] else "❌" +async def show_node_details(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + node_uuid = callback.data.split('_')[-1] - status_change = ( - format_datetime(node["last_status_change"]) - if node.get("last_status_change") - else "—" - ) - created_at = ( - format_datetime(node["created_at"]) - if node.get("created_at") - else "—" - ) - updated_at = ( - format_datetime(node["updated_at"]) - if node.get("updated_at") - else "—" - ) - notify_percent = ( - f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—" - ) - cpu_info = node.get("cpu_model") or "—" - if node.get("cpu_count"): - cpu_info = f"{node['cpu_count']}x {cpu_info}" + remnawave_service = RemnaWaveService() + node = await remnawave_service.get_node_details(node_uuid) - text = f""" + if not node: + await callback.answer('❌ Нода не найдена', show_alert=True) + return + + status_emoji = '🟢' if node['is_node_online'] else '🔴' + xray_emoji = '✅' if node['is_xray_running'] else '❌' + + status_change = format_datetime(node['last_status_change']) if node.get('last_status_change') else '—' + created_at = format_datetime(node['created_at']) if node.get('created_at') else '—' + updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—' + notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—' + cpu_info = node.get('cpu_model') or '—' + if node.get('cpu_count'): + cpu_info = f'{node["cpu_count"]}x {cpu_info}' + + text = f""" 🖥️ Нода: {node['name']} Статус: @@ -1371,93 +1279,67 @@ async def show_node_details( - Создана: {created_at} - Обновлена: {updated_at} """ - - await callback.message.edit_text( - text, - reply_markup=get_node_management_keyboard(node_uuid, db_user.language) - ) - await callback.answer() + + await callback.message.edit_text(text, reply_markup=get_node_management_keyboard(node_uuid, db_user.language)) + await callback.answer() @admin_required @error_handler -async def manage_node( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def manage_node(callback: types.CallbackQuery, db_user: User, db: AsyncSession): action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1] remnawave_service = RemnaWaveService() success = await remnawave_service.manage_node(node_uuid, action) if success: - action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"} - await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}") + action_text = {'enable': 'включена', 'disable': 'отключена', 'restart': 'перезагружена'} + await callback.answer(f'✅ Нода {action_text.get(action, "обработана")}') else: - await callback.answer("❌ Ошибка выполнения действия", show_alert=True) + await callback.answer('❌ Ошибка выполнения действия', show_alert=True) await show_node_details(callback, db_user, db) + @admin_required @error_handler -async def show_node_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): node_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() - + node = await remnawave_service.get_node_details(node_uuid) - + if not node: - await callback.answer("❌ Нода не найдена", show_alert=True) + await callback.answer('❌ Нода не найдена', show_alert=True) return - + try: from datetime import datetime, timedelta - + end_date = datetime.now() start_date = end_date - timedelta(days=7) - - node_usage = await remnawave_service.get_node_user_usage_by_range( - node_uuid, start_date, end_date - ) - + + node_usage = await remnawave_service.get_node_user_usage_by_range(node_uuid, start_date, end_date) + realtime_stats = await remnawave_service.get_nodes_realtime_usage() - + node_realtime = None for stats in realtime_stats: if stats.get('nodeUuid') == node_uuid: node_realtime = stats break - status_change = ( - format_datetime(node["last_status_change"]) - if node.get("last_status_change") - else "—" - ) - created_at = ( - format_datetime(node["created_at"]) - if node.get("created_at") - else "—" - ) - updated_at = ( - format_datetime(node["updated_at"]) - if node.get("updated_at") - else "—" - ) - notify_percent = ( - f"{node['notify_percent']}%" if node.get("notify_percent") is not None else "—" - ) - cpu_info = node.get("cpu_model") or "—" - if node.get("cpu_count"): - cpu_info = f"{node['cpu_count']}x {cpu_info}" + status_change = format_datetime(node['last_status_change']) if node.get('last_status_change') else '—' + created_at = format_datetime(node['created_at']) if node.get('created_at') else '—' + updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—' + notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—' + cpu_info = node.get('cpu_model') or '—' + if node.get('cpu_count'): + cpu_info = f'{node["cpu_count"]}x {cpu_info}' - status_emoji = "🟢" if node["is_node_online"] else "🔴" - xray_emoji = "✅" if node["is_xray_running"] else "❌" + status_emoji = '🟢' if node['is_node_online'] else '🔴' + xray_emoji = '✅' if node['is_xray_running'] else '❌' text = f""" 📊 Статистика ноды: {node['name']} @@ -1499,27 +1381,29 @@ async def show_node_statistics( """ if node_usage: - text += f"\nСтатистика за 7 дней:\n" + text += '\nСтатистика за 7 дней:\n' total_usage = 0 - for usage in node_usage[-5:]: + for usage in node_usage[-5:]: daily_usage = usage.get('total', 0) total_usage += daily_usage - text += f"- {usage.get('date', 'N/A')}: {format_bytes(daily_usage)}\n" - - text += f"\nОбщий трафик за 7 дней: {format_bytes(total_usage)}" + text += f'- {usage.get("date", "N/A")}: {format_bytes(daily_usage)}\n' + + text += f'\nОбщий трафик за 7 дней: {format_bytes(total_usage)}' else: - text += "\nСтатистика за 7 дней: Данные недоступны" - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"node_stats_{node_uuid}")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_node_manage_{node_uuid}")] - ]) - + text += '\nСтатистика за 7 дней: Данные недоступны' + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'node_stats_{node_uuid}')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_node_manage_{node_uuid}')], + ] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() - + except Exception as e: - logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") + logger.error(f'Ошибка получения статистики ноды {node_uuid}: {e}') text = f""" 📊 Статистика ноды: {node['name']} @@ -1548,31 +1432,30 @@ async def show_node_statistics( Обновлено: {format_datetime('now')} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data=f"node_stats_{node_uuid}")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_node_manage_{node_uuid}")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data=f'node_stats_{node_uuid}')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_node_manage_{node_uuid}')], + ] + ) + await callback.message.edit_text(text, reply_markup=keyboard) await callback.answer() + @admin_required @error_handler -async def show_squad_details( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_squad_details(callback: types.CallbackQuery, db_user: User, db: AsyncSession): squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() squad = await remnawave_service.get_squad_details(squad_uuid) - + if not squad: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + text = f""" 🌐 Сквад: {squad['name']} @@ -1583,129 +1466,109 @@ async def show_squad_details( Инбаунды: """ - + if squad.get('inbounds'): for inbound in squad['inbounds']: - text += f"- {inbound['tag']} ({inbound['type']})\n" + text += f'- {inbound["tag"]} ({inbound["type"]})\n' else: - text += "Нет активных инбаундов" - - await callback.message.edit_text( - text, - reply_markup=get_squad_management_keyboard(squad_uuid, db_user.language) - ) + text += 'Нет активных инбаундов' + + await callback.message.edit_text(text, reply_markup=get_squad_management_keyboard(squad_uuid, db_user.language)) await callback.answer() @admin_required @error_handler -async def manage_squad_action( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def manage_squad_action(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') - action = parts[1] + action = parts[1] squad_uuid = parts[-1] - + remnawave_service = RemnaWaveService() - - if action == "add_users": + + if action == 'add_users': success = await remnawave_service.add_all_users_to_squad(squad_uuid) if success: - await callback.answer("✅ Задача добавления пользователей в очередь") + await callback.answer('✅ Задача добавления пользователей в очередь') else: - await callback.answer("❌ Ошибка добавления пользователей", show_alert=True) - - elif action == "remove_users": + await callback.answer('❌ Ошибка добавления пользователей', show_alert=True) + + elif action == 'remove_users': success = await remnawave_service.remove_all_users_from_squad(squad_uuid) if success: - await callback.answer("✅ Задача удаления пользователей в очередь") + await callback.answer('✅ Задача удаления пользователей в очередь') else: - await callback.answer("❌ Ошибка удаления пользователей", show_alert=True) - - elif action == "delete": + await callback.answer('❌ Ошибка удаления пользователей', show_alert=True) + + elif action == 'delete': success = await remnawave_service.delete_squad(squad_uuid) if success: await callback.message.edit_text( - "✅ Сквад успешно удален", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] - ]) + '✅ Сквад успешно удален', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')]] + ), ) else: - await callback.answer("❌ Ошибка удаления сквада", show_alert=True) + await callback.answer('❌ Ошибка удаления сквада', show_alert=True) return - - refreshed_callback = callback.model_copy( - update={"data": f"admin_squad_manage_{squad_uuid}"} - ).as_(callback.bot) + + refreshed_callback = callback.model_copy(update={'data': f'admin_squad_manage_{squad_uuid}'}).as_(callback.bot) await show_squad_details(refreshed_callback, db_user, db) + @admin_required @error_handler -async def show_squad_edit_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_squad_edit_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() squad = await remnawave_service.get_squad_details(squad_uuid) - + if not squad: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + text = f""" ✏️ Редактирование сквада: {squad['name']} Текущие инбаунды: """ - + if squad.get('inbounds'): for inbound in squad['inbounds']: - text += f"✅ {inbound['tag']} ({inbound['type']})\n" + text += f'✅ {inbound["tag"]} ({inbound["type"]})\n' else: - text += "Нет активных инбаундов\n" - - text += "\nДоступные действия:" - - await callback.message.edit_text( - text, - reply_markup=get_squad_edit_keyboard(squad_uuid, db_user.language) - ) + text += 'Нет активных инбаундов\n' + + text += '\nДоступные действия:' + + await callback.message.edit_text(text, reply_markup=get_squad_edit_keyboard(squad_uuid, db_user.language)) await callback.answer() + @admin_required @error_handler -async def show_squad_inbounds_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_squad_inbounds_selection(callback: types.CallbackQuery, db_user: User, db: AsyncSession): squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() - + squad = await remnawave_service.get_squad_details(squad_uuid) all_inbounds = await remnawave_service.get_all_inbounds() - + if not squad: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + if not all_inbounds: - await callback.answer("❌ Нет доступных инбаундов", show_alert=True) + await callback.answer('❌ Нет доступных инбаундов', show_alert=True) return - + if squad_uuid not in squad_inbound_selections: - squad_inbound_selections[squad_uuid] = set( - inbound['uuid'] for inbound in squad.get('inbounds', []) - ) - + squad_inbound_selections[squad_uuid] = {inbound['uuid'] for inbound in squad.get('inbounds', [])} + text = f""" 🔧 Изменение инбаундов @@ -1714,54 +1577,50 @@ async def show_squad_inbounds_selection( Доступные инбаунды: """ - + keyboard = [] - - for i, inbound in enumerate(all_inbounds[:15]): + + for i, inbound in enumerate(all_inbounds[:15]): is_selected = inbound['uuid'] in squad_inbound_selections[squad_uuid] - emoji = "✅" if is_selected else "☐" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {inbound['tag']} ({inbound['type']})", - callback_data=f"sqd_tgl_{i}_{squad_uuid[:8]}" - ) - ]) - + emoji = '✅' if is_selected else '☐' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {inbound["tag"]} ({inbound["type"]})', callback_data=f'sqd_tgl_{i}_{squad_uuid[:8]}' + ) + ] + ) + if len(all_inbounds) > 15: - text += f"\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов" - - keyboard.extend([ - [types.InlineKeyboardButton(text="💾 Сохранить изменения", callback_data=f"sqd_save_{squad_uuid[:8]}")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"sqd_edit_{squad_uuid[:8]}")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + text += f'\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов' + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='💾 Сохранить изменения', callback_data=f'sqd_save_{squad_uuid[:8]}')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'sqd_edit_{squad_uuid[:8]}')], + ] ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def show_squad_rename_form( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def show_squad_rename_form(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() squad = await remnawave_service.get_squad_details(squad_uuid) - + if not squad: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + await state.update_data(squad_uuid=squad_uuid, squad_name=squad['name']) await state.set_state(SquadRenameStates.waiting_for_new_name) - + text = f""" ✏️ Переименование сквада @@ -1776,140 +1635,132 @@ async def show_squad_rename_form( Отправьте сообщение с новым названием или нажмите "Отмена" для выхода. """ - - keyboard = [ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"cancel_rename_{squad_uuid}")] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + keyboard = [[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'cancel_rename_{squad_uuid}')]] + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def cancel_squad_rename( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def cancel_squad_rename(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): squad_uuid = callback.data.split('_')[-1] - + await state.clear() - - refreshed_callback = callback.model_copy( - update={"data": f"squad_edit_{squad_uuid}"} - ).as_(callback.bot) + + refreshed_callback = callback.model_copy(update={'data': f'squad_edit_{squad_uuid}'}).as_(callback.bot) await show_squad_edit_menu(refreshed_callback, db_user, db) + @admin_required @error_handler -async def process_squad_new_name( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_squad_new_name(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): data = await state.get_data() squad_uuid = data.get('squad_uuid') old_name = data.get('squad_name') - + if not squad_uuid: - await message.answer("❌ Ошибка: сквад не найден") + await message.answer('❌ Ошибка: сквад не найден') await state.clear() return - + new_name = message.text.strip() - + if not new_name: - await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:") + await message.answer('❌ Название не может быть пустым. Попробуйте еще раз:') return - + if len(new_name) < 2 or len(new_name) > 20: - await message.answer("❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:") + await message.answer('❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:') return - + import re + if not re.match(r'^[A-Za-z0-9_-]+$', new_name): - await message.answer("❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:") + await message.answer( + '❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:' + ) return - + if new_name == old_name: - await message.answer("❌ Новое название совпадает с текущим. Введите другое название:") + await message.answer('❌ Новое название совпадает с текущим. Введите другое название:') return - + remnawave_service = RemnaWaveService() success = await remnawave_service.rename_squad(squad_uuid, new_name) - + if success: await message.answer( - f"✅ Сквад успешно переименован!\n\n" - f"Старое название: {old_name}\n" - f"Новое название: {new_name}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📋 Детали сквада", callback_data=f"admin_squad_manage_{squad_uuid}")], - [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] - ]) + f'✅ Сквад успешно переименован!\n\n' + f'Старое название: {old_name}\n' + f'Новое название: {new_name}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📋 Детали сквада', callback_data=f'admin_squad_manage_{squad_uuid}' + ) + ], + [types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')], + ] + ), ) await state.clear() else: await message.answer( - "❌ Ошибка переименования сквада\n\n" - "Возможные причины:\n" - "• Сквад с таким названием уже существует\n" - "• Проблемы с подключением к API\n" - "• Недостаточно прав\n\n" - "Попробуйте другое название:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"cancel_rename_{squad_uuid}")] - ]) + '❌ Ошибка переименования сквада\n\n' + 'Возможные причины:\n' + '• Сквад с таким названием уже существует\n' + '• Проблемы с подключением к API\n' + '• Недостаточно прав\n\n' + 'Попробуйте другое название:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'cancel_rename_{squad_uuid}')] + ] + ), ) @admin_required @error_handler -async def toggle_squad_inbound( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_squad_inbound(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') inbound_index = int(parts[2]) short_squad_uuid = parts[3] - + remnawave_service = RemnaWaveService() squads = await remnawave_service.get_all_squads() - + full_squad_uuid = None for squad in squads: if squad['uuid'].startswith(short_squad_uuid): full_squad_uuid = squad['uuid'] break - + if not full_squad_uuid: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + all_inbounds = await remnawave_service.get_all_inbounds() if inbound_index >= len(all_inbounds): - await callback.answer("❌ Инбаунд не найден", show_alert=True) + await callback.answer('❌ Инбаунд не найден', show_alert=True) return - + selected_inbound = all_inbounds[inbound_index] - + if full_squad_uuid not in squad_inbound_selections: squad_inbound_selections[full_squad_uuid] = set() - + if selected_inbound['uuid'] in squad_inbound_selections[full_squad_uuid]: squad_inbound_selections[full_squad_uuid].remove(selected_inbound['uuid']) - await callback.answer(f"➖ Убран: {selected_inbound['tag']}") + await callback.answer(f'➖ Убран: {selected_inbound["tag"]}') else: squad_inbound_selections[full_squad_uuid].add(selected_inbound['uuid']) - await callback.answer(f"➕ Добавлен: {selected_inbound['tag']}") - + await callback.answer(f'➕ Добавлен: {selected_inbound["tag"]}') + text = f""" 🔧 Изменение инбаундов @@ -1918,42 +1769,39 @@ async def toggle_squad_inbound( Доступные инбаунды: """ - + keyboard = [] for i, inbound in enumerate(all_inbounds[:15]): is_selected = inbound['uuid'] in squad_inbound_selections[full_squad_uuid] - emoji = "✅" if is_selected else "☐" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {inbound['tag']} ({inbound['type']})", - callback_data=f"sqd_tgl_{i}_{short_squad_uuid}" - ) - ]) - - keyboard.extend([ - [types.InlineKeyboardButton(text="💾 Сохранить изменения", callback_data=f"sqd_save_{short_squad_uuid}")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"sqd_edit_{short_squad_uuid}")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + emoji = '✅' if is_selected else '☐' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {inbound["tag"]} ({inbound["type"]})', + callback_data=f'sqd_tgl_{i}_{short_squad_uuid}', + ) + ] + ) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='💾 Сохранить изменения', callback_data=f'sqd_save_{short_squad_uuid}')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'sqd_edit_{short_squad_uuid}')], + ] ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) -@admin_required + +@admin_required @error_handler -async def save_squad_inbounds( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def save_squad_inbounds(callback: types.CallbackQuery, db_user: User, db: AsyncSession): short_squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() squads = await remnawave_service.get_all_squads() - + full_squad_uuid = None squad_name = None for squad in squads: @@ -1961,75 +1809,71 @@ async def save_squad_inbounds( full_squad_uuid = squad['uuid'] squad_name = squad['name'] break - + if not full_squad_uuid: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - + selected_inbounds = squad_inbound_selections.get(full_squad_uuid, set()) - + try: success = await remnawave_service.update_squad_inbounds(full_squad_uuid, list(selected_inbounds)) - + if success: - if full_squad_uuid in squad_inbound_selections: - del squad_inbound_selections[full_squad_uuid] - + squad_inbound_selections.pop(full_squad_uuid, None) + await callback.message.edit_text( - f"✅ Инбаунды сквада обновлены\n\n" - f"Сквад: {squad_name}\n" - f"Количество инбаундов: {len(selected_inbounds)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")], - [types.InlineKeyboardButton(text="📋 Детали сквада", callback_data=f"admin_squad_manage_{full_squad_uuid}")] - ]) + f'✅ Инбаунды сквада обновлены\n\n' + f'Сквад: {squad_name}\n' + f'Количество инбаундов: {len(selected_inbounds)}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')], + [ + types.InlineKeyboardButton( + text='📋 Детали сквада', callback_data=f'admin_squad_manage_{full_squad_uuid}' + ) + ], + ] + ), ) - await callback.answer("✅ Изменения сохранены!") + await callback.answer('✅ Изменения сохранены!') else: - await callback.answer("❌ Ошибка сохранения изменений", show_alert=True) - + await callback.answer('❌ Ошибка сохранения изменений', show_alert=True) + except Exception as e: - logger.error(f"Error saving squad inbounds: {e}") - await callback.answer("❌ Ошибка при сохранении", show_alert=True) + logger.error(f'Error saving squad inbounds: {e}') + await callback.answer('❌ Ошибка при сохранении', show_alert=True) + @admin_required @error_handler -async def show_squad_edit_menu_short( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_squad_edit_menu_short(callback: types.CallbackQuery, db_user: User, db: AsyncSession): short_squad_uuid = callback.data.split('_')[-1] - + remnawave_service = RemnaWaveService() squads = await remnawave_service.get_all_squads() - + full_squad_uuid = None for squad in squads: if squad['uuid'].startswith(short_squad_uuid): full_squad_uuid = squad['uuid'] break - + if not full_squad_uuid: - await callback.answer("❌ Сквад не найден", show_alert=True) + await callback.answer('❌ Сквад не найден', show_alert=True) return - - refreshed_callback = callback.model_copy( - update={"data": f"squad_edit_{full_squad_uuid}"} - ).as_(callback.bot) + + refreshed_callback = callback.model_copy(update={'data': f'squad_edit_{full_squad_uuid}'}).as_(callback.bot) await show_squad_edit_menu(refreshed_callback, db_user, db) + @admin_required @error_handler -async def start_squad_creation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def start_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): await state.set_state(SquadCreateStates.waiting_for_name) - + text = """ ➕ Создание нового сквада @@ -2044,61 +1888,53 @@ async def start_squad_creation( Отправьте сообщение с названием или нажмите "Отмена" для выхода. """ - - keyboard = [ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] - ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + keyboard = [[types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')]] + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def process_squad_name( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_squad_name(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): squad_name = message.text.strip() - + if not squad_name: - await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:") + await message.answer('❌ Название не может быть пустым. Попробуйте еще раз:') return - + if len(squad_name) < 2 or len(squad_name) > 20: - await message.answer("❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:") + await message.answer('❌ Название должно быть от 2 до 20 символов. Попробуйте еще раз:') return - + import re + if not re.match(r'^[A-Za-z0-9_-]+$', squad_name): - await message.answer("❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:") + await message.answer( + '❌ Название может содержать только буквы, цифры, дефисы и подчеркивания. Попробуйте еще раз:' + ) return - + await state.update_data(squad_name=squad_name) await state.set_state(SquadCreateStates.selecting_inbounds) - + user_id = message.from_user.id squad_create_data[user_id] = {'name': squad_name, 'selected_inbounds': set()} - + remnawave_service = RemnaWaveService() all_inbounds = await remnawave_service.get_all_inbounds() - + if not all_inbounds: await message.answer( - "❌ Нет доступных инбаундов\n\n" - "Для создания сквада необходимо иметь хотя бы один инбаунд.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] - ]) + '❌ Нет доступных инбаундов\n\nДля создания сквада необходимо иметь хотя бы один инбаунд.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')]] + ), ) await state.clear() return - + text = f""" ➕ Создание сквада: {squad_name} @@ -2108,65 +1944,61 @@ async def process_squad_name( Доступные инбаунды: """ - + keyboard = [] - - for i, inbound in enumerate(all_inbounds[:15]): - keyboard.append([ - types.InlineKeyboardButton( - text=f"☐ {inbound['tag']} ({inbound['type']})", - callback_data=f"create_tgl_{i}" - ) - ]) - + + for i, inbound in enumerate(all_inbounds[:15]): + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'☐ {inbound["tag"]} ({inbound["type"]})', callback_data=f'create_tgl_{i}' + ) + ] + ) + if len(all_inbounds) > 15: - text += f"\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов" - - keyboard.extend([ - [types.InlineKeyboardButton(text="✅ Создать сквад", callback_data="create_squad_finish")], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] - ]) - - await message.answer( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + text += f'\n⚠️ Показано первые 15 из {len(all_inbounds)} инбаундов' + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='✅ Создать сквад', callback_data='create_squad_finish')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')], + ] ) + await message.answer(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + + @admin_required @error_handler -async def toggle_create_inbound( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def toggle_create_inbound(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): inbound_index = int(callback.data.split('_')[-1]) user_id = callback.from_user.id - + if user_id not in squad_create_data: - await callback.answer("❌ Ошибка: данные сессии не найдены", show_alert=True) + await callback.answer('❌ Ошибка: данные сессии не найдены', show_alert=True) await state.clear() return - + remnawave_service = RemnaWaveService() all_inbounds = await remnawave_service.get_all_inbounds() - + if inbound_index >= len(all_inbounds): - await callback.answer("❌ Инбаунд не найден", show_alert=True) + await callback.answer('❌ Инбаунд не найден', show_alert=True) return - + selected_inbound = all_inbounds[inbound_index] selected_inbounds = squad_create_data[user_id]['selected_inbounds'] - + if selected_inbound['uuid'] in selected_inbounds: selected_inbounds.remove(selected_inbound['uuid']) - await callback.answer(f"➖ Убран: {selected_inbound['tag']}") + await callback.answer(f'➖ Убран: {selected_inbound["tag"]}') else: selected_inbounds.add(selected_inbound['uuid']) - await callback.answer(f"➕ Добавлен: {selected_inbound['tag']}") - + await callback.answer(f'➕ Добавлен: {selected_inbound["tag"]}') + squad_name = squad_create_data[user_id]['name'] - + text = f""" ➕ Создание сквада: {squad_name} @@ -2176,198 +2008,183 @@ async def toggle_create_inbound( Доступные инбаунды: """ - + keyboard = [] - + for i, inbound in enumerate(all_inbounds[:15]): is_selected = inbound['uuid'] in selected_inbounds - emoji = "✅" if is_selected else "☐" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {inbound['tag']} ({inbound['type']})", - callback_data=f"create_tgl_{i}" - ) - ]) - - keyboard.extend([ - [types.InlineKeyboardButton(text="✅ Создать сквад", callback_data="create_squad_finish")], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_squad_create")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + emoji = '✅' if is_selected else '☐' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {inbound["tag"]} ({inbound["type"]})', callback_data=f'create_tgl_{i}' + ) + ] + ) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='✅ Создать сквад', callback_data='create_squad_finish')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='cancel_squad_create')], + ] ) -@admin_required -@error_handler -async def finish_squad_creation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - user_id = callback.from_user.id - - if user_id not in squad_create_data: - await callback.answer("❌ Ошибка: данные сессии не найдены", show_alert=True) - await state.clear() - return - - squad_name = squad_create_data[user_id]['name'] - selected_inbounds = list(squad_create_data[user_id]['selected_inbounds']) - - if not selected_inbounds: - await callback.answer("❌ Необходимо выбрать хотя бы один инбаунд", show_alert=True) - return - - remnawave_service = RemnaWaveService() - success = await remnawave_service.create_squad(squad_name, selected_inbounds) - - if user_id in squad_create_data: - del squad_create_data[user_id] - await state.clear() - - if success: - await callback.message.edit_text( - f"✅ Сквад успешно создан!\n\n" - f"Название: {squad_name}\n" - f"Количество инбаундов: {len(selected_inbounds)}\n\n" - f"Сквад готов к использованию!", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📋 Список сквадов", callback_data="admin_rw_squads")], - [types.InlineKeyboardButton(text="⬅️ К панели Remnawave", callback_data="admin_remnawave")] - ]) - ) - await callback.answer("✅ Сквад создан!") - else: - await callback.message.edit_text( - f"❌ Ошибка создания сквада\n\n" - f"Название: {squad_name}\n\n" - f"Возможные причины:\n" - f"• Сквад с таким названием уже существует\n" - f"• Проблемы с подключением к API\n" - f"• Недостаточно прав\n" - f"• Некорректные инбаунды", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_squad_create")], - [types.InlineKeyboardButton(text="⬅️ К сквадам", callback_data="admin_rw_squads")] - ]) - ) - await callback.answer("❌ Ошибка создания сквада", show_alert=True) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + @admin_required @error_handler -async def cancel_squad_creation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def finish_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): user_id = callback.from_user.id - - if user_id in squad_create_data: - del squad_create_data[user_id] + + if user_id not in squad_create_data: + await callback.answer('❌ Ошибка: данные сессии не найдены', show_alert=True) + await state.clear() + return + + squad_name = squad_create_data[user_id]['name'] + selected_inbounds = list(squad_create_data[user_id]['selected_inbounds']) + + if not selected_inbounds: + await callback.answer('❌ Необходимо выбрать хотя бы один инбаунд', show_alert=True) + return + + remnawave_service = RemnaWaveService() + success = await remnawave_service.create_squad(squad_name, selected_inbounds) + + squad_create_data.pop(user_id, None) await state.clear() - + + if success: + await callback.message.edit_text( + f'✅ Сквад успешно создан!\n\n' + f'Название: {squad_name}\n' + f'Количество инбаундов: {len(selected_inbounds)}\n\n' + f'Сквад готов к использованию!', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📋 Список сквадов', callback_data='admin_rw_squads')], + [types.InlineKeyboardButton(text='⬅️ К панели Remnawave', callback_data='admin_remnawave')], + ] + ), + ) + await callback.answer('✅ Сквад создан!') + else: + await callback.message.edit_text( + f'❌ Ошибка создания сквада\n\n' + f'Название: {squad_name}\n\n' + f'Возможные причины:\n' + f'• Сквад с таким названием уже существует\n' + f'• Проблемы с подключением к API\n' + f'• Недостаточно прав\n' + f'• Некорректные инбаунды', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='admin_squad_create')], + [types.InlineKeyboardButton(text='⬅️ К сквадам', callback_data='admin_rw_squads')], + ] + ), + ) + await callback.answer('❌ Ошибка создания сквада', show_alert=True) + + +@admin_required +@error_handler +async def cancel_squad_creation(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + user_id = callback.from_user.id + + squad_create_data.pop(user_id, None) + await state.clear() + await show_squads_management(callback, db_user, db) @admin_required @error_handler -async def restart_all_nodes( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - remnawave_service = RemnaWaveService() - success = await remnawave_service.restart_all_nodes() - - if success: - await callback.message.edit_text( - "✅ Команда перезагрузки всех нод отправлена", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К нодам", callback_data="admin_rw_nodes")] - ]) - ) - else: - await callback.message.edit_text( - "❌ Ошибка перезагрузки нод", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К нодам", callback_data="admin_rw_nodes")] - ]) - ) - - await callback.answer() +async def restart_all_nodes(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + remnawave_service = RemnaWaveService() + success = await remnawave_service.restart_all_nodes() + + if success: + await callback.message.edit_text( + '✅ Команда перезагрузки всех нод отправлена', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К нодам', callback_data='admin_rw_nodes')]] + ), + ) + else: + await callback.message.edit_text( + '❌ Ошибка перезагрузки нод', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ К нодам', callback_data='admin_rw_nodes')]] + ), + ) + + await callback.answer() @admin_required @error_handler -async def show_sync_options( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_sync_options(callback: types.CallbackQuery, db_user: User, db: AsyncSession): status = remnawave_sync_service.get_status() - times_text = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—" - next_run_text = format_datetime(status.next_run) if status.next_run else "—" - last_result = "—" + times_text = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—' + next_run_text = format_datetime(status.next_run) if status.next_run else '—' + last_result = '—' if status.last_run_finished_at: - result_icon = "✅" if status.last_run_success else "❌" - result_label = "успешно" if status.last_run_success else "с ошибками" + result_icon = '✅' if status.last_run_success else '❌' + result_label = 'успешно' if status.last_run_success else 'с ошибками' finished_text = format_datetime(status.last_run_finished_at) - last_result = f"{result_icon} {result_label} ({finished_text})" + last_result = f'{result_icon} {result_label} ({finished_text})' elif status.last_run_started_at: - last_result = f"⏳ Запущено {format_datetime(status.last_run_started_at)}" + last_result = f'⏳ Запущено {format_datetime(status.last_run_started_at)}' status_lines = [ - f"⚙️ Статус: {'✅ Включена' if status.enabled else '❌ Отключена'}", - f"🕒 Расписание: {times_text}", - f"📅 Следующий запуск: {next_run_text if status.enabled else '—'}", - f"📊 Последний запуск: {last_result}", + f'⚙️ Статус: {"✅ Включена" if status.enabled else "❌ Отключена"}', + f'🕒 Расписание: {times_text}', + f'📅 Следующий запуск: {next_run_text if status.enabled else "—"}', + f'📊 Последний запуск: {last_result}', ] text = ( - "🔄 Синхронизация с Remnawave\n\n" - "🔄 Полная синхронизация выполняет:\n" - "• Создание новых пользователей из панели в боте\n" - "• Обновление данных существующих пользователей\n" - "• Деактивация подписок пользователей, отсутствующих в панели\n" - "• Сохранение балансов пользователей\n" - "• ⏱️ Время выполнения: 2-5 минут\n\n" - "⚠️ Важно:\n" - "• Во время синхронизации не выполняйте другие операции\n" - "• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n" - "• Рекомендуется делать полную синхронизацию ежедневно\n" - "• Баланс пользователей НЕ удаляется\n\n" - "⬆️ Обратная синхронизация:\n" - "• Отправляет активных пользователей из бота в панель\n" - "• Используйте при сбоях панели или для восстановления данных\n\n" - + "\n".join(status_lines) + '🔄 Синхронизация с Remnawave\n\n' + '🔄 Полная синхронизация выполняет:\n' + '• Создание новых пользователей из панели в боте\n' + '• Обновление данных существующих пользователей\n' + '• Деактивация подписок пользователей, отсутствующих в панели\n' + '• Сохранение балансов пользователей\n' + '• ⏱️ Время выполнения: 2-5 минут\n\n' + '⚠️ Важно:\n' + '• Во время синхронизации не выполняйте другие операции\n' + '• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n' + '• Рекомендуется делать полную синхронизацию ежедневно\n' + '• Баланс пользователей НЕ удаляется\n\n' + '⬆️ Обратная синхронизация:\n' + '• Отправляет активных пользователей из бота в панель\n' + '• Используйте при сбоях панели или для восстановления данных\n\n' + '\n'.join(status_lines) ) keyboard = [ [ types.InlineKeyboardButton( - text="🔄 Запустить полную синхронизацию", - callback_data="sync_all_users", + text='🔄 Запустить полную синхронизацию', + callback_data='sync_all_users', ) ], [ types.InlineKeyboardButton( - text="⬆️ Синхронизация в панель", - callback_data="sync_to_panel", + text='⬆️ Синхронизация в панель', + callback_data='sync_to_panel', ) ], [ types.InlineKeyboardButton( - text="⚙️ Настройки автосинхронизации", - callback_data="admin_rw_auto_sync", + text='⚙️ Настройки автосинхронизации', + callback_data='admin_rw_auto_sync', ) ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], ] await callback.message.edit_text( @@ -2392,7 +2209,7 @@ async def show_auto_sync_settings( await callback.message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -2409,7 +2226,7 @@ async def toggle_auto_sync_setting( new_value = not bool(settings.REMNAWAVE_AUTO_SYNC_ENABLED) await bot_configuration_service.set_value( db, - "REMNAWAVE_AUTO_SYNC_ENABLED", + 'REMNAWAVE_AUTO_SYNC_ENABLED', new_value, ) await db.commit() @@ -2420,11 +2237,9 @@ async def toggle_auto_sync_setting( await callback.message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", - ) - await callback.answer( - f"Автосинхронизация {'включена' if new_value else 'отключена'}" + parse_mode='HTML', ) + await callback.answer(f'Автосинхронизация {"включена" if new_value else "отключена"}') @admin_required @@ -2436,14 +2251,14 @@ async def prompt_auto_sync_schedule( state: FSMContext, ): status = remnawave_sync_service.get_status() - current_schedule = ", ".join(t.strftime("%H:%M") for t in status.times) if status.times else "—" + current_schedule = ', '.join(t.strftime('%H:%M') for t in status.times) if status.times else '—' instructions = ( - "🕒 Настройка расписания автосинхронизации\n\n" - "Укажите время запуска через запятую или с новой строки в формате HH:MM.\n" - f"Текущее расписание: {current_schedule}\n\n" - "Примеры: 03:00, 15:30 или 00:15\n06:00\n18:45\n\n" - "Отправьте отмена, чтобы вернуться без изменений." + '🕒 Настройка расписания автосинхронизации\n\n' + 'Укажите время запуска через запятую или с новой строки в формате HH:MM.\n' + f'Текущее расписание: {current_schedule}\n\n' + 'Примеры: 03:00, 15:30 или 00:15\n06:00\n18:45\n\n' + 'Отправьте отмена, чтобы вернуться без изменений.' ) await state.set_state(RemnaWaveSyncStates.waiting_for_schedule) @@ -2454,13 +2269,13 @@ async def prompt_auto_sync_schedule( await callback.message.edit_text( instructions, - parse_mode="HTML", + parse_mode='HTML', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text="❌ Отмена", - callback_data="remnawave_auto_sync_cancel", + text='❌ Отмена', + callback_data='remnawave_auto_sync_cancel', ) ] ] @@ -2484,9 +2299,9 @@ async def cancel_auto_sync_schedule( await callback.message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) - await callback.answer("Изменение расписания отменено") + await callback.answer('Изменение расписания отменено') @admin_required @@ -2498,53 +2313,50 @@ async def run_auto_sync_now( state: FSMContext, ): if remnawave_sync_service.get_status().is_running: - await callback.answer("Синхронизация уже выполняется", show_alert=True) + await callback.answer('Синхронизация уже выполняется', show_alert=True) return await state.clear() await callback.message.edit_text( - "🔄 Запуск автосинхронизации...\n\nПодождите, это может занять несколько минут.", - parse_mode="HTML", + '🔄 Запуск автосинхронизации...\n\nПодождите, это может занять несколько минут.', + parse_mode='HTML', ) - await callback.answer("Автосинхронизация запущена") + await callback.answer('Автосинхронизация запущена') - result = await remnawave_sync_service.run_sync_now(reason="manual") + result = await remnawave_sync_service.run_sync_now(reason='manual') status = remnawave_sync_service.get_status() base_text, keyboard = _build_auto_sync_view(status) - if not result.get("started"): + if not result.get('started'): await callback.message.edit_text( - "⚠️ Синхронизация уже выполняется\n\n" + base_text, + '⚠️ Синхронизация уже выполняется\n\n' + base_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) return - if result.get("success"): - user_stats = result.get("user_stats") or {} - server_stats = result.get("server_stats") or {} + if result.get('success'): + user_stats = result.get('user_stats') or {} + server_stats = result.get('server_stats') or {} summary = ( - "✅ Синхронизация завершена\n" - f"👥 Пользователи: создано {user_stats.get('created', 0)}, обновлено {user_stats.get('updated', 0)}, " - f"деактивировано {user_stats.get('deleted', user_stats.get('deactivated', 0))}, ошибок {user_stats.get('errors', 0)}\n" - f"🌐 Серверы: создано {server_stats.get('created', 0)}, обновлено {server_stats.get('updated', 0)}, удалено {server_stats.get('removed', 0)}\n\n" + '✅ Синхронизация завершена\n' + f'👥 Пользователи: создано {user_stats.get("created", 0)}, обновлено {user_stats.get("updated", 0)}, ' + f'деактивировано {user_stats.get("deleted", user_stats.get("deactivated", 0))}, ошибок {user_stats.get("errors", 0)}\n' + f'🌐 Серверы: создано {server_stats.get("created", 0)}, обновлено {server_stats.get("updated", 0)}, удалено {server_stats.get("removed", 0)}\n\n' ) final_text = summary + base_text await callback.message.edit_text( final_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: - error_text = result.get("error") or "Неизвестная ошибка" - summary = ( - "❌ Синхронизация завершилась с ошибкой\n" - f"Причина: {error_text}\n\n" - ) + error_text = result.get('error') or 'Неизвестная ошибка' + summary = f'❌ Синхронизация завершилась с ошибкой\nПричина: {error_text}\n\n' await callback.message.edit_text( summary + base_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -2556,52 +2368,52 @@ async def save_auto_sync_schedule( db: AsyncSession, state: FSMContext, ): - text = (message.text or "").strip() + text = (message.text or '').strip() data = await state.get_data() - if text.lower() in {"отмена", "cancel"}: + if text.lower() in {'отмена', 'cancel'}: await state.clear() status = remnawave_sync_service.get_status() view_text, keyboard = _build_auto_sync_view(status) - message_id = data.get("auto_sync_message_id") - chat_id = data.get("auto_sync_message_chat_id", message.chat.id) + message_id = data.get('auto_sync_message_id') + chat_id = data.get('auto_sync_message_chat_id', message.chat.id) if message_id: await message.bot.edit_message_text( view_text, chat_id=chat_id, message_id=message_id, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message.answer( view_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) - await message.answer("Настройка расписания отменена") + await message.answer('Настройка расписания отменена') return parsed_times = settings.parse_daily_time_list(text) if not parsed_times: await message.answer( - "❌ Не удалось распознать время. Используйте формат HH:MM, например 03:00 или 18:45.", + '❌ Не удалось распознать время. Используйте формат HH:MM, например 03:00 или 18:45.', ) return - normalized_value = ", ".join(t.strftime("%H:%M") for t in parsed_times) + normalized_value = ', '.join(t.strftime('%H:%M') for t in parsed_times) await bot_configuration_service.set_value( db, - "REMNAWAVE_AUTO_SYNC_TIMES", + 'REMNAWAVE_AUTO_SYNC_TIMES', normalized_value, ) await db.commit() status = remnawave_sync_service.get_status() view_text, keyboard = _build_auto_sync_view(status) - message_id = data.get("auto_sync_message_id") - chat_id = data.get("auto_sync_message_chat_id", message.chat.id) + message_id = data.get('auto_sync_message_id') + chat_id = data.get('auto_sync_message_chat_id', message.chat.id) if message_id: await message.bot.edit_message_text( @@ -2609,28 +2421,24 @@ async def save_auto_sync_schedule( chat_id=chat_id, message_id=message_id, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message.answer( view_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() - await message.answer("✅ Расписание автосинхронизации обновлено") + await message.answer('✅ Расписание автосинхронизации обновлено') @admin_required @error_handler -async def sync_all_users( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def sync_all_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Выполняет полную синхронизацию всех пользователей""" - + progress_text = """ 🔄 Выполняется полная синхронизация... @@ -2643,81 +2451,75 @@ async def sync_all_users( ⏳ Пожалуйста, подождите... """ - + await callback.message.edit_text(progress_text, reply_markup=None) - + remnawave_service = RemnaWaveService() - stats = await remnawave_service.sync_users_from_panel(db, "all") - + stats = await remnawave_service.sync_users_from_panel(db, 'all') + total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0) - + if stats['errors'] == 0: - status_emoji = "✅" - status_text = "успешно завершена" + status_emoji = '✅' + status_text = 'успешно завершена' elif stats['errors'] < total_operations: - status_emoji = "⚠️" - status_text = "завершена с предупреждениями" + status_emoji = '⚠️' + status_text = 'завершена с предупреждениями' else: - status_emoji = "❌" - status_text = "завершена с ошибками" - + status_emoji = '❌' + status_text = 'завершена с ошибками' + text = f""" {status_emoji} Полная синхронизация {status_text} 📊 Результат: • 🆕 Создано: {stats['created']} -• 🔄 Обновлено: {stats['updated']} +• 🔄 Обновлено: {stats['updated']} • 🗑️ Деактивировано: {stats.get('deleted', 0)} • ❌ Ошибок: {stats['errors']} """ - + if stats.get('deleted', 0) > 0: - text += f""" + text += """ 🗑️ Деактивированные подписки: Деактивированы подписки пользователей, которые отсутствуют в панели Remnawave. 💰 Балансы пользователей сохранены. """ - + if stats['errors'] > 0: - text += f""" + text += """ ⚠️ Внимание: Некоторые операции завершились с ошибками. Проверьте логи для получения подробной информации. """ - - text += f""" + + text += """ 💡 Рекомендации: • Полная синхронизация выполнена • Рекомендуется запускать раз в день • Все пользователи из панели синхронизированы """ - + keyboard = [] - + if stats['errors'] > 0: - keyboard.append([ - types.InlineKeyboardButton( - text="🔄 Повторить синхронизацию", - callback_data="sync_all_users" - ) - ]) - - keyboard.extend([ + keyboard.append([types.InlineKeyboardButton(text='🔄 Повторить синхронизацию', callback_data='sync_all_users')]) + + keyboard.extend( [ - types.InlineKeyboardButton(text="📊 Статистика системы", callback_data="admin_rw_system"), - types.InlineKeyboardButton(text="🌐 Ноды", callback_data="admin_rw_nodes") - ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + [ + types.InlineKeyboardButton(text='📊 Статистика системы', callback_data='admin_rw_system'), + types.InlineKeyboardButton(text='🌐 Ноды', callback_data='admin_rw_nodes'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @@ -2729,33 +2531,32 @@ async def sync_users_to_panel( db: AsyncSession, ): await callback.message.edit_text( - "⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n" - "Это может занять несколько минут.", + '⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\nЭто может занять несколько минут.', reply_markup=None, ) remnawave_service = RemnaWaveService() stats = await remnawave_service.sync_users_to_panel(db) - if stats["errors"] == 0: - status_emoji = "✅" - status_text = "успешно завершена" + if stats['errors'] == 0: + status_emoji = '✅' + status_text = 'успешно завершена' else: - status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else "❌" - status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками" + status_emoji = '⚠️' if (stats['created'] + stats['updated']) > 0 else '❌' + status_text = 'завершена с предупреждениями' if status_emoji == '⚠️' else 'завершена с ошибками' text = ( - f"{status_emoji} Синхронизация в панель {status_text}\n\n" - "📊 Результаты:\n" - f"• 🆕 Создано: {stats['created']}\n" - f"• 🔄 Обновлено: {stats['updated']}\n" - f"• ❌ Ошибок: {stats['errors']}" + f'{status_emoji} Синхронизация в панель {status_text}\n\n' + '📊 Результаты:\n' + f'• 🆕 Создано: {stats["created"]}\n' + f'• 🔄 Обновлено: {stats["updated"]}\n' + f'• ❌ Ошибок: {stats["errors"]}' ) keyboard = [ - [types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")], - [types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")], - [types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")], + [types.InlineKeyboardButton(text='🔄 Повторить', callback_data='sync_to_panel')], + [types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')], + [types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')], ] await callback.message.edit_text( @@ -2764,28 +2565,17 @@ async def sync_users_to_panel( ) await callback.answer() + @admin_required @error_handler -async def show_sync_recommendations( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - - await callback.message.edit_text( - "🔍 Анализируем состояние синхронизации...", - reply_markup=None - ) - +async def show_sync_recommendations(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.message.edit_text('🔍 Анализируем состояние синхронизации...', reply_markup=None) + remnawave_service = RemnaWaveService() recommendations = await remnawave_service.get_sync_recommendations(db) - - priority_emoji = { - "low": "🟢", - "medium": "🟡", - "high": "🔴" - } - + + priority_emoji = {'low': '🟢', 'medium': '🟡', 'high': '🔴'} + text = f""" 💡 Рекомендации по синхронизации @@ -2794,64 +2584,62 @@ async def show_sync_recommendations( Рекомендуемое действие: """ - + if recommendations['sync_type'] == 'all': - text += "🔄 Полная синхронизация" + text += '🔄 Полная синхронизация' elif recommendations['sync_type'] == 'update_only': - text += "📈 Обновление данных" + text += '📈 Обновление данных' elif recommendations['sync_type'] == 'new_only': - text += "🆕 Синхронизация новых" + text += '🆕 Синхронизация новых' else: - text += "✅ Синхронизация не требуется" - - text += "\n\nПричины:\n" + text += '✅ Синхронизация не требуется' + + text += '\n\nПричины:\n' for reason in recommendations['reasons']: - text += f"• {reason}\n" - + text += f'• {reason}\n' + keyboard = [] - + if recommendations['should_sync'] and recommendations['sync_type'] != 'none': - keyboard.append([ - types.InlineKeyboardButton( - text=f"✅ Выполнить рекомендацию", - callback_data=f"sync_{recommendations['sync_type']}_users" if recommendations['sync_type'] != 'update_only' else "sync_update_data" - ) - ]) - - keyboard.extend([ - [types.InlineKeyboardButton(text="🔄 Другие опции", callback_data="admin_rw_sync")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + keyboard.append( + [ + types.InlineKeyboardButton( + text='✅ Выполнить рекомендацию', + callback_data=f'sync_{recommendations["sync_type"]}_users' + if recommendations['sync_type'] != 'update_only' + else 'sync_update_data', + ) + ] + ) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='🔄 Другие опции', callback_data='admin_rw_sync')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def validate_subscriptions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def validate_subscriptions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "🔍 Выполняется валидация подписок...\n\nПроверяем данные, может занять несколько минут.", - reply_markup=None + '🔍 Выполняется валидация подписок...\n\nПроверяем данные, может занять несколько минут.', reply_markup=None ) - + remnawave_service = RemnaWaveService() stats = await remnawave_service.validate_and_fix_subscriptions(db) - + if stats['errors'] == 0: - status_emoji = "✅" - status_text = "успешно завершена" + status_emoji = '✅' + status_text = 'успешно завершена' else: - status_emoji = "⚠️" - status_text = "завершена с ошибками" - + status_emoji = '⚠️' + status_text = 'завершена с ошибками' + text = f""" {status_emoji} Валидация {status_text} @@ -2861,52 +2649,45 @@ async def validate_subscriptions( • ⚠️ Найдено проблем: {stats['issues_found']} • ❌ Ошибок: {stats['errors']} """ - + if stats['fixed'] > 0: - text += "\n✅ Исправленные проблемы:\n" - text += "• Статусы просроченных подписок\n" - text += "• Отсутствующие данные Remnawave\n" - text += "• Некорректные лимиты трафика\n" - text += "• Настройки устройств\n" - + text += '\n✅ Исправленные проблемы:\n' + text += '• Статусы просроченных подписок\n' + text += '• Отсутствующие данные Remnawave\n' + text += '• Некорректные лимиты трафика\n' + text += '• Настройки устройств\n' + if stats['errors'] > 0: - text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации." - + text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.' + keyboard = [ - [types.InlineKeyboardButton(text="🔄 Повторить валидацию", callback_data="sync_validate")], - [types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")], - [types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")] + [types.InlineKeyboardButton(text='🔄 Повторить валидацию', callback_data='sync_validate')], + [types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')], + [types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def cleanup_subscriptions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def cleanup_subscriptions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "🧹 Выполняется очистка неактуальных подписок...\n\nУдаляем подписки пользователей, отсутствующих в панели.", - reply_markup=None + '🧹 Выполняется очистка неактуальных подписок...\n\nУдаляем подписки пользователей, отсутствующих в панели.', + reply_markup=None, ) - + remnawave_service = RemnaWaveService() stats = await remnawave_service.cleanup_orphaned_subscriptions(db) - + if stats['errors'] == 0: - status_emoji = "✅" - status_text = "успешно завершена" + status_emoji = '✅' + status_text = 'успешно завершена' else: - status_emoji = "⚠️" - status_text = "завершена с ошибками" - + status_emoji = '⚠️' + status_text = 'завершена с ошибками' + text = f""" {status_emoji} Очистка {status_text} @@ -2915,55 +2696,48 @@ async def cleanup_subscriptions( • 🗑️ Деактивировано: {stats['deactivated']} • ❌ Ошибок: {stats['errors']} """ - + if stats['deactivated'] > 0: - text += f"\n🗑️ Деактивированные подписки:\n" - text += f"Отключены подписки пользователей, которые\n" - text += f"отсутствуют в панели Remnawave.\n" + text += '\n🗑️ Деактивированные подписки:\n' + text += 'Отключены подписки пользователей, которые\n' + text += 'отсутствуют в панели Remnawave.\n' else: - text += f"\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено." - + text += '\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено.' + if stats['errors'] > 0: - text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации." - + text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.' + keyboard = [ - [types.InlineKeyboardButton(text="🔄 Повторить очистку", callback_data="sync_cleanup")], - [types.InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")], - [types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")] + [types.InlineKeyboardButton(text='🔄 Повторить очистку', callback_data='sync_cleanup')], + [types.InlineKeyboardButton(text='🔍 Валидация', callback_data='sync_validate')], + [types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def force_cleanup_all_orphaned_users( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def force_cleanup_all_orphaned_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "🗑️ Выполняется принудительная очистка всех пользователей, отсутствующих в панели...\n\n" - "⚠️ ВНИМАНИЕ: Это полностью удалит ВСЕ данные пользователей!\n" - "📊 Включая: транзакции, реферальные доходы, промокоды, серверы, балансы\n\n" - "⏳ Пожалуйста, подождите...", - reply_markup=None + '🗑️ Выполняется принудительная очистка всех пользователей, отсутствующих в панели...\n\n' + '⚠️ ВНИМАНИЕ: Это полностью удалит ВСЕ данные пользователей!\n' + '📊 Включая: транзакции, реферальные доходы, промокоды, серверы, балансы\n\n' + '⏳ Пожалуйста, подождите...', + reply_markup=None, ) - + remnawave_service = RemnaWaveService() stats = await remnawave_service.cleanup_orphaned_subscriptions(db) - + if stats['errors'] == 0: - status_emoji = "✅" - status_text = "успешно завершена" + status_emoji = '✅' + status_text = 'успешно завершена' else: - status_emoji = "⚠️" - status_text = "завершена с ошибками" - + status_emoji = '⚠️' + status_text = 'завершена с ошибками' + text = f""" {status_emoji} Принудительная очистка {status_text} @@ -2972,14 +2746,14 @@ async def force_cleanup_all_orphaned_users( • 🗑️ Полностью очищено: {stats['deactivated']} • ❌ Ошибок: {stats['errors']} """ - + if stats['deactivated'] > 0: - text += f""" + text += """ 🗑️ Полностью очищенные данные: • Подписки сброшены к начальному состоянию • Удалены ВСЕ транзакции пользователей -• Удалены ВСЕ реферальные доходы +• Удалены ВСЕ реферальные доходы • Удалены использования промокодов • Сброшены балансы к нулю • Удалены подключенные серверы @@ -2987,32 +2761,24 @@ async def force_cleanup_all_orphaned_users( • Очищены Remnawave UUID """ else: - text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью." - + text += '\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью.' + if stats['errors'] > 0: - text += f"\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации." - + text += '\n⚠️ Обнаружены ошибки при обработке.\nПроверьте логи для подробной информации.' + keyboard = [ - [types.InlineKeyboardButton(text="🔄 Повторить очистку", callback_data="force_cleanup_orphaned")], - [types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")], - [types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")] + [types.InlineKeyboardButton(text='🔄 Повторить очистку', callback_data='force_cleanup_orphaned')], + [types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')], + [types.InlineKeyboardButton(text='⬅️ К синхронизации', callback_data='admin_rw_sync')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def confirm_force_cleanup( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def confirm_force_cleanup(callback: types.CallbackQuery, db_user: User, db: AsyncSession): text = """ ⚠️ ВНИМАНИЕ! ОПАСНАЯ ОПЕРАЦИЯ! @@ -3034,246 +2800,211 @@ async def confirm_force_cleanup( ❓ Вы действительно хотите продолжить? """ - + keyboard = [ - [types.InlineKeyboardButton(text="🗑️ ДА, ОЧИСТИТЬ ВСЕ", callback_data="force_cleanup_orphaned")], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")] + [types.InlineKeyboardButton(text='🗑️ ДА, ОЧИСТИТЬ ВСЕ', callback_data='force_cleanup_orphaned')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rw_sync')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def sync_users( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - sync_type = callback.data.split('_')[-2] + "_" + callback.data.split('_')[-1] - - progress_text = "🔄 Выполняется синхронизация...\n\n" - - if sync_type == "all_users": - progress_text += "📋 Тип: Полная синхронизация\n" - progress_text += "• Создание новых пользователей\n" - progress_text += "• Обновление существующих\n" - progress_text += "• Удаление неактуальных подписок\n" - elif sync_type == "new_users": - progress_text += "📋 Тип: Только новые пользователи\n" - progress_text += "• Создание пользователей из панели\n" - elif sync_type == "update_data": - progress_text += "📋 Тип: Обновление данных\n" - progress_text += "• Обновление информации о трафике\n" - progress_text += "• Синхронизация подписок\n" - - progress_text += "\n⏳ Пожалуйста, подождите..." - - await callback.message.edit_text( - progress_text, - reply_markup=None - ) - - remnawave_service = RemnaWaveService() - - sync_map = { - "all_users": "all", - "new_users": "new_only", - "update_data": "update_only" - } - - stats = await remnawave_service.sync_users_from_panel(db, sync_map.get(sync_type, "all")) - - total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0) - success_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0) - - if stats['errors'] == 0: - status_emoji = "✅" - status_text = "успешно завершена" - elif stats['errors'] < total_operations: - status_emoji = "⚠️" - status_text = "завершена с предупреждениями" - else: - status_emoji = "❌" - status_text = "завершена с ошибками" - - text = f""" +async def sync_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + sync_type = callback.data.split('_')[-2] + '_' + callback.data.split('_')[-1] + + progress_text = '🔄 Выполняется синхронизация...\n\n' + + if sync_type == 'all_users': + progress_text += '📋 Тип: Полная синхронизация\n' + progress_text += '• Создание новых пользователей\n' + progress_text += '• Обновление существующих\n' + progress_text += '• Удаление неактуальных подписок\n' + elif sync_type == 'new_users': + progress_text += '📋 Тип: Только новые пользователи\n' + progress_text += '• Создание пользователей из панели\n' + elif sync_type == 'update_data': + progress_text += '📋 Тип: Обновление данных\n' + progress_text += '• Обновление информации о трафике\n' + progress_text += '• Синхронизация подписок\n' + + progress_text += '\n⏳ Пожалуйста, подождите...' + + await callback.message.edit_text(progress_text, reply_markup=None) + + remnawave_service = RemnaWaveService() + + sync_map = {'all_users': 'all', 'new_users': 'new_only', 'update_data': 'update_only'} + + stats = await remnawave_service.sync_users_from_panel(db, sync_map.get(sync_type, 'all')) + + total_operations = stats['created'] + stats['updated'] + stats.get('deleted', 0) + stats['created'] + stats['updated'] + stats.get('deleted', 0) + + if stats['errors'] == 0: + status_emoji = '✅' + status_text = 'успешно завершена' + elif stats['errors'] < total_operations: + status_emoji = '⚠️' + status_text = 'завершена с предупреждениями' + else: + status_emoji = '❌' + status_text = 'завершена с ошибками' + + text = f""" {status_emoji} Синхронизация {status_text} 📊 Результат: """ - - if sync_type == "all_users": - text += f"• 🆕 Создано: {stats['created']}\n" - text += f"• 🔄 Обновлено: {stats['updated']}\n" - if 'deleted' in stats: - text += f"• 🗑️ Удалено: {stats['deleted']}\n" - text += f"• ❌ Ошибок: {stats['errors']}\n" - elif sync_type == "new_users": - text += f"• 🆕 Создано: {stats['created']}\n" - text += f"• ❌ Ошибок: {stats['errors']}\n" - if stats['created'] == 0 and stats['errors'] == 0: - text += "\n💡 Новых пользователей не найдено" - elif sync_type == "update_data": - text += f"• 🔄 Обновлено: {stats['updated']}\n" - text += f"• ❌ Ошибок: {stats['errors']}\n" - if stats['updated'] == 0 and stats['errors'] == 0: - text += "\n💡 Все данные актуальны" - - if stats['errors'] > 0: - text += f"\n⚠️ Внимание:\n" - text += f"Некоторые операции завершились с ошибками.\n" - text += f"Проверьте логи для получения подробной информации." - - if sync_type == "all_users" and 'deleted' in stats and stats['deleted'] > 0: - text += f"\n🗑️ Удаленные подписки:\n" - text += f"Деактивированы подписки пользователей,\n" - text += f"которые отсутствуют в панели Remnawave." - - text += f"\n\n💡 Рекомендации:\n" - if sync_type == "all_users": - text += "• Полная синхронизация выполнена\n" - text += "• Рекомендуется запускать раз в день\n" - elif sync_type == "new_users": - text += "• Синхронизация новых пользователей\n" - text += "• Используйте при массовом добавлении\n" - elif sync_type == "update_data": - text += "• Обновление данных о трафике\n" - text += "• Запускайте для актуализации статистики\n" - - keyboard = [] - - if stats['errors'] > 0: - keyboard.append([ - types.InlineKeyboardButton( - text="🔄 Повторить синхронизацию", - callback_data=callback.data - ) - ]) - - if sync_type != "all_users": - keyboard.append([ - types.InlineKeyboardButton( - text="🔄 Полная синхронизация", - callback_data="sync_all_users" - ) - ]) - - keyboard.extend([ - [ - types.InlineKeyboardButton(text="📊 Статистика системы", callback_data="admin_rw_system"), - types.InlineKeyboardButton(text="🌐 Ноды", callback_data="admin_rw_nodes") - ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() + + if sync_type == 'all_users': + text += f'• 🆕 Создано: {stats["created"]}\n' + text += f'• 🔄 Обновлено: {stats["updated"]}\n' + if 'deleted' in stats: + text += f'• 🗑️ Удалено: {stats["deleted"]}\n' + text += f'• ❌ Ошибок: {stats["errors"]}\n' + elif sync_type == 'new_users': + text += f'• 🆕 Создано: {stats["created"]}\n' + text += f'• ❌ Ошибок: {stats["errors"]}\n' + if stats['created'] == 0 and stats['errors'] == 0: + text += '\n💡 Новых пользователей не найдено' + elif sync_type == 'update_data': + text += f'• 🔄 Обновлено: {stats["updated"]}\n' + text += f'• ❌ Ошибок: {stats["errors"]}\n' + if stats['updated'] == 0 and stats['errors'] == 0: + text += '\n💡 Все данные актуальны' + + if stats['errors'] > 0: + text += '\n⚠️ Внимание:\n' + text += 'Некоторые операции завершились с ошибками.\n' + text += 'Проверьте логи для получения подробной информации.' + + if sync_type == 'all_users' and 'deleted' in stats and stats['deleted'] > 0: + text += '\n🗑️ Удаленные подписки:\n' + text += 'Деактивированы подписки пользователей,\n' + text += 'которые отсутствуют в панели Remnawave.' + + text += '\n\n💡 Рекомендации:\n' + if sync_type == 'all_users': + text += '• Полная синхронизация выполнена\n' + text += '• Рекомендуется запускать раз в день\n' + elif sync_type == 'new_users': + text += '• Синхронизация новых пользователей\n' + text += '• Используйте при массовом добавлении\n' + elif sync_type == 'update_data': + text += '• Обновление данных о трафике\n' + text += '• Запускайте для актуализации статистики\n' + + keyboard = [] + + if stats['errors'] > 0: + keyboard.append([types.InlineKeyboardButton(text='🔄 Повторить синхронизацию', callback_data=callback.data)]) + + if sync_type != 'all_users': + keyboard.append([types.InlineKeyboardButton(text='🔄 Полная синхронизация', callback_data='sync_all_users')]) + + keyboard.extend( + [ + [ + types.InlineKeyboardButton(text='📊 Статистика системы', callback_data='admin_rw_system'), + types.InlineKeyboardButton(text='🌐 Ноды', callback_data='admin_rw_nodes'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] + ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + await callback.answer() @admin_required @error_handler -async def show_squads_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - remnawave_service = RemnaWaveService() - squads = await remnawave_service.get_all_squads() - - text = "🌍 Управление сквадами\n\n" - keyboard = [] - - if squads: - for squad in squads: - text += f"🔹 {squad['name']}\n" - text += f"👥 Участников: {squad['members_count']}\n" - text += f"📡 Инбаундов: {squad['inbounds_count']}\n\n" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"⚙️ {squad['name']}", - callback_data=f"admin_squad_manage_{squad['uuid']}" - ) - ]) - else: - text += "Сквады не найдены" - - keyboard.extend([ - [types.InlineKeyboardButton(text="➕ Создать сквад", callback_data="admin_squad_create")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - await callback.answer() +async def show_squads_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + remnawave_service = RemnaWaveService() + squads = await remnawave_service.get_all_squads() + + text = '🌍 Управление сквадами\n\n' + keyboard = [] + + if squads: + for squad in squads: + text += f'🔹 {squad["name"]}\n' + text += f'👥 Участников: {squad["members_count"]}\n' + text += f'📡 Инбаундов: {squad["inbounds_count"]}\n\n' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'⚙️ {squad["name"]}', callback_data=f'admin_squad_manage_{squad["uuid"]}' + ) + ] + ) + else: + text += 'Сквады не найдены' + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='➕ Создать сквад', callback_data='admin_squad_create')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_remnawave')], + ] + ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + await callback.answer() def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_remnawave_menu, F.data == "admin_remnawave") - dp.callback_query.register(show_system_stats, F.data == "admin_rw_system") - dp.callback_query.register(show_traffic_stats, F.data == "admin_rw_traffic") - dp.callback_query.register(show_nodes_management, F.data == "admin_rw_nodes") - dp.callback_query.register(show_node_details, F.data.startswith("admin_node_manage_")) - dp.callback_query.register(show_node_statistics, F.data.startswith("node_stats_")) - dp.callback_query.register(manage_node, F.data.startswith("node_enable_")) - dp.callback_query.register(manage_node, F.data.startswith("node_disable_")) - dp.callback_query.register(manage_node, F.data.startswith("node_restart_")) - dp.callback_query.register(restart_all_nodes, F.data == "admin_restart_all_nodes") - dp.callback_query.register(show_sync_options, F.data == "admin_rw_sync") - dp.callback_query.register(show_auto_sync_settings, F.data == "admin_rw_auto_sync") - dp.callback_query.register(toggle_auto_sync_setting, F.data == "remnawave_auto_sync_toggle") - dp.callback_query.register(prompt_auto_sync_schedule, F.data == "remnawave_auto_sync_times") - dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel") - dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run") - dp.callback_query.register(sync_all_users, F.data == "sync_all_users") - dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel") - dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration") - dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_")) - dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_")) - dp.callback_query.register(paginate_migration_target, F.data.startswith("admin_migration_target_page_")) - dp.callback_query.register(handle_migration_target_selection, F.data.startswith("admin_migration_target_")) - dp.callback_query.register(change_migration_target, F.data == "admin_migration_change_target") - dp.callback_query.register(confirm_squad_migration, F.data == "admin_migration_confirm") - dp.callback_query.register(cancel_squad_migration, F.data == "admin_migration_cancel") - dp.callback_query.register(handle_migration_page_info, F.data == "admin_migration_page_info") - dp.callback_query.register(show_squads_management, F.data == "admin_rw_squads") - dp.callback_query.register(show_squad_details, F.data.startswith("admin_squad_manage_")) - dp.callback_query.register(manage_squad_action, F.data.startswith("squad_add_users_")) - dp.callback_query.register(manage_squad_action, F.data.startswith("squad_remove_users_")) - dp.callback_query.register(manage_squad_action, F.data.startswith("squad_delete_")) - dp.callback_query.register(show_squad_edit_menu, F.data.startswith("squad_edit_") & ~F.data.startswith("squad_edit_inbounds_")) - dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith("squad_edit_inbounds_")) - dp.callback_query.register(show_squad_rename_form, F.data.startswith("squad_rename_")) - dp.callback_query.register(cancel_squad_rename, F.data.startswith("cancel_rename_")) - dp.callback_query.register(toggle_squad_inbound, F.data.startswith("sqd_tgl_")) - dp.callback_query.register(save_squad_inbounds, F.data.startswith("sqd_save_")) - dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith("sqd_edit_")) - dp.callback_query.register(start_squad_creation, F.data == "admin_squad_create") - dp.callback_query.register(cancel_squad_creation, F.data == "cancel_squad_create") - dp.callback_query.register(toggle_create_inbound, F.data.startswith("create_tgl_")) - dp.callback_query.register(finish_squad_creation, F.data == "create_squad_finish") - - dp.message.register( - process_squad_new_name, - SquadRenameStates.waiting_for_new_name, - F.text + dp.callback_query.register(show_remnawave_menu, F.data == 'admin_remnawave') + dp.callback_query.register(show_system_stats, F.data == 'admin_rw_system') + dp.callback_query.register(show_traffic_stats, F.data == 'admin_rw_traffic') + dp.callback_query.register(show_nodes_management, F.data == 'admin_rw_nodes') + dp.callback_query.register(show_node_details, F.data.startswith('admin_node_manage_')) + dp.callback_query.register(show_node_statistics, F.data.startswith('node_stats_')) + dp.callback_query.register(manage_node, F.data.startswith('node_enable_')) + dp.callback_query.register(manage_node, F.data.startswith('node_disable_')) + dp.callback_query.register(manage_node, F.data.startswith('node_restart_')) + dp.callback_query.register(restart_all_nodes, F.data == 'admin_restart_all_nodes') + dp.callback_query.register(show_sync_options, F.data == 'admin_rw_sync') + dp.callback_query.register(show_auto_sync_settings, F.data == 'admin_rw_auto_sync') + dp.callback_query.register(toggle_auto_sync_setting, F.data == 'remnawave_auto_sync_toggle') + dp.callback_query.register(prompt_auto_sync_schedule, F.data == 'remnawave_auto_sync_times') + dp.callback_query.register(cancel_auto_sync_schedule, F.data == 'remnawave_auto_sync_cancel') + dp.callback_query.register(run_auto_sync_now, F.data == 'remnawave_auto_sync_run') + dp.callback_query.register(sync_all_users, F.data == 'sync_all_users') + dp.callback_query.register(sync_users_to_panel, F.data == 'sync_to_panel') + dp.callback_query.register(show_squad_migration_menu, F.data == 'admin_rw_migration') + dp.callback_query.register(paginate_migration_source, F.data.startswith('admin_migration_source_page_')) + dp.callback_query.register(handle_migration_source_selection, F.data.startswith('admin_migration_source_')) + dp.callback_query.register(paginate_migration_target, F.data.startswith('admin_migration_target_page_')) + dp.callback_query.register(handle_migration_target_selection, F.data.startswith('admin_migration_target_')) + dp.callback_query.register(change_migration_target, F.data == 'admin_migration_change_target') + dp.callback_query.register(confirm_squad_migration, F.data == 'admin_migration_confirm') + dp.callback_query.register(cancel_squad_migration, F.data == 'admin_migration_cancel') + dp.callback_query.register(handle_migration_page_info, F.data == 'admin_migration_page_info') + dp.callback_query.register(show_squads_management, F.data == 'admin_rw_squads') + dp.callback_query.register(show_squad_details, F.data.startswith('admin_squad_manage_')) + dp.callback_query.register(manage_squad_action, F.data.startswith('squad_add_users_')) + dp.callback_query.register(manage_squad_action, F.data.startswith('squad_remove_users_')) + dp.callback_query.register(manage_squad_action, F.data.startswith('squad_delete_')) + dp.callback_query.register( + show_squad_edit_menu, F.data.startswith('squad_edit_') & ~F.data.startswith('squad_edit_inbounds_') ) + dp.callback_query.register(show_squad_inbounds_selection, F.data.startswith('squad_edit_inbounds_')) + dp.callback_query.register(show_squad_rename_form, F.data.startswith('squad_rename_')) + dp.callback_query.register(cancel_squad_rename, F.data.startswith('cancel_rename_')) + dp.callback_query.register(toggle_squad_inbound, F.data.startswith('sqd_tgl_')) + dp.callback_query.register(save_squad_inbounds, F.data.startswith('sqd_save_')) + dp.callback_query.register(show_squad_edit_menu_short, F.data.startswith('sqd_edit_')) + dp.callback_query.register(start_squad_creation, F.data == 'admin_squad_create') + dp.callback_query.register(cancel_squad_creation, F.data == 'cancel_squad_create') + dp.callback_query.register(toggle_create_inbound, F.data.startswith('create_tgl_')) + dp.callback_query.register(finish_squad_creation, F.data == 'create_squad_finish') - dp.message.register( - process_squad_name, - SquadCreateStates.waiting_for_name, - F.text - ) + dp.message.register(process_squad_new_name, SquadRenameStates.waiting_for_new_name, F.text) + + dp.message.register(process_squad_name, SquadCreateStates.waiting_for_name, F.text) dp.message.register( save_auto_sync_schedule, diff --git a/app/handlers/admin/reports.py b/app/handlers/admin/reports.py index 44049276..50761ffa 100644 --- a/app/handlers/admin/reports.py +++ b/app/handlers/admin/reports.py @@ -1,4 +1,5 @@ import logging + from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from sqlalchemy.ext.asyncio import AsyncSession @@ -10,8 +11,8 @@ from app.keyboards.admin import ( ) from app.localization.texts import get_texts from app.services.reporting_service import ( - ReportPeriod, ReportingServiceError, + ReportPeriod, reporting_service, ) from app.utils.decorators import admin_required, error_handler @@ -28,10 +29,9 @@ async def show_reports_menu( db: AsyncSession, ) -> None: await callback.message.edit_text( - "📊 Отчеты\n\n" - "Выберите период, чтобы отправить отчет в админский топик.", + '📊 Отчеты\n\nВыберите период, чтобы отправить отчет в админский топик.', reply_markup=get_admin_reports_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -74,19 +74,19 @@ async def _send_report( try: report_text = await reporting_service.send_report(period, send_to_topic=True) except ReportingServiceError as exc: - logger.warning("Не удалось отправить отчет: %s", exc) + logger.warning('Не удалось отправить отчет: %s', exc) await callback.answer(str(exc), show_alert=True) return - except Exception as exc: # noqa: BLE001 - logger.error("Непредвиденная ошибка при отправке отчета: %s", exc) - await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True) + except Exception as exc: + logger.error('Непредвиденная ошибка при отправке отчета: %s', exc) + await callback.answer('Не удалось отправить отчет. Попробуйте позже.', show_alert=True) return await callback.message.answer( report_text, reply_markup=get_admin_report_result_keyboard(language), ) - await callback.answer("Отчет отправлен в топик") + await callback.answer('Отчет отправлен в топик') @admin_required @@ -101,17 +101,16 @@ async def close_report_message( try: await callback.message.delete() except (TelegramBadRequest, TelegramForbiddenError) as exc: - logger.warning("Не удалось закрыть сообщение отчета: %s", exc) - await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True) + logger.warning('Не удалось закрыть сообщение отчета: %s', exc) + await callback.answer(texts.t('REPORT_CLOSE_ERROR', 'Не удалось закрыть отчет.'), show_alert=True) return - await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт.")) + await callback.answer(texts.t('REPORT_CLOSED', 'Отчет закрыт.')) def register_handlers(dp: Dispatcher) -> None: - dp.callback_query.register(show_reports_menu, F.data == "admin_reports") - dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily") - dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly") - dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly") - dp.callback_query.register(close_report_message, F.data == "admin_close_report") - + dp.callback_query.register(show_reports_menu, F.data == 'admin_reports') + dp.callback_query.register(send_daily_report, F.data == 'admin_reports_daily') + dp.callback_query.register(send_weekly_report, F.data == 'admin_reports_weekly') + dp.callback_query.register(send_monthly_report, F.data == 'admin_reports_monthly') + dp.callback_query.register(close_report_message, F.data == 'admin_close_report') diff --git a/app/handlers/admin/rules.py b/app/handlers/admin/rules.py index b449c627..5c068535 100644 --- a/app/handlers/admin/rules.py +++ b/app/handlers/admin/rules.py @@ -1,26 +1,22 @@ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings -from app.states import AdminStates +from app.database.crud.rules import clear_all_rules, create_or_update_rules, get_current_rules_content from app.database.models import User -from app.localization.texts import get_texts +from app.states import AdminStates from app.utils.decorators import admin_required, error_handler -from app.utils.validators import validate_html_tags, get_html_help_text -from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules +from app.utils.validators import get_html_help_text, validate_html_tags + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_rules_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_rules_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): text = """ 📋 Управление правилами сервиса @@ -28,322 +24,309 @@ async def show_rules_management( Выберите действие: """ - + keyboard = [ - [types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")], - [types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")], - [types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")], - [types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")] + [types.InlineKeyboardButton(text='📝 Редактировать правила', callback_data='admin_edit_rules')], + [types.InlineKeyboardButton(text='👀 Просмотр правил', callback_data='admin_view_rules')], + [types.InlineKeyboardButton(text='🗑️ Очистить правила', callback_data='admin_clear_rules')], + [types.InlineKeyboardButton(text='ℹ️ Помощь по HTML', callback_data='admin_rules_help')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def view_current_rules( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def view_current_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: current_rules = await get_current_rules_content(db, db_user.language) - + is_valid, error_msg = validate_html_tags(current_rules) - warning = "" + warning = '' if not is_valid: - warning = f"\n\n⚠️ Внимание: В правилах найдена ошибка HTML: {error_msg}" - + warning = f'\n\n⚠️ Внимание: В правилах найдена ошибка HTML: {error_msg}' + await callback.message.edit_text( - f"📋 Текущие правила сервиса\n\n{current_rules}{warning}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")], - [types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")] - ]) + f'📋 Текущие правила сервиса\n\n{current_rules}{warning}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='✏️ Редактировать', callback_data='admin_edit_rules')], + [types.InlineKeyboardButton(text='🗑️ Очистить', callback_data='admin_clear_rules')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_rules')], + ] + ), ) await callback.answer() except Exception as e: - logger.error(f"Ошибка при показе правил: {e}") + logger.error(f'Ошибка при показе правил: {e}') await callback.message.edit_text( - "❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")] - ]) + '❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🗑️ Очистить правила', callback_data='admin_clear_rules')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_rules')], + ] + ), ) await callback.answer() @admin_required @error_handler -async def start_edit_rules( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): try: current_rules = await get_current_rules_content(db, db_user.language) - + preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '') - + text = ( - "✏️ Редактирование правил\n\n" - f"Текущие правила:\n{preview}\n\n" - "Отправьте новый текст правил сервиса.\n\n" - "Поддерживается HTML разметка. Все теги будут проверены перед сохранением.\n\n" - "💡 Совет: Нажмите /html_help для просмотра поддерживаемых тегов" + '✏️ Редактирование правил\n\n' + f'Текущие правила:\n{preview}\n\n' + 'Отправьте новый текст правил сервиса.\n\n' + 'Поддерживается HTML разметка. Все теги будут проверены перед сохранением.\n\n' + '💡 Совет: Нажмите /html_help для просмотра поддерживаемых тегов' ) - + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='ℹ️ HTML помощь', callback_data='admin_rules_help')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rules')], + ] + ), ) - + await state.set_state(AdminStates.editing_rules_page) await callback.answer() - + except Exception as e: - logger.error(f"Ошибка при начале редактирования правил: {e}") - await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True) + logger.error(f'Ошибка при начале редактирования правил: {e}') + await callback.answer('❌ Ошибка при загрузке правил для редактирования', show_alert=True) @admin_required @error_handler -async def process_rules_edit( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_rules_edit(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): new_rules = message.text - + if len(new_rules) > 4000: - await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)") + await message.answer('❌ Текст правил слишком длинный (максимум 4000 символов)') return - + is_valid, error_msg = validate_html_tags(new_rules) if not is_valid: await message.answer( - f"❌ Ошибка в HTML разметке:\n{error_msg}\n\n" - f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n" - f"💡 Используйте /html_help для просмотра правильного синтаксиса", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")], - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")] - ]) + f'❌ Ошибка в HTML разметке:\n{error_msg}\n\n' + f'Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n' + f'💡 Используйте /html_help для просмотра правильного синтаксиса', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='ℹ️ HTML помощь', callback_data='admin_rules_help')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rules')], + ] + ), ) return - + try: - preview_text = f"📋 Предварительный просмотр новых правил:\n\n{new_rules}\n\n" - preview_text += f"⚠️ Внимание! Новые правила будут показываться всем пользователям.\n\n" - preview_text += f"Сохранить изменения?" - + preview_text = f'📋 Предварительный просмотр новых правил:\n\n{new_rules}\n\n' + preview_text += '⚠️ Внимание! Новые правила будут показываться всем пользователям.\n\n' + preview_text += 'Сохранить изменения?' + if len(preview_text) > 4000: preview_text = ( - "📋 Предварительный просмотр новых правил:\n\n" - f"{new_rules[:500]}...\n\n" - f"⚠️ Внимание! Новые правила будут показываться всем пользователям.\n\n" - f"Текст правил: {len(new_rules)} символов\n" - f"Сохранить изменения?" + '📋 Предварительный просмотр новых правил:\n\n' + f'{new_rules[:500]}...\n\n' + f'⚠️ Внимание! Новые правила будут показываться всем пользователям.\n\n' + f'Текст правил: {len(new_rules)} символов\n' + f'Сохранить изменения?' ) - + await message.answer( preview_text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"), - types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules") + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='✅ Сохранить', callback_data='admin_save_rules'), + types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rules'), + ] ] - ]) + ), ) - + await state.update_data(new_rules=new_rules) - + except Exception as e: - logger.error(f"Ошибка при показе превью правил: {e}") + logger.error(f'Ошибка при показе превью правил: {e}') await message.answer( - "⚠️ Подтверждение сохранения правил\n\n" - f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n" - f"HTML теги проверены и корректны.\n\n" - f"Сохранить изменения?", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"), - types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules") + '⚠️ Подтверждение сохранения правил\n\n' + f'Новые правила готовы к сохранению ({len(new_rules)} символов).\n' + f'HTML теги проверены и корректны.\n\n' + f'Сохранить изменения?', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='✅ Сохранить', callback_data='admin_save_rules'), + types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rules'), + ] ] - ]) + ), ) - + await state.update_data(new_rules=new_rules) @admin_required @error_handler -async def save_rules( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def save_rules(callback: types.CallbackQuery, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() new_rules = data.get('new_rules') - + if not new_rules: - await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True) + await callback.answer('❌ Ошибка: текст правил не найден', show_alert=True) return - + is_valid, error_msg = validate_html_tags(new_rules) if not is_valid: await callback.message.edit_text( - f"❌ Ошибка при сохранении:\n{error_msg}\n\n" - f"Правила не были сохранены из-за ошибок в HTML разметке.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")], - [types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")] - ]) + f'❌ Ошибка при сохранении:\n{error_msg}\n\nПравила не были сохранены из-за ошибок в HTML разметке.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='admin_edit_rules')], + [types.InlineKeyboardButton(text='📋 К правилам', callback_data='admin_rules')], + ] + ), ) await state.clear() await callback.answer() return - + try: - await create_or_update_rules( - db=db, - content=new_rules, - language=db_user.language - ) - + await create_or_update_rules(db=db, content=new_rules, language=db_user.language) + from app.localization.texts import clear_rules_cache + clear_rules_cache() - + from app.localization.texts import refresh_rules_cache + await refresh_rules_cache(db_user.language) - + await callback.message.edit_text( - "✅ Правила сервиса успешно обновлены!\n\n" - "✓ Новые правила сохранены в базе данных\n" - "✓ HTML теги проверены и корректны\n" - "✓ Кеш правил очищен и обновлен\n" - "✓ Правила будут показываться пользователям\n\n" - f"📊 Размер текста: {len(new_rules)} символов", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")], - [types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")] - ]) + '✅ Правила сервиса успешно обновлены!\n\n' + '✓ Новые правила сохранены в базе данных\n' + '✓ HTML теги проверены и корректны\n' + '✓ Кеш правил очищен и обновлен\n' + '✓ Правила будут показываться пользователям\n\n' + f'📊 Размер текста: {len(new_rules)} символов', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👀 Просмотреть', callback_data='admin_view_rules')], + [types.InlineKeyboardButton(text='📋 К правилам', callback_data='admin_rules')], + ] + ), ) - + await state.clear() - logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}") + logger.info(f'Правила сервиса обновлены администратором {db_user.telegram_id}') await callback.answer() - + except Exception as e: - logger.error(f"Ошибка сохранения правил: {e}") + logger.error(f'Ошибка сохранения правил: {e}') await callback.message.edit_text( - "❌ Ошибка при сохранении правил\n\n" - "Произошла ошибка при записи в базу данных. Попробуйте еще раз.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")], - [types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")] - ]) + '❌ Ошибка при сохранении правил\n\nПроизошла ошибка при записи в базу данных. Попробуйте еще раз.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='admin_save_rules')], + [types.InlineKeyboardButton(text='📋 К правилам', callback_data='admin_rules')], + ] + ), ) await callback.answer() @admin_required @error_handler -async def clear_rules_confirmation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def clear_rules_confirmation(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "🗑️ Очистка правил сервиса\n\n" - "⚠️ ВНИМАНИЕ! Вы собираетесь полностью удалить все правила сервиса.\n\n" - "После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n" - "Это действие нельзя отменить. Продолжить?", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"), - types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules") + '🗑️ Очистка правил сервиса\n\n' + '⚠️ ВНИМАНИЕ! Вы собираетесь полностью удалить все правила сервиса.\n\n' + 'После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n' + 'Это действие нельзя отменить. Продолжить?', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='✅ Да, очистить', callback_data='admin_confirm_clear_rules'), + types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_rules'), + ] ] - ]) + ), ) await callback.answer() @admin_required @error_handler -async def confirm_clear_rules( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_clear_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: await clear_all_rules(db, db_user.language) - + from app.localization.texts import clear_rules_cache + clear_rules_cache() - + await callback.message.edit_text( - "✅ Правила успешно очищены!\n\n" - "✓ Все пользовательские правила удалены\n" - "✓ Теперь используются стандартные правила\n" - "✓ Кеш правил очищен\n\n" - "Пользователи будут видеть правила по умолчанию.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")], - [types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")], - [types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")] - ]) + '✅ Правила успешно очищены!\n\n' + '✓ Все пользовательские правила удалены\n' + '✓ Теперь используются стандартные правила\n' + '✓ Кеш правил очищен\n\n' + 'Пользователи будут видеть правила по умолчанию.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📝 Создать новые', callback_data='admin_edit_rules')], + [types.InlineKeyboardButton(text='👀 Посмотреть текущие', callback_data='admin_view_rules')], + [types.InlineKeyboardButton(text='📋 К правилам', callback_data='admin_rules')], + ] + ), ) - - logger.info(f"Правила очищены администратором {db_user.telegram_id}") + + logger.info(f'Правила очищены администратором {db_user.telegram_id}') await callback.answer() - + except Exception as e: - logger.error(f"Ошибка при очистке правил: {e}") - await callback.answer("❌ Ошибка при очистке правил", show_alert=True) + logger.error(f'Ошибка при очистке правил: {e}') + await callback.answer('❌ Ошибка при очистке правил', show_alert=True) @admin_required @error_handler -async def show_html_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_html_help(callback: types.CallbackQuery, db_user: User, db: AsyncSession): help_text = get_html_help_text() - + await callback.message.edit_text( - f"ℹ️ Справка по HTML форматированию\n\n{help_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")] - ]) + f'ℹ️ Справка по HTML форматированию\n\n{help_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📝 Редактировать правила', callback_data='admin_edit_rules')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_rules')], + ] + ), ) await callback.answer() def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_rules_management, F.data == "admin_rules") - dp.callback_query.register(view_current_rules, F.data == "admin_view_rules") - dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules") - dp.callback_query.register(save_rules, F.data == "admin_save_rules") - - dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules") - dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules") - - dp.callback_query.register(show_html_help, F.data == "admin_rules_help") - - dp.message.register(process_rules_edit, AdminStates.editing_rules_page) \ No newline at end of file + dp.callback_query.register(show_rules_management, F.data == 'admin_rules') + dp.callback_query.register(view_current_rules, F.data == 'admin_view_rules') + dp.callback_query.register(start_edit_rules, F.data == 'admin_edit_rules') + dp.callback_query.register(save_rules, F.data == 'admin_save_rules') + + dp.callback_query.register(clear_rules_confirmation, F.data == 'admin_clear_rules') + dp.callback_query.register(confirm_clear_rules, F.data == 'admin_confirm_clear_rules') + + dp.callback_query.register(show_html_help, F.data == 'admin_rules_help') + + dp.message.register(process_rules_edit, AdminStates.editing_rules_page) diff --git a/app/handlers/admin/servers.py b/app/handlers/admin/servers.py index b8379ade..67ceb231 100644 --- a/app/handlers/admin/servers.py +++ b/app/handlers/admin/servers.py @@ -1,41 +1,42 @@ import html import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.states import AdminStates -from app.database.models import User -from app.database.crud.server_squad import ( - get_all_server_squads, - get_server_squad_by_id, - update_server_squad, - delete_server_squad, - sync_with_remnawave, - get_server_statistics, - 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.database.crud.server_squad import ( + delete_server_squad, + get_all_server_squads, + get_available_server_squads, + get_server_connected_users, + get_server_squad_by_id, + get_server_statistics, + sync_with_remnawave, + update_server_squad, + update_server_squad_promo_groups, +) +from app.database.models import User from app.services.remnawave_service import RemnaWaveService -from app.utils.decorators import admin_required, error_handler +from app.states import AdminStates from app.utils.cache import cache +from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) def _build_server_edit_view(server): - status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" + status_emoji = '✅ Доступен' if server.is_available else '❌ Недоступен' + price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно' promo_groups_text = ( - ", ".join(sorted(pg.name for pg in server.allowed_promo_groups)) + ', '.join(sorted(pg.name for pg in server.allowed_promo_groups)) if server.allowed_promo_groups - else "Не выбраны" + else 'Не выбраны' ) - trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет" + trial_status = '✅ Да' if server.is_trial_eligible else '⚪️ Нет' text = f""" 🌐 Редактирование сервера @@ -63,51 +64,35 @@ def _build_server_edit_view(server): keyboard = [ [ - types.InlineKeyboardButton( - text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}" - ), - types.InlineKeyboardButton( - text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}" - ), + types.InlineKeyboardButton(text='✏️ Название', callback_data=f'admin_server_edit_name_{server.id}'), + types.InlineKeyboardButton(text='💰 Цена', callback_data=f'admin_server_edit_price_{server.id}'), + ], + [ + types.InlineKeyboardButton(text='🌍 Страна', callback_data=f'admin_server_edit_country_{server.id}'), + types.InlineKeyboardButton(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_country_{server.id}" - ), - types.InlineKeyboardButton( - text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}" + text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад', + callback_data=f'admin_server_trial_{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}'), + types.InlineKeyboardButton(text='📝 Описание', callback_data=f'admin_server_edit_desc_{server.id}'), ], [ types.InlineKeyboardButton( - text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад", - callback_data=f"admin_server_trial_{server.id}", - ), - ], - [ - types.InlineKeyboardButton( - text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}" - ), - types.InlineKeyboardButton( - text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}" - ), - ], - [ - types.InlineKeyboardButton( - text="❌ Отключить" if server.is_available else "✅ Включить", - callback_data=f"admin_server_toggle_{server.id}", + text='❌ Отключить' if server.is_available else '✅ Включить', + callback_data=f'admin_server_toggle_{server.id}', ) ], [ - types.InlineKeyboardButton( - text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}" - ), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"), + types.InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'admin_server_delete_{server.id}'), + types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers_list'), ], ] @@ -117,44 +102,29 @@ def _build_server_edit_view(server): def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids): keyboard = [] for group in promo_groups: - emoji = "✅" if group["id"] in selected_ids else "⚪" + emoji = '✅' if group['id'] in selected_ids else '⚪' keyboard.append( [ types.InlineKeyboardButton( - text=f"{emoji} {group['name']}", - callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}", + text=f'{emoji} {group["name"]}', + callback_data=f'admin_server_promo_toggle_{server_id}_{group["id"]}', ) ] ) keyboard.append( - [ - types.InlineKeyboardButton( - text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}" - ) - ] - ) - keyboard.append( - [ - types.InlineKeyboardButton( - text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}" - ) - ] + [types.InlineKeyboardButton(text='💾 Сохранить', callback_data=f'admin_server_promo_save_{server_id}')] ) + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_server_edit_{server_id}')]) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) @admin_required @error_handler -async def show_servers_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_servers_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_server_statistics(db) - + text = f""" 🌐 Управление серверами @@ -169,133 +139,105 @@ async def show_servers_menu( Выберите действие: """ - + keyboard = [ [ - types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), - types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync") + types.InlineKeyboardButton(text='📋 Список серверов', callback_data='admin_servers_list'), + types.InlineKeyboardButton(text='🔄 Синхронизация', callback_data='admin_servers_sync'), ], [ - types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"), - types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats") + types.InlineKeyboardButton(text='📊 Синхронизировать счетчики', callback_data='admin_servers_sync_counts'), + types.InlineKeyboardButton(text='📈 Подробная статистика', callback_data='admin_servers_stats'), ], - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") - ] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_panel')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_servers_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - page: int = 1 -): - +async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1): servers, total_count = await get_all_server_squads(db, page=page, limit=10) total_pages = (total_count + 9) // 10 - + if not servers: - text = "🌐 Список серверов\n\n❌ Серверы не найдены." + text = '🌐 Список серверов\n\n❌ Серверы не найдены.' else: - text = f"🌐 Список серверов\n\n" - text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n" - + text = '🌐 Список серверов\n\n' + text += f'📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n' + for i, server in enumerate(servers, 1 + (page - 1) * 10): - status_emoji = "✅" if server.is_available else "❌" - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - - text += f"{i}. {status_emoji} {server.display_name}\n" - text += f" 💰 Цена: {price_text}" - + status_emoji = '✅' if server.is_available else '❌' + price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно' + + text += f'{i}. {status_emoji} {server.display_name}\n' + text += f' 💰 Цена: {price_text}' + if server.max_users: - text += f" | 👥 {server.current_users}/{server.max_users}" - - text += f"\n UUID: {server.squad_uuid}\n\n" - + text += f' | 👥 {server.current_users}/{server.max_users}' + + text += f'\n UUID: {server.squad_uuid}\n\n' + keyboard = [] - + for i, server in enumerate(servers): - row_num = i // 2 + row_num = i // 2 if len(keyboard) <= row_num: keyboard.append([]) - - status_emoji = "✅" if server.is_available else "❌" + + status_emoji = '✅' if server.is_available else '❌' keyboard[row_num].append( types.InlineKeyboardButton( - text=f"{status_emoji} {server.display_name[:15]}...", - callback_data=f"admin_server_edit_{server.id}" + text=f'{status_emoji} {server.display_name[:15]}...', callback_data=f'admin_server_edit_{server.id}' ) ) - + if total_pages > 1: nav_row = [] if page > 1: - nav_row.append(types.InlineKeyboardButton( - text="⬅️", callback_data=f"admin_servers_list_page_{page-1}" - )) - - nav_row.append(types.InlineKeyboardButton( - text=f"{page}/{total_pages}", callback_data="current_page" - )) - + nav_row.append(types.InlineKeyboardButton(text='⬅️', callback_data=f'admin_servers_list_page_{page - 1}')) + + nav_row.append(types.InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='current_page')) + if page < total_pages: - nav_row.append(types.InlineKeyboardButton( - text="➡️", callback_data=f"admin_servers_list_page_{page+1}" - )) - + nav_row.append(types.InlineKeyboardButton(text='➡️', callback_data=f'admin_servers_list_page_{page + 1}')) + keyboard.append(nav_row) - - keyboard.extend([ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] - ]) - + + keyboard.extend([[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')]]) + await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def sync_servers_with_remnawave( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def sync_servers_with_remnawave(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.", - reply_markup=None + '🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.', reply_markup=None ) - + try: remnawave_service = RemnaWaveService() squads = await remnawave_service.get_all_squads() - + if not squads: await callback.message.edit_text( - "❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] - ]) + '❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')]] + ), ) return - + created, updated, removed = await sync_with_remnawave(db, squads) - - await cache.delete_pattern("available_countries*") - + + await cache.delete_pattern('available_countries*') + text = f""" ✅ Синхронизация завершена @@ -308,27 +250,24 @@ async def sync_servers_with_remnawave( ℹ️ Новые серверы созданы как недоступные. Настройте их в списке серверов. """ - + keyboard = [ [ - types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), - types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync") + types.InlineKeyboardButton(text='📋 Список серверов', callback_data='admin_servers_list'), + types.InlineKeyboardButton(text='🔄 Повторить', callback_data='admin_servers_sync'), ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + except Exception as e: - logger.error(f"Ошибка синхронизации серверов: {e}") + logger.error(f'Ошибка синхронизации серверов: {e}') await callback.message.edit_text( - f"❌ Ошибка синхронизации: {str(e)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] - ]) + f'❌ Ошибка синхронизации: {e!s}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')]] + ), ) await callback.answer() @@ -336,39 +275,25 @@ async def sync_servers_with_remnawave( @admin_required @error_handler -async def show_server_edit_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_server_edit_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return text, keyboard = _build_server_edit_view(server) - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') 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("_") +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 @@ -376,7 +301,7 @@ async def show_server_users( server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return users = await get_server_connected_users(db, server_id) @@ -385,36 +310,35 @@ async def show_server_users( page_size = 10 total_pages = max((total_users + page_size - 1) // page_size, 1) - if page > total_pages: - page = total_pages + page = min(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 "—") + safe_name = html.escape(server.display_name or '—') + safe_uuid = html.escape(server.squad_uuid or '—') header = [ - "🌐 Пользователи сервера", - "", - f"• Сервер: {safe_name}", - f"• UUID: {safe_uuid}", - f"• Подключений: {total_users}", + '🌐 Пользователи сервера', + '', + f'• Сервер: {safe_name}', + f'• UUID: {safe_uuid}', + f'• Подключений: {total_users}', ] if total_pages > 1: - header.append(f"• Страница: {page}/{total_pages}") + header.append(f'• Страница: {page}/{total_pages}') - header.append("") + header.append('') - text = "\n".join(header) + text = '\n'.join(header) def _get_status_icon(status_text: str) -> str: if not status_text: - return "" + return '' - parts = status_text.split(" ", 1) + parts = status_text.split(' ', 1) return parts[0] if parts else status_text if users: @@ -424,38 +348,36 @@ async def show_server_users( if user.telegram_id: user_link = f'{safe_user_name}' else: - user_link = f"{safe_user_name}" - lines.append(f"{index}. {user_link}") + user_link = f'{safe_user_name}' + lines.append(f'{index}. {user_link}') - text += "\n" + "\n".join(lines) + text += '\n' + '\n'.join(lines) else: - text += "Пользователи не найдены." + 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] + "..." + display_name = display_name[:27] + '...' - subscription_status = ( - user.subscription.status_display - if user.subscription - else "❌ Нет подписки" - ) + 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}" + 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}", - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f'admin_user_manage_{user.id}', + ) + ] + ) if total_pages > 1: navigation_buttons: list[types.InlineKeyboardButton] = [] @@ -463,44 +385,36 @@ async def show_server_users( if page > 1: navigation_buttons.append( types.InlineKeyboardButton( - text="⬅️ Предыдущая", - callback_data=f"admin_server_users_{server_id}_{page - 1}", + 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}", + 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}", + 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=f'admin_server_edit_{server_id}')]) - keyboard.append([ - types.InlineKeyboardButton( - text="⬅️ К списку", callback_data="admin_servers_list" - ) - ]) + 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", + parse_mode='HTML', ) await callback.answer() @@ -508,236 +422,201 @@ async def show_server_users( @admin_required @error_handler -async def toggle_server_availability( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def toggle_server_availability(callback: types.CallbackQuery, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + new_status = not server.is_available await update_server_squad(db, server_id, is_available=new_status) - - await cache.delete_pattern("available_countries*") - - status_text = "включен" if new_status else "отключен" - await callback.answer(f"✅ Сервер {status_text}!") - + + await cache.delete_pattern('available_countries*') + + status_text = 'включен' if new_status else 'отключен' + await callback.answer(f'✅ Сервер {status_text}!') + server = await get_server_squad_by_id(db, server_id) - + text, keyboard = _build_server_edit_view(server) - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') @admin_required @error_handler -async def toggle_server_trial_assignment( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def toggle_server_trial_assignment(callback: types.CallbackQuery, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return new_status = not server.is_trial_eligible await update_server_squad(db, server_id, is_trial_eligible=new_status) - status_text = "будет выдаваться" if new_status else "перестанет выдаваться" - await callback.answer(f"✅ Сквад {status_text} в триал") + status_text = 'будет выдаваться' if new_status else 'перестанет выдаваться' + await callback.answer(f'✅ Сквад {status_text} в триал') server = await get_server_squad_by_id(db, server_id) text, keyboard = _build_server_edit_view(server) - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') @admin_required @error_handler -async def start_server_edit_price( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def start_server_edit_price(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + await state.set_data({'server_id': server_id}) await state.set_state(AdminStates.editing_server_price) - - current_price = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - + + current_price = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно' + await callback.message.edit_text( - f"💰 Редактирование цены\n\n" - f"Текущая цена: {current_price}\n\n" - f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'💰 Редактирование цены\n\n' + f'Текущая цена: {current_price}\n\n' + f'Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def process_server_price_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def process_server_price_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): data = await state.get_data() server_id = data.get('server_id') - + try: price_rubles = float(message.text.replace(',', '.')) - + if price_rubles < 0: - await message.answer("❌ Цена не может быть отрицательной") + await message.answer('❌ Цена не может быть отрицательной') return - + if price_rubles > 10000: - await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)") + await message.answer('❌ Слишком высокая цена (максимум 10,000 ₽)') return - + price_kopeks = int(price_rubles * 100) - + server = await update_server_squad(db, server_id, price_kopeks=price_kopeks) - + if server: await state.clear() - - await cache.delete_pattern("available_countries*") - - price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно" + + await cache.delete_pattern('available_countries*') + + price_text = f'{int(price_rubles)} ₽' if price_kopeks > 0 else 'Бесплатно' await message.answer( - f"✅ Цена сервера изменена на: {price_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✅ Цена сервера изменена на: {price_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}' + ) + ] + ] + ), + parse_mode='HTML', ) else: - await message.answer("❌ Ошибка при обновлении сервера") - + await message.answer('❌ Ошибка при обновлении сервера') + except ValueError: - await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)") + await message.answer('❌ Неверный формат цены. Используйте числа (например: 15.50)') @admin_required @error_handler -async def start_server_edit_name( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + await state.set_data({'server_id': server_id}) await state.set_state(AdminStates.editing_server_name) - + await callback.message.edit_text( - f"✏️ Редактирование названия\n\n" - f"Текущее название: {server.display_name}\n\n" - f"Отправьте новое название для сервера:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✏️ Редактирование названия\n\n' + f'Текущее название: {server.display_name}\n\n' + f'Отправьте новое название для сервера:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def process_server_name_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def process_server_name_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): data = await state.get_data() server_id = data.get('server_id') - + new_name = message.text.strip() - + if len(new_name) > 255: - await message.answer("❌ Название слишком длинное (максимум 255 символов)") + await message.answer('❌ Название слишком длинное (максимум 255 символов)') return - + if len(new_name) < 3: - await message.answer("❌ Название слишком короткое (минимум 3 символа)") + await message.answer('❌ Название слишком короткое (минимум 3 символа)') return - + server = await update_server_squad(db, server_id, display_name=new_name) - + if server: await state.clear() - - await cache.delete_pattern("available_countries*") - + + await cache.delete_pattern('available_countries*') + await message.answer( - f"✅ Название сервера изменено на: {new_name}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✅ Название сервера изменено на: {new_name}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) else: - await message.answer("❌ Ошибка при обновлении сервера") + await message.answer('❌ Ошибка при обновлении сервера') @admin_required @error_handler -async def delete_server_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + text = f""" 🗑️ Удаление сервера @@ -749,73 +628,64 @@ async def delete_server_confirm( Это действие нельзя отменить! """ - + keyboard = [ [ - types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"), - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}") + types.InlineKeyboardButton(text='🗑️ Да, удалить', callback_data=f'admin_server_delete_confirm_{server_id}'), + types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}'), ] ] - + await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def delete_server_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + success = await delete_server_squad(db, server_id) - + if success: - await cache.delete_pattern("available_countries*") - + await cache.delete_pattern('available_countries*') + await callback.message.edit_text( - f"✅ Сервер {server.display_name} успешно удален!", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")] - ]), - parse_mode="HTML" + f'✅ Сервер {server.display_name} успешно удален!', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')] + ] + ), + parse_mode='HTML', ) else: await callback.message.edit_text( - f"❌ Не удалось удалить сервер {server.display_name}\n\n" - f"Возможно, к нему есть активные подключения.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'❌ Не удалось удалить сервер {server.display_name}\n\nВозможно, к нему есть активные подключения.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) - + await callback.answer() @admin_required @error_handler -async def show_server_detailed_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_server_statistics(db) available_servers = await get_available_server_squads(db) - + text = f""" 📊 Подробная статистика серверов @@ -831,250 +701,229 @@ async def show_server_detailed_stats( 🔥 Топ серверов по цене: """ - + sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True) - + for i, server in enumerate(sorted_servers[:5], 1): - price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно" - text += f"{i}. {server.display_name} - {price_text}\n" - + price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно' + text += f'{i}. {server.display_name} - {price_text}\n' + if not sorted_servers: - text += "Нет доступных серверов\n" - + text += 'Нет доступных серверов\n' + keyboard = [ [ - types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"), - types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list") + types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_servers_stats'), + types.InlineKeyboardButton(text='📋 Список', callback_data='admin_servers_list'), ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def start_server_edit_country( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def start_server_edit_country(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + await state.set_data({'server_id': server_id}) await state.set_state(AdminStates.editing_server_country) - - current_country = server.country_code or "Не указан" - + + current_country = server.country_code or 'Не указан' + await callback.message.edit_text( - f"🌍 Редактирование кода страны\n\n" - f"Текущий код страны: {current_country}\n\n" + f'🌍 Редактирование кода страны\n\n' + f'Текущий код страны: {current_country}\n\n' f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def process_server_country_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def process_server_country_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): data = await state.get_data() server_id = data.get('server_id') - + new_country = message.text.strip().upper() - - if new_country == "-": + + if new_country == '-': new_country = None elif len(new_country) > 5: - await message.answer("❌ Код страны слишком длинный (максимум 5 символов)") + await message.answer('❌ Код страны слишком длинный (максимум 5 символов)') return - + server = await update_server_squad(db, server_id, country_code=new_country) - + if server: await state.clear() - - await cache.delete_pattern("available_countries*") - - country_text = new_country or "Удален" + + await cache.delete_pattern('available_countries*') + + country_text = new_country or 'Удален' await message.answer( - f"✅ Код страны изменен на: {country_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✅ Код страны изменен на: {country_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) else: - await message.answer("❌ Ошибка при обновлении сервера") + await message.answer('❌ Ошибка при обновлении сервера') @admin_required @error_handler -async def start_server_edit_limit( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def start_server_edit_limit(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + await state.set_data({'server_id': server_id}) await state.set_state(AdminStates.editing_server_limit) - - current_limit = server.max_users or "Без лимита" - + + current_limit = server.max_users or 'Без лимита' + await callback.message.edit_text( - f"👥 Редактирование лимита пользователей\n\n" - f"Текущий лимит: {current_limit}\n\n" - f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'👥 Редактирование лимита пользователей\n\n' + f'Текущий лимит: {current_limit}\n\n' + f'Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def process_server_limit_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def process_server_limit_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): data = await state.get_data() server_id = data.get('server_id') - + try: limit = int(message.text.strip()) - + if limit < 0: - await message.answer("❌ Лимит не может быть отрицательным") + await message.answer('❌ Лимит не может быть отрицательным') return - + if limit > 10000: - await message.answer("❌ Слишком большой лимит (максимум 10,000)") + await message.answer('❌ Слишком большой лимит (максимум 10,000)') return - + max_users = limit if limit > 0 else None - + server = await update_server_squad(db, server_id, max_users=max_users) - + if server: await state.clear() - - limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита" + + limit_text = f'{limit} пользователей' if limit > 0 else 'Без лимита' await message.answer( - f"✅ Лимит пользователей изменен на: {limit_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✅ Лимит пользователей изменен на: {limit_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}' + ) + ] + ] + ), + parse_mode='HTML', ) else: - await message.answer("❌ Ошибка при обновлении сервера") - + await message.answer('❌ Ошибка при обновлении сервера') + except ValueError: - await message.answer("❌ Неверный формат числа. Введите целое число.") + await message.answer('❌ Неверный формат числа. Введите целое число.') @admin_required @error_handler async def start_server_edit_description( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession ): - server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) - + if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return - + await state.set_data({'server_id': server_id}) await state.set_state(AdminStates.editing_server_description) - - current_desc = server.description or "Не указано" - + + current_desc = server.description or 'Не указано' + await callback.message.edit_text( - f"📝 Редактирование описания\n\n" - f"Текущее описание:\n{current_desc}\n\n" + f'📝 Редактирование описания\n\n' + f'Текущее описание:\n{current_desc}\n\n' f"Отправьте новое описание сервера или '-' для удаления:", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def process_server_description_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - +async def process_server_description_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): data = await state.get_data() server_id = data.get('server_id') - + new_description = message.text.strip() - - if new_description == "-": + + if new_description == '-': new_description = None elif len(new_description) > 1000: - await message.answer("❌ Описание слишком длинное (максимум 1000 символов)") + await message.answer('❌ Описание слишком длинное (максимум 1000 символов)') return - + server = await update_server_squad(db, server_id, description=new_description) if server: await state.clear() - desc_text = new_description or "Удалено" - await cache.delete_pattern("available_countries*") + desc_text = new_description or 'Удалено' + await cache.delete_pattern('available_countries*') await message.answer( - f"✅ Описание сервера изменено:\n\n{desc_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")] - ]), - parse_mode="HTML" + f'✅ Описание сервера изменено:\n\n{desc_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')] + ] + ), + parse_mode='HTML', ) else: - await message.answer("❌ Ошибка при обновлении сервера") + await message.answer('❌ Ошибка при обновлении сервера') @admin_required @@ -1085,51 +934,49 @@ async def start_server_edit_promo_groups( db_user: User, db: AsyncSession, ): - server_id = int(callback.data.split('_')[-1]) server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден!", show_alert=True) + await callback.answer('❌ Сервер не найден!', show_alert=True) return promo_groups_data = await get_promo_groups_with_counts(db) promo_groups = [ - {"id": group.id, "name": group.name, "is_default": group.is_default} - for group, _ in promo_groups_data + {'id': group.id, 'name': group.name, 'is_default': group.is_default} for group, _ in promo_groups_data ] if not promo_groups: - await callback.answer("❌ Не найдены промогруппы", show_alert=True) + await callback.answer('❌ Не найдены промогруппы', show_alert=True) return selected_ids = {pg.id for pg in server.allowed_promo_groups} if not selected_ids: - default_group = next((pg for pg in promo_groups if pg["is_default"]), None) + default_group = next((pg for pg in promo_groups if pg['is_default']), None) if default_group: - selected_ids.add(default_group["id"]) + selected_ids.add(default_group['id']) await state.set_state(AdminStates.editing_server_promo_groups) await state.set_data( { - "server_id": server_id, - "promo_groups": promo_groups, - "selected_promo_groups": list(selected_ids), - "server_name": server.display_name, + 'server_id': server_id, + 'promo_groups': promo_groups, + 'selected_promo_groups': list(selected_ids), + 'server_name': server.display_name, } ) text = ( - "🎯 Настройка промогрупп\n\n" - f"Сервер: {server.display_name}\n\n" - "Выберите промогруппы, которым будет доступен этот сервер.\n" - "Должна быть выбрана минимум одна промогруппа." + '🎯 Настройка промогрупп\n\n' + f'Сервер: {server.display_name}\n\n' + 'Выберите промогруппы, которым будет доступен этот сервер.\n' + 'Должна быть выбрана минимум одна промогруппа.' ) await callback.message.edit_text( text, reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -1142,28 +989,27 @@ async def toggle_server_promo_group( db_user: User, db: AsyncSession, ): - parts = callback.data.split('_') server_id = int(parts[4]) group_id = int(parts[5]) data = await state.get_data() - if not data or data.get("server_id") != server_id: - await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True) + if not data or data.get('server_id') != server_id: + await callback.answer('⚠️ Сессия редактирования устарела', show_alert=True) return - selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", [])) - promo_groups = data.get("promo_groups", []) + selected = {int(pg_id) for pg_id in data.get('selected_promo_groups', [])} + promo_groups = data.get('promo_groups', []) if group_id in selected: if len(selected) == 1: - await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True) + await callback.answer('⚠️ Нельзя отключить последнюю промогруппу', show_alert=True) return selected.remove(group_id) - message = "Промогруппа отключена" + message = 'Промогруппа отключена' else: selected.add(group_id) - message = "Промогруппа добавлена" + message = 'Промогруппа добавлена' await state.update_data(selected_promo_groups=list(selected)) @@ -1181,30 +1027,29 @@ async def save_server_promo_groups( db_user: User, db: AsyncSession, ): - data = await state.get_data() if not data: - await callback.answer("⚠️ Нет данных для сохранения", show_alert=True) + await callback.answer('⚠️ Нет данных для сохранения', show_alert=True) return - server_id = data.get("server_id") - selected = data.get("selected_promo_groups", []) + server_id = data.get('server_id') + selected = data.get('selected_promo_groups', []) if not selected: - await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True) + await callback.answer('❌ Выберите хотя бы одну промогруппу', show_alert=True) return try: server = await update_server_squad_promo_groups(db, server_id, selected) except ValueError as exc: - await callback.answer(f"❌ {exc}", show_alert=True) + await callback.answer(f'❌ {exc}', show_alert=True) return if not server: - await callback.answer("❌ Сервер не найден", show_alert=True) + await callback.answer('❌ Сервер не найден', show_alert=True) return - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') await state.clear() text, keyboard = _build_server_edit_view(server) @@ -1212,29 +1057,21 @@ async def save_server_promo_groups( await callback.message.edit_text( text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) - await callback.answer("✅ Промогруппы обновлены!") + await callback.answer('✅ Промогруппы обновлены!') @admin_required @error_handler -async def sync_server_user_counts_handler( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - - await callback.message.edit_text( - "🔄 Синхронизация счетчиков пользователей...", - reply_markup=None - ) - +async def sync_server_user_counts_handler(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.message.edit_text('🔄 Синхронизация счетчиков пользователей...', reply_markup=None) + try: from app.database.crud.server_squad import sync_server_user_counts - + updated_count = await sync_server_user_counts(db) - + text = f""" ✅ Синхронизация завершена @@ -1243,82 +1080,75 @@ async def sync_server_user_counts_handler( Счетчики пользователей синхронизированы с реальными данными. """ - + keyboard = [ [ - types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"), - types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts") + types.InlineKeyboardButton(text='📋 Список серверов', callback_data='admin_servers_list'), + types.InlineKeyboardButton(text='🔄 Повторить', callback_data='admin_servers_sync_counts'), ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) - + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + except Exception as e: - logger.error(f"Ошибка синхронизации счетчиков: {e}") + logger.error(f'Ошибка синхронизации счетчиков: {e}') await callback.message.edit_text( - f"❌ Ошибка синхронизации: {str(e)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")] - ]) + f'❌ Ошибка синхронизации: {e!s}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_servers')]] + ), ) - + await callback.answer() @admin_required -@error_handler -async def handle_servers_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +@error_handler +async def handle_servers_pagination(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[-1]) await show_servers_list(callback, db_user, db, page) def register_handlers(dp: Dispatcher): - - dp.callback_query.register(show_servers_menu, F.data == "admin_servers") - dp.callback_query.register(show_servers_list, F.data == "admin_servers_list") - dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync") - dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts") - dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats") - + dp.callback_query.register(show_servers_menu, F.data == 'admin_servers') + dp.callback_query.register(show_servers_list, F.data == 'admin_servers_list') + dp.callback_query.register(sync_servers_with_remnawave, F.data == 'admin_servers_sync') + dp.callback_query.register(sync_server_user_counts_handler, F.data == 'admin_servers_sync_counts') + dp.callback_query.register(show_server_detailed_stats, F.data == 'admin_servers_stats') + dp.callback_query.register( show_server_edit_menu, - F.data.startswith("admin_server_edit_") - & ~F.data.contains("name") - & ~F.data.contains("price") - & ~F.data.contains("country") - & ~F.data.contains("limit") - & ~F.data.contains("desc") - & ~F.data.contains("promo"), + F.data.startswith('admin_server_edit_') + & ~F.data.contains('name') + & ~F.data.contains('price') + & ~F.data.contains('country') + & ~F.data.contains('limit') + & ~F.data.contains('desc') + & ~F.data.contains('promo'), ) - dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_")) - dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_")) - dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_")) + dp.callback_query.register(toggle_server_availability, F.data.startswith('admin_server_toggle_')) + dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith('admin_server_trial_')) + 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_')) + dp.callback_query.register(start_server_edit_country, F.data.startswith('admin_server_edit_country_')) + dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith('admin_server_edit_promo_')) + dp.callback_query.register(start_server_edit_limit, F.data.startswith('admin_server_edit_limit_')) + dp.callback_query.register(start_server_edit_description, F.data.startswith('admin_server_edit_desc_')) - 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_")) - dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_")) - dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_")) - dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_")) - dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_")) - dp.message.register(process_server_name_edit, AdminStates.editing_server_name) dp.message.register(process_server_price_edit, AdminStates.editing_server_price) - dp.message.register(process_server_country_edit, AdminStates.editing_server_country) - dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) + dp.message.register(process_server_country_edit, AdminStates.editing_server_country) + dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit) dp.message.register(process_server_description_edit, AdminStates.editing_server_description) - dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_")) - dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_")) - - dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm")) - dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_")) - - dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_")) + dp.callback_query.register(toggle_server_promo_group, F.data.startswith('admin_server_promo_toggle_')) + dp.callback_query.register(save_server_promo_groups, F.data.startswith('admin_server_promo_save_')) + + dp.callback_query.register( + delete_server_confirm, F.data.startswith('admin_server_delete_') & ~F.data.contains('confirm') + ) + dp.callback_query.register(delete_server_execute, F.data.startswith('admin_server_delete_confirm_')) + + dp.callback_query.register(handle_servers_pagination, F.data.startswith('admin_servers_list_page_')) diff --git a/app/handlers/admin/statistics.py b/app/handlers/admin/statistics.py index 12ef93e2..906ee8e2 100644 --- a/app/handlers/admin/statistics.py +++ b/app/handlers/admin/statistics.py @@ -1,57 +1,47 @@ import logging from datetime import datetime, timedelta -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import User -from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard -from app.localization.texts import get_texts -from app.services.user_service import UserService -from app.database.crud.subscription import get_subscriptions_statistics -from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period from app.database.crud.referral import get_referral_statistics +from app.database.crud.subscription import get_subscriptions_statistics +from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics +from app.database.models import User +from app.keyboards.admin import get_admin_statistics_keyboard +from app.services.user_service import UserService from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_percentage + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_statistics_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_statistics_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): text = """ 📊 Статистика системы Выберите раздел для просмотра статистики: """ - - await callback.message.edit_text( - text, - reply_markup=get_admin_statistics_keyboard(db_user.language) - ) + + await callback.message.edit_text(text, reply_markup=get_admin_statistics_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler -async def show_users_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_users_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_service = UserService() stats = await user_service.get_user_statistics(db) - + total_users = stats['total_users'] active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0) - + current_time = format_datetime(datetime.utcnow()) - + text = f""" 👥 Статистика пользователей @@ -71,39 +61,36 @@ async def show_users_statistics( Обновлено: {current_time} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_stats_users')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_statistics')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) except Exception as e: - if "message is not modified" in str(e): - await callback.answer("📊 Данные актуальны", show_alert=False) + if 'message is not modified' in str(e): + await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f"Ошибка обновления статистики пользователей: {e}") - await callback.answer("❌ Ошибка обновления данных", show_alert=True) + logger.error(f'Ошибка обновления статистики пользователей: {e}') + await callback.answer('❌ Ошибка обновления данных', show_alert=True) return - - await callback.answer("✅ Статистика обновлена") + await callback.answer('✅ Статистика обновлена') @admin_required @error_handler -async def show_subscriptions_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_subscriptions_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_subscriptions_statistics(db) - + total_subs = stats['total_subscriptions'] conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0) current_time = format_datetime(datetime.utcnow()) - + text = f""" 📱 Статистика подписок @@ -124,37 +111,35 @@ async def show_subscriptions_statistics( Обновлено: {current_time} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_stats_subs')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_statistics')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer("✅ Статистика обновлена") + await callback.answer('✅ Статистика обновлена') except Exception as e: - if "message is not modified" in str(e): - await callback.answer("📊 Данные актуальны", show_alert=False) + if 'message is not modified' in str(e): + await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f"Ошибка обновления статистики подписок: {e}") - await callback.answer("❌ Ошибка обновления данных", show_alert=True) + logger.error(f'Ошибка обновления статистики подписок: {e}') + await callback.answer('❌ Ошибка обновления данных', show_alert=True) @admin_required @error_handler -async def show_revenue_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_revenue_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): now = datetime.utcnow() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - + month_stats = await get_transactions_statistics(db, month_start, now) all_time_stats = await get_transactions_statistics(db) current_time = format_datetime(datetime.utcnow()) - + text = f""" 💰 Статистика доходов @@ -174,44 +159,42 @@ async def show_revenue_statistics( Способы оплаты: """ - + for method, data in month_stats['by_payment_method'].items(): if method and data['count'] > 0: - text += f"• {method}: {data['count']} ({settings.format_price(data['amount'])})\n" - - text += f"\nОбновлено: {current_time}" - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - # [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")], - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] - ]) - + text += f'• {method}: {data["count"]} ({settings.format_price(data["amount"])})\n' + + text += f'\nОбновлено: {current_time}' + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + # [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")], + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_stats_revenue')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_statistics')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer("✅ Статистика обновлена") + await callback.answer('✅ Статистика обновлена') except Exception as e: - if "message is not modified" in str(e): - await callback.answer("📊 Данные актуальны", show_alert=False) + if 'message is not modified' in str(e): + await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f"Ошибка обновления статистики доходов: {e}") - await callback.answer("❌ Ошибка обновления данных", show_alert=True) + logger.error(f'Ошибка обновления статистики доходов: {e}') + await callback.answer('❌ Ошибка обновления данных', show_alert=True) @admin_required @error_handler -async def show_referral_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_referral_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_referral_statistics(db) current_time = format_datetime(datetime.utcnow()) - + avg_per_referrer = 0 if stats['active_referrers'] > 0: avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers'] - + text = f""" 🤝 Реферальная статистика @@ -230,58 +213,56 @@ async def show_referral_statistics( Топ рефереры: """ - + if stats['top_referrers']: for i, referrer in enumerate(stats['top_referrers'][:5], 1): name = referrer['display_name'] earned = settings.format_price(referrer['total_earned_kopeks']) count = referrer['referrals_count'] - text += f"{i}. {name}: {earned} ({count} реф.)\n" + text += f'{i}. {name}: {earned} ({count} реф.)\n' else: - text += "Пока нет активных рефереров" - - text += f"\nОбновлено: {current_time}" - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] - ]) - + text += 'Пока нет активных рефереров' + + text += f'\nОбновлено: {current_time}' + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_stats_referrals')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_statistics')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer("✅ Статистика обновлена") + await callback.answer('✅ Статистика обновлена') except Exception as e: - if "message is not modified" in str(e): - await callback.answer("📊 Данные актуальны", show_alert=False) + if 'message is not modified' in str(e): + await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f"Ошибка обновления реферальной статистики: {e}") - await callback.answer("❌ Ошибка обновления данных", show_alert=True) + logger.error(f'Ошибка обновления реферальной статистики: {e}') + await callback.answer('❌ Ошибка обновления данных', show_alert=True) @admin_required @error_handler -async def show_summary_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_summary_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_service = UserService() user_stats = await user_service.get_user_statistics(db) sub_stats = await get_subscriptions_statistics(db) - + now = datetime.utcnow() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) revenue_stats = await get_transactions_statistics(db, month_start, now) current_time = format_datetime(datetime.utcnow()) - + conversion_rate = 0 if user_stats['total_users'] > 0: conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100 - + arpu = 0 if user_stats['active_users'] > 0: arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users'] - + text = f""" 📊 Общая сводка системы @@ -306,52 +287,45 @@ async def show_summary_statistics( Обновлено: {current_time} """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_stats_summary')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_statistics')], + ] + ) + try: await callback.message.edit_text(text, reply_markup=keyboard) - await callback.answer("✅ Статистика обновлена") + await callback.answer('✅ Статистика обновлена') except Exception as e: - if "message is not modified" in str(e): - await callback.answer("📊 Данные актуальны", show_alert=False) + if 'message is not modified' in str(e): + await callback.answer('📊 Данные актуальны', show_alert=False) else: - logger.error(f"Ошибка обновления общей статистики: {e}") - await callback.answer("❌ Ошибка обновления данных", show_alert=True) + logger.error(f'Ошибка обновления общей статистики: {e}') + await callback.answer('❌ Ошибка обновления данных', show_alert=True) + @admin_required @error_handler -async def show_revenue_by_period( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_revenue_by_period(callback: types.CallbackQuery, db_user: User, db: AsyncSession): period = callback.data.split('_')[-1] - - period_map = { - "today": 1, - "yesterday": 1, - "week": 7, - "month": 30, - "all": 365 - } - + + period_map = {'today': 1, 'yesterday': 1, 'week': 7, 'month': 30, 'all': 365} + days = period_map.get(period, 30) revenue_data = await get_revenue_by_period(db, days) - - if period == "yesterday": + + if period == 'yesterday': yesterday = datetime.utcnow().date() - timedelta(days=1) revenue_data = [r for r in revenue_data if r['date'] == yesterday] - elif period == "today": + elif period == 'today': today = datetime.utcnow().date() revenue_data = [r for r in revenue_data if r['date'] == today] - + total_revenue = sum(r['amount_kopeks'] for r in revenue_data) avg_daily = total_revenue / len(revenue_data) if revenue_data else 0 - + text = f""" 📈 Доходы за период: {period} @@ -362,35 +336,34 @@ async def show_revenue_by_period( По дням: """ - + for revenue in revenue_data[-10:]: - text += f"• {revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n" - + text += f'• {revenue["date"].strftime("%d.%m")}: {settings.format_price(revenue["amount_kopeks"])}\n' + if len(revenue_data) > 10: - text += f"... и еще {len(revenue_data) - 10} дней" - + text += f'... и еще {len(revenue_data) - 10} дней' + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📊 Другой период", callback_data="admin_revenue_period")], - [types.InlineKeyboardButton(text="⬅️ К доходам", callback_data="admin_stats_revenue")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📊 Другой период', callback_data='admin_revenue_period')], + [types.InlineKeyboardButton(text='⬅️ К доходам', callback_data='admin_stats_revenue')], + ] + ), ) await callback.answer() def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics") - dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users") - dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs") - dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue") - dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals") - dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary") - dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_")) - - periods = ["today", "yesterday", "week", "month", "all"] + dp.callback_query.register(show_statistics_menu, F.data == 'admin_statistics') + dp.callback_query.register(show_users_statistics, F.data == 'admin_stats_users') + dp.callback_query.register(show_subscriptions_statistics, F.data == 'admin_stats_subs') + dp.callback_query.register(show_revenue_statistics, F.data == 'admin_stats_revenue') + dp.callback_query.register(show_referral_statistics, F.data == 'admin_stats_referrals') + dp.callback_query.register(show_summary_statistics, F.data == 'admin_stats_summary') + dp.callback_query.register(show_revenue_by_period, F.data.startswith('period_')) + + periods = ['today', 'yesterday', 'week', 'month', 'all'] for period in periods: - dp.callback_query.register( - show_revenue_by_period, - F.data == f"period_{period}" - ) + dp.callback_query.register(show_revenue_by_period, F.data == f'period_{period}') diff --git a/app/handlers/admin/subscriptions.py b/app/handlers/admin/subscriptions.py index 3a0dfba5..b88692f2 100644 --- a/app/handlers/admin/subscriptions.py +++ b/app/handlers/admin/subscriptions.py @@ -1,33 +1,44 @@ import logging -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from app.states import AdminStates -from app.database.models import User -from app.keyboards.admin import get_admin_subscriptions_keyboard -from app.localization.texts import get_texts +from aiogram import Dispatcher, F, types +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + from app.database.crud.subscription import ( - get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions, - get_all_subscriptions + get_all_subscriptions, + get_expired_subscriptions, + get_expiring_subscriptions, + get_subscriptions_statistics, ) -from app.services.subscription_service import SubscriptionService +from app.database.models import User from app.utils.decorators import admin_required, error_handler -from app.utils.formatters import format_datetime, format_time_ago +from app.utils.formatters import format_datetime def get_country_flag(country_name: str) -> str: flags = { - 'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸', - 'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪', - 'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱', - 'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧', - 'Japan': '🇯🇵', 'JP': '🇯🇵', - 'France': '🇫🇷', 'FR': '🇫🇷', - 'Canada': '🇨🇦', 'CA': '🇨🇦', - 'Russia': '🇷🇺', 'RU': '🇷🇺', - 'Singapore': '🇸🇬', 'SG': '🇸🇬', + 'USA': '🇺🇸', + 'United States': '🇺🇸', + 'US': '🇺🇸', + 'Germany': '🇩🇪', + 'DE': '🇩🇪', + 'Deutschland': '🇩🇪', + 'Netherlands': '🇳🇱', + 'NL': '🇳🇱', + 'Holland': '🇳🇱', + 'United Kingdom': '🇬🇧', + 'UK': '🇬🇧', + 'GB': '🇬🇧', + 'Japan': '🇯🇵', + 'JP': '🇯🇵', + 'France': '🇫🇷', + 'FR': '🇫🇷', + 'Canada': '🇨🇦', + 'CA': '🇨🇦', + 'Russia': '🇷🇺', + 'RU': '🇷🇺', + 'Singapore': '🇸🇬', + 'SG': '🇸🇬', } return flags.get(country_name, '🌍') @@ -39,29 +50,26 @@ async def get_users_by_countries(db: AsyncSession) -> dict: .where(User.preferred_location.isnot(None)) .group_by(User.preferred_location) ) - + stats = {} for location, count in result.fetchall(): if location: stats[location] = count - + return stats except Exception as e: - logger.error(f"Ошибка получения статистики по странам: {e}") + logger.error(f'Ошибка получения статистики по странам: {e}') return {} + logger = logging.getLogger(__name__) @admin_required @error_handler -async def show_subscriptions_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_subscriptions_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_subscriptions_statistics(db) - + text = f""" 📱 Управление подписками @@ -78,100 +86,82 @@ async def show_subscriptions_menu( Выберите действие: """ - + keyboard = [ [ - types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"), - types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring") + types.InlineKeyboardButton(text='📋 Список подписок', callback_data='admin_subs_list'), + types.InlineKeyboardButton(text='⏰ Истекающие', callback_data='admin_subs_expiring'), ], [ - types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"), - types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries") + types.InlineKeyboardButton(text='📊 Статистика', callback_data='admin_subs_stats'), + types.InlineKeyboardButton(text='🌍 География', callback_data='admin_subs_countries'), ], - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel") - ] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_panel')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_subscriptions_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - page: int = 1 -): - +async def show_subscriptions_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1): subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10) - total_pages = (total_count + 9) // 10 - + total_pages = (total_count + 9) // 10 + if not subscriptions: - text = "📱 Список подписок\n\n❌ Подписки не найдены." + text = '📱 Список подписок\n\n❌ Подписки не найдены.' else: - text = f"📱 Список подписок\n\n" - text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n" - + text = '📱 Список подписок\n\n' + text += f'📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n' + for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10): - user_info = (f"ID{sub.user.telegram_id}" if sub.user.telegram_id else sub.user.email or f"#{sub.user.id}") if sub.user else "Неизвестно" - sub_type = "🎁" if sub.is_trial else "💎" - status = "✅ Активна" if sub.is_active else "❌ Неактивна" - - text += f"{i}. {sub_type} {user_info}\n" - text += f" {status} | До: {format_datetime(sub.end_date)}\n" + user_info = ( + (f'ID{sub.user.telegram_id}' if sub.user.telegram_id else sub.user.email or f'#{sub.user.id}') + if sub.user + else 'Неизвестно' + ) + sub_type = '🎁' if sub.is_trial else '💎' + status = '✅ Активна' if sub.is_active else '❌ Неактивна' + + text += f'{i}. {sub_type} {user_info}\n' + text += f' {status} | До: {format_datetime(sub.end_date)}\n' if sub.device_limit > 0: - text += f" 📱 Устройств: {sub.device_limit}\n" - text += "\n" - + text += f' 📱 Устройств: {sub.device_limit}\n' + text += '\n' + keyboard = [] - + if total_pages > 1: nav_row = [] if page > 1: - nav_row.append(types.InlineKeyboardButton( - text="⬅️", callback_data=f"admin_subs_list_page_{page-1}" - )) - - nav_row.append(types.InlineKeyboardButton( - text=f"{page}/{total_pages}", callback_data="current_page" - )) - + nav_row.append(types.InlineKeyboardButton(text='⬅️', callback_data=f'admin_subs_list_page_{page - 1}')) + + nav_row.append(types.InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='current_page')) + if page < total_pages: - nav_row.append(types.InlineKeyboardButton( - text="➡️", callback_data=f"admin_subs_list_page_{page+1}" - )) - + nav_row.append(types.InlineKeyboardButton(text='➡️', callback_data=f'admin_subs_list_page_{page + 1}')) + keyboard.append(nav_row) - - keyboard.extend([ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + + keyboard.extend( + [ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_subs_list')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_subscriptions')], + ] ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_expiring_subscriptions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_expiring_subscriptions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): expiring_3d = await get_expiring_subscriptions(db, 3) expiring_1d = await get_expiring_subscriptions(db, 1) expired = await get_expired_subscriptions(db) - + text = f""" ⏰ Истекающие подписки @@ -182,51 +172,51 @@ async def show_expiring_subscriptions( Истекают через 3 дня: """ - + for sub in expiring_3d[:5]: - user_info = (f"ID{sub.user.telegram_id}" if sub.user.telegram_id else sub.user.email or f"#{sub.user.id}") if sub.user else "Неизвестно" - sub_type = "🎁" if sub.is_trial else "💎" - text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n" - + user_info = ( + (f'ID{sub.user.telegram_id}' if sub.user.telegram_id else sub.user.email or f'#{sub.user.id}') + if sub.user + else 'Неизвестно' + ) + sub_type = '🎁' if sub.is_trial else '💎' + text += f'{sub_type} {user_info} - {format_datetime(sub.end_date)}\n' + if len(expiring_3d) > 5: - text += f"... и еще {len(expiring_3d) - 5}\n" - - text += f"\nИстекают завтра:\n" + text += f'... и еще {len(expiring_3d) - 5}\n' + + text += '\nИстекают завтра:\n' for sub in expiring_1d[:5]: - user_info = (f"ID{sub.user.telegram_id}" if sub.user.telegram_id else sub.user.email or f"#{sub.user.id}") if sub.user else "Неизвестно" - sub_type = "🎁" if sub.is_trial else "💎" - text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n" - + user_info = ( + (f'ID{sub.user.telegram_id}' if sub.user.telegram_id else sub.user.email or f'#{sub.user.id}') + if sub.user + else 'Неизвестно' + ) + sub_type = '🎁' if sub.is_trial else '💎' + text += f'{sub_type} {user_info} - {format_datetime(sub.end_date)}\n' + if len(expiring_1d) > 5: - text += f"... и еще {len(expiring_1d) - 5}\n" - + text += f'... и еще {len(expiring_1d) - 5}\n' + keyboard = [ - [types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")], - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + [types.InlineKeyboardButton(text='📨 Отправить напоминания', callback_data='admin_send_expiry_reminders')], + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_subs_expiring')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_subscriptions')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_subscriptions_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_subscriptions_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_subscriptions_statistics(db) - + expiring_3d = await get_expiring_subscriptions(db, 3) expiring_7d = await get_expiring_subscriptions(db, 7) expired = await get_expired_subscriptions(db) - + text = f""" 📊 Детальная статистика подписок @@ -241,7 +231,7 @@ async def show_subscriptions_stats( 📈 Продажи: • Сегодня: {stats['purchased_today']} -• За неделю: {stats['purchased_week']} +• За неделю: {stats['purchased_week']} • За месяц: {stats['purchased_month']} ⏰ Истечение: @@ -253,90 +243,83 @@ async def show_subscriptions_stats( • Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}% • Продлений: {stats.get('renewals_count', 0)} """ - + keyboard = [ - # [ - # types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"), - # types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts") - # ], - # [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + # [ + # types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"), + # types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts") + # ], + # [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_subscriptions')] ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_countries_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_countries_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: from app.services.remnawave_service import RemnaWaveService + remnawave_service = RemnaWaveService() - + nodes_data = await remnawave_service.get_all_nodes() - squads_data = await remnawave_service.get_all_squads() - - text = "🌍 Управление странами\n\n" - + squads_data = await remnawave_service.get_all_squads() + + text = '🌍 Управление странами\n\n' + if nodes_data: - text += "Доступные серверы:\n" + text += 'Доступные серверы:\n' countries = {} - + for node in nodes_data: - country_code = node.get('country_code', 'XX') + country_code = node.get('country_code', 'XX') country_name = country_code - + if country_name not in countries: countries[country_name] = [] countries[country_name].append(node) - + for country, nodes in countries.items(): active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')]) total_nodes = len(nodes) - + country_flag = get_country_flag(country) - text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n" - + text += f'{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n' + total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes) if total_users_online > 0: - text += f" 👥 Пользователей онлайн: {total_users_online}\n" + text += f' 👥 Пользователей онлайн: {total_users_online}\n' else: - text += "❌ Не удалось загрузить данные о серверах\n" - + text += '❌ Не удалось загрузить данные о серверах\n' + if squads_data: - text += f"\nВсего сквадов: {len(squads_data)}\n" - + text += f'\nВсего сквадов: {len(squads_data)}\n' + total_members = sum(squad.get('members_count', 0) for squad in squads_data) - text += f"Участников в сквадах: {total_members}\n" - - text += "\nСквады:\n" - for squad in squads_data[:5]: + text += f'Участников в сквадах: {total_members}\n' + + text += '\nСквады:\n' + for squad in squads_data[:5]: name = squad.get('name', 'Неизвестно') members = squad.get('members_count', 0) inbounds = squad.get('inbounds_count', 0) - text += f"• {name}: {members} участников, {inbounds} inbound(s)\n" - + text += f'• {name}: {members} участников, {inbounds} inbound(s)\n' + if len(squads_data) > 5: - text += f"... и еще {len(squads_data) - 5} сквадов\n" - + text += f'... и еще {len(squads_data) - 5} сквадов\n' + user_stats = await get_users_by_countries(db) if user_stats: - text += "\nПользователи по регионам:\n" + text += '\nПользователи по регионам:\n' for country, count in user_stats.items(): country_flag = get_country_flag(country) - text += f"{country_flag} {country}: {count} пользователей\n" - + text += f'{country_flag} {country}: {count} пользователей\n' + except Exception as e: - logger.error(f"Ошибка получения данных о странах: {e}") + logger.error(f'Ошибка получения данных о странах: {e}') text = f""" 🌍 Управление странами @@ -345,49 +328,39 @@ async def show_countries_management( Проверьте подключение к RemnaWave API. -Детали ошибки: {str(e)} +Детали ошибки: {e!s} """ - + keyboard = [ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_subs_countries')], [ - types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries") + types.InlineKeyboardButton(text='📊 Статистика нод', callback_data='admin_rw_nodes'), + types.InlineKeyboardButton(text='🔧 Сквады', callback_data='admin_rw_squads'), ], - [ - types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"), - types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads") - ], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")] + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_subscriptions')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def send_expiry_reminders( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def send_expiry_reminders(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await callback.message.edit_text( - "📨 Отправка напоминаний...\n\nПодождите, это может занять время.", - reply_markup=None + '📨 Отправка напоминаний...\n\nПодождите, это может занять время.', reply_markup=None ) - + expiring_subs = await get_expiring_subscriptions(db, 1) sent_count = 0 - + for subscription in expiring_subs: if subscription.user: try: user = subscription.user # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f"Пропуск email-пользователя {user.id} при отправке напоминания") + logger.debug(f'Пропуск email-пользователя {user.id} при отправке напоминания') continue days_left = max(1, subscription.days_left) @@ -402,45 +375,34 @@ async def send_expiry_reminders( 💎 Продлить подписку можно в главном меню. """ - await callback.bot.send_message( - chat_id=user.telegram_id, - text=reminder_text - ) + await callback.bot.send_message(chat_id=user.telegram_id, text=reminder_text) sent_count += 1 except Exception as e: - logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}") - + logger.error(f'Ошибка отправки напоминания пользователю {subscription.user_id}: {e}') + await callback.message.edit_text( - f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")] - ]) + f'✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_subs_expiring')]] + ), ) await callback.answer() @admin_required -@error_handler -async def handle_subscriptions_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +@error_handler +async def handle_subscriptions_pagination(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[-1]) await show_subscriptions_list(callback, db_user, db, page) def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions") - dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list") - dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring") - dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats") - dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries") - dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders") - - dp.callback_query.register( - handle_subscriptions_pagination, - F.data.startswith("admin_subs_list_page_") - ) + dp.callback_query.register(show_subscriptions_menu, F.data == 'admin_subscriptions') + dp.callback_query.register(show_subscriptions_list, F.data == 'admin_subs_list') + dp.callback_query.register(show_expiring_subscriptions, F.data == 'admin_subs_expiring') + dp.callback_query.register(show_subscriptions_stats, F.data == 'admin_subs_stats') + dp.callback_query.register(show_countries_management, F.data == 'admin_subs_countries') + dp.callback_query.register(send_expiry_reminders, F.data == 'admin_send_expiry_reminders') + + dp.callback_query.register(handle_subscriptions_pagination, F.data.startswith('admin_subs_list_page_')) diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py index ffd9c27f..e8d3cd56 100644 --- a/app/handlers/admin/support_settings.py +++ b/app/handlers/admin/support_settings.py @@ -1,18 +1,19 @@ +import contextlib +import html import logging import re -import html -import contextlib -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User from app.config import settings +from app.database.models import User from app.localization.texts import get_texts -from app.utils.decorators import admin_required, error_handler from app.services.support_settings_service import SupportSettingsService from app.states import SupportSettingsStates +from app.utils.decorators import admin_required, error_handler logger = logging.getLogger(__name__) @@ -29,138 +30,155 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup: rows: list[list[types.InlineKeyboardButton]] = [] - status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены") - status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены") + status_enabled = texts.t('ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED', 'Включены') + status_disabled = texts.t('ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED', 'Отключены') def mode_button(label_key: str, default: str, active: bool) -> str: - prefix = "🔘" if active else "⚪" - return f"{prefix} {texts.t(label_key, default)}" + prefix = '🔘' if active else '⚪' + return f'{prefix} {texts.t(label_key, default)}' - rows.append([ - types.InlineKeyboardButton( - text=( - f"{'✅' if menu_enabled else '🚫'} " - f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}" + rows.append( + [ + types.InlineKeyboardButton( + text=( + f'{"✅" if menu_enabled else "🚫"} ' + f'{texts.t("ADMIN_SUPPORT_SETTINGS_MENU_LABEL", "Пункт «Техподдержка» в меню")}' + ), + callback_data='admin_support_toggle_menu', + ) + ] + ) + + rows.append( + [ + types.InlineKeyboardButton( + text=mode_button('ADMIN_SUPPORT_SETTINGS_MODE_TICKETS', 'Тикеты', mode == 'tickets'), + callback_data='admin_support_mode_tickets', ), - callback_data="admin_support_toggle_menu" - ) - ]) + types.InlineKeyboardButton( + text=mode_button('ADMIN_SUPPORT_SETTINGS_MODE_CONTACT', 'Контакт', mode == 'contact'), + callback_data='admin_support_mode_contact', + ), + types.InlineKeyboardButton( + text=mode_button('ADMIN_SUPPORT_SETTINGS_MODE_BOTH', 'Оба', mode == 'both'), + callback_data='admin_support_mode_both', + ), + ] + ) - rows.append([ - types.InlineKeyboardButton( - text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"), - callback_data="admin_support_mode_tickets" - ), - types.InlineKeyboardButton( - text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"), - callback_data="admin_support_mode_contact" - ), - types.InlineKeyboardButton( - text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"), - callback_data="admin_support_mode_both" - ), - ]) - - rows.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"), - callback_data="admin_support_edit_desc" - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION', '📝 Изменить описание'), + callback_data='admin_support_edit_desc', + ) + ] + ) # Notifications block - rows.append([ - types.InlineKeyboardButton( - text=( - f"{'🔔' if admin_notif else '🔕'} " - f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: " - f"{status_enabled if admin_notif else status_disabled}" - ), - callback_data="admin_support_toggle_admin_notifications" - ) - ]) - rows.append([ - types.InlineKeyboardButton( - text=( - f"{'🔔' if user_notif else '🔕'} " - f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: " - f"{status_enabled if user_notif else status_disabled}" - ), - callback_data="admin_support_toggle_user_notifications" - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text=( + f'{"🔔" if admin_notif else "🔕"} ' + f'{texts.t("ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS", "Админ-уведомления")}: ' + f'{status_enabled if admin_notif else status_disabled}' + ), + callback_data='admin_support_toggle_admin_notifications', + ) + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text=( + f'{"🔔" if user_notif else "🔕"} ' + f'{texts.t("ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS", "Пользовательские уведомления")}: ' + f'{status_enabled if user_notif else status_disabled}' + ), + callback_data='admin_support_toggle_user_notifications', + ) + ] + ) # SLA block - rows.append([ - types.InlineKeyboardButton( - text=( - f"{'⏰' if sla_enabled else '⏹️'} " - f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: " - f"{status_enabled if sla_enabled else status_disabled}" - ), - callback_data="admin_support_toggle_sla" - ) - ]) - rows.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes), - callback_data="admin_support_set_sla_minutes" - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text=( + f'{"⏰" if sla_enabled else "⏹️"} ' + f'{texts.t("ADMIN_SUPPORT_SETTINGS_SLA_LABEL", "SLA")}: ' + f'{status_enabled if sla_enabled else status_disabled}' + ), + callback_data='admin_support_toggle_sla', + ) + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SETTINGS_SLA_TIME', '⏳ Время SLA: {minutes} мин').format( + minutes=sla_minutes + ), + callback_data='admin_support_set_sla_minutes', + ) + ] + ) # Moderators moderators = SupportSettingsService.get_moderators() mod_count = len(moderators) - rows.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count), - callback_data="admin_support_list_moderators" - ) - ]) - rows.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"), - callback_data="admin_support_add_moderator" - ), - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"), - callback_data="admin_support_remove_moderator" - ) - ]) + rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT', '🧑‍⚖️ Модераторы: {count}').format( + count=mod_count + ), + callback_data='admin_support_list_moderators', + ) + ] + ) + rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR', '➕ Назначить модератора'), + callback_data='admin_support_add_moderator', + ), + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR', '➖ Удалить модератора'), + callback_data='admin_support_remove_moderator', + ), + ] + ) - rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support") - ]) + rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_support')]) return types.InlineKeyboardMarkup(inline_keyboard=rows) @admin_required @error_handler -async def show_support_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_support_settings(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) desc = SupportSettingsService.get_support_info_text(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 Настройки поддержки") + "\n\n" + - texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" + - desc, + texts.t('ADMIN_SUPPORT_SETTINGS_TITLE', '🛟 Настройки поддержки') + + '\n\n' + + texts.t( + 'ADMIN_SUPPORT_SETTINGS_DESCRIPTION', + 'Режим работы и видимость в меню. Ниже текущее описание меню поддержки:', + ) + + '\n\n' + + desc, reply_markup=_get_support_settings_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def toggle_support_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_support_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): current = SupportSettingsService.is_support_menu_enabled() SupportSettingsService.set_support_menu_enabled(not current) await show_support_settings(callback, db_user, db) @@ -201,13 +219,13 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "ADMIN_SUPPORT_SLA_SETUP_PROMPT", - "⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):" + 'ADMIN_SUPPORT_SLA_SETUP_PROMPT', + '⏳ Настройка SLA\n\nВведите количество минут ожидания ответа (целое число > 0):', ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] - ) + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_support_settings')]] + ), ) await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes) await callback.answer() @@ -217,20 +235,26 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db @error_handler async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): texts = get_texts(db_user.language) - text = (message.text or "").strip() + text = (message.text or '').strip() try: minutes = int(text) if minutes <= 0 or minutes > 1440: - raise ValueError() + raise ValueError except Exception: - await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)")) + await message.answer(texts.t('ADMIN_SUPPORT_SLA_INVALID', '❌ Введите корректное число минут (1-1440)')) return SupportSettingsService.set_sla_minutes(minutes) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), callback_data='admin_support_delete_msg' + ) + ] + ] ) - await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup) + await message.answer(texts.t('ADMIN_SUPPORT_SLA_SAVED', '✅ Значение SLA сохранено'), reply_markup=markup) @admin_required @@ -239,13 +263,13 @@ async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT", - "🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)" + 'ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT', + '🧑‍⚖️ Назначение модератора\n\nОтправьте Telegram ID пользователя (число)', ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] - ) + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_support_settings')]] + ), ) await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) await callback.answer() @@ -257,17 +281,17 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT", - "🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)" + 'ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT', + '🧑‍⚖️ Удаление модератора\n\nОтправьте Telegram ID пользователя (число)', ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] - ) + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_support_settings')]] + ), ) await state.set_state(SupportAdvancedStates.waiting_for_moderator_id) # We'll reuse the same state; next message will decide action via flag - await state.update_data(action="remove_moderator") + await state.update_data(action='remove_moderator') await callback.answer() @@ -276,30 +300,38 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): texts = get_texts(db_user.language) data = await state.get_data() - action = data.get("action", "add") - text = (message.text or "").strip() + action = data.get('action', 'add') + text = (message.text or '').strip() try: tid = int(text) except Exception: - await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)")) + await message.answer(texts.t('ADMIN_SUPPORT_INVALID_TELEGRAM_ID', '❌ Введите корректный Telegram ID (число)')) return - if action == "remove_moderator": + if action == 'remove_moderator': ok = SupportSettingsService.remove_moderator(tid) msg = ( - texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid) + texts.t('ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS', '✅ Модератор {tid} удалён').format(tid=tid) if ok - else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора") + else texts.t('ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL', '❌ Не удалось удалить модератора') ) else: ok = SupportSettingsService.add_moderator(tid) msg = ( - texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid) + texts.t('ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS', '✅ Пользователь {tid} назначен модератором').format( + tid=tid + ) if ok - else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора") + else texts.t('ADMIN_SUPPORT_MODERATOR_ADDED_FAIL', '❌ Не удалось назначить модератора') ) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), callback_data='admin_support_delete_msg' + ) + ] + ] ) await message.answer(msg, reply_markup=markup) @@ -310,37 +342,38 @@ async def list_moderators(callback: types.CallbackQuery, db_user: User, db: Asyn texts = get_texts(db_user.language) moderators = SupportSettingsService.get_moderators() if not moderators: - await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True) + await callback.answer(texts.t('ADMIN_SUPPORT_MODERATORS_EMPTY', 'Список пуст'), show_alert=True) return text = ( - texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ Модераторы") + - "\n\n" + "\n".join([f"• {tid}" for tid in moderators]) + texts.t('ADMIN_SUPPORT_MODERATORS_TITLE', '🧑‍⚖️ Модераторы') + + '\n\n' + + '\n'.join([f'• {tid}' for tid in moderators]) ) markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_support_settings')]] ) - await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup) + await callback.message.edit_text(text, parse_mode='HTML', reply_markup=markup) await callback.answer() @admin_required @error_handler async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): - SupportSettingsService.set_system_mode("tickets") + SupportSettingsService.set_system_mode('tickets') await show_support_settings(callback, db_user, db) @admin_required @error_handler async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession): - SupportSettingsService.set_system_mode("contact") + SupportSettingsService.set_system_mode('contact') await show_support_settings(callback, db_user, db) @admin_required @error_handler async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession): - SupportSettingsService.set_system_mode("both") + SupportSettingsService.set_system_mode('both') await show_support_settings(callback, db_user, db) @@ -350,41 +383,40 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn texts = get_texts(db_user.language) current_desc_html = SupportSettingsService.get_support_info_text(db_user.language) # plain text for display-only code block - current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html) + current_desc_plain = re.sub(r'<[^>]+>', '', current_desc_html) kb_rows: list[list[types.InlineKeyboardButton]] = [] - kb_rows.append([ - types.InlineKeyboardButton( - text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"), - callback_data="admin_support_send_desc" - ) - ]) + kb_rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('ADMIN_SUPPORT_SEND_DESCRIPTION', '📨 Прислать текст'), + callback_data='admin_support_send_desc', + ) + ] + ) # Подготовим блок контакта (отдельным инлайном) from app.config import settings + support_contact_display = settings.get_support_contact_display() - kb_rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings") - ]) + kb_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_support_settings')]) text_parts = [ - texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 Редактирование описания поддержки"), - "", - texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"), - "", - f"{html.escape(current_desc_plain)}", + texts.t('ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE', '📝 Редактирование описания поддержки'), + '', + texts.t('ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT', 'Текущее описание:'), + '', + f'{html.escape(current_desc_plain)}', ] if support_contact_display: text_parts += [ - "", - texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "Контакт для режима «Контакт»"), - f"{html.escape(support_contact_display)}", - "", - texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."), + '', + texts.t('ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE', 'Контакт для режима «Контакт»'), + f'{html.escape(support_contact_display)}', + '', + texts.t('ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT', 'Добавьте в описание при необходимости.'), ] await callback.message.edit_text( - "\n".join(text_parts), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows), - parse_mode="HTML" + '\n'.join(text_parts), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows), parse_mode='HTML' ) await state.set_state(SupportSettingsStates.waiting_for_desc) await callback.answer() @@ -398,9 +430,15 @@ async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSessio SupportSettingsService.set_support_info_text(db_user.language, new_text) await state.clear() markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), callback_data='admin_support_delete_msg' + ) + ] + ] ) - await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup) + await message.answer(texts.t('ADMIN_SUPPORT_DESCRIPTION_UPDATED', '✅ Описание обновлено.'), reply_markup=markup) @admin_required @@ -409,10 +447,16 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async # send plain text for easy copying texts = get_texts(db_user.language) current_desc_html = SupportSettingsService.get_support_info_text(db_user.language) - current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html) + current_desc_plain = re.sub(r'<[^>]+>', '', current_desc_html) # attach delete button to the sent message markup = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]] + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), callback_data='admin_support_delete_msg' + ) + ] + ] ) if len(current_desc_plain) <= 4000: await callback.message.answer(current_desc_plain, reply_markup=markup) @@ -420,18 +464,20 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async # split long messages (attach delete only to the last chunk) chunk = 0 while chunk < len(current_desc_plain): - next_chunk = current_desc_plain[chunk:chunk+4000] + next_chunk = current_desc_plain[chunk : chunk + 4000] is_last = (chunk + 4000) >= len(current_desc_plain) await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None)) chunk += 4000 - await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже")) + await callback.answer(texts.t('ADMIN_SUPPORT_DESCRIPTION_SENT', 'Текст отправлен ниже')) @error_handler async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Allow admins and moderators to delete informational notifications try: - may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)) + may_delete = settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator( + callback.from_user.id + ) except Exception: may_delete = False texts = get_texts(db_user.language if db_user else 'ru') @@ -442,27 +488,25 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: await callback.message.delete() finally: with contextlib.suppress(Exception): - await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено")) + await callback.answer(texts.t('ADMIN_SUPPORT_MESSAGE_DELETED', 'Сообщение удалено')) def register_handlers(dp: Dispatcher): - dp.callback_query.register(show_support_settings, F.data == "admin_support_settings") - dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu") - dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets") - dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact") - dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both") - dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc") - dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc") - dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg") - dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications") - dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications") - dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla") - dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes") - dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator") - dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator") - dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators") + dp.callback_query.register(show_support_settings, F.data == 'admin_support_settings') + dp.callback_query.register(toggle_support_menu, F.data == 'admin_support_toggle_menu') + dp.callback_query.register(set_mode_tickets, F.data == 'admin_support_mode_tickets') + dp.callback_query.register(set_mode_contact, F.data == 'admin_support_mode_contact') + dp.callback_query.register(set_mode_both, F.data == 'admin_support_mode_both') + dp.callback_query.register(start_edit_desc, F.data == 'admin_support_edit_desc') + dp.callback_query.register(send_desc_copy, F.data == 'admin_support_send_desc') + dp.callback_query.register(delete_sent_message, F.data == 'admin_support_delete_msg') + dp.callback_query.register(toggle_admin_notifications, F.data == 'admin_support_toggle_admin_notifications') + dp.callback_query.register(toggle_user_notifications, F.data == 'admin_support_toggle_user_notifications') + dp.callback_query.register(toggle_sla, F.data == 'admin_support_toggle_sla') + dp.callback_query.register(start_set_sla_minutes, F.data == 'admin_support_set_sla_minutes') + dp.callback_query.register(start_add_moderator, F.data == 'admin_support_add_moderator') + dp.callback_query.register(start_remove_moderator, F.data == 'admin_support_remove_moderator') + dp.callback_query.register(list_moderators, F.data == 'admin_support_list_moderators') dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc) dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes) dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id) - - diff --git a/app/handlers/admin/system_logs.py b/app/handlers/admin/system_logs.py index eaa24fdb..305c141a 100644 --- a/app/handlers/admin/system_logs.py +++ b/app/handlers/admin/system_logs.py @@ -4,13 +4,14 @@ from html import escape from pathlib import Path from aiogram import Dispatcher, F, types -from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile +from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) LOG_PREVIEW_LIMIT = 2300 @@ -24,27 +25,24 @@ def _resolve_log_path() -> Path: def _format_preview_block(text: str) -> str: - escaped_text = escape(text) if text else "" - return f"
{escaped_text}
" + escaped_text = escape(text) if text else '' + return f'
{escaped_text}
' def _build_logs_message(log_path: Path) -> str: if not log_path.exists(): message = ( - "🧾 Системные логи\n\n" - f"Файл {log_path} пока не создан.\n" - "Логи появятся автоматически после первой записи." + '🧾 Системные логи\n\n' + f'Файл {log_path} пока не создан.\n' + 'Логи появятся автоматически после первой записи.' ) return message try: - content = log_path.read_text(encoding="utf-8", errors="ignore") + content = log_path.read_text(encoding='utf-8', errors='ignore') except Exception as error: # pragma: no cover - защита от проблем чтения - logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) - message = ( - "❌ Ошибка чтения логов\n\n" - f"Не удалось прочитать файл {log_path}." - ) + logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + message = f'❌ Ошибка чтения логов\n\nНе удалось прочитать файл {log_path}.' return message total_length = len(content) @@ -52,36 +50,32 @@ def _build_logs_message(log_path: Path) -> str: updated_at = datetime.fromtimestamp(stats.st_mtime) if not content: - preview_text = "Лог-файл пуст." + preview_text = 'Лог-файл пуст.' truncated = False else: preview_text = content[-LOG_PREVIEW_LIMIT:] truncated = total_length > LOG_PREVIEW_LIMIT details_lines = [ - "🧾 Системные логи", - "", - f"📁 Файл: {log_path}", - f"🕒 Обновлен: {updated_at.strftime('%d.%m.%Y %H:%M:%S')}", - f"🧮 Размер: {total_length} символов", - ( - f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов." - if truncated - else "📄 Показано все содержимое файла." - ), - "", + '🧾 Системные логи', + '', + f'📁 Файл: {log_path}', + f'🕒 Обновлен: {updated_at.strftime("%d.%m.%Y %H:%M:%S")}', + f'🧮 Размер: {total_length} символов', + (f'👇 Показаны последние {LOG_PREVIEW_LIMIT} символов.' if truncated else '📄 Показано все содержимое файла.'), + '', _format_preview_block(preview_text), ] - return "\n".join(details_lines) + return '\n'.join(details_lines) def _get_logs_keyboard() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")], - [InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")], - [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")], + [InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_system_logs_refresh')], + [InlineKeyboardButton(text='⬇️ Скачать лог', callback_data='admin_system_logs_download')], + [InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_system')], ] ) @@ -97,7 +91,7 @@ async def show_system_logs( message = _build_logs_message(log_path) reply_markup = _get_logs_keyboard() - await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML") + await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode='HTML') await callback.answer() @@ -112,8 +106,8 @@ async def refresh_system_logs( message = _build_logs_message(log_path) reply_markup = _get_logs_keyboard() - await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML") - await callback.answer("🔄 Обновлено") + await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode='HTML') + await callback.answer('🔄 Обновлено') @admin_required @@ -126,40 +120,37 @@ async def download_system_logs( log_path = _resolve_log_path() if not log_path.exists() or not log_path.is_file(): - await callback.answer("❌ Лог-файл не найден", show_alert=True) + await callback.answer('❌ Лог-файл не найден', show_alert=True) return try: - await callback.answer("⬇️ Отправляю лог...") + await callback.answer('⬇️ Отправляю лог...') document = FSInputFile(log_path) stats = log_path.stat() - updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S") + updated_at = datetime.fromtimestamp(stats.st_mtime).strftime('%d.%m.%Y %H:%M:%S') caption = ( - f"🧾 Лог-файл {log_path.name}\n" - f"📁 Путь: {log_path}\n" - f"🕒 Обновлен: {updated_at}" + f'🧾 Лог-файл {log_path.name}\n📁 Путь: {log_path}\n🕒 Обновлен: {updated_at}' ) - await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML") + await callback.message.answer_document(document=document, caption=caption, parse_mode='HTML') except Exception as error: # pragma: no cover - защита от ошибок отправки - logger.error("Ошибка отправки лог-файла %s: %s", log_path, error) + logger.error('Ошибка отправки лог-файла %s: %s', log_path, error) await callback.message.answer( - "❌ Не удалось отправить лог-файл\n\n" - "Проверьте журналы приложения или повторите попытку позже.", - parse_mode="HTML", + '❌ Не удалось отправить лог-файл\n\nПроверьте журналы приложения или повторите попытку позже.', + parse_mode='HTML', ) def register_handlers(dp: Dispatcher): dp.callback_query.register( show_system_logs, - F.data == "admin_system_logs", + F.data == 'admin_system_logs', ) dp.callback_query.register( refresh_system_logs, - F.data == "admin_system_logs_refresh", + F.data == 'admin_system_logs_refresh', ) dp.callback_query.register( download_system_logs, - F.data == "admin_system_logs_download", + F.data == 'admin_system_logs_download', ) diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index bdaee15c..53c237a4 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -1,25 +1,24 @@ """Управление тарифами в админ-панели.""" -import logging -from typing import Dict, List, Optional, Tuple -from aiogram import Dispatcher, types, F +import logging + +from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.tariff import ( - get_all_tariffs, - get_tariff_by_id, - create_tariff, - update_tariff, - delete_tariff, - get_tariff_subscriptions_count, - get_tariffs_with_subscriptions_count, -) from app.database.crud.promo_group import get_promo_groups_with_counts from app.database.crud.server_squad import get_all_server_squads +from app.database.crud.tariff import ( + create_tariff, + delete_tariff, + get_tariff_by_id, + get_tariff_subscriptions_count, + get_tariffs_with_subscriptions_count, + update_tariff, +) from app.database.models import Tariff, User from app.localization.texts import get_texts from app.states import AdminStates @@ -34,49 +33,48 @@ ITEMS_PER_PAGE = 10 def _format_traffic(gb: int) -> str: """Форматирует трафик.""" if gb == 0: - return "Безлимит" - return f"{gb} ГБ" + return 'Безлимит' + return f'{gb} ГБ' def _format_price_kopeks(kopeks: int) -> str: """Форматирует цену из копеек в рубли.""" rubles = kopeks / 100 if rubles == int(rubles): - return f"{int(rubles)} ₽" - return f"{rubles:.2f} ₽" + return f'{int(rubles)} ₽' + return f'{rubles:.2f} ₽' def _format_period(days: int) -> str: """Форматирует период.""" if days == 1: - return "1 день" - elif days < 5: - return f"{days} дня" - elif days < 21 or days % 10 >= 5 or days % 10 == 0: - return f"{days} дней" - elif days % 10 == 1: - return f"{days} день" - else: - return f"{days} дня" + return '1 день' + if days < 5: + return f'{days} дня' + if days < 21 or days % 10 >= 5 or days % 10 == 0: + return f'{days} дней' + if days % 10 == 1: + return f'{days} день' + return f'{days} дня' -def _parse_period_prices(text: str) -> Dict[str, int]: +def _parse_period_prices(text: str) -> dict[str, int]: """ Парсит строку с ценами периодов. Формат: "30:9900, 90:24900, 180:44900" или "30=9900; 90=24900" """ prices = {} - text = text.replace(";", ",").replace("=", ":") + text = text.replace(';', ',').replace('=', ':') - for part in text.split(","): + for part in text.split(','): part = part.strip() if not part: continue - if ":" not in part: + if ':' not in part: continue - period_str, price_str = part.split(":", 1) + period_str, price_str = part.split(':', 1) try: period = int(period_str.strip()) price = int(price_str.strip()) @@ -88,34 +86,34 @@ def _parse_period_prices(text: str) -> Dict[str, int]: return prices -def _format_period_prices_display(prices: Dict[str, int]) -> str: +def _format_period_prices_display(prices: dict[str, int]) -> str: """Форматирует цены периодов для отображения.""" if not prices: - return "Не заданы" + return 'Не заданы' lines = [] for period_str in sorted(prices.keys(), key=int): period = int(period_str) price = prices[period_str] - lines.append(f" • {_format_period(period)}: {_format_price_kopeks(price)}") + lines.append(f' • {_format_period(period)}: {_format_price_kopeks(price)}') - return "\n".join(lines) + return '\n'.join(lines) -def _format_period_prices_for_edit(prices: Dict[str, int]) -> str: +def _format_period_prices_for_edit(prices: dict[str, int]) -> str: """Форматирует цены периодов для редактирования.""" if not prices: - return "30:9900, 90:24900, 180:44900" + return '30:9900, 90:24900, 180:44900' parts = [] for period_str in sorted(prices.keys(), key=int): - parts.append(f"{period_str}:{prices[period_str]}") + parts.append(f'{period_str}:{prices[period_str]}') - return ", ".join(parts) + return ', '.join(parts) def get_tariffs_list_keyboard( - tariffs: List[Tuple[Tariff, int]], + tariffs: list[tuple[Tariff, int]], language: str, page: int = 0, total_pages: int = 1, @@ -125,43 +123,24 @@ def get_tariffs_list_keyboard( buttons = [] for tariff, subs_count in tariffs: - status = "✅" if tariff.is_active else "❌" - button_text = f"{status} {tariff.name} ({subs_count})" - buttons.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"admin_tariff_view:{tariff.id}" - ) - ]) + status = '✅' if tariff.is_active else '❌' + button_text = f'{status} {tariff.name} ({subs_count})' + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'admin_tariff_view:{tariff.id}')]) # Пагинация nav_buttons = [] if page > 0: - nav_buttons.append( - InlineKeyboardButton(text="◀️", callback_data=f"admin_tariffs_page:{page-1}") - ) + nav_buttons.append(InlineKeyboardButton(text='◀️', callback_data=f'admin_tariffs_page:{page - 1}')) if page < total_pages - 1: - nav_buttons.append( - InlineKeyboardButton(text="▶️", callback_data=f"admin_tariffs_page:{page+1}") - ) + nav_buttons.append(InlineKeyboardButton(text='▶️', callback_data=f'admin_tariffs_page:{page + 1}')) if nav_buttons: buttons.append(nav_buttons) # Кнопка создания - buttons.append([ - InlineKeyboardButton( - text="➕ Создать тариф", - callback_data="admin_tariff_create" - ) - ]) + buttons.append([InlineKeyboardButton(text='➕ Создать тариф', callback_data='admin_tariff_create')]) # Кнопка назад - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_settings" - ) - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -175,85 +154,107 @@ def get_tariff_view_keyboard( buttons = [] # Редактирование полей - buttons.append([ - InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_tariff_edit_name:{tariff.id}"), - InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_tariff_edit_desc:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="📊 Трафик", callback_data=f"admin_tariff_edit_traffic:{tariff.id}"), - InlineKeyboardButton(text="📱 Устройства", callback_data=f"admin_tariff_edit_devices:{tariff.id}"), - ]) + buttons.append( + [ + InlineKeyboardButton(text='✏️ Название', callback_data=f'admin_tariff_edit_name:{tariff.id}'), + InlineKeyboardButton(text='📝 Описание', callback_data=f'admin_tariff_edit_desc:{tariff.id}'), + ] + ) + buttons.append( + [ + InlineKeyboardButton(text='📊 Трафик', callback_data=f'admin_tariff_edit_traffic:{tariff.id}'), + InlineKeyboardButton(text='📱 Устройства', callback_data=f'admin_tariff_edit_devices:{tariff.id}'), + ] + ) # Цены за периоды только для обычных тарифов (не суточных) is_daily = getattr(tariff, 'is_daily', False) if not is_daily: - buttons.append([ - InlineKeyboardButton(text="💰 Цены", callback_data=f"admin_tariff_edit_prices:{tariff.id}"), - InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), - ]) + buttons.append( + [ + InlineKeyboardButton(text='💰 Цены', callback_data=f'admin_tariff_edit_prices:{tariff.id}'), + InlineKeyboardButton(text='🎚️ Уровень', callback_data=f'admin_tariff_edit_tier:{tariff.id}'), + ] + ) else: - buttons.append([ - InlineKeyboardButton(text="🎚️ Уровень", callback_data=f"admin_tariff_edit_tier:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="📱💰 Цена за устройство", callback_data=f"admin_tariff_edit_device_price:{tariff.id}"), - InlineKeyboardButton(text="📱🔒 Макс. устройств", callback_data=f"admin_tariff_edit_max_devices:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="⏰ Дни триала", callback_data=f"admin_tariff_edit_trial_days:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="📈 Докупка трафика", callback_data=f"admin_tariff_edit_traffic_topup:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="🔄 Сброс трафика", callback_data=f"admin_tariff_edit_reset_mode:{tariff.id}"), - ]) - buttons.append([ - InlineKeyboardButton(text="🌐 Серверы", callback_data=f"admin_tariff_edit_squads:{tariff.id}"), - InlineKeyboardButton(text="👥 Промогруппы", callback_data=f"admin_tariff_edit_promo:{tariff.id}"), - ]) + buttons.append( + [ + InlineKeyboardButton(text='🎚️ Уровень', callback_data=f'admin_tariff_edit_tier:{tariff.id}'), + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text='📱💰 Цена за устройство', callback_data=f'admin_tariff_edit_device_price:{tariff.id}' + ), + InlineKeyboardButton( + text='📱🔒 Макс. устройств', callback_data=f'admin_tariff_edit_max_devices:{tariff.id}' + ), + ] + ) + buttons.append( + [ + InlineKeyboardButton(text='⏰ Дни триала', callback_data=f'admin_tariff_edit_trial_days:{tariff.id}'), + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text='📈 Докупка трафика', callback_data=f'admin_tariff_edit_traffic_topup:{tariff.id}' + ), + ] + ) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Сброс трафика', callback_data=f'admin_tariff_edit_reset_mode:{tariff.id}'), + ] + ) + buttons.append( + [ + InlineKeyboardButton(text='🌐 Серверы', callback_data=f'admin_tariff_edit_squads:{tariff.id}'), + InlineKeyboardButton(text='👥 Промогруппы', callback_data=f'admin_tariff_edit_promo:{tariff.id}'), + ] + ) # Суточный режим - только для уже суточных тарифов показываем настройки # Новые тарифы делаются суточными только при создании if is_daily: - buttons.append([ - InlineKeyboardButton(text="💰 Суточная цена", callback_data=f"admin_tariff_edit_daily_price:{tariff.id}"), - ]) + buttons.append( + [ + InlineKeyboardButton( + text='💰 Суточная цена', callback_data=f'admin_tariff_edit_daily_price:{tariff.id}' + ), + ] + ) # Примечание: отключение суточного режима убрано - это необратимое решение при создании # Переключение триала if tariff.is_trial_available: - buttons.append([ - InlineKeyboardButton(text="🎁 ❌ Убрать триал", callback_data=f"admin_tariff_toggle_trial:{tariff.id}") - ]) + buttons.append( + [InlineKeyboardButton(text='🎁 ❌ Убрать триал', callback_data=f'admin_tariff_toggle_trial:{tariff.id}')] + ) else: - buttons.append([ - InlineKeyboardButton(text="🎁 Сделать триальным", callback_data=f"admin_tariff_toggle_trial:{tariff.id}") - ]) + buttons.append( + [InlineKeyboardButton(text='🎁 Сделать триальным', callback_data=f'admin_tariff_toggle_trial:{tariff.id}')] + ) # Переключение активности if tariff.is_active: - buttons.append([ - InlineKeyboardButton(text="❌ Деактивировать", callback_data=f"admin_tariff_toggle:{tariff.id}") - ]) + buttons.append( + [InlineKeyboardButton(text='❌ Деактивировать', callback_data=f'admin_tariff_toggle:{tariff.id}')] + ) else: - buttons.append([ - InlineKeyboardButton(text="✅ Активировать", callback_data=f"admin_tariff_toggle:{tariff.id}") - ]) + buttons.append([InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_tariff_toggle:{tariff.id}')]) # Удаление - buttons.append([ - InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_tariff_delete:{tariff.id}") - ]) + buttons.append([InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'admin_tariff_delete:{tariff.id}')]) # Назад к списку - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_tariffs") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_tariffs')]) return InlineKeyboardMarkup(inline_keyboard=buttons) -def _format_traffic_reset_mode(mode: Optional[str]) -> str: +def _format_traffic_reset_mode(mode: str | None) -> str: """Форматирует режим сброса трафика для отображения.""" mode_labels = { 'DAY': '📅 Ежедневно', @@ -262,68 +263,68 @@ def _format_traffic_reset_mode(mode: Optional[str]) -> str: 'NO_RESET': '🚫 Никогда', } if mode is None: - return f"🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" - return mode_labels.get(mode, f"⚠️ Неизвестно ({mode})") + return f'🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})' + return mode_labels.get(mode, f'⚠️ Неизвестно ({mode})') def _format_traffic_topup_packages(tariff: Tariff) -> str: """Форматирует пакеты докупки трафика для отображения.""" if not getattr(tariff, 'traffic_topup_enabled', False): - return "❌ Отключено" + return '❌ Отключено' packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} if not packages: - return "✅ Включено, но пакеты не настроены" + return '✅ Включено, но пакеты не настроены' - lines = ["✅ Включено"] + lines = ['✅ Включено'] for gb in sorted(packages.keys()): price = packages[gb] - lines.append(f" • {gb} ГБ: {_format_price_kopeks(price)}") + lines.append(f' • {gb} ГБ: {_format_price_kopeks(price)}') - return "\n".join(lines) + return '\n'.join(lines) def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> str: """Форматирует информацию о тарифе.""" - texts = get_texts(language) + get_texts(language) - status = "✅ Активен" if tariff.is_active else "❌ Неактивен" + status = '✅ Активен' if tariff.is_active else '❌ Неактивен' traffic = _format_traffic(tariff.traffic_limit_gb) prices_display = _format_period_prices_display(tariff.period_prices or {}) # Форматируем список серверов squads_list = tariff.allowed_squads or [] - squads_display = f"{len(squads_list)} серверов" if squads_list else "Все серверы" + squads_display = f'{len(squads_list)} серверов' if squads_list else 'Все серверы' # Форматируем промогруппы promo_groups = tariff.allowed_promo_groups or [] if promo_groups: - promo_display = ", ".join(pg.name for pg in promo_groups) + promo_display = ', '.join(pg.name for pg in promo_groups) else: - promo_display = "Доступен всем" + promo_display = 'Доступен всем' - trial_status = "✅ Да" if tariff.is_trial_available else "❌ Нет" + trial_status = '✅ Да' if tariff.is_trial_available else '❌ Нет' # Форматируем дни триала trial_days = getattr(tariff, 'trial_duration_days', None) if trial_days: - trial_days_display = f"{trial_days} дней" + trial_days_display = f'{trial_days} дней' else: - trial_days_display = f"По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)" + trial_days_display = f'По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)' # Форматируем цену за устройство device_price = getattr(tariff, 'device_price_kopeks', None) if device_price is not None and device_price > 0: - device_price_display = _format_price_kopeks(device_price) + "/мес" + device_price_display = _format_price_kopeks(device_price) + '/мес' else: - device_price_display = "Недоступно" + device_price_display = 'Недоступно' # Форматируем макс. устройств max_devices = getattr(tariff, 'max_device_limit', None) if max_devices is not None and max_devices > 0: max_devices_display = str(max_devices) else: - max_devices_display = "∞ (без лимита)" + max_devices_display = '∞ (без лимита)' # Форматируем докупку трафика traffic_topup_display = _format_traffic_topup_packages(tariff) @@ -338,11 +339,11 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st # Формируем блок цен в зависимости от типа тарифа if is_daily: - price_block = f"💰 Суточная цена: {_format_price_kopeks(daily_price_kopeks)}/день" - tariff_type = "🔄 Суточный" + price_block = f'💰 Суточная цена: {_format_price_kopeks(daily_price_kopeks)}/день' + tariff_type = '🔄 Суточный' else: - price_block = f"Цены:\n{prices_display}" - tariff_type = "📅 Периодный" + price_block = f'Цены:\n{prices_display}' + tariff_type = '📅 Периодный' return f"""📦 Тариф: {tariff.name} @@ -370,7 +371,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st 📊 Подписок на тарифе: {subs_count} -{f"📝 {tariff.description}" if tariff.description else ""}""" +{f'📝 {tariff.description}' if tariff.description else ''}""" @admin_required @@ -388,14 +389,14 @@ async def show_tariffs_list( # Проверяем режим продаж if not settings.is_tariffs_mode(): await callback.message.edit_text( - "⚠️ Режим тарифов отключен\n\n" - "Для использования тарифов установите:\n" - "SALES_MODE=tariffs\n\n" - "Текущий режим: classic", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] - ]), - parse_mode="HTML" + '⚠️ Режим тарифов отключен\n\n' + 'Для использования тарифов установите:\n' + 'SALES_MODE=tariffs\n\n' + 'Текущий режим: classic', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')]] + ), + parse_mode='HTML', ) await callback.answer() return @@ -404,14 +405,14 @@ async def show_tariffs_list( if not tariffs_data: await callback.message.edit_text( - "📦 Тарифы\n\n" - "Тарифы ещё не созданы.\n" - "Создайте первый тариф для начала работы.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="➕ Создать тариф", callback_data="admin_tariff_create")], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] - ]), - parse_mode="HTML" + '📦 Тарифы\n\nТарифы ещё не созданы.\nСоздайте первый тариф для начала работы.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='➕ Создать тариф', callback_data='admin_tariff_create')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')], + ] + ), + parse_mode='HTML', ) await callback.answer() return @@ -423,12 +424,12 @@ async def show_tariffs_list( active_count = sum(1 for t, _ in tariffs_data if t.is_active) await callback.message.edit_text( - f"📦 Тарифы\n\n" - f"Всего: {len(tariffs_data)} (активных: {active_count})\n" - f"Подписок на тарифах: {total_subs}\n\n" - "Выберите тариф для просмотра и редактирования:", + f'📦 Тарифы\n\n' + f'Всего: {len(tariffs_data)} (активных: {active_count})\n' + f'Подписок на тарифах: {total_subs}\n\n' + 'Выберите тариф для просмотра и редактирования:', reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, 0, total_pages), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -441,8 +442,8 @@ async def show_tariffs_page( db: AsyncSession, ): """Показывает страницу списка тарифов.""" - texts = get_texts(db_user.language) - page = int(callback.data.split(":")[1]) + get_texts(db_user.language) + page = int(callback.data.split(':')[1]) tariffs_data = await get_tariffs_with_subscriptions_count(db, include_inactive=True) total_pages = (len(tariffs_data) + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE @@ -455,12 +456,12 @@ async def show_tariffs_page( active_count = sum(1 for t, _ in tariffs_data if t.is_active) await callback.message.edit_text( - f"📦 Тарифы (стр. {page + 1}/{total_pages})\n\n" - f"Всего: {len(tariffs_data)} (активных: {active_count})\n" - f"Подписок на тарифах: {total_subs}\n\n" - "Выберите тариф для просмотра и редактирования:", + f'📦 Тарифы (стр. {page + 1}/{total_pages})\n\n' + f'Всего: {len(tariffs_data)} (активных: {active_count})\n' + f'Подписок на тарифах: {total_subs}\n\n' + 'Выберите тариф для просмотра и редактирования:', reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, page, total_pages), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -473,11 +474,11 @@ async def view_tariff( db: AsyncSession, ): """Просмотр тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return subs_count = await get_tariff_subscriptions_count(db, tariff_id) @@ -485,7 +486,7 @@ async def view_tariff( await callback.message.edit_text( format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -498,23 +499,23 @@ async def toggle_tariff( db: AsyncSession, ): """Переключает активность тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return tariff = await update_tariff(db, tariff, is_active=not tariff.is_active) subs_count = await get_tariff_subscriptions_count(db, tariff_id) - status = "активирован" if tariff.is_active else "деактивирован" - await callback.answer(f"Тариф {status}", show_alert=True) + status = 'активирован' if tariff.is_active else 'деактивирован' + await callback.answer(f'Тариф {status}', show_alert=True) await callback.message.edit_text( format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -526,23 +527,23 @@ async def toggle_trial_tariff( db: AsyncSession, ): """Переключает тариф как триальный.""" - from app.database.crud.tariff import set_trial_tariff, clear_trial_tariff + from app.database.crud.tariff import clear_trial_tariff, set_trial_tariff - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return if tariff.is_trial_available: # Снимаем флаг триала await clear_trial_tariff(db) - await callback.answer("Триал снят с тарифа", show_alert=True) + await callback.answer('Триал снят с тарифа', show_alert=True) else: # Устанавливаем этот тариф как триальный (снимает флаг с других) await set_trial_tariff(db, tariff_id) - await callback.answer(f"Тариф «{tariff.name}» установлен как триальный", show_alert=True) + await callback.answer(f'Тариф «{tariff.name}» установлен как триальный', show_alert=True) # Перезагружаем тариф tariff = await get_tariff_by_id(db, tariff_id) @@ -551,7 +552,7 @@ async def toggle_trial_tariff( await callback.message.edit_text( format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -563,11 +564,11 @@ async def toggle_daily_tariff( db: AsyncSession, ): """Переключает суточный режим тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return is_daily = getattr(tariff, 'is_daily', False) @@ -575,14 +576,12 @@ async def toggle_daily_tariff( if is_daily: # Отключаем суточный режим tariff = await update_tariff(db, tariff, is_daily=False, daily_price_kopeks=0) - await callback.answer("Суточный режим отключен", show_alert=True) + await callback.answer('Суточный режим отключен', show_alert=True) else: # Включаем суточный режим (с ценой по умолчанию) tariff = await update_tariff(db, tariff, is_daily=True, daily_price_kopeks=5000) # 50 руб по умолчанию await callback.answer( - f"Суточный режим включен. Цена: 50 ₽/день\n" - "Настройте цену через кнопку «💰 Суточная цена»", - show_alert=True + 'Суточный режим включен. Цена: 50 ₽/день\nНастройте цену через кнопку «💰 Суточная цена»', show_alert=True ) subs_count = await get_tariff_subscriptions_count(db, tariff_id) @@ -590,7 +589,7 @@ async def toggle_daily_tariff( await callback.message.edit_text( format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -605,29 +604,29 @@ async def start_edit_daily_price( """Начинает редактирование суточной цены.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return current_price = getattr(tariff, 'daily_price_kopeks', 0) - current_rubles = current_price / 100 if current_price else 0 + current_price / 100 if current_price else 0 await state.set_state(AdminStates.editing_tariff_daily_price) await state.update_data(tariff_id=tariff_id, language=db_user.language) await callback.message.edit_text( - f"💰 Редактирование суточной цены\n\n" - f"Тариф: {tariff.name}\n" - f"Текущая цена: {_format_price_kopeks(current_price)}/день\n\n" - "Введите новую цену за день в рублях.\n" - "Пример: 50 или 99.90", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'💰 Редактирование суточной цены\n\n' + f'Тариф: {tariff.name}\n' + f'Текущая цена: {_format_price_kopeks(current_price)}/день\n\n' + 'Введите новую цену за день в рублях.\n' + 'Пример: 50 или 99.90', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -641,27 +640,26 @@ async def process_daily_price_input( state: FSMContext, ): """Обрабатывает ввод суточной цены (создание и редактирование).""" - texts = get_texts(db_user.language) + get_texts(db_user.language) data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') # Парсим цену try: - price_rubles = float(message.text.strip().replace(",", ".")) + price_rubles = float(message.text.strip().replace(',', '.')) if price_rubles <= 0: - raise ValueError("Цена должна быть положительной") + raise ValueError('Цена должна быть положительной') price_kopeks = int(price_rubles * 100) except ValueError: await message.answer( - "❌ Некорректная цена. Введите положительное число.\n" - "Пример: 50 или 99.90", - parse_mode="HTML" + '❌ Некорректная цена. Введите положительное число.\nПример: 50 или 99.90', + parse_mode='HTML', ) return # Проверяем - это создание или редактирование - is_creating = data.get("tariff_is_daily") and not tariff_id + is_creating = data.get('tariff_is_daily') and not tariff_id if is_creating: # Создаем новый суточный тариф @@ -679,10 +677,9 @@ async def process_daily_price_input( await state.clear() await message.answer( - f"✅ Суточный тариф создан!\n\n" - + format_tariff_info(tariff, db_user.language, 0), + '✅ Суточный тариф создан!\n\n' + format_tariff_info(tariff, db_user.language, 0), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # Редактируем существующий тариф @@ -692,7 +689,7 @@ async def process_daily_price_input( tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -702,15 +699,16 @@ async def process_daily_price_input( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n" + f'✅ Суточная цена установлена: {_format_price_kopeks(price_kopeks)}/день\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ СОЗДАНИЕ ТАРИФА ============ + @admin_required @error_handler async def start_create_tariff( @@ -726,13 +724,13 @@ async def start_create_tariff( await state.update_data(language=db_user.language) await callback.message.edit_text( - "📦 Создание тарифа\n\n" - "Шаг 1/6: Введите название тарифа\n\n" - "Пример: Базовый, Премиум, Бизнес", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + 'Шаг 1/6: Введите название тарифа\n\n' + 'Пример: Базовый, Премиум, Бизнес', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -750,26 +748,26 @@ async def process_tariff_name( name = message.text.strip() if len(name) < 2: - await message.answer("Название должно быть не короче 2 символов") + await message.answer('Название должно быть не короче 2 символов') return if len(name) > 50: - await message.answer("Название должно быть не длиннее 50 символов") + await message.answer('Название должно быть не длиннее 50 символов') return await state.update_data(tariff_name=name) await state.set_state(AdminStates.creating_tariff_traffic) await message.answer( - "📦 Создание тарифа\n\n" - f"Название: {name}\n\n" - "Шаг 2/6: Введите лимит трафика в ГБ\n\n" - "Введите 0 для безлимитного трафика\n" - "Пример: 100, 500, 0", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + f'Название: {name}\n\n' + 'Шаг 2/6: Введите лимит трафика в ГБ\n\n' + 'Введите 0 для безлимитного трафика\n' + 'Пример: 100, 500, 0', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) @@ -789,7 +787,7 @@ async def process_tariff_traffic( if traffic < 0: raise ValueError except ValueError: - await message.answer("Введите корректное число (0 или больше)") + await message.answer('Введите корректное число (0 или больше)') return data = await state.get_data() @@ -799,15 +797,15 @@ async def process_tariff_traffic( traffic_display = _format_traffic(traffic) await message.answer( - "📦 Создание тарифа\n\n" - f"Название: {data['tariff_name']}\n" - f"Трафик: {traffic_display}\n\n" - "Шаг 3/6: Введите лимит устройств\n\n" - "Пример: 1, 3, 5", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + f'Название: {data["tariff_name"]}\n' + f'Трафик: {traffic_display}\n\n' + 'Шаг 3/6: Введите лимит устройств\n\n' + 'Пример: 1, 3, 5', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) @@ -827,7 +825,7 @@ async def process_tariff_devices( if devices < 1: raise ValueError except ValueError: - await message.answer("Введите корректное число (1 или больше)") + await message.answer('Введите корректное число (1 или больше)') return data = await state.get_data() @@ -837,18 +835,18 @@ async def process_tariff_devices( traffic_display = _format_traffic(data['tariff_traffic']) await message.answer( - "📦 Создание тарифа\n\n" - f"Название: {data['tariff_name']}\n" - f"Трафик: {traffic_display}\n" - f"Устройств: {devices}\n\n" - "Шаг 4/6: Введите уровень тарифа (1-10)\n\n" - "Уровень используется для визуального отображения\n" - "1 - базовый, 10 - максимальный\n" - "Пример: 1, 2, 3", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + f'Название: {data["tariff_name"]}\n' + f'Трафик: {traffic_display}\n' + f'Устройств: {devices}\n\n' + 'Шаг 4/6: Введите уровень тарифа (1-10)\n\n' + 'Уровень используется для визуального отображения\n' + '1 - базовый, 10 - максимальный\n' + 'Пример: 1, 2, 3', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) @@ -868,7 +866,7 @@ async def process_tariff_tier( if tier < 1 or tier > 10: raise ValueError except ValueError: - await message.answer("Введите число от 1 до 10") + await message.answer('Введите число от 1 до 10') return data = await state.get_data() @@ -878,18 +876,20 @@ async def process_tariff_tier( # Шаг 5/6: Выбор типа тарифа await message.answer( - "📦 Создание тарифа\n\n" - f"Название: {data['tariff_name']}\n" - f"Трафик: {traffic_display}\n" - f"Устройств: {data['tariff_devices']}\n" - f"Уровень: {tier}\n\n" - "Шаг 5/6: Выберите тип тарифа", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📅 Периодный (месяцы)", callback_data="tariff_type_periodic")], - [InlineKeyboardButton(text="🔄 Суточный (оплата за день)", callback_data="tariff_type_daily")], - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + f'Название: {data["tariff_name"]}\n' + f'Трафик: {traffic_display}\n' + f'Устройств: {data["tariff_devices"]}\n' + f'Уровень: {tier}\n\n' + 'Шаг 5/6: Выберите тип тарифа', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📅 Периодный (месяцы)', callback_data='tariff_type_periodic')], + [InlineKeyboardButton(text='🔄 Суточный (оплата за день)', callback_data='tariff_type_daily')], + [InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')], + ] + ), + parse_mode='HTML', ) @@ -911,20 +911,20 @@ async def select_tariff_type_periodic( traffic_display = _format_traffic(data['tariff_traffic']) await callback.message.edit_text( - "📦 Создание тарифа\n\n" - f"Название: {data['tariff_name']}\n" - f"Трафик: {traffic_display}\n" - f"Устройств: {data['tariff_devices']}\n" - f"Уровень: {data['tariff_tier']}\n" - f"Тип: 📅 Периодный\n\n" - "Шаг 6/6: Введите цены на периоды\n\n" - "Формат: дней:цена_в_копейках\n" - "Несколько периодов через запятую\n\n" - "Пример:\n30:9900, 90:24900, 180:44900, 360:79900", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание тарифа\n\n' + f'Название: {data["tariff_name"]}\n' + f'Трафик: {traffic_display}\n' + f'Устройств: {data["tariff_devices"]}\n' + f'Уровень: {data["tariff_tier"]}\n' + f'Тип: 📅 Периодный\n\n' + 'Шаг 6/6: Введите цены на периоды\n\n' + 'Формат: дней:цена_в_копейках\n' + 'Несколько периодов через запятую\n\n' + 'Пример:\n30:9900, 90:24900, 180:44900, 360:79900', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -949,18 +949,18 @@ async def select_tariff_type_daily( traffic_display = _format_traffic(data['tariff_traffic']) await callback.message.edit_text( - "📦 Создание суточного тарифа\n\n" - f"Название: {data['tariff_name']}\n" - f"Трафик: {traffic_display}\n" - f"Устройств: {data['tariff_devices']}\n" - f"Уровень: {data['tariff_tier']}\n" - f"Тип: 🔄 Суточный\n\n" - "Шаг 6/6: Введите суточную цену в рублях\n\n" - "Пример: 50 (50 ₽/день), 99.90 (99.90 ₽/день)", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data="admin_tariffs")] - ]), - parse_mode="HTML" + '📦 Создание суточного тарифа\n\n' + f'Название: {data["tariff_name"]}\n' + f'Трафик: {traffic_display}\n' + f'Устройств: {data["tariff_devices"]}\n' + f'Уровень: {data["tariff_tier"]}\n' + f'Тип: 🔄 Суточный\n\n' + 'Шаг 6/6: Введите суточную цену в рублях\n\n' + 'Пример: 50 (50 ₽/день), 99.90 (99.90 ₽/день)', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data='admin_tariffs')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -974,24 +974,24 @@ async def process_tariff_prices( state: FSMContext, ): """Обрабатывает цены тарифа.""" - texts = get_texts(db_user.language) + get_texts(db_user.language) prices = _parse_period_prices(message.text.strip()) if not prices: await message.answer( - "Не удалось распознать цены.\n\n" - "Формат: дней:цена_в_копейках\n" - "Пример: 30:9900, 90:24900", - parse_mode="HTML" + 'Не удалось распознать цены.\n\n' + 'Формат: дней:цена_в_копейках\n' + 'Пример: 30:9900, 90:24900', + parse_mode='HTML', ) return data = await state.get_data() await state.update_data(tariff_prices=prices) - traffic_display = _format_traffic(data['tariff_traffic']) - prices_display = _format_period_prices_display(prices) + _format_traffic(data['tariff_traffic']) + _format_period_prices_display(prices) # Создаем тариф tariff = await create_tariff( @@ -1009,15 +1009,15 @@ async def process_tariff_prices( subs_count = 0 await message.answer( - f"✅ Тариф создан!\n\n" - + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Тариф создан!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ ТАРИФА ============ + @admin_required @error_handler async def start_edit_tariff_name( @@ -1028,24 +1028,22 @@ async def start_edit_tariff_name( ): """Начинает редактирование названия тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_name) await state.update_data(tariff_id=tariff_id, language=db_user.language) await callback.message.edit_text( - f"✏️ Редактирование названия\n\n" - f"Текущее название: {tariff.name}\n\n" - "Введите новое название:", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'✏️ Редактирование названия\n\nТекущее название: {tariff.name}\n\nВведите новое название:', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1060,17 +1058,17 @@ async def process_edit_tariff_name( ): """Обрабатывает новое название тарифа.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return name = message.text.strip() if len(name) < 2 or len(name) > 50: - await message.answer("Название должно быть от 2 до 50 символов") + await message.answer('Название должно быть от 2 до 50 символов') return tariff = await update_tariff(db, tariff, name=name) @@ -1079,9 +1077,9 @@ async def process_edit_tariff_name( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Название изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Название изменено!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -1095,26 +1093,26 @@ async def start_edit_tariff_description( ): """Начинает редактирование описания тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_description) await state.update_data(tariff_id=tariff_id, language=db_user.language) - current_desc = tariff.description or "Не задано" + current_desc = tariff.description or 'Не задано' await callback.message.edit_text( - f"📝 Редактирование описания\n\n" - f"Текущее описание:\n{current_desc}\n\n" - "Введите новое описание (или - для удаления):", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'📝 Редактирование описания\n\n' + f'Текущее описание:\n{current_desc}\n\n' + 'Введите новое описание (или - для удаления):', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1129,16 +1127,16 @@ async def process_edit_tariff_description( ): """Обрабатывает новое описание тарифа.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return description = message.text.strip() - if description == "-": + if description == '-': description = None tariff = await update_tariff(db, tariff, description=description) @@ -1147,9 +1145,9 @@ async def process_edit_tariff_description( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Описание изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Описание изменено!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -1163,11 +1161,11 @@ async def start_edit_tariff_traffic( ): """Начинает редактирование трафика тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_traffic) @@ -1176,13 +1174,13 @@ async def start_edit_tariff_traffic( current_traffic = _format_traffic(tariff.traffic_limit_gb) await callback.message.edit_text( - f"📊 Редактирование трафика\n\n" - f"Текущий лимит: {current_traffic}\n\n" - "Введите новый лимит в ГБ (0 = безлимит):", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'📊 Редактирование трафика\n\n' + f'Текущий лимит: {current_traffic}\n\n' + 'Введите новый лимит в ГБ (0 = безлимит):', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1197,11 +1195,11 @@ async def process_edit_tariff_traffic( ): """Обрабатывает новый лимит трафика.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -1210,7 +1208,7 @@ async def process_edit_tariff_traffic( if traffic < 0: raise ValueError except ValueError: - await message.answer("Введите корректное число (0 или больше)") + await message.answer('Введите корректное число (0 или больше)') return tariff = await update_tariff(db, tariff, traffic_limit_gb=traffic) @@ -1219,9 +1217,9 @@ async def process_edit_tariff_traffic( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Трафик изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Трафик изменен!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -1235,24 +1233,24 @@ async def start_edit_tariff_devices( ): """Начинает редактирование лимита устройств.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_devices) await state.update_data(tariff_id=tariff_id, language=db_user.language) await callback.message.edit_text( - f"📱 Редактирование устройств\n\n" - f"Текущий лимит: {tariff.device_limit}\n\n" - "Введите новый лимит устройств:", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'📱 Редактирование устройств\n\n' + f'Текущий лимит: {tariff.device_limit}\n\n' + 'Введите новый лимит устройств:', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1267,11 +1265,11 @@ async def process_edit_tariff_devices( ): """Обрабатывает новый лимит устройств.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -1280,7 +1278,7 @@ async def process_edit_tariff_devices( if devices < 1: raise ValueError except ValueError: - await message.answer("Введите корректное число (1 или больше)") + await message.answer('Введите корректное число (1 или больше)') return tariff = await update_tariff(db, tariff, device_limit=devices) @@ -1289,9 +1287,9 @@ async def process_edit_tariff_devices( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Лимит устройств изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Лимит устройств изменен!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -1305,24 +1303,24 @@ async def start_edit_tariff_tier( ): """Начинает редактирование уровня тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_tier) await state.update_data(tariff_id=tariff_id, language=db_user.language) await callback.message.edit_text( - f"🎚️ Редактирование уровня\n\n" - f"Текущий уровень: {tariff.tier_level}\n\n" - "Введите новый уровень (1-10):", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'🎚️ Редактирование уровня\n\n' + f'Текущий уровень: {tariff.tier_level}\n\n' + 'Введите новый уровень (1-10):', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1337,11 +1335,11 @@ async def process_edit_tariff_tier( ): """Обрабатывает новый уровень тарифа.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -1350,7 +1348,7 @@ async def process_edit_tariff_tier( if tier < 1 or tier > 10: raise ValueError except ValueError: - await message.answer("Введите число от 1 до 10") + await message.answer('Введите число от 1 до 10') return tariff = await update_tariff(db, tariff, tier_level=tier) @@ -1359,9 +1357,9 @@ async def process_edit_tariff_tier( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Уровень изменен!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Уровень изменен!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @@ -1375,11 +1373,11 @@ async def start_edit_tariff_prices( ): """Начинает редактирование цен тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_prices) @@ -1389,15 +1387,15 @@ async def start_edit_tariff_prices( prices_display = _format_period_prices_display(tariff.period_prices or {}) await callback.message.edit_text( - f"💰 Редактирование цен\n\n" - f"Текущие цены:\n{prices_display}\n\n" - "Введите новые цены в формате:\n" - f"{current_prices}\n\n" - "(дней:цена_в_копейках, через запятую)", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'💰 Редактирование цен\n\n' + f'Текущие цены:\n{prices_display}\n\n' + 'Введите новые цены в формате:\n' + f'{current_prices}\n\n' + '(дней:цена_в_копейках, через запятую)', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1412,21 +1410,19 @@ async def process_edit_tariff_prices( ): """Обрабатывает новые цены тарифа.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return prices = _parse_period_prices(message.text.strip()) if not prices: await message.answer( - "Не удалось распознать цены.\n" - "Формат: дней:цена\n" - "Пример: 30:9900, 90:24900", - parse_mode="HTML" + 'Не удалось распознать цены.\nФормат: дней:цена\nПример: 30:9900, 90:24900', + parse_mode='HTML', ) return @@ -1436,14 +1432,15 @@ async def process_edit_tariff_prices( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Цены изменены!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Цены изменены!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ ЦЕНЫ ЗА УСТРОЙСТВО ============ + @admin_required @error_handler async def start_edit_tariff_device_price( @@ -1454,11 +1451,11 @@ async def start_edit_tariff_device_price( ): """Начинает редактирование цены за устройство.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_device_price) @@ -1466,20 +1463,20 @@ async def start_edit_tariff_device_price( device_price = getattr(tariff, 'device_price_kopeks', None) if device_price is not None and device_price > 0: - current_price = _format_price_kopeks(device_price) + "/мес" + current_price = _format_price_kopeks(device_price) + '/мес' else: - current_price = "Недоступно (докупка устройств запрещена)" + current_price = 'Недоступно (докупка устройств запрещена)' await callback.message.edit_text( - f"📱💰 Редактирование цены за устройство\n\n" - f"Текущая цена: {current_price}\n\n" - "Введите цену в копейках за одно устройство в месяц.\n\n" - "• 0 или - — докупка устройств недоступна\n" - "• Например: 5000 = 50₽/мес за устройство", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'📱💰 Редактирование цены за устройство\n\n' + f'Текущая цена: {current_price}\n\n' + 'Введите цену в копейках за одно устройство в месяц.\n\n' + '• 0 или - — докупка устройств недоступна\n' + '• Например: 5000 = 50₽/мес за устройство', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1494,17 +1491,17 @@ async def process_edit_tariff_device_price( ): """Обрабатывает новую цену за устройство.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return text = message.text.strip() - if text == "-" or text == "0": + if text == '-' or text == '0': device_price = None else: try: @@ -1513,9 +1510,9 @@ async def process_edit_tariff_device_price( raise ValueError except ValueError: await message.answer( - "Введите корректное число (0 или больше).\n" - "Для отключения докупки введите 0 или -", - parse_mode="HTML" + 'Введите корректное число (0 или больше).\n' + 'Для отключения докупки введите 0 или -', + parse_mode='HTML', ) return @@ -1525,14 +1522,15 @@ async def process_edit_tariff_device_price( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Цена за устройство изменена!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Цена за устройство изменена!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ МАКС. УСТРОЙСТВ ============ + @admin_required @error_handler async def start_edit_tariff_max_devices( @@ -1543,11 +1541,11 @@ async def start_edit_tariff_max_devices( ): """Начинает редактирование макс. устройств.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_max_devices) @@ -1557,19 +1555,19 @@ async def start_edit_tariff_max_devices( if max_devices is not None and max_devices > 0: current_max = str(max_devices) else: - current_max = "∞ (без лимита)" + current_max = '∞ (без лимита)' await callback.message.edit_text( - f"📱🔒 Редактирование макс. устройств\n\n" - f"Текущее значение: {current_max}\n" - f"Базовое кол-во устройств: {tariff.device_limit}\n\n" - "Введите максимальное количество устройств, которое пользователь может докупить.\n\n" - "• 0 или - — без ограничений\n" - "• Например: 5 = максимум 5 устройств на тарифе", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'📱🔒 Редактирование макс. устройств\n\n' + f'Текущее значение: {current_max}\n' + f'Базовое кол-во устройств: {tariff.device_limit}\n\n' + 'Введите максимальное количество устройств, которое пользователь может докупить.\n\n' + '• 0 или - — без ограничений\n' + '• Например: 5 = максимум 5 устройств на тарифе', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1584,17 +1582,17 @@ async def process_edit_tariff_max_devices( ): """Обрабатывает новое макс. кол-во устройств.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return text = message.text.strip() - if text == "-" or text == "0": + if text == '-' or text == '0': max_devices = None else: try: @@ -1603,9 +1601,9 @@ async def process_edit_tariff_max_devices( raise ValueError except ValueError: await message.answer( - "Введите корректное число (1 или больше).\n" - "Для снятия ограничения введите 0 или -", - parse_mode="HTML" + 'Введите корректное число (1 или больше).\n' + 'Для снятия ограничения введите 0 или -', + parse_mode='HTML', ) return @@ -1615,14 +1613,15 @@ async def process_edit_tariff_max_devices( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Макс. устройств изменено!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Макс. устройств изменено!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ ДНЕЙ ТРИАЛА ============ + @admin_required @error_handler async def start_edit_tariff_trial_days( @@ -1633,11 +1632,11 @@ async def start_edit_tariff_trial_days( ): """Начинает редактирование дней триала.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_trial_days) @@ -1645,20 +1644,20 @@ async def start_edit_tariff_trial_days( trial_days = getattr(tariff, 'trial_duration_days', None) if trial_days: - current_days = f"{trial_days} дней" + current_days = f'{trial_days} дней' else: - current_days = f"По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)" + current_days = f'По умолчанию ({settings.TRIAL_DURATION_DAYS} дней)' await callback.message.edit_text( - f"⏰ Редактирование дней триала\n\n" - f"Текущее значение: {current_days}\n\n" - "Введите количество дней триала.\n\n" - f"• 0 или - — использовать настройку по умолчанию ({settings.TRIAL_DURATION_DAYS} дней)\n" - "• Например: 7 = 7 дней триала", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_view:{tariff_id}")] - ]), - parse_mode="HTML" + f'⏰ Редактирование дней триала\n\n' + f'Текущее значение: {current_days}\n\n' + 'Введите количество дней триала.\n\n' + f'• 0 или - — использовать настройку по умолчанию ({settings.TRIAL_DURATION_DAYS} дней)\n' + '• Например: 7 = 7 дней триала', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] + ), + parse_mode='HTML', ) await callback.answer() @@ -1673,17 +1672,17 @@ async def process_edit_tariff_trial_days( ): """Обрабатывает новое количество дней триала.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return text = message.text.strip() - if text == "-" or text == "0": + if text == '-' or text == '0': trial_days = None else: try: @@ -1692,9 +1691,9 @@ async def process_edit_tariff_trial_days( raise ValueError except ValueError: await message.answer( - "Введите корректное число дней (1 или больше).\n" - "Для использования настройки по умолчанию введите 0 или -", - parse_mode="HTML" + 'Введите корректное число дней (1 или больше).\n' + 'Для использования настройки по умолчанию введите 0 или -', + parse_mode='HTML', ) return @@ -1704,31 +1703,32 @@ async def process_edit_tariff_trial_days( subs_count = await get_tariff_subscriptions_count(db, tariff_id) await message.answer( - f"✅ Дни триала изменены!\n\n" + format_tariff_info(tariff, db_user.language, subs_count), + '✅ Дни триала изменены!\n\n' + format_tariff_info(tariff, db_user.language, subs_count), reply_markup=get_tariff_view_keyboard(tariff, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ ДОКУПКИ ТРАФИКА ============ -def _parse_traffic_topup_packages(text: str) -> Dict[int, int]: + +def _parse_traffic_topup_packages(text: str) -> dict[int, int]: """ Парсит строку с пакетами докупки трафика. Формат: "5:5000, 10:9000, 20:15000" (ГБ:цена_в_копейках) """ packages = {} - text = text.replace(";", ",").replace("=", ":") + text = text.replace(';', ',').replace('=', ':') - for part in text.split(","): + for part in text.split(','): part = part.strip() if not part: continue - if ":" not in part: + if ':' not in part: continue - gb_str, price_str = part.split(":", 1) + gb_str, price_str = part.split(':', 1) try: gb = int(gb_str.strip()) price = int(price_str.strip()) @@ -1740,16 +1740,16 @@ def _parse_traffic_topup_packages(text: str) -> Dict[int, int]: return packages -def _format_traffic_topup_packages_for_edit(packages: Dict[int, int]) -> str: +def _format_traffic_topup_packages_for_edit(packages: dict[int, int]) -> str: """Форматирует пакеты докупки для редактирования.""" if not packages: - return "5:5000, 10:9000, 20:15000" + return '5:5000, 10:9000, 20:15000' parts = [] for gb in sorted(packages.keys()): - parts.append(f"{gb}:{packages[gb]}") + parts.append(f'{gb}:{packages[gb]}') - return ", ".join(parts) + return ', '.join(parts) @admin_required @@ -1762,16 +1762,16 @@ async def start_edit_tariff_traffic_topup( ): """Показывает меню настройки докупки трафика.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return # Проверяем, безлимитный ли тариф if tariff.is_unlimited_traffic: - await callback.answer("Докупка недоступна для безлимитного тарифа", show_alert=True) + await callback.answer('Докупка недоступна для безлимитного тарифа', show_alert=True) return is_enabled = getattr(tariff, 'traffic_topup_enabled', False) @@ -1780,54 +1780,62 @@ async def start_edit_tariff_traffic_topup( # Форматируем текущие настройки if is_enabled: - status = "✅ Включено" + status = '✅ Включено' if packages: - packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + packages_display = '\n'.join( + f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items()) + ) else: - packages_display = " Пакеты не настроены" + packages_display = ' Пакеты не настроены' else: - status = "❌ Отключено" - packages_display = " -" + status = '❌ Отключено' + packages_display = ' -' # Форматируем лимит if max_topup_traffic > 0: - max_limit_display = f"{max_topup_traffic} ГБ" + max_limit_display = f'{max_topup_traffic} ГБ' else: - max_limit_display = "Без ограничений" + max_limit_display = 'Без ограничений' buttons = [] # Переключение вкл/выкл if is_enabled: - buttons.append([ - InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") - ]) + buttons.append( + [InlineKeyboardButton(text='❌ Отключить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')] + ) else: - buttons.append([ - InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") - ]) + buttons.append( + [InlineKeyboardButton(text='✅ Включить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')] + ) # Редактирование пакетов и лимита (только если включено) if is_enabled: - buttons.append([ - InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") - ]) - buttons.append([ - InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton( + text='📦 Настроить пакеты', callback_data=f'admin_tariff_edit_topup_packages:{tariff_id}' + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text='📊 Макс. лимит трафика', callback_data=f'admin_tariff_edit_max_topup:{tariff_id}' + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) await callback.message.edit_text( - f"📈 Докупка трафика для «{tariff.name}»\n\n" - f"Статус: {status}\n\n" - f"Пакеты:\n{packages_display}\n\n" - f"Макс. лимит: {max_limit_display}\n\n" - "Пользователи смогут докупать трафик по заданным ценам.", + f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'Статус: {status}\n\n' + f'Пакеты:\n{packages_display}\n\n' + f'Макс. лимит: {max_limit_display}\n\n' + 'Пользователи смогут докупать трафик по заданным ценам.', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -1840,11 +1848,11 @@ async def toggle_tariff_traffic_topup( db: AsyncSession, ): """Переключает включение/выключение докупки трафика.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return is_enabled = getattr(tariff, 'traffic_topup_enabled', False) @@ -1852,8 +1860,8 @@ async def toggle_tariff_traffic_topup( tariff = await update_tariff(db, tariff, traffic_topup_enabled=new_value) - status_text = "включена" if new_value else "отключена" - await callback.answer(f"Докупка трафика {status_text}") + status_text = 'включена' if new_value else 'отключена' + await callback.answer(f'Докупка трафика {status_text}') # Перерисовываем меню texts = get_texts(db_user.language) @@ -1861,51 +1869,59 @@ async def toggle_tariff_traffic_topup( max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 if new_value: - status = "✅ Включено" + status = '✅ Включено' if packages: - packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + packages_display = '\n'.join( + f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items()) + ) else: - packages_display = " Пакеты не настроены" + packages_display = ' Пакеты не настроены' else: - status = "❌ Отключено" - packages_display = " -" + status = '❌ Отключено' + packages_display = ' -' # Форматируем лимит if max_topup_traffic > 0: - max_limit_display = f"{max_topup_traffic} ГБ" + max_limit_display = f'{max_topup_traffic} ГБ' else: - max_limit_display = "Без ограничений" + max_limit_display = 'Без ограничений' buttons = [] if new_value: - buttons.append([ - InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") - ]) - buttons.append([ - InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}") - ]) - buttons.append([ - InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}") - ]) + buttons.append( + [InlineKeyboardButton(text='❌ Отключить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')] + ) + buttons.append( + [ + InlineKeyboardButton( + text='📦 Настроить пакеты', callback_data=f'admin_tariff_edit_topup_packages:{tariff_id}' + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text='📊 Макс. лимит трафика', callback_data=f'admin_tariff_edit_max_topup:{tariff_id}' + ) + ] + ) else: - buttons.append([ - InlineKeyboardButton(text="✅ Включить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}") - ]) + buttons.append( + [InlineKeyboardButton(text='✅ Включить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')] + ) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"📈 Докупка трафика для «{tariff.name}»\n\n" - f"Статус: {status}\n\n" - f"Пакеты:\n{packages_display}\n\n" - f"Макс. лимит: {max_limit_display}\n\n" - "Пользователи смогут докупать трафик по заданным ценам.", + f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'Статус: {status}\n\n' + f'Пакеты:\n{packages_display}\n\n' + f'Макс. лимит: {max_limit_display}\n\n' + 'Пользователи смогут докупать трафик по заданным ценам.', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -1921,11 +1937,11 @@ async def start_edit_traffic_topup_packages( ): """Начинает редактирование пакетов докупки трафика.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_traffic_topup_packages) @@ -1935,22 +1951,26 @@ async def start_edit_traffic_topup_packages( current_packages = _format_traffic_topup_packages_for_edit(packages) if packages: - packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + packages_display = '\n'.join( + f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items()) + ) else: - packages_display = " Не настроены" + packages_display = ' Не настроены' await callback.message.edit_text( - f"📦 Настройка пакетов докупки трафика\n\n" - f"Тариф: {tariff.name}\n\n" - f"Текущие пакеты:\n{packages_display}\n\n" - "Введите пакеты в формате:\n" - f"{current_packages}\n\n" - "(ГБ:цена_в_копейках, через запятую)\n" - "Например: 5:5000, 10:9000 = 5ГБ за 50₽, 10ГБ за 90₽", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] - ]), - parse_mode="HTML" + f'📦 Настройка пакетов докупки трафика\n\n' + f'Тариф: {tariff.name}\n\n' + f'Текущие пакеты:\n{packages_display}\n\n' + 'Введите пакеты в формате:\n' + f'{current_packages}\n\n' + '(ГБ:цена_в_копейках, через запятую)\n' + 'Например: 5:5000, 10:9000 = 5ГБ за 50₽, 10ГБ за 90₽', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_edit_traffic_topup:{tariff_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @@ -1965,11 +1985,11 @@ async def process_edit_traffic_topup_packages( ): """Обрабатывает новые пакеты докупки трафика.""" data = await state.get_data() - tariff_id = data.get("tariff_id") + tariff_id = data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -1977,10 +1997,10 @@ async def process_edit_traffic_topup_packages( if not packages: await message.answer( - "Не удалось распознать пакеты.\n\n" - "Формат: ГБ:цена_в_копейках\n" - "Пример: 5:5000, 10:9000, 20:15000", - parse_mode="HTML" + 'Не удалось распознать пакеты.\n\n' + 'Формат: ГБ:цена_в_копейках\n' + 'Пример: 5:5000, 10:9000, 20:15000', + parse_mode='HTML', ) return @@ -1992,31 +2012,38 @@ async def process_edit_traffic_topup_packages( # Показываем обновленное меню texts = get_texts(db_user.language) - packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + packages_display = '\n'.join( + f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items()) + ) max_topup_traffic = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 - max_limit_display = f"{max_topup_traffic} ГБ" if max_topup_traffic > 0 else "Без ограничений" + max_limit_display = f'{max_topup_traffic} ГБ' if max_topup_traffic > 0 else 'Без ограничений' buttons = [ - [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], - [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], - [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + [InlineKeyboardButton(text='❌ Отключить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')], + [ + InlineKeyboardButton( + text='📦 Настроить пакеты', callback_data=f'admin_tariff_edit_topup_packages:{tariff_id}' + ) + ], + [InlineKeyboardButton(text='📊 Макс. лимит трафика', callback_data=f'admin_tariff_edit_max_topup:{tariff_id}')], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')], ] await message.answer( - f"✅ Пакеты обновлены!\n\n" - f"📈 Докупка трафика для «{tariff.name}»\n\n" - f"Статус: ✅ Включено\n\n" - f"Пакеты:\n{packages_display}\n\n" - f"Макс. лимит: {max_limit_display}\n\n" - "Пользователи смогут докупать трафик по заданным ценам.", + f'✅ Пакеты обновлены!\n\n' + f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'Статус: ✅ Включено\n\n' + f'Пакеты:\n{packages_display}\n\n' + f'Макс. лимит: {max_limit_display}\n\n' + 'Пользователи смогут докупать трафик по заданным ценам.', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) # ============ МАКСИМАЛЬНЫЙ ЛИМИТ ДОКУПКИ ТРАФИКА ============ + @admin_required @error_handler async def start_edit_max_topup_traffic( @@ -2027,11 +2054,11 @@ async def start_edit_max_topup_traffic( ): """Начинает редактирование максимального лимита докупки трафика.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await state.set_state(AdminStates.editing_tariff_max_topup_traffic) @@ -2039,21 +2066,23 @@ async def start_edit_max_topup_traffic( current_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0 if current_limit > 0: - current_display = f"{current_limit} ГБ" + current_display = f'{current_limit} ГБ' else: - current_display = "Без ограничений" + current_display = 'Без ограничений' await callback.message.edit_text( - f"📊 Максимальный лимит трафика\n\n" - f"Тариф: {tariff.name}\n" - f"Текущий лимит: {current_display}\n\n" - f"Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n" - f"• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n" - f"• Введите 0 для снятия ограничения", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.CANCEL, callback_data=f"admin_tariff_edit_traffic_topup:{tariff_id}")] - ]), - parse_mode="HTML" + f'📊 Максимальный лимит трафика\n\n' + f'Тариф: {tariff.name}\n' + f'Текущий лимит: {current_display}\n\n' + f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n' + f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n' + f'• Введите 0 для снятия ограничения', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_edit_traffic_topup:{tariff_id}')] + ] + ), + parse_mode='HTML', ) await callback.answer() @@ -2069,11 +2098,11 @@ async def process_edit_max_topup_traffic( """Обрабатывает новое значение максимального лимита докупки трафика.""" texts = get_texts(db_user.language) state_data = await state.get_data() - tariff_id = state_data.get("tariff_id") + tariff_id = state_data.get('tariff_id') tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await message.answer("Тариф не найден") + await message.answer('Тариф не найден') await state.clear() return @@ -2082,13 +2111,13 @@ async def process_edit_max_topup_traffic( try: new_limit = int(text) if new_limit < 0: - raise ValueError("Negative value") + raise ValueError('Negative value') except ValueError: await message.answer( - "Введите целое число (0 или больше).\n\n" - "• 0 — без ограничений\n" - "• 200 — максимум 200 ГБ на подписке", - parse_mode="HTML" + 'Введите целое число (0 или больше).\n\n' + '• 0 — без ограничений\n' + '• 200 — максимум 200 ГБ на подписке', + parse_mode='HTML', ) return @@ -2098,33 +2127,40 @@ async def process_edit_max_topup_traffic( # Показываем обновленное меню packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {} if packages: - packages_display = "\n".join(f" • {gb} ГБ: {_format_price_kopeks(price)}" for gb, price in sorted(packages.items())) + packages_display = '\n'.join( + f' • {gb} ГБ: {_format_price_kopeks(price)}' for gb, price in sorted(packages.items()) + ) else: - packages_display = " Пакеты не настроены" + packages_display = ' Пакеты не настроены' - max_limit_display = f"{new_limit} ГБ" if new_limit > 0 else "Без ограничений" + max_limit_display = f'{new_limit} ГБ' if new_limit > 0 else 'Без ограничений' buttons = [ - [InlineKeyboardButton(text="❌ Отключить", callback_data=f"admin_tariff_toggle_traffic_topup:{tariff_id}")], - [InlineKeyboardButton(text="📦 Настроить пакеты", callback_data=f"admin_tariff_edit_topup_packages:{tariff_id}")], - [InlineKeyboardButton(text="📊 Макс. лимит трафика", callback_data=f"admin_tariff_edit_max_topup:{tariff_id}")], - [InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}")] + [InlineKeyboardButton(text='❌ Отключить', callback_data=f'admin_tariff_toggle_traffic_topup:{tariff_id}')], + [ + InlineKeyboardButton( + text='📦 Настроить пакеты', callback_data=f'admin_tariff_edit_topup_packages:{tariff_id}' + ) + ], + [InlineKeyboardButton(text='📊 Макс. лимит трафика', callback_data=f'admin_tariff_edit_max_topup:{tariff_id}')], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')], ] await message.answer( - f"✅ Лимит обновлен!\n\n" - f"📈 Докупка трафика для «{tariff.name}»\n\n" - f"Статус: ✅ Включено\n\n" - f"Пакеты:\n{packages_display}\n\n" - f"Макс. лимит: {max_limit_display}\n\n" - "Пользователи смогут докупать трафик по заданным ценам.", + f'✅ Лимит обновлен!\n\n' + f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'Статус: ✅ Включено\n\n' + f'Пакеты:\n{packages_display}\n\n' + f'Макс. лимит: {max_limit_display}\n\n' + 'Пользователи смогут докупать трафик по заданным ценам.', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) # ============ УДАЛЕНИЕ ТАРИФА ============ + @admin_required @error_handler async def confirm_delete_tariff( @@ -2133,31 +2169,33 @@ async def confirm_delete_tariff( db: AsyncSession, ): """Запрашивает подтверждение удаления тарифа.""" - texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + get_texts(db_user.language) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return subs_count = await get_tariff_subscriptions_count(db, tariff_id) - warning = "" + warning = '' if subs_count > 0: - warning = f"\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа." + warning = f'\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.' await callback.message.edit_text( - f"🗑️ Удаление тарифа\n\n" - f"Вы действительно хотите удалить тариф {tariff.name}?" - f"{warning}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"admin_tariff_delete_confirm:{tariff_id}"), - InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_tariff_view:{tariff_id}"), + f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {tariff.name}?{warning}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Да, удалить', callback_data=f'admin_tariff_delete_confirm:{tariff_id}' + ), + InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_tariff_view:{tariff_id}'), + ] ] - ]), - parse_mode="HTML" + ), + parse_mode='HTML', ) await callback.answer() @@ -2171,30 +2209,31 @@ async def delete_tariff_confirmed( ): """Удаляет тариф после подтверждения.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return tariff_name = tariff.name await delete_tariff(db, tariff) - await callback.answer(f"Тариф «{tariff_name}» удален", show_alert=True) + await callback.answer(f'Тариф «{tariff_name}» удален', show_alert=True) # Возвращаемся к списку tariffs_data = await get_tariffs_with_subscriptions_count(db, include_inactive=True) if not tariffs_data: await callback.message.edit_text( - "📦 Тарифы\n\n" - "Тарифы ещё не созданы.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="➕ Создать тариф", callback_data="admin_tariff_create")], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings")] - ]), - parse_mode="HTML" + '📦 Тарифы\n\nТарифы ещё не созданы.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='➕ Создать тариф', callback_data='admin_tariff_create')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')], + ] + ), + parse_mode='HTML', ) return @@ -2202,16 +2241,15 @@ async def delete_tariff_confirmed( page_data = tariffs_data[:ITEMS_PER_PAGE] await callback.message.edit_text( - f"📦 Тарифы\n\n" - f"✅ Тариф «{tariff_name}» удален\n\n" - f"Всего: {len(tariffs_data)}", + f'📦 Тарифы\n\n✅ Тариф «{tariff_name}» удален\n\nВсего: {len(tariffs_data)}', reply_markup=get_tariffs_list_keyboard(page_data, db_user.language, 0, total_pages), - parse_mode="HTML" + parse_mode='HTML', ) # ============ РЕДАКТИРОВАНИЕ СЕРВЕРОВ ============ + @admin_required @error_handler async def start_edit_tariff_squads( @@ -2222,17 +2260,17 @@ async def start_edit_tariff_squads( ): """Показывает меню выбора серверов для тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return squads, _ = await get_all_server_squads(db) if not squads: - await callback.answer("Нет доступных серверов", show_alert=True) + await callback.answer('Нет доступных серверов', show_alert=True) return current_squads = set(tariff.allowed_squads or []) @@ -2240,31 +2278,33 @@ async def start_edit_tariff_squads( buttons = [] for squad in squads: is_selected = squad.squad_uuid in current_squads - prefix = "✅" if is_selected else "⬜" - buttons.append([ - InlineKeyboardButton( - text=f"{prefix} {squad.display_name}", - callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" - ) - ]) + prefix = '✅' if is_selected else '⬜' + buttons.append( + [ + InlineKeyboardButton( + text=f'{prefix} {squad.display_name}', + callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), - InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_squads:{tariff_id}'), + InlineKeyboardButton(text='✅ Выбрать все', callback_data=f'admin_tariff_select_all_squads:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) selected_count = len(current_squads) await callback.message.edit_text( - f"🌐 Серверы для тарифа «{tariff.name}»\n\n" - f"Выбрано: {selected_count} из {len(squads)}\n\n" - "Если не выбран ни один сервер - доступны все.\n" - "Нажмите на сервер для выбора/отмены:", + f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'Выбрано: {selected_count} из {len(squads)}\n\n' + 'Если не выбран ни один сервер - доступны все.\n' + 'Нажмите на сервер для выбора/отмены:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -2277,13 +2317,13 @@ async def toggle_tariff_squad( db: AsyncSession, ): """Переключает выбор сервера для тарифа.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) squad_uuid = parts[2] tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return current_squads = set(tariff.allowed_squads or []) @@ -2302,30 +2342,32 @@ async def toggle_tariff_squad( buttons = [] for squad in squads: is_selected = squad.squad_uuid in current_squads - prefix = "✅" if is_selected else "⬜" - buttons.append([ - InlineKeyboardButton( - text=f"{prefix} {squad.display_name}", - callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" - ) - ]) + prefix = '✅' if is_selected else '⬜' + buttons.append( + [ + InlineKeyboardButton( + text=f'{prefix} {squad.display_name}', + callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), - InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_squads:{tariff_id}'), + InlineKeyboardButton(text='✅ Выбрать все', callback_data=f'admin_tariff_select_all_squads:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"🌐 Серверы для тарифа «{tariff.name}»\n\n" - f"Выбрано: {len(current_squads)} из {len(squads)}\n\n" - "Если не выбран ни один сервер - доступны все.\n" - "Нажмите на сервер для выбора/отмены:", + f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'Выбрано: {len(current_squads)} из {len(squads)}\n\n' + 'Если не выбран ни один сервер - доступны все.\n' + 'Нажмите на сервер для выбора/отмены:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -2341,15 +2383,15 @@ async def clear_tariff_squads( db: AsyncSession, ): """Очищает список серверов тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return tariff = await update_tariff(db, tariff, allowed_squads=[]) - await callback.answer("Все серверы очищены") + await callback.answer('Все серверы очищены') # Перерисовываем меню squads, _ = await get_all_server_squads(db) @@ -2357,29 +2399,31 @@ async def clear_tariff_squads( buttons = [] for squad in squads: - buttons.append([ - InlineKeyboardButton( - text=f"⬜ {squad.display_name}", - callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=f'⬜ {squad.display_name}', + callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), - InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_squads:{tariff_id}'), + InlineKeyboardButton(text='✅ Выбрать все', callback_data=f'admin_tariff_select_all_squads:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"🌐 Серверы для тарифа «{tariff.name}»\n\n" - f"Выбрано: 0 из {len(squads)}\n\n" - "Если не выбран ни один сервер - доступны все.\n" - "Нажмите на сервер для выбора/отмены:", + f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'Выбрано: 0 из {len(squads)}\n\n' + 'Если не выбран ни один сервер - доступны все.\n' + 'Нажмите на сервер для выбора/отмены:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -2393,46 +2437,48 @@ async def select_all_tariff_squads( db: AsyncSession, ): """Выбирает все серверы для тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return squads, _ = await get_all_server_squads(db) all_uuids = [s.squad_uuid for s in squads] tariff = await update_tariff(db, tariff, allowed_squads=all_uuids) - await callback.answer("Все серверы выбраны") + await callback.answer('Все серверы выбраны') texts = get_texts(db_user.language) buttons = [] for squad in squads: - buttons.append([ - InlineKeyboardButton( - text=f"✅ {squad.display_name}", - callback_data=f"admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}" - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=f'✅ {squad.display_name}', + callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_squads:{tariff_id}"), - InlineKeyboardButton(text="✅ Выбрать все", callback_data=f"admin_tariff_select_all_squads:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_squads:{tariff_id}'), + InlineKeyboardButton(text='✅ Выбрать все', callback_data=f'admin_tariff_select_all_squads:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"🌐 Серверы для тарифа «{tariff.name}»\n\n" - f"Выбрано: {len(squads)} из {len(squads)}\n\n" - "Если не выбран ни один сервер - доступны все.\n" - "Нажмите на сервер для выбора/отмены:", + f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'Выбрано: {len(squads)} из {len(squads)}\n\n' + 'Если не выбран ни один сервер - доступны все.\n' + 'Нажмите на сервер для выбора/отмены:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -2440,6 +2486,7 @@ async def select_all_tariff_squads( # ============ РЕДАКТИРОВАНИЕ ПРОМОГРУПП ============ + @admin_required @error_handler async def start_edit_tariff_promo_groups( @@ -2449,17 +2496,17 @@ async def start_edit_tariff_promo_groups( ): """Показывает меню выбора промогрупп для тарифа.""" texts = get_texts(db_user.language) - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return promo_groups_data = await get_promo_groups_with_counts(db) if not promo_groups_data: - await callback.answer("Нет промогрупп", show_alert=True) + await callback.answer('Нет промогрупп', show_alert=True) return current_groups = {pg.id for pg in (tariff.allowed_promo_groups or [])} @@ -2467,30 +2514,32 @@ async def start_edit_tariff_promo_groups( buttons = [] for promo_group, _ in promo_groups_data: is_selected = promo_group.id in current_groups - prefix = "✅" if is_selected else "⬜" - buttons.append([ - InlineKeyboardButton( - text=f"{prefix} {promo_group.name}", - callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" - ) - ]) + prefix = '✅' if is_selected else '⬜' + buttons.append( + [ + InlineKeyboardButton( + text=f'{prefix} {promo_group.name}', + callback_data=f'admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_promo:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) selected_count = len(current_groups) await callback.message.edit_text( - f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" - f"Выбрано: {selected_count}\n\n" - "Если не выбрана ни одна группа - тариф доступен всем.\n" - "Выберите группы, которым доступен этот тариф:", + f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'Выбрано: {selected_count}\n\n' + 'Если не выбрана ни одна группа - тариф доступен всем.\n' + 'Выберите группы, которым доступен этот тариф:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -2505,13 +2554,13 @@ async def toggle_tariff_promo_group( """Переключает выбор промогруппы для тарифа.""" from app.database.crud.tariff import add_promo_group_to_tariff, remove_promo_group_from_tariff - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) promo_group_id = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return current_groups = {pg.id for pg in (tariff.allowed_promo_groups or [])} @@ -2534,29 +2583,31 @@ async def toggle_tariff_promo_group( buttons = [] for promo_group, _ in promo_groups_data: is_selected = promo_group.id in current_groups - prefix = "✅" if is_selected else "⬜" - buttons.append([ - InlineKeyboardButton( - text=f"{prefix} {promo_group.name}", - callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" - ) - ]) + prefix = '✅' if is_selected else '⬜' + buttons.append( + [ + InlineKeyboardButton( + text=f'{prefix} {promo_group.name}', + callback_data=f'admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_promo:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" - f"Выбрано: {len(current_groups)}\n\n" - "Если не выбрана ни одна группа - тариф доступен всем.\n" - "Выберите группы, которым доступен этот тариф:", + f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'Выбрано: {len(current_groups)}\n\n' + 'Если не выбрана ни одна группа - тариф доступен всем.\n' + 'Выберите группы, которым доступен этот тариф:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -2574,15 +2625,15 @@ async def clear_tariff_promo_groups( """Очищает список промогрупп тарифа.""" from app.database.crud.tariff import set_tariff_promo_groups - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return await set_tariff_promo_groups(db, tariff, []) - await callback.answer("Все промогруппы очищены") + await callback.answer('Все промогруппы очищены') # Перерисовываем меню promo_groups_data = await get_promo_groups_with_counts(db) @@ -2590,28 +2641,30 @@ async def clear_tariff_promo_groups( buttons = [] for promo_group, _ in promo_groups_data: - buttons.append([ - InlineKeyboardButton( - text=f"⬜ {promo_group.name}", - callback_data=f"admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}" - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=f'⬜ {promo_group.name}', + callback_data=f'admin_tariff_toggle_promo:{tariff_id}:{promo_group.id}', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text="🔄 Очистить все", callback_data=f"admin_tariff_clear_promo:{tariff_id}"), - ]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append( + [ + InlineKeyboardButton(text='🔄 Очистить все', callback_data=f'admin_tariff_clear_promo:{tariff_id}'), + ] + ) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) try: await callback.message.edit_text( - f"👥 Промогруппы для тарифа «{tariff.name}»\n\n" - f"Выбрано: 0\n\n" - "Если не выбрана ни одна группа - тариф доступен всем.\n" - "Выберите группы, которым доступен этот тариф:", + f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'Выбрано: 0\n\n' + 'Если не выбрана ни одна группа - тариф доступен всем.\n' + 'Выберите группы, которым доступен этот тариф:', reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons), - parse_mode="HTML" + parse_mode='HTML', ) except TelegramBadRequest: pass @@ -2627,35 +2680,29 @@ TRAFFIC_RESET_MODES = [ ] -def get_traffic_reset_mode_keyboard(tariff_id: int, current_mode: Optional[str], language: str) -> InlineKeyboardMarkup: +def get_traffic_reset_mode_keyboard(tariff_id: int, current_mode: str | None, language: str) -> InlineKeyboardMarkup: """Создает клавиатуру для выбора режима сброса трафика.""" texts = get_texts(language) buttons = [] # Кнопка "Глобальная настройка" - global_label = f"{'✅ ' if current_mode is None else ''}🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})" - buttons.append([ - InlineKeyboardButton( - text=global_label, - callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:GLOBAL" - ) - ]) + global_label = ( + f'{"✅ " if current_mode is None else ""}🌐 Глобальная настройка ({settings.DEFAULT_TRAFFIC_RESET_STRATEGY})' + ) + buttons.append( + [InlineKeyboardButton(text=global_label, callback_data=f'admin_tariff_set_reset_mode:{tariff_id}:GLOBAL')] + ) # Кнопки для каждого режима for mode_value, mode_label, mode_desc in TRAFFIC_RESET_MODES: is_selected = current_mode == mode_value - label = f"{'✅ ' if is_selected else ''}{mode_label}" - buttons.append([ - InlineKeyboardButton( - text=label, - callback_data=f"admin_tariff_set_reset_mode:{tariff_id}:{mode_value}" - ) - ]) + label = f'{"✅ " if is_selected else ""}{mode_label}' + buttons.append( + [InlineKeyboardButton(text=label, callback_data=f'admin_tariff_set_reset_mode:{tariff_id}:{mode_value}')] + ) # Кнопка назад - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=f"admin_tariff_view:{tariff_id}") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -2668,26 +2715,26 @@ async def start_edit_traffic_reset_mode( db: AsyncSession, ): """Начинает редактирование режима сброса трафика.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return current_mode = getattr(tariff, 'traffic_reset_mode', None) await callback.message.edit_text( - f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" - f"Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n" - "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" - "• Глобальная настройка — использовать значение из конфига бота\n" - "• Ежедневно — сброс каждый день\n" - "• Еженедельно — сброс каждую неделю\n" - "• Ежемесячно — сброс каждый месяц\n" - "• Никогда — трафик накапливается за весь период подписки", + f'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n' + f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n' + 'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n' + '• Глобальная настройка — использовать значение из конфига бота\n' + '• Ежедневно — сброс каждый день\n' + '• Еженедельно — сброс каждую неделю\n' + '• Ежемесячно — сброс каждый месяц\n' + '• Никогда — трафик накапливается за весь период подписки', reply_markup=get_traffic_reset_mode_keyboard(tariff_id, current_mode, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -2700,128 +2747,132 @@ async def set_traffic_reset_mode( db: AsyncSession, ): """Устанавливает режим сброса трафика для тарифа.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) new_mode = parts[2] tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return # Преобразуем GLOBAL в None - if new_mode == "GLOBAL": + if new_mode == 'GLOBAL': new_mode = None # Обновляем тариф tariff = await update_tariff(db, tariff, traffic_reset_mode=new_mode) mode_display = _format_traffic_reset_mode(new_mode) - await callback.answer(f"Режим сброса изменён: {mode_display}", show_alert=True) + await callback.answer(f'Режим сброса изменён: {mode_display}', show_alert=True) # Обновляем клавиатуру await callback.message.edit_text( - f"🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n" - f"Текущий режим: {mode_display}\n\n" - "Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n" - "• Глобальная настройка — использовать значение из конфига бота\n" - "• Ежедневно — сброс каждый день\n" - "• Еженедельно — сброс каждую неделю\n" - "• Ежемесячно — сброс каждый месяц\n" - "• Никогда — трафик накапливается за весь период подписки", + f'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n' + f'Текущий режим: {mode_display}\n\n' + 'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n' + '• Глобальная настройка — использовать значение из конфига бота\n' + '• Ежедневно — сброс каждый день\n' + '• Еженедельно — сброс каждую неделю\n' + '• Ежемесячно — сброс каждый месяц\n' + '• Никогда — трафик накапливается за весь период подписки', reply_markup=get_traffic_reset_mode_keyboard(tariff_id, new_mode, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) def register_handlers(dp: Dispatcher): """Регистрирует обработчики для управления тарифами.""" # Список тарифов - dp.callback_query.register(show_tariffs_list, F.data == "admin_tariffs") - dp.callback_query.register(show_tariffs_page, F.data.startswith("admin_tariffs_page:")) + dp.callback_query.register(show_tariffs_list, F.data == 'admin_tariffs') + dp.callback_query.register(show_tariffs_page, F.data.startswith('admin_tariffs_page:')) # Просмотр и переключение - dp.callback_query.register(view_tariff, F.data.startswith("admin_tariff_view:")) - dp.callback_query.register(toggle_tariff, F.data.startswith("admin_tariff_toggle:") & ~F.data.startswith("admin_tariff_toggle_trial:")) - dp.callback_query.register(toggle_trial_tariff, F.data.startswith("admin_tariff_toggle_trial:")) + dp.callback_query.register(view_tariff, F.data.startswith('admin_tariff_view:')) + dp.callback_query.register( + toggle_tariff, F.data.startswith('admin_tariff_toggle:') & ~F.data.startswith('admin_tariff_toggle_trial:') + ) + dp.callback_query.register(toggle_trial_tariff, F.data.startswith('admin_tariff_toggle_trial:')) # Создание тарифа - dp.callback_query.register(start_create_tariff, F.data == "admin_tariff_create") + dp.callback_query.register(start_create_tariff, F.data == 'admin_tariff_create') dp.message.register(process_tariff_name, AdminStates.creating_tariff_name) dp.message.register(process_tariff_traffic, AdminStates.creating_tariff_traffic) dp.message.register(process_tariff_devices, AdminStates.creating_tariff_devices) dp.message.register(process_tariff_tier, AdminStates.creating_tariff_tier) - dp.callback_query.register(select_tariff_type_periodic, F.data == "tariff_type_periodic") - dp.callback_query.register(select_tariff_type_daily, F.data == "tariff_type_daily") + dp.callback_query.register(select_tariff_type_periodic, F.data == 'tariff_type_periodic') + dp.callback_query.register(select_tariff_type_daily, F.data == 'tariff_type_daily') dp.message.register(process_tariff_prices, AdminStates.creating_tariff_prices) # Редактирование названия - dp.callback_query.register(start_edit_tariff_name, F.data.startswith("admin_tariff_edit_name:")) + dp.callback_query.register(start_edit_tariff_name, F.data.startswith('admin_tariff_edit_name:')) dp.message.register(process_edit_tariff_name, AdminStates.editing_tariff_name) # Редактирование описания - dp.callback_query.register(start_edit_tariff_description, F.data.startswith("admin_tariff_edit_desc:")) + dp.callback_query.register(start_edit_tariff_description, F.data.startswith('admin_tariff_edit_desc:')) dp.message.register(process_edit_tariff_description, AdminStates.editing_tariff_description) # Редактирование трафика - dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith("admin_tariff_edit_traffic:")) + dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith('admin_tariff_edit_traffic:')) dp.message.register(process_edit_tariff_traffic, AdminStates.editing_tariff_traffic) # Редактирование устройств - dp.callback_query.register(start_edit_tariff_devices, F.data.startswith("admin_tariff_edit_devices:")) + dp.callback_query.register(start_edit_tariff_devices, F.data.startswith('admin_tariff_edit_devices:')) dp.message.register(process_edit_tariff_devices, AdminStates.editing_tariff_devices) # Редактирование уровня - dp.callback_query.register(start_edit_tariff_tier, F.data.startswith("admin_tariff_edit_tier:")) + dp.callback_query.register(start_edit_tariff_tier, F.data.startswith('admin_tariff_edit_tier:')) dp.message.register(process_edit_tariff_tier, AdminStates.editing_tariff_tier) # Редактирование цен - dp.callback_query.register(start_edit_tariff_prices, F.data.startswith("admin_tariff_edit_prices:")) + dp.callback_query.register(start_edit_tariff_prices, F.data.startswith('admin_tariff_edit_prices:')) dp.message.register(process_edit_tariff_prices, AdminStates.editing_tariff_prices) # Редактирование цены за устройство - dp.callback_query.register(start_edit_tariff_device_price, F.data.startswith("admin_tariff_edit_device_price:")) + dp.callback_query.register(start_edit_tariff_device_price, F.data.startswith('admin_tariff_edit_device_price:')) dp.message.register(process_edit_tariff_device_price, AdminStates.editing_tariff_device_price) # Редактирование макс. устройств - dp.callback_query.register(start_edit_tariff_max_devices, F.data.startswith("admin_tariff_edit_max_devices:")) + dp.callback_query.register(start_edit_tariff_max_devices, F.data.startswith('admin_tariff_edit_max_devices:')) dp.message.register(process_edit_tariff_max_devices, AdminStates.editing_tariff_max_devices) # Редактирование дней триала - dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith("admin_tariff_edit_trial_days:")) + dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith('admin_tariff_edit_trial_days:')) dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days) # Редактирование докупки трафика - dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith("admin_tariff_edit_traffic_topup:")) - dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith("admin_tariff_toggle_traffic_topup:")) - dp.callback_query.register(start_edit_traffic_topup_packages, F.data.startswith("admin_tariff_edit_topup_packages:")) + dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:')) + dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith('admin_tariff_toggle_traffic_topup:')) + dp.callback_query.register( + start_edit_traffic_topup_packages, F.data.startswith('admin_tariff_edit_topup_packages:') + ) dp.message.register(process_edit_traffic_topup_packages, AdminStates.editing_tariff_traffic_topup_packages) # Редактирование макс. лимита докупки трафика - dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith("admin_tariff_edit_max_topup:")) + dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith('admin_tariff_edit_max_topup:')) dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic) # Удаление - dp.callback_query.register(confirm_delete_tariff, F.data.startswith("admin_tariff_delete:")) - dp.callback_query.register(delete_tariff_confirmed, F.data.startswith("admin_tariff_delete_confirm:")) + dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:')) + dp.callback_query.register(delete_tariff_confirmed, F.data.startswith('admin_tariff_delete_confirm:')) # Редактирование серверов - dp.callback_query.register(start_edit_tariff_squads, F.data.startswith("admin_tariff_edit_squads:")) - dp.callback_query.register(toggle_tariff_squad, F.data.startswith("admin_tariff_toggle_squad:")) - dp.callback_query.register(clear_tariff_squads, F.data.startswith("admin_tariff_clear_squads:")) - dp.callback_query.register(select_all_tariff_squads, F.data.startswith("admin_tariff_select_all_squads:")) + dp.callback_query.register(start_edit_tariff_squads, F.data.startswith('admin_tariff_edit_squads:')) + dp.callback_query.register(toggle_tariff_squad, F.data.startswith('admin_tariff_toggle_squad:')) + dp.callback_query.register(clear_tariff_squads, F.data.startswith('admin_tariff_clear_squads:')) + dp.callback_query.register(select_all_tariff_squads, F.data.startswith('admin_tariff_select_all_squads:')) # Редактирование промогрупп - dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith("admin_tariff_edit_promo:")) - dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith("admin_tariff_toggle_promo:")) - dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith("admin_tariff_clear_promo:")) + dp.callback_query.register(start_edit_tariff_promo_groups, F.data.startswith('admin_tariff_edit_promo:')) + dp.callback_query.register(toggle_tariff_promo_group, F.data.startswith('admin_tariff_toggle_promo:')) + dp.callback_query.register(clear_tariff_promo_groups, F.data.startswith('admin_tariff_clear_promo:')) # Суточный режим - dp.callback_query.register(toggle_daily_tariff, F.data.startswith("admin_tariff_toggle_daily:")) - dp.callback_query.register(start_edit_daily_price, F.data.startswith("admin_tariff_edit_daily_price:")) + dp.callback_query.register(toggle_daily_tariff, F.data.startswith('admin_tariff_toggle_daily:')) + dp.callback_query.register(start_edit_daily_price, F.data.startswith('admin_tariff_edit_daily_price:')) dp.message.register(process_daily_price_input, AdminStates.editing_tariff_daily_price) # Режим сброса трафика - dp.callback_query.register(start_edit_traffic_reset_mode, F.data.startswith("admin_tariff_edit_reset_mode:")) - dp.callback_query.register(set_traffic_reset_mode, F.data.startswith("admin_tariff_set_reset_mode:")) + dp.callback_query.register(start_edit_traffic_reset_mode, F.data.startswith('admin_tariff_edit_reset_mode:')) + dp.callback_query.register(set_traffic_reset_mode, F.data.startswith('admin_tariff_set_reset_mode:')) diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index a0055ca9..d524b7d6 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -1,29 +1,27 @@ +import html import logging -from typing import List, Dict, Any, Optional -from aiogram import Dispatcher, types, F, Bot +import time +from datetime import datetime, timedelta + +from aiogram import Bot, Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, and_ -from datetime import datetime, timedelta -import time -import html -from app.database.models import User, Ticket, TicketStatus +from app.config import settings from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD -from app.states import TicketStates, AdminTicketStates +from app.database.models import Ticket, TicketStatus, User from app.keyboards.inline import ( - get_admin_tickets_keyboard, + get_admin_ticket_reply_cancel_keyboard, get_admin_ticket_view_keyboard, - get_admin_ticket_reply_cancel_keyboard + get_admin_tickets_keyboard, ) from app.localization.texts import get_texts -from app.utils.pagination import paginate_list, get_pagination_info -from app.services.admin_notification_service import AdminNotificationService from app.services.support_settings_service import SupportSettingsService -from app.config import settings +from app.states import AdminTicketStates from app.utils.cache import RateLimitCache + logger = logging.getLogger(__name__) # Максимальная длина сообщения Telegram (с запасом) @@ -86,14 +84,7 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int return pages if pages else [header] - - - -async def show_admin_tickets( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показать все тикеты для админов""" # permission gate: admin or active moderator only if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): @@ -101,69 +92,71 @@ async def show_admin_tickets( await callback.answer(texts.ACCESS_DENIED, show_alert=True) return texts = get_texts(db_user.language) - + # Определяем текущую страницу и scope current_page = 1 - scope = "open" + scope = 'open' data_str = callback.data - if data_str == "admin_tickets_scope_open": - scope = "open" - elif data_str == "admin_tickets_scope_closed": - scope = "closed" - elif data_str.startswith("admin_tickets_page_"): + if data_str == 'admin_tickets_scope_open': + scope = 'open' + elif data_str == 'admin_tickets_scope_closed': + scope = 'closed' + elif data_str.startswith('admin_tickets_page_'): try: - parts = data_str.split("_") + parts = data_str.split('_') # format: admin_tickets_page_{scope}_{page} if len(parts) >= 5: scope = parts[3] current_page = int(parts[4]) else: - current_page = int(data_str.replace("admin_tickets_page_", "")) + current_page = int(data_str.replace('admin_tickets_page_', '')) except ValueError: current_page = 1 - statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] if scope == "open" else [TicketStatus.CLOSED.value] + statuses = ( + [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] if scope == 'open' else [TicketStatus.CLOSED.value] + ) page_size = 10 # total count for proper pagination total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses) total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1 - if current_page < 1: - current_page = 1 - if current_page > total_pages: - current_page = total_pages + current_page = max(current_page, 1) + current_page = min(current_page, total_pages) offset = (current_page - 1) * page_size tickets = await TicketCRUD.get_tickets_by_statuses(db, statuses=statuses, limit=page_size, offset=offset) - + # Даже если тикетов нет, показываем переключатели разделов - + # Формируем данные для клавиатуры ticket_data = [] for ticket in tickets: - user_name = ticket.user.full_name if ticket.user else "Unknown" + 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 "") - }) - + 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 ''), + } + ) + # Итоговые страницы уже посчитаны выше header_text = ( - texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:") - if scope == "open" - else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:") + texts.t('ADMIN_TICKETS_TITLE_OPEN', '🎫 Открытые тикеты поддержки:') + if scope == 'open' + else texts.t('ADMIN_TICKETS_TITLE_CLOSED', '🎫 Закрытые тикеты поддержки:') ) # Determine proper back target for moderators - back_cb = "admin_submenu_support" + back_cb = 'admin_submenu_support' try: if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id): - back_cb = "moderator_panel" + back_cb = 'moderator_panel' except Exception: pass @@ -176,11 +169,12 @@ async def show_admin_tickets( back_callback=back_cb, ) from app.utils.photo_message import edit_or_answer_photo + await edit_or_answer_photo( callback=callback, caption=header_text, keyboard=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -189,8 +183,8 @@ async def view_admin_ticket( callback: types.CallbackQuery, db_user: User, db: AsyncSession, - state: Optional[FSMContext] = None, - ticket_id: Optional[int] = None + state: FSMContext | None = None, + ticket_id: int | None = None, ): """Показать детали тикета для админа с пагинацией""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): @@ -200,25 +194,22 @@ async def view_admin_ticket( # Парсим ticket_id и page из callback_data page = 1 - data_str = callback.data or "" + data_str = callback.data or '' - if data_str.startswith("admin_ticket_page_"): + if data_str.startswith('admin_ticket_page_'): # format: admin_ticket_page_{ticket_id}_{page} try: - parts = data_str.split("_") + parts = data_str.split('_') ticket_id = int(parts[3]) page = max(1, int(parts[4])) except (ValueError, IndexError): pass elif ticket_id is None: try: - ticket_id = int(data_str.split("_")[-1]) + ticket_id = int(data_str.split('_')[-1]) except (ValueError, AttributeError): texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return if state is None: @@ -228,80 +219,71 @@ async def view_admin_ticket( if not ticket: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return texts = get_texts(db_user.language) # Формируем заголовок тикета status_text = { - TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), - TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), - TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), - TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") + TicketStatus.OPEN.value: texts.t('TICKET_STATUS_OPEN', 'Открыт'), + TicketStatus.ANSWERED.value: texts.t('TICKET_STATUS_ANSWERED', 'Отвечен'), + TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'), + TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'), }.get(ticket.status, ticket.status) - user_name = ticket.user.full_name if ticket.user else "Unknown" - telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f"#{ticket.user.id}") if ticket.user else "—" + user_name = ticket.user.full_name if ticket.user else 'Unknown' + telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else '—' username_value = ticket.user.username if ticket.user else None - id_label = "Telegram ID" if (ticket.user and ticket.user.telegram_id) else "ID" + id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID' - header = f"🎫 Тикет #{ticket.id}\n\n" - header += f"👤 Пользователь: {user_name}\n" - header += f"🆔 {id_label}: {telegram_id_display}\n" + header = f'🎫 Тикет #{ticket.id}\n\n' + header += f'👤 Пользователь: {user_name}\n' + header += f'🆔 {id_label}: {telegram_id_display}\n' if username_value: safe_username = html.escape(username_value) - header += f"📱 Username: @{safe_username}\n" + header += f'📱 Username: @{safe_username}\n' else: - header += "📱 Username: отсутствует\n" - header += f"📝 Заголовок: {ticket.title}\n" - header += f"📊 Статус: {ticket.status_emoji} {status_text}\n" - header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" + header += '📱 Username: отсутствует\n' + header += f'📝 Заголовок: {ticket.title}\n' + header += f'📊 Статус: {ticket.status_emoji} {status_text}\n' + header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' if ticket.is_user_reply_blocked: if ticket.user_reply_block_permanent: - header += "🚫 Пользователь заблокирован навсегда\n\n" + header += '🚫 Пользователь заблокирован навсегда\n\n' elif ticket.user_reply_block_until: - header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n" + header += f'⏳ Блок до: {ticket.user_reply_block_until.strftime("%d.%m.%Y %H:%M")}\n\n' # Формируем блоки сообщений message_blocks: list[str] = [] if ticket.messages: - message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n") + message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n') for msg in ticket.messages: - sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка" - block = ( - f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" - f"{msg.message_text}\n\n" - ) - if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": - block += "📎 Вложение: фото\n\n" + sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка' + block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n' + if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': + block += '📎 Вложение: фото\n\n' message_blocks.append(block) # Разбиваем на страницы pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN) total_pages = len(pages) - if page > total_pages: - page = total_pages + page = min(page, total_pages) # Формируем клавиатуру - has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or []) + has_photos = any( + getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' for m in ticket.messages or [] + ) keyboard = get_admin_ticket_view_keyboard( - ticket_id, - ticket.is_closed, - db_user.language, - is_user_blocked=ticket.is_user_reply_blocked + ticket_id, ticket.is_closed, 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}" + text='👤 К пользователю', callback_data=f'admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}' ) keyboard.inline_keyboard.insert(0, [admin_profile_btn]) except Exception: @@ -312,10 +294,10 @@ async def view_admin_ticket( if ticket.user and ticket.user.telegram_id and ticket.user.username: safe_username = html.escape(ticket.user.username) buttons_row = [] - pm_url = f"tg://resolve?domain={safe_username}" - buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url)) - profile_url = f"tg://user?id={ticket.user.telegram_id}" - buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url)) + pm_url = f'tg://resolve?domain={safe_username}' + buttons_row.append(types.InlineKeyboardButton(text='✉ ЛС', url=pm_url)) + 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: @@ -324,12 +306,15 @@ async def view_admin_ticket( # Кнопка вложений if has_photos: try: - keyboard.inline_keyboard.insert(0, [ - types.InlineKeyboardButton( - text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), - callback_data=f"admin_ticket_attachments_{ticket_id}" - ) - ]) + keyboard.inline_keyboard.insert( + 0, + [ + types.InlineKeyboardButton( + text=texts.t('TICKET_ATTACHMENTS', '📎 Вложения'), + callback_data=f'admin_ticket_attachments_{ticket_id}', + ) + ], + ) except Exception: pass @@ -337,19 +322,14 @@ async def view_admin_ticket( if total_pages > 1: nav_row = [] if page > 1: - nav_row.append(types.InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_ticket_page_{ticket_id}_{page - 1}" - )) - nav_row.append(types.InlineKeyboardButton( - text=f"{page}/{total_pages}", - callback_data="noop" - )) + nav_row.append( + types.InlineKeyboardButton(text='⬅️', callback_data=f'admin_ticket_page_{ticket_id}_{page - 1}') + ) + nav_row.append(types.InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='noop')) if page < total_pages: - nav_row.append(types.InlineKeyboardButton( - text="➡️", - callback_data=f"admin_ticket_page_{ticket_id}_{page + 1}" - )) + nav_row.append( + types.InlineKeyboardButton(text='➡️', callback_data=f'admin_ticket_page_{ticket_id}_{page + 1}') + ) try: keyboard.inline_keyboard.insert(0, nav_row) except Exception: @@ -359,21 +339,13 @@ async def view_admin_ticket( # Отправка сообщения try: - await callback.message.edit_text( - page_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(page_text, reply_markup=keyboard, parse_mode='HTML') except TelegramBadRequest: try: await callback.message.delete() except Exception: pass - await callback.message.answer( - page_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(page_text, reply_markup=keyboard, parse_mode='HTML') # Сохраняем id для дальнейших действий if state is not None: @@ -384,35 +356,26 @@ async def view_admin_ticket( await callback.answer() -async def reply_to_admin_ticket( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): +async def reply_to_admin_ticket(callback: types.CallbackQuery, state: FSMContext, db_user: User): """Начать ответ на тикет от админа""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - ticket_id = int(callback.data.replace("admin_reply_ticket_", "")) - + ticket_id = int(callback.data.replace('admin_reply_ticket_', '')) + await state.update_data(ticket_id=ticket_id, reply_mode=True) texts = get_texts(db_user.language) await callback.message.edit_text( - texts.t("ADMIN_TICKET_REPLY_INPUT", "Введите ответ от поддержки:"), - reply_markup=get_admin_ticket_reply_cancel_keyboard(db_user.language) + texts.t('ADMIN_TICKET_REPLY_INPUT', 'Введите ответ от поддержки:'), + reply_markup=get_admin_ticket_reply_cancel_keyboard(db_user.language), ) await state.set_state(AdminTicketStates.waiting_for_reply) await callback.answer() -async def handle_admin_ticket_reply( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_admin_ticket_reply(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): texts = get_texts(db_user.language) await message.answer(texts.ACCESS_DENIED) @@ -426,15 +389,17 @@ async def handle_admin_ticket_reply( # Анти-спам: одно сообщение за короткое окно по конкретному тикету try: data_rl = await state.get_data() - rl_ticket_id = data_rl.get("ticket_id") or "admin_reply" - limited = await RateLimitCache.is_rate_limited(db_user.id, f"admin_ticket_reply_{rl_ticket_id}", limit=1, window=2) + rl_ticket_id = data_rl.get('ticket_id') or 'admin_reply' + limited = await RateLimitCache.is_rate_limited( + db_user.id, f'admin_ticket_reply_{rl_ticket_id}', limit=1, window=2 + ) if limited: return except Exception: pass try: data_rl = await state.get_data() - last_ts = data_rl.get("admin_rl_ts_reply") + last_ts = data_rl.get('admin_rl_ts_reply') now_ts = time.time() if last_ts and (now_ts - float(last_ts)) < 2: return @@ -444,26 +409,26 @@ async def handle_admin_ticket_reply( """Обработать ответ админа на тикет""" # Поддержка фото вложений в ответе админа - reply_text = (message.text or message.caption or "").strip() + reply_text = (message.text or message.caption or '').strip() if len(reply_text) > 400: reply_text = reply_text[:400] media_type = None media_file_id = None media_caption = None if message.photo: - media_type = "photo" + media_type = 'photo' media_file_id = message.photo[-1].file_id media_caption = message.caption if len(reply_text) < 1 and not media_file_id: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_REPLY_TOO_SHORT", "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:") + texts.t('TICKET_REPLY_TOO_SHORT', 'Ответ должен содержать минимум 5 символов. Попробуйте еще раз:') ) return data = await state.get_data() - ticket_id = data.get("ticket_id") + ticket_id = data.get('ticket_id') try: ticket_id = int(ticket_id) if ticket_id is not None else None except (TypeError, ValueError): @@ -471,27 +436,25 @@ async def handle_admin_ticket_reply( if not ticket_id: texts = get_texts(db_user.language) - await message.answer( - texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.") - ) + await message.answer(texts.t('TICKET_REPLY_ERROR', 'Ошибка: не найден ID тикета.')) await state.clear() return try: # Если это режим ввода длительности блокировки - if not data.get("reply_mode"): + if not data.get('reply_mode'): try: minutes = int(reply_text) - minutes = max(1, min(60*24*365, minutes)) + minutes = max(1, min(60 * 24 * 365, minutes)) except ValueError: - await message.answer("❌ Введите целое число минут") + await message.answer('❌ Введите целое число минут') return until = datetime.utcnow() + timedelta(minutes=minutes) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) if ok: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + await message.answer(f'✅ Пользователь заблокирован на {minutes} минут') else: - await message.answer("❌ Ошибка блокировки") + await message.answer('❌ Ошибка блокировки') await state.clear() return @@ -499,9 +462,7 @@ async def handle_admin_ticket_reply( ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True) if not ticket: texts = get_texts(db_user.language) - await message.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден.") - ) + await message.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.')) await state.clear() return @@ -520,17 +481,22 @@ async def handle_admin_ticket_reply( texts = get_texts(db_user.language) await message.answer( - texts.t("ADMIN_TICKET_REPLY_SENT", "✅ Ответ отправлен!"), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), - callback_data=f"admin_view_ticket_{ticket_id}" - )], - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"), - callback_data="admin_tickets" - )] - ]) + texts.t('ADMIN_TICKET_REPLY_SENT', '✅ Ответ отправлен!'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), + callback_data=f'admin_view_ticket_{ticket_id}', + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_TICKETS', '⬅️ К тикетам'), callback_data='admin_tickets' + ) + ], + ] + ), ) await state.clear() @@ -540,57 +506,39 @@ async def handle_admin_ticket_reply( # Админ-уведомления о ответе в тикет отключены по требованию except Exception as e: - logger.error(f"Error adding admin ticket reply: {e}") + logger.error(f'Error adding admin ticket reply: {e}') texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка при отправке ответа. Попробуйте позже.") + texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка при отправке ответа. Попробуйте позже.') ) -async def mark_ticket_as_answered( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def mark_ticket_as_answered(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): """Отметить тикет как отвеченный""" - ticket_id = int(callback.data.replace("admin_mark_answered_", "")) - + ticket_id = int(callback.data.replace('admin_mark_answered_', '')) + try: - success = await TicketCRUD.update_ticket_status( - db, ticket_id, TicketStatus.ANSWERED.value - ) - + success = await TicketCRUD.update_ticket_status(db, ticket_id, TicketStatus.ANSWERED.value) + if success: texts = get_texts(db_user.language) await callback.answer( - texts.t("TICKET_MARKED_ANSWERED", "✅ Тикет отмечен как отвеченный."), - show_alert=True + texts.t('TICKET_MARKED_ANSWERED', '✅ Тикет отмечен как отвеченный.'), show_alert=True ) - + # Обновляем сообщение await view_admin_ticket(callback, db_user, db, state) else: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), - show_alert=True - ) - + await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) + except Exception as e: - logger.error(f"Error marking ticket as answered: {e}") + logger.error(f'Error marking ticket as answered: {e}') texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) -async def close_all_open_admin_tickets( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def close_all_open_admin_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Закрыть все открытые тикеты.""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) @@ -602,53 +550,47 @@ async def close_all_open_admin_tickets( try: closed_ticket_ids = await TicketCRUD.close_all_open_tickets(db) except Exception as error: - logger.error("Error closing all open tickets: %s", error) - await callback.answer( - texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."), - show_alert=True - ) + logger.error('Error closing all open tickets: %s', error) + await callback.answer(texts.t('TICKET_UPDATE_ERROR', '❌ Ошибка при обновлении тикета.'), show_alert=True) return closed_count = len(closed_ticket_ids) if closed_count == 0: await callback.answer( - texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY", "ℹ️ Нет открытых тикетов для закрытия."), - show_alert=True + texts.t('ADMIN_CLOSE_ALL_OPEN_TICKETS_EMPTY', 'ℹ️ Нет открытых тикетов для закрытия.'), show_alert=True ) return try: - is_moderator = ( - not settings.is_admin(callback.from_user.id) - and SupportSettingsService.is_moderator(callback.from_user.id) + is_moderator = not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator( + callback.from_user.id ) await TicketCRUD.add_support_audit( db, actor_user_id=db_user.id if db_user else None, actor_telegram_id=callback.from_user.id, is_moderator=is_moderator, - action="close_all_tickets", + action='close_all_tickets', ticket_id=None, target_user_id=None, details={ - "count": closed_count, - "ticket_ids": closed_ticket_ids, - } + 'count': closed_count, + 'ticket_ids': closed_ticket_ids, + }, ) except Exception as audit_error: - logger.warning("Failed to add support audit for bulk close: %s", audit_error) + logger.warning('Failed to add support audit for bulk close: %s', audit_error) # Обновляем список тикетов await show_admin_tickets(callback, db_user, db) - success_text = texts.t( - "ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS", - "✅ Закрыто открытых тикетов: {count}" - ).format(count=closed_count) + success_text = texts.t('ADMIN_CLOSE_ALL_OPEN_TICKETS_SUCCESS', '✅ Закрыто открытых тикетов: {count}').format( + count=closed_count + ) notification_keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] + inline_keyboard=[[types.InlineKeyboardButton(text='🗑 Удалить', callback_data='admin_support_delete_msg')]] ) try: @@ -661,34 +603,34 @@ async def close_all_open_admin_tickets( pass -async def close_admin_ticket( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def close_admin_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Закрыть тикет админом""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - ticket_id = int(callback.data.replace("admin_close_ticket_", "")) - + ticket_id = int(callback.data.replace('admin_close_ticket_', '')) + try: success = await TicketCRUD.close_ticket(db, ticket_id) - + if success: # audit try: - is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + 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, - }) + details.update( + { + 'target_telegram_id': t.user.telegram_id, + 'target_username': t.user.username, + } + ) except Exception: pass await TicketCRUD.add_support_audit( @@ -696,10 +638,10 @@ async def close_admin_ticket( actor_user_id=db_user.id if db_user else None, actor_telegram_id=callback.from_user.id, is_moderator=is_mod, - action="close_ticket", + action='close_ticket', ticket_id=ticket_id, target_user_id=None, - details=details + details=details, ) except Exception: pass @@ -707,71 +649,61 @@ async def close_admin_ticket( # Notify with deletable inline message try: await callback.message.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] - ) + inline_keyboard=[ + [types.InlineKeyboardButton(text='🗑 Удалить', callback_data='admin_support_delete_msg')] + ] + ), ) except Exception: - await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True) - + await callback.answer(texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'), show_alert=True) + # Обновляем inline-клавиатуру в текущем сообщении без кнопок действий await callback.message.edit_reply_markup( reply_markup=get_admin_ticket_view_keyboard(ticket_id, True, db_user.language) ) else: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."), - show_alert=True - ) - + await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) + except Exception as e: - logger.error(f"Error closing admin ticket: {e}") + logger.error(f'Error closing admin ticket: {e}') texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) -async def cancel_admin_ticket_reply( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): +async def cancel_admin_ticket_reply(callback: types.CallbackQuery, state: FSMContext, db_user: User): """Отменить ответ админа на тикет""" if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return await state.clear() - + texts = get_texts(db_user.language) - + await callback.message.edit_text( - texts.t("TICKET_REPLY_CANCELLED", "Ответ отменен."), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"), - callback_data="admin_tickets" - )] - ]) + texts.t('TICKET_REPLY_CANCELLED', 'Ответ отменен.'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_TICKETS', '⬅️ К тикетам'), callback_data='admin_tickets' + ) + ] + ] + ), ) await callback.answer() -async def block_user_in_ticket( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def block_user_in_ticket(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - ticket_id = int(callback.data.replace("admin_block_user_ticket_", "")) + ticket_id = int(callback.data.replace('admin_block_user_ticket_', '')) texts = get_texts(db_user.language) # Save original ticket message ids to update it after blocking without reopening try: @@ -779,25 +711,23 @@ async def block_user_in_ticket( except Exception: pass await callback.message.edit_text( - texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"), - callback_data="cancel_admin_ticket_reply" - )] - ]) + texts.t('ENTER_BLOCK_MINUTES', 'Введите количество минут для блокировки пользователя (например, 15):'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CANCEL_REPLY', '❌ Отменить ввод'), callback_data='cancel_admin_ticket_reply' + ) + ] + ] + ), ) await state.update_data(ticket_id=ticket_id) await state.set_state(AdminTicketStates.waiting_for_block_duration) await callback.answer() -async def handle_admin_block_duration_input( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_admin_block_duration_input(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): # permission gate for message flow if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)): texts = get_texts(db_user.language) @@ -808,54 +738,56 @@ async def handle_admin_block_duration_input( current_state = await state.get_state() if current_state != AdminTicketStates.waiting_for_block_duration: return - + reply_text = message.text.strip() if len(reply_text) < 1: - await message.answer("❌ Введите целое число минут") + await message.answer('❌ Введите целое число минут') return - + data = await state.get_data() - ticket_id = data.get("ticket_id") - origin_chat_id = data.get("origin_chat_id") - origin_message_id = data.get("origin_message_id") + ticket_id = data.get('ticket_id') + origin_chat_id = data.get('origin_chat_id') + origin_message_id = data.get('origin_message_id') try: minutes = int(reply_text) - minutes = max(1, min(60*24*365, minutes)) # максимум 1 год + minutes = max(1, min(60 * 24 * 365, minutes)) # максимум 1 год except ValueError: - await message.answer("❌ Введите целое число минут") + await message.answer('❌ Введите целое число минут') return - + if not ticket_id: texts = get_texts(db_user.language) - await message.answer(texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.")) + await message.answer(texts.t('TICKET_REPLY_ERROR', 'Ошибка: не найден ID тикета.')) await state.clear() return - + try: ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if not ticket: texts = get_texts(db_user.language) - await message.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден.")) + await message.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.')) await state.clear() return - + until = datetime.utcnow() + timedelta(minutes=minutes) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until) if not ok: - await message.answer("❌ Ошибка блокировки") + await message.answer('❌ Ошибка блокировки') return # audit try: - is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id)) + is_mod = not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator( + message.from_user.id + ) await TicketCRUD.add_support_audit( db, actor_user_id=db_user.id if db_user else None, actor_telegram_id=message.from_user.id, is_moderator=is_mod, - action="block_user_timed", + action='block_user_timed', ticket_id=ticket_id, target_user_id=ticket.user_id if ticket else None, - details={"minutes": minutes} + details={'minutes': minutes}, ) except Exception: pass @@ -864,58 +796,60 @@ async def handle_admin_block_duration_input( updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True) texts = get_texts(db_user.language) status_text = { - TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), - TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), - TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), - TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") + TicketStatus.OPEN.value: texts.t('TICKET_STATUS_OPEN', 'Открыт'), + TicketStatus.ANSWERED.value: texts.t('TICKET_STATUS_ANSWERED', 'Отвечен'), + TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'), + TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'), }.get(updated.status, updated.status) - user_name = updated.user.full_name if updated.user else "Unknown" - ticket_text = f"🎫 Тикет #{updated.id}\n\n" - ticket_text += f"👤 Пользователь: {user_name}\n" - ticket_text += f"📝 Заголовок: {updated.title}\n" - ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n" - ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n" - ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n" + user_name = updated.user.full_name if updated.user else 'Unknown' + ticket_text = f'🎫 Тикет #{updated.id}\n\n' + ticket_text += f'👤 Пользователь: {user_name}\n' + ticket_text += f'📝 Заголовок: {updated.title}\n' + ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n' + ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n' + ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n' if updated.user and updated.user.telegram_id: - ticket_text += f"🆔 Telegram ID: {updated.user.telegram_id}\n" + ticket_text += f'🆔 Telegram ID: {updated.user.telegram_id}\n' if updated.user.username: safe_username = html.escape(updated.user.username) - ticket_text += f"📱 Username: @{safe_username}\n" + ticket_text += f'📱 Username: @{safe_username}\n' ticket_text += ( - f"🔗 ЛС: " - f"tg://resolve?domain={safe_username}\n" + f'🔗 ЛС: ' + f'tg://resolve?domain={safe_username}\n' ) else: - ticket_text += "📱 Username: отсутствует\n" - chat_link = f"tg://user?id={int(updated.user.telegram_id)}" - ticket_text += f"🔗 Чат по ID: {chat_link}\n" + ticket_text += '📱 Username: отсутствует\n' + chat_link = f'tg://user?id={int(updated.user.telegram_id)}' + ticket_text += f'🔗 Чат по ID: {chat_link}\n' elif updated.user: # Email-only user - user_id_display = updated.user.email or f"#{updated.user.id}" - ticket_text += f"🆔 ID: {user_id_display}\n" - ticket_text += "📧 Тип: Email-пользователь\n" - ticket_text += "\n" + user_id_display = updated.user.email or f'#{updated.user.id}' + ticket_text += f'🆔 ID: {user_id_display}\n' + ticket_text += '📧 Тип: Email-пользователь\n' + ticket_text += '\n' if updated.is_user_reply_blocked: if updated.user_reply_block_permanent: - ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n" + ticket_text += '🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n' elif updated.user_reply_block_until: - ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n" + ticket_text += f'⏳ Блок до: {updated.user_reply_block_until.strftime("%d.%m.%Y %H:%M")}\n' if updated.messages: - ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n" + ticket_text += f'💬 Сообщения ({len(updated.messages)}):\n\n' for msg in updated.messages: - sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка" - ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n" - ticket_text += f"{msg.message_text}\n\n" - if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": - ticket_text += "📎 Вложение: фото\n\n" + sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка' + ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n' + ticket_text += f'{msg.message_text}\n\n' + if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': + 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) + 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}" + text='👤 К пользователю', + callback_data=f'admin_user_manage_{updated.user.id}_from_ticket_{updated.id}', ) kb.inline_keyboard.insert(0, [admin_profile_btn]) except Exception: @@ -925,76 +859,98 @@ async def handle_admin_block_duration_input( if updated.user and updated.user.telegram_id and updated.user.username: safe_username = html.escape(updated.user.username) buttons_row = [] - pm_url = f"tg://resolve?domain={safe_username}" - 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)) + pm_url = f'tg://resolve?domain={safe_username}' + 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 []) + 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: - kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")]) + kb.inline_keyboard.insert( + 0, + [ + types.InlineKeyboardButton( + text=texts.t('TICKET_ATTACHMENTS', '📎 Вложения'), + callback_data=f'admin_ticket_attachments_{updated.id}', + ) + ], + ) except Exception: pass if origin_chat_id and origin_message_id: try: - await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML") + await message.bot.edit_message_caption( + chat_id=origin_chat_id, + message_id=origin_message_id, + caption=ticket_text, + reply_markup=kb, + parse_mode='HTML', + ) except Exception: try: - await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML") + await message.bot.edit_message_text( + chat_id=origin_chat_id, + message_id=origin_message_id, + text=ticket_text, + reply_markup=kb, + parse_mode='HTML', + ) except Exception: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + await message.answer(f'✅ Пользователь заблокирован на {minutes} минут') else: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + await message.answer(f'✅ Пользователь заблокирован на {minutes} минут') except Exception: - await message.answer(f"✅ Пользователь заблокирован на {minutes} минут") + await message.answer(f'✅ Пользователь заблокирован на {minutes} минут') finally: await state.clear() except Exception as e: - logger.error(f"Error setting block duration: {e}") + logger.error(f'Error setting block duration: {e}') texts = get_texts(db_user.language) - await message.answer(texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка. Попробуйте позже.")) + await message.answer(texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка. Попробуйте позже.')) - - -async def unblock_user_in_ticket( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def unblock_user_in_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", "")) + ticket_id = int(callback.data.replace('admin_unblock_user_ticket_', '')) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None) if ok: try: await callback.message.answer( - "✅ Блок снят", + '✅ Блок снят', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] - ) + inline_keyboard=[ + [types.InlineKeyboardButton(text='🗑 Удалить', callback_data='admin_support_delete_msg')] + ] + ), ) except Exception: - await callback.answer("✅ Блок снят") + await callback.answer('✅ Блок снят') # audit 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_", "")) + 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, - }) + details.update( + { + 'target_telegram_id': t.user.telegram_id, + 'target_username': t.user.username, + } + ) except Exception: pass await TicketCRUD.add_support_audit( @@ -1002,51 +958,52 @@ async def unblock_user_in_ticket( actor_user_id=db_user.id if db_user else None, actor_telegram_id=callback.from_user.id, is_moderator=is_mod, - action="unblock_user", + action='unblock_user', ticket_id=ticket_id, target_user_id=None, - details=details + details=details, ) except Exception: pass await view_admin_ticket(callback, db_user, db, state) else: - await callback.answer("❌ Ошибка", show_alert=True) + await callback.answer('❌ Ошибка', show_alert=True) -async def block_user_permanently( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def block_user_permanently(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) await callback.answer(texts.ACCESS_DENIED, show_alert=True) return - ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", "")) + ticket_id = int(callback.data.replace('admin_block_user_perm_ticket_', '')) ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None) if ok: try: await callback.message.answer( - "✅ Пользователь заблокирован навсегда", + '✅ Пользователь заблокирован навсегда', reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]] - ) + inline_keyboard=[ + [types.InlineKeyboardButton(text='🗑 Удалить', callback_data='admin_support_delete_msg')] + ] + ), ) except Exception: - await callback.answer("✅ Пользователь заблокирован") + await callback.answer('✅ Пользователь заблокирован') # audit try: - is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id)) + 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, - }) + details.update( + { + 'target_telegram_id': t.user.telegram_id, + 'target_username': t.user.username, + } + ) except Exception: pass await TicketCRUD.add_support_audit( @@ -1054,16 +1011,16 @@ async def block_user_permanently( actor_user_id=db_user.id if db_user else None, actor_telegram_id=callback.from_user.id, is_moderator=is_mod, - action="block_user_perm", + action='block_user_perm', ticket_id=ticket_id, target_user_id=None, - details=details + details=details, ) except Exception: pass await view_admin_ticket(callback, db_user, db, state) else: - await callback.answer("❌ Ошибка", show_alert=True) + await callback.answer('❌ Ошибка', show_alert=True) async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession): @@ -1079,19 +1036,19 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s # Обеспечим наличие данных пользователя в объекте тикета ticket_with_user = ticket - if not getattr(ticket_with_user, "user", None): + if not getattr(ticket_with_user, 'user', None): ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True) - user = getattr(ticket_with_user, "user", None) + user = getattr(ticket_with_user, 'user', None) if not user: - logger.error(f"User not found for ticket #{ticket.id}") + logger.error(f'User not found for ticket #{ticket.id}') return - if not getattr(user, "telegram_id", None): + if not getattr(user, 'telegram_id', None): logger.error( - "Cannot notify ticket #%s user without telegram_id (username=%s)", + 'Cannot notify ticket #%s user without telegram_id (username=%s)', ticket.id, - getattr(user, "username", None), + getattr(user, 'username', None), ) return @@ -1100,20 +1057,33 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s # Формируем уведомление base_text = texts.t( - "TICKET_REPLY_NOTIFICATION", - "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:" - ).format( - ticket_id=ticket.id, - reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text + 'TICKET_REPLY_NOTIFICATION', + '🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:', + ).format(ticket_id=ticket.id, reply_preview=reply_text[:100] + '...' if len(reply_text) > 100 else reply_text) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), callback_data=f'view_ticket_{ticket.id}' + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('CLOSE_NOTIFICATION', '❌ Закрыть уведомление'), + callback_data=f'close_ticket_notification_{ticket.id}', + ) + ], + ] ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")], - [types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")] - ]) # Если было фото в последнем ответе админа — отправим как фото last_message = await TicketMessageCRUD.get_last_message(db, ticket.id) - if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin: + if ( + last_message + and last_message.has_media + and last_message.media_type == 'photo' + and last_message.is_from_admin + ): caption = base_text try: await bot.send_photo( @@ -1125,13 +1095,13 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s return except TelegramBadRequest as photo_error: logger.error( - "Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s", + 'Не удалось отправить фото-уведомление пользователю %s для тикета %s: %s', chat_id, ticket.id, photo_error, ) except Exception as e: - logger.error(f"Не удалось отправить фото-уведомление: {e}") + logger.error(f'Не удалось отправить фото-уведомление: {e}') # Фоллбек: текстовое уведомление await bot.send_message( chat_id=chat_id, @@ -1139,60 +1109,47 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s reply_markup=keyboard, ) - logger.info(f"Ticket #{ticket.id} reply notification sent to user {chat_id}") + logger.info(f'Ticket #{ticket.id} reply notification sent to user {chat_id}') except Exception as e: - logger.error(f"Error notifying user about ticket reply: {e}") + logger.error(f'Error notifying user about ticket reply: {e}') def register_handlers(dp: Dispatcher): """Регистрация админских обработчиков тикетов""" - - # Просмотр тикетов - dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets") - dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open") - dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed") - dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open") - dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_")) - dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_")) + # Просмотр тикетов + dp.callback_query.register(show_admin_tickets, F.data == 'admin_tickets') + dp.callback_query.register(show_admin_tickets, F.data == 'admin_tickets_scope_open') + dp.callback_query.register(show_admin_tickets, F.data == 'admin_tickets_scope_closed') + dp.callback_query.register(close_all_open_admin_tickets, F.data == 'admin_tickets_close_all_open') + + dp.callback_query.register(view_admin_ticket, F.data.startswith('admin_view_ticket_')) + dp.callback_query.register(view_admin_ticket, F.data.startswith('admin_ticket_page_')) # Ответы на тикеты - dp.callback_query.register( - reply_to_admin_ticket, - F.data.startswith("admin_reply_ticket_") - ) - + dp.callback_query.register(reply_to_admin_ticket, F.data.startswith('admin_reply_ticket_')) + dp.message.register(handle_admin_ticket_reply, AdminTicketStates.waiting_for_reply) dp.message.register(handle_admin_block_duration_input, AdminTicketStates.waiting_for_block_duration) - + # Управление статусами: явная кнопка больше не используется (статус меняется автоматически) - - dp.callback_query.register( - close_admin_ticket, - F.data.startswith("admin_close_ticket_") - ) - dp.callback_query.register(block_user_in_ticket, F.data.startswith("admin_block_user_ticket_")) - dp.callback_query.register(unblock_user_in_ticket, F.data.startswith("admin_unblock_user_ticket_")) - dp.callback_query.register(block_user_permanently, F.data.startswith("admin_block_user_perm_ticket_")) - + + dp.callback_query.register(close_admin_ticket, F.data.startswith('admin_close_ticket_')) + dp.callback_query.register(block_user_in_ticket, F.data.startswith('admin_block_user_ticket_')) + dp.callback_query.register(unblock_user_in_ticket, F.data.startswith('admin_unblock_user_ticket_')) + dp.callback_query.register(block_user_permanently, F.data.startswith('admin_block_user_perm_ticket_')) + # Отмена операций - dp.callback_query.register( - cancel_admin_ticket_reply, - F.data == "cancel_admin_ticket_reply" - ) - + dp.callback_query.register(cancel_admin_ticket_reply, F.data == 'cancel_admin_ticket_reply') + # Пагинация админских тикетов - dp.callback_query.register(show_admin_tickets, F.data.startswith("admin_tickets_page_")) + dp.callback_query.register(show_admin_tickets, F.data.startswith('admin_tickets_page_')) # Управление компоновкой ответа — (отключено) # Вложения в тикете (админ) - async def send_admin_ticket_attachments( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession - ): + async def send_admin_ticket_attachments(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # permission gate for attachments view if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)): texts = get_texts(db_user.language) @@ -1200,20 +1157,25 @@ def register_handlers(dp: Dispatcher): return texts = get_texts(db_user.language) try: - ticket_id = int(callback.data.replace("admin_ticket_attachments_", "")) + ticket_id = int(callback.data.replace('admin_ticket_attachments_', '')) except ValueError: - await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True) if not ticket: - await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return - photos = [m.media_file_id for m in ticket.messages if getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" and m.media_file_id] + photos = [ + m.media_file_id + for m in ticket.messages + if getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' and m.media_file_id + ] if not photos: - await callback.answer(texts.t("NO_ATTACHMENTS", "Вложений нет."), show_alert=True) + await callback.answer(texts.t('NO_ATTACHMENTS', 'Вложений нет.'), show_alert=True) return from aiogram.types import InputMediaPhoto - chunks = [photos[i:i+10] for i in range(0, len(photos), 10)] + + chunks = [photos[i : i + 10] for i in range(0, len(photos), 10)] last_group_message = None for chunk in chunks: media = [InputMediaPhoto(media=pid) for pid in chunk] @@ -1226,29 +1188,39 @@ def register_handlers(dp: Dispatcher): # После отправки добавим кнопку удалить под последним сообщением группы if last_group_message: try: - kb = types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data=f"admin_delete_message_{last_group_message.message_id}")]]) - await callback.message.bot.send_message(chat_id=callback.from_user.id, text=texts.t("ATTACHMENTS_SENT", "Вложения отправлены."), reply_markup=kb) + kb = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), + callback_data=f'admin_delete_message_{last_group_message.message_id}', + ) + ] + ] + ) + await callback.message.bot.send_message( + chat_id=callback.from_user.id, + text=texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.'), + reply_markup=kb, + ) except Exception: - await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены.")) + await callback.answer(texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.')) else: - await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены.")) + await callback.answer(texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.')) - dp.callback_query.register(send_admin_ticket_attachments, F.data.startswith("admin_ticket_attachments_")) + dp.callback_query.register(send_admin_ticket_attachments, F.data.startswith('admin_ticket_attachments_')) - async def admin_delete_message( - callback: types.CallbackQuery - ): + async def admin_delete_message(callback: types.CallbackQuery): try: - msg_id = int(callback.data.replace("admin_delete_message_", "")) + msg_id = int(callback.data.replace('admin_delete_message_', '')) except ValueError: - await callback.answer("❌") + await callback.answer('❌') return try: await callback.message.bot.delete_message(chat_id=callback.from_user.id, message_id=msg_id) await callback.message.delete() except Exception: pass - await callback.answer("✅") - - dp.callback_query.register(admin_delete_message, F.data.startswith("admin_delete_message_")) + await callback.answer('✅') + dp.callback_query.register(admin_delete_message, F.data.startswith('admin_delete_message_')) diff --git a/app/handlers/admin/trials.py b/app/handlers/admin/trials.py index 9a95f192..ea5278a9 100644 --- a/app/handlers/admin/trials.py +++ b/app/handlers/admin/trials.py @@ -12,6 +12,7 @@ from app.keyboards.admin import get_admin_trials_keyboard from app.localization.texts import get_texts from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @@ -25,15 +26,17 @@ async def show_trials_panel( texts = get_texts(db_user.language) stats = await get_trial_statistics(db) - message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t( - "ADMIN_TRIALS_STATS", - "• Использовано всего: {used}\n" - "• Активно сейчас: {active}\n" - "• Доступно к сбросу: {resettable}", - ).format( - used=stats.get("used_trials", 0), - active=stats.get("active_trials", 0), - resettable=stats.get("resettable_trials", 0), + message = ( + texts.t('ADMIN_TRIALS_TITLE', '🧪 Управление триалами') + + '\n\n' + + texts.t( + 'ADMIN_TRIALS_STATS', + '• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}', + ).format( + used=stats.get('used_trials', 0), + active=stats.get('active_trials', 0), + resettable=stats.get('resettable_trials', 0), + ) ) await callback.message.edit_text( @@ -56,31 +59,31 @@ async def reset_trials( stats = await get_trial_statistics(db) message = texts.t( - "ADMIN_TRIALS_RESET_RESULT", - "♻️ Сбросили {reset_count} триалов.\n\n" - "• Использовано всего: {used}\n" - "• Активно сейчас: {active}\n" - "• Доступно к сбросу: {resettable}", + 'ADMIN_TRIALS_RESET_RESULT', + '♻️ Сбросили {reset_count} триалов.\n\n' + '• Использовано всего: {used}\n' + '• Активно сейчас: {active}\n' + '• Доступно к сбросу: {resettable}', ).format( reset_count=reset_count, - used=stats.get("used_trials", 0), - active=stats.get("active_trials", 0), - resettable=stats.get("resettable_trials", 0), + used=stats.get('used_trials', 0), + active=stats.get('active_trials', 0), + resettable=stats.get('resettable_trials', 0), ) await callback.message.edit_text( message, reply_markup=get_admin_trials_keyboard(db_user.language), ) - await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен")) + await callback.answer(texts.t('ADMIN_TRIALS_RESET_TOAST', '✅ Сброс завершен')) def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_trials_panel, - F.data == "admin_trials", + F.data == 'admin_trials', ) dp.callback_query.register( reset_trials, - F.data == "admin_trials_reset", + F.data == 'admin_trials_reset', ) diff --git a/app/handlers/admin/updates.py b/app/handlers/admin/updates.py index 53716ff1..af1ed887 100644 --- a/app/handlers/admin/updates.py +++ b/app/handlers/admin/updates.py @@ -1,88 +1,59 @@ import logging -from aiogram import Dispatcher, types, F -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +from aiogram import Dispatcher, F, types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.models import User from app.services.version_service import version_service from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) -def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_updates_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: buttons = [ + [InlineKeyboardButton(text='🔄 Проверить обновления', callback_data='admin_updates_check')], + [InlineKeyboardButton(text='📋 Информация о версии', callback_data='admin_updates_info')], [ InlineKeyboardButton( - text="🔄 Проверить обновления", - callback_data="admin_updates_check" + text='🔗 Открыть репозиторий', url=f'https://github.com/{version_service.repo}/releases' ) ], - [ - InlineKeyboardButton( - text="📋 Информация о версии", - callback_data="admin_updates_info" - ) - ], - [ - InlineKeyboardButton( - text="🔗 Открыть репозиторий", - url=f"https://github.com/{version_service.repo}/releases" - ) - ], - [ - InlineKeyboardButton( - text="◀️ Назад", - callback_data="admin_panel" - ) - ] + [InlineKeyboardButton(text='◀️ Назад', callback_data='admin_panel')], ] - + return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_version_info_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: buttons = [ - [ - InlineKeyboardButton( - text="🔄 Обновить", - callback_data="admin_updates_info" - ) - ], - [ - InlineKeyboardButton( - text="◀️ К обновлениям", - callback_data="admin_updates" - ) - ] + [InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_updates_info')], + [InlineKeyboardButton(text='◀️ К обновлениям', callback_data='admin_updates')], ] - + return InlineKeyboardMarkup(inline_keyboard=buttons) @admin_required @error_handler -async def show_updates_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: version_info = await version_service.get_version_info() - + current_version = version_info['current_version'] has_updates = version_info['has_updates'] total_newer = version_info['total_newer'] last_check = version_info['last_check'] - - status_icon = "🆕" if has_updates else "✅" - status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия" - - last_check_text = "" + + status_icon = '🆕' if has_updates else '✅' + status_text = f'Доступно {total_newer} обновлений' if has_updates else 'Актуальная версия' + + last_check_text = '' if last_check: - last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}" - + last_check_text = f'\n🕐 Последняя проверка: {last_check.strftime("%d.%m.%Y %H:%M")}' + message = f"""🔄 СИСТЕМА ОБНОВЛЕНИЙ 📦 Текущая версия: {current_version} @@ -91,31 +62,25 @@ async def show_updates_menu( 🔗 Репозиторий: {version_service.repo}{last_check_text} ℹ️ Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях.""" - + await callback.message.edit_text( - message, - reply_markup=get_updates_keyboard(db_user.language), - parse_mode="HTML" + message, reply_markup=get_updates_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() - + except Exception as e: - logger.error(f"Ошибка показа меню обновлений: {e}") - await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True) + logger.error(f'Ошибка показа меню обновлений: {e}') + await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True) @admin_required @error_handler -async def check_updates( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - await callback.answer("🔄 Проверяю обновления...") - +async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.answer('🔄 Проверяю обновления...') + try: has_updates, newer_releases = await version_service.check_for_updates(force=True) - + if not has_updates: message = f"""✅ ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ @@ -123,18 +88,16 @@ async def check_updates( 🎯 Статус: У вас установлена последняя версия 🔗 Репозиторий: {version_service.repo}""" - + else: updates_list = [] - for i, release in enumerate(newer_releases[:5]): + for i, release in enumerate(newer_releases[:5]): icon = version_service.format_version_display(release).split()[0] - updates_list.append( - f"{i+1}. {icon} {release.tag_name} • {release.formatted_date}" - ) - - updates_text = "\n".join(updates_list) - more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else "" - + updates_list.append(f'{i + 1}. {icon} {release.tag_name} • {release.formatted_date}') + + updates_text = '\n'.join(updates_list) + more_text = f'\n\n📋 И еще {len(newer_releases) - 5} обновлений...' if len(newer_releases) > 5 else '' + message = f"""🆕 НАЙДЕНЫ ОБНОВЛЕНИЯ 📦 Текущая версия: {version_service.current_version} @@ -144,131 +107,110 @@ async def check_updates( {updates_text}{more_text} 🔗 Репозиторий: {version_service.repo}""" - + keyboard = get_updates_keyboard(db_user.language) - + if has_updates: - keyboard.inline_keyboard.insert(-2, [ - InlineKeyboardButton( - text="📋 Подробнее о версиях", - callback_data="admin_updates_info" - ) - ]) - - await callback.message.edit_text( - message, - reply_markup=keyboard, - parse_mode="HTML" - ) - + keyboard.inline_keyboard.insert( + -2, [InlineKeyboardButton(text='📋 Подробнее о версиях', callback_data='admin_updates_info')] + ) + + await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML') + except Exception as e: - logger.error(f"Ошибка проверки обновлений: {e}") + logger.error(f'Ошибка проверки обновлений: {e}') await callback.message.edit_text( - f"❌ ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ\n\n" - f"Не удалось связаться с сервером GitHub.\n" - f"Попробуйте позже.\n\n" - f"📦 Текущая версия: {version_service.current_version}", + f'❌ ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ\n\n' + f'Не удалось связаться с сервером GitHub.\n' + f'Попробуйте позже.\n\n' + f'📦 Текущая версия: {version_service.current_version}', reply_markup=get_updates_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) @admin_required @error_handler -async def show_version_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - await callback.answer("📋 Загружаю информацию о версиях...") - +async def show_version_info(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + await callback.answer('📋 Загружаю информацию о версиях...') + try: version_info = await version_service.get_version_info() - + current_version = version_info['current_version'] current_release = version_info['current_release'] newer_releases = version_info['newer_releases'] has_updates = version_info['has_updates'] last_check = version_info['last_check'] - repo_url = version_info['repo_url'] - - current_info = f"📦 ТЕКУЩАЯ ВЕРСИЯ\n\n" - + version_info['repo_url'] + + current_info = '📦 ТЕКУЩАЯ ВЕРСИЯ\n\n' + if current_release: - current_info += f"🏷️ Версия: {current_release.tag_name}\n" - current_info += f"📅 Дата релиза: {current_release.formatted_date}\n" + current_info += f'🏷️ Версия: {current_release.tag_name}\n' + current_info += f'📅 Дата релиза: {current_release.formatted_date}\n' if current_release.short_description: - current_info += f"📝 Описание:\n{current_release.short_description}\n" + current_info += f'📝 Описание:\n{current_release.short_description}\n' else: - current_info += f"🏷️ Версия: {current_version}\n" - current_info += f"ℹ️ Статус: Информация о релизе недоступна\n" - + current_info += f'🏷️ Версия: {current_version}\n' + current_info += 'ℹ️ Статус: Информация о релизе недоступна\n' + message_parts = [current_info] - + if has_updates and newer_releases: - updates_info = f"\n🆕 ДОСТУПНЫЕ ОБНОВЛЕНИЯ\n\n" - + updates_info = '\n🆕 ДОСТУПНЫЕ ОБНОВЛЕНИЯ\n\n' + for i, release in enumerate(newer_releases): - icon = "🔥" if i == 0 else "📦" + icon = '🔥' if i == 0 else '📦' if release.prerelease: - icon = "🧪" + icon = '🧪' elif release.is_dev: - icon = "🔧" - - updates_info += f"{icon} {release.tag_name}\n" - updates_info += f" 📅 {release.formatted_date}\n" + icon = '🔧' + + updates_info += f'{icon} {release.tag_name}\n' + updates_info += f' 📅 {release.formatted_date}\n' if release.short_description: - updates_info += f" 📝 {release.short_description}\n" - updates_info += "\n" - + updates_info += f' 📝 {release.short_description}\n' + updates_info += '\n' + message_parts.append(updates_info.rstrip()) - - system_info = f"\n🔧 СИСТЕМА ОБНОВЛЕНИЙ\n\n" - system_info += f"🔗 Репозиторий: {version_service.repo}\n" - system_info += f"⚡ Автопроверка: {'Включена' if version_service.enabled else 'Отключена'}\n" - system_info += f"🕐 Интервал: Каждый час\n" - + + system_info = '\n🔧 СИСТЕМА ОБНОВЛЕНИЙ\n\n' + system_info += f'🔗 Репозиторий: {version_service.repo}\n' + system_info += f'⚡ Автопроверка: {"Включена" if version_service.enabled else "Отключена"}\n' + system_info += '🕐 Интервал: Каждый час\n' + if last_check: - system_info += f"🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}\n" - + system_info += f'🕐 Последняя проверка: {last_check.strftime("%d.%m.%Y %H:%M")}\n' + message_parts.append(system_info.rstrip()) - - final_message = "\n".join(message_parts) - + + final_message = '\n'.join(message_parts) + if len(final_message) > 4000: - final_message = final_message[:3900] + "\n\n... (информация обрезана)" - + final_message = final_message[:3900] + '\n\n... (информация обрезана)' + await callback.message.edit_text( final_message, reply_markup=get_version_info_keyboard(db_user.language), - parse_mode="HTML", - disable_web_page_preview=True + parse_mode='HTML', + disable_web_page_preview=True, ) - + except Exception as e: - logger.error(f"Ошибка получения информации о версиях: {e}") + logger.error(f'Ошибка получения информации о версиях: {e}') await callback.message.edit_text( - f"❌ ОШИБКА ЗАГРУЗКИ\n\n" - f"Не удалось получить информацию о версиях.\n\n" - f"📦 Текущая версия: {version_service.current_version}", + f'❌ ОШИБКА ЗАГРУЗКИ\n\n' + f'Не удалось получить информацию о версиях.\n\n' + f'📦 Текущая версия: {version_service.current_version}', reply_markup=get_version_info_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_updates_menu, - F.data == "admin_updates" - ) - - dp.callback_query.register( - check_updates, - F.data == "admin_updates_check" - ) - - dp.callback_query.register( - show_version_info, - F.data == "admin_updates_info" - ) + dp.callback_query.register(show_updates_menu, F.data == 'admin_updates') + + dp.callback_query.register(check_updates, F.data == 'admin_updates_check') + + dp.callback_query.register(show_version_info, F.data == 'admin_updates_info') diff --git a/app/handlers/admin/user_messages.py b/app/handlers/admin/user_messages.py index ceacd20a..6fb89e39 100644 --- a/app/handlers/admin/user_messages.py +++ b/app/handlers/admin/user_messages.py @@ -1,24 +1,29 @@ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types +from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup -from aiogram.filters import StateFilter from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.user_message import ( - create_user_message, get_all_user_messages, get_user_message_by_id, - update_user_message, delete_user_message, toggle_user_message_status, - get_user_messages_stats + create_user_message, + delete_user_message, + get_all_user_messages, + get_user_message_by_id, + get_user_messages_stats, + toggle_user_message_status, + update_user_message, ) from app.database.models import User -from app.keyboards.admin import get_admin_main_keyboard +from app.localization.texts import get_texts +from app.utils.decorators import admin_required, error_handler from app.utils.validators import ( get_html_help_text, sanitize_html, validate_html_tags, ) -from app.utils.decorators import admin_required, error_handler -from app.localization.texts import get_texts + logger = logging.getLogger(__name__) @@ -28,569 +33,402 @@ class UserMessageStates(StatesGroup): waiting_for_edit_text = State() -def get_user_messages_keyboard(language: str = "ru"): - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="📝 Добавить сообщение", - callback_data="add_user_message" - ) - ], - [ - InlineKeyboardButton( - text="📋 Список сообщений", - callback_data="list_user_messages:0" - ) - ], - [ - InlineKeyboardButton( - text="📊 Статистика", - callback_data="user_messages_stats" - ) - ], - [ - InlineKeyboardButton( - text="🔙 Назад в админку", - callback_data="admin_panel" - ) +def get_user_messages_keyboard(language: str = 'ru'): + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📝 Добавить сообщение', callback_data='add_user_message')], + [InlineKeyboardButton(text='📋 Список сообщений', callback_data='list_user_messages:0')], + [InlineKeyboardButton(text='📊 Статистика', callback_data='user_messages_stats')], + [InlineKeyboardButton(text='🔙 Назад в админку', callback_data='admin_panel')], ] - ]) + ) -def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"): - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать" - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✏️ Редактировать", - callback_data=f"edit_user_message:{message_id}" - ) - ], - [ - InlineKeyboardButton( - text=status_text, - callback_data=f"toggle_user_message:{message_id}" - ) - ], - [ - InlineKeyboardButton( - text="🗑️ Удалить", - callback_data=f"delete_user_message:{message_id}" - ) - ], - [ - InlineKeyboardButton( - text="🔙 К списку", - callback_data="list_user_messages:0" - ) +def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = 'ru'): + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + status_text = '🔴 Деактивировать' if is_active else '🟢 Активировать' + + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✏️ Редактировать', callback_data=f'edit_user_message:{message_id}')], + [InlineKeyboardButton(text=status_text, callback_data=f'toggle_user_message:{message_id}')], + [InlineKeyboardButton(text='🗑️ Удалить', callback_data=f'delete_user_message:{message_id}')], + [InlineKeyboardButton(text='🔙 К списку', callback_data='list_user_messages:0')], ] - ]) + ) @admin_required @error_handler -async def show_user_messages_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - texts = get_texts(db_user.language) - +async def show_user_messages_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + get_texts(db_user.language) + text = ( - "📢 Управление сообщениями в главном меню\n\n" - "Здесь вы можете добавлять сообщения, которые будут показываться пользователям " - "в главном меню между информацией о подписке и кнопками действий.\n\n" - "• Сообщения поддерживают HTML теги\n" - "• Можно создать несколько сообщений\n" - "• Активные сообщения показываются случайно\n" - "• Неактивные сообщения не показываются" - ) - - await callback.message.edit_text( - text, - reply_markup=get_user_messages_keyboard(db_user.language), - parse_mode="HTML" + '📢 Управление сообщениями в главном меню\n\n' + 'Здесь вы можете добавлять сообщения, которые будут показываться пользователям ' + 'в главном меню между информацией о подписке и кнопками действий.\n\n' + '• Сообщения поддерживают HTML теги\n' + '• Можно создать несколько сообщений\n' + '• Активные сообщения показываются случайно\n' + '• Неактивные сообщения не показываются' ) + + await callback.message.edit_text(text, reply_markup=get_user_messages_keyboard(db_user.language), parse_mode='HTML') await callback.answer() @admin_required @error_handler -async def add_user_message_start( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def add_user_message_start(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): await callback.message.edit_text( - f"📝 Добавление нового сообщения\n\n" - f"Введите текст сообщения, которое будет показываться в главном меню.\n\n" - f"{get_html_help_text()}\n\n" - f"Отправьте /cancel для отмены.", - parse_mode="HTML" + f'📝 Добавление нового сообщения\n\n' + f'Введите текст сообщения, которое будет показываться в главном меню.\n\n' + f'{get_html_help_text()}\n\n' + f'Отправьте /cancel для отмены.', + parse_mode='HTML', ) - + await state.set_state(UserMessageStates.waiting_for_message_text) await callback.answer() @admin_required @error_handler -async def process_new_message_text( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if message.text == "/cancel": +async def process_new_message_text(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): + if message.text == '/cancel': await state.clear() await message.answer( - "❌ Добавление сообщения отменено.", - reply_markup=get_user_messages_keyboard(db_user.language) + '❌ Добавление сообщения отменено.', reply_markup=get_user_messages_keyboard(db_user.language) ) return - + message_text = message.text.strip() - + if len(message_text) > 4000: await message.answer( - "❌ Сообщение слишком длинное. Максимум 4000 символов.\n" - "Попробуйте еще раз или отправьте /cancel для отмены." + '❌ Сообщение слишком длинное. Максимум 4000 символов.\n' + 'Попробуйте еще раз или отправьте /cancel для отмены.' ) return - + is_valid, error_msg = validate_html_tags(message_text) if not is_valid: await message.answer( - f"❌ Ошибка в HTML разметке: {error_msg}\n\n" - f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.", - parse_mode=None + f'❌ Ошибка в HTML разметке: {error_msg}\n\n' + f'Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.', + parse_mode=None, ) return - + try: - new_message = await create_user_message( - db=db, - message_text=message_text, - created_by=db_user.id, - is_active=True - ) - + new_message = await create_user_message(db=db, message_text=message_text, created_by=db_user.id, is_active=True) + await state.clear() - + await message.answer( - f"✅ Сообщение добавлено!\n\n" - f"ID: {new_message.id}\n" - f"Статус: {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n" - f"Создано: {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" - f"Предварительный просмотр:\n" - f"
{message_text}
", + f'✅ Сообщение добавлено!\n\n' + f'ID: {new_message.id}\n' + f'Статус: {"🟢 Активно" if new_message.is_active else "🔴 Неактивно"}\n' + f'Создано: {new_message.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' + f'Предварительный просмотр:\n' + f'
{message_text}
', reply_markup=get_user_messages_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) - + except Exception as e: - logger.error(f"Ошибка создания сообщения: {e}") + logger.error(f'Ошибка создания сообщения: {e}') await state.clear() await message.answer( - "❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.", - reply_markup=get_user_messages_keyboard(db_user.language) + '❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.', + reply_markup=get_user_messages_keyboard(db_user.language), ) + @admin_required @error_handler -async def list_user_messages( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def list_user_messages(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = 0 - if ":" in callback.data: + if ':' in callback.data: try: - page = int(callback.data.split(":")[1]) + page = int(callback.data.split(':')[1]) except (ValueError, IndexError): page = 0 - + limit = 5 offset = page * limit - + messages = await get_all_user_messages(db, offset=offset, limit=limit) - + if not messages: await callback.message.edit_text( - "📋 Список сообщений\n\n" - "Сообщений пока нет. Добавьте первое сообщение!", + '📋 Список сообщений\n\nСообщений пока нет. Добавьте первое сообщение!', reply_markup=get_user_messages_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() return - - text = "📋 Список сообщений\n\n" - + + text = '📋 Список сообщений\n\n' + for msg in messages: - status_emoji = "🟢" if msg.is_active else "🔴" - preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text + status_emoji = '🟢' if msg.is_active else '🔴' + preview = msg.message_text[:100] + '...' if len(msg.message_text) > 100 else msg.message_text preview = preview.replace('<', '<').replace('>', '>') - - text += ( - f"{status_emoji} ID {msg.id}\n" - f"{preview}\n" - f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" - ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - + + text += f'{status_emoji} ID {msg.id}\n{preview}\n📅 {msg.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' + + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + keyboard = [] - + for msg in messages: - status_emoji = "🟢" if msg.is_active else "🔴" - keyboard.append([ - InlineKeyboardButton( - text=f"{status_emoji} ID {msg.id}", - callback_data=f"view_user_message:{msg.id}" - ) - ]) - + status_emoji = '🟢' if msg.is_active else '🔴' + keyboard.append( + [InlineKeyboardButton(text=f'{status_emoji} ID {msg.id}', callback_data=f'view_user_message:{msg.id}')] + ) + nav_buttons = [] if page > 0: - nav_buttons.append( - InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"list_user_messages:{page-1}" - ) - ) - - nav_buttons.append( - InlineKeyboardButton( - text="➕ Добавить", - callback_data="add_user_message" - ) - ) - - if len(messages) == limit: - nav_buttons.append( - InlineKeyboardButton( - text="Вперед ➡️", - callback_data=f"list_user_messages:{page+1}" - ) - ) - + nav_buttons.append(InlineKeyboardButton(text='⬅️ Назад', callback_data=f'list_user_messages:{page - 1}')) + + nav_buttons.append(InlineKeyboardButton(text='➕ Добавить', callback_data='add_user_message')) + + if len(messages) == limit: + nav_buttons.append(InlineKeyboardButton(text='Вперед ➡️', callback_data=f'list_user_messages:{page + 1}')) + if nav_buttons: keyboard.append(nav_buttons) - - keyboard.append([ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="user_messages_panel" - ) - ]) - + + keyboard.append([InlineKeyboardButton(text='🔙 Назад', callback_data='user_messages_panel')]) + await callback.message.edit_text( - text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def view_user_message( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def view_user_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: - message_id = int(callback.data.split(":")[1]) + message_id = int(callback.data.split(':')[1]) except (ValueError, IndexError): - await callback.answer("❌ Неверный ID сообщения", show_alert=True) + await callback.answer('❌ Неверный ID сообщения', show_alert=True) return - + message = await get_user_message_by_id(db, message_id) if not message: - await callback.answer("❌ Сообщение не найдено", show_alert=True) + await callback.answer('❌ Сообщение не найдено', show_alert=True) return safe_content = sanitize_html(message.message_text) - status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно" + status_text = '🟢 Активно' if message.is_active else '🔴 Неактивно' text = ( - f"📋 Сообщение ID {message.id}\n\n" - f"Статус: {status_text}\n" - f"Создано: {message.created_at.strftime('%d.%m.%Y %H:%M')}\n" - f"Обновлено: {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" - f"Содержимое:\n" - f"
{safe_content}
" + f'📋 Сообщение ID {message.id}\n\n' + f'Статус: {status_text}\n' + f'Создано: {message.created_at.strftime("%d.%m.%Y %H:%M")}\n' + f'Обновлено: {message.updated_at.strftime("%d.%m.%Y %H:%M")}\n\n' + f'Содержимое:\n' + f'
{safe_content}
' ) - + await callback.message.edit_text( text, - reply_markup=get_message_actions_keyboard( - message_id, message.is_active, db_user.language - ), - parse_mode="HTML" + reply_markup=get_message_actions_keyboard(message_id, message.is_active, db_user.language), + parse_mode='HTML', ) await callback.answer() @admin_required @error_handler -async def toggle_message_status( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_message_status(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: - message_id = int(callback.data.split(":")[1]) + message_id = int(callback.data.split(':')[1]) except (ValueError, IndexError): - await callback.answer("❌ Неверный ID сообщения", show_alert=True) + await callback.answer('❌ Неверный ID сообщения', show_alert=True) return - + message = await toggle_user_message_status(db, message_id) - + if not message: - await callback.answer("❌ Сообщение не найдено", show_alert=True) + await callback.answer('❌ Сообщение не найдено', show_alert=True) return - - status_text = "активировано" if message.is_active else "деактивировано" - await callback.answer(f"✅ Сообщение {status_text}") - + + status_text = 'активировано' if message.is_active else 'деактивировано' + await callback.answer(f'✅ Сообщение {status_text}') + await view_user_message(callback, db_user, db) @admin_required @error_handler -async def delete_message_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def delete_message_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Подтвердить удаление сообщения""" try: - message_id = int(callback.data.split(":")[1]) + message_id = int(callback.data.split(':')[1]) except (ValueError, IndexError): - await callback.answer("❌ Неверный ID сообщения", show_alert=True) + await callback.answer('❌ Неверный ID сообщения', show_alert=True) return - + success = await delete_user_message(db, message_id) - + if success: - await callback.answer("✅ Сообщение удалено") + await callback.answer('✅ Сообщение удалено') await list_user_messages( types.CallbackQuery( id=callback.id, from_user=callback.from_user, chat_instance=callback.chat_instance, - data="list_user_messages:0", - message=callback.message + data='list_user_messages:0', + message=callback.message, ), db_user, - db + db, ) else: - await callback.answer("❌ Ошибка удаления сообщения", show_alert=True) + await callback.answer('❌ Ошибка удаления сообщения', show_alert=True) @admin_required @error_handler -async def show_messages_stats( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_messages_stats(callback: types.CallbackQuery, db_user: User, db: AsyncSession): stats = await get_user_messages_stats(db) - + text = ( - "📊 Статистика сообщений\n\n" - f"📝 Всего сообщений: {stats['total_messages']}\n" - f"🟢 Активных: {stats['active_messages']}\n" - f"🔴 Неактивных: {stats['inactive_messages']}\n\n" - "Активные сообщения показываются пользователям случайным образом " - "в главном меню между информацией о подписке и кнопками действий." + '📊 Статистика сообщений\n\n' + f'📝 Всего сообщений: {stats["total_messages"]}\n' + f'🟢 Активных: {stats["active_messages"]}\n' + f'🔴 Неактивных: {stats["inactive_messages"]}\n\n' + 'Активные сообщения показываются пользователям случайным образом ' + 'в главном меню между информацией о подписке и кнопками действий.' ) - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="🔙 Назад", - callback_data="user_messages_panel" - ) - ] - ]) - - await callback.message.edit_text( - text, - reply_markup=keyboard, - parse_mode="HTML" + + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='🔙 Назад', callback_data='user_messages_panel')]] ) + + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() + @admin_required @error_handler -async def edit_user_message_start( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def edit_user_message_start(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): try: - message_id = int(callback.data.split(":")[1]) + message_id = int(callback.data.split(':')[1]) except (ValueError, IndexError): - await callback.answer("❌ Неверный ID сообщения", show_alert=True) + await callback.answer('❌ Неверный ID сообщения', show_alert=True) return - + message = await get_user_message_by_id(db, message_id) - + if not message: - await callback.answer("❌ Сообщение не найдено", show_alert=True) + await callback.answer('❌ Сообщение не найдено', show_alert=True) return - + await callback.message.edit_text( - f"✏️ Редактирование сообщения ID {message.id}\n\n" - f"Текущий текст:\n" - f"
{sanitize_html(message.message_text)}
\n\n" - f"Введите новый текст сообщения или отправьте /cancel для отмены:", - parse_mode="HTML" + f'✏️ Редактирование сообщения ID {message.id}\n\n' + f'Текущий текст:\n' + f'
{sanitize_html(message.message_text)}
\n\n' + f'Введите новый текст сообщения или отправьте /cancel для отмены:', + parse_mode='HTML', ) - - await state.set_data({"editing_message_id": message_id}) + + await state.set_data({'editing_message_id': message_id}) await state.set_state(UserMessageStates.waiting_for_edit_text) await callback.answer() + @admin_required @error_handler -async def process_edit_message_text( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if message.text == "/cancel": +async def process_edit_message_text(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): + if message.text == '/cancel': await state.clear() - await message.answer( - "❌ Редактирование отменено.", - reply_markup=get_user_messages_keyboard(db_user.language) - ) + await message.answer('❌ Редактирование отменено.', reply_markup=get_user_messages_keyboard(db_user.language)) return - + data = await state.get_data() - message_id = data.get("editing_message_id") - + message_id = data.get('editing_message_id') + if not message_id: await state.clear() - await message.answer("❌ Ошибка: ID сообщения не найден") + await message.answer('❌ Ошибка: ID сообщения не найден') return - + new_text = message.text.strip() if len(new_text) > 4000: await message.answer( - "❌ Сообщение слишком длинное. Максимум 4000 символов.\n" - "Попробуйте еще раз или отправьте /cancel для отмены." + '❌ Сообщение слишком длинное. Максимум 4000 символов.\n' + 'Попробуйте еще раз или отправьте /cancel для отмены.' ) return is_valid, error_msg = validate_html_tags(new_text) if not is_valid: await message.answer( - f"❌ Ошибка в HTML разметке: {error_msg}\n\n" - f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.", - parse_mode=None + f'❌ Ошибка в HTML разметке: {error_msg}\n\n' + f'Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.', + parse_mode=None, ) return try: - updated_message = await update_user_message( - db=db, - message_id=message_id, - message_text=new_text - ) - + updated_message = await update_user_message(db=db, message_id=message_id, message_text=new_text) + if updated_message: await state.clear() await message.answer( - f"✅ Сообщение обновлено!\n\n" - f"ID: {updated_message.id}\n" - f"Обновлено: {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" - f"Новый текст:\n" - f"
{sanitize_html(new_text)}
", + f'✅ Сообщение обновлено!\n\n' + f'ID: {updated_message.id}\n' + f'Обновлено: {updated_message.updated_at.strftime("%d.%m.%Y %H:%M")}\n\n' + f'Новый текст:\n' + f'
{sanitize_html(new_text)}
', reply_markup=get_user_messages_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: await state.clear() await message.answer( - "❌ Сообщение не найдено или ошибка обновления.", - reply_markup=get_user_messages_keyboard(db_user.language) + '❌ Сообщение не найдено или ошибка обновления.', + reply_markup=get_user_messages_keyboard(db_user.language), ) - + except Exception as e: - logger.error(f"Ошибка обновления сообщения: {e}") + logger.error(f'Ошибка обновления сообщения: {e}') await state.clear() await message.answer( - "❌ Произошла ошибка при обновлении сообщения.", - reply_markup=get_user_messages_keyboard(db_user.language) + '❌ Произошла ошибка при обновлении сообщения.', reply_markup=get_user_messages_keyboard(db_user.language) ) def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_user_messages_panel, - F.data == "user_messages_panel" - ) - - dp.callback_query.register( - add_user_message_start, - F.data == "add_user_message" - ) - - dp.message.register( - process_new_message_text, - StateFilter(UserMessageStates.waiting_for_message_text) - ) + dp.callback_query.register(show_user_messages_panel, F.data == 'user_messages_panel') - dp.callback_query.register( - edit_user_message_start, - F.data.startswith("edit_user_message:") - ) - - dp.message.register( - process_edit_message_text, - StateFilter(UserMessageStates.waiting_for_edit_text) - ) - - dp.callback_query.register( - list_user_messages, - F.data.startswith("list_user_messages") - ) - - dp.callback_query.register( - view_user_message, - F.data.startswith("view_user_message:") - ) - - dp.callback_query.register( - toggle_message_status, - F.data.startswith("toggle_user_message:") - ) - - dp.callback_query.register( - delete_message_confirm, - F.data.startswith("delete_user_message:") - ) - - dp.callback_query.register( - show_messages_stats, - F.data == "user_messages_stats" - ) + dp.callback_query.register(add_user_message_start, F.data == 'add_user_message') + + dp.message.register(process_new_message_text, StateFilter(UserMessageStates.waiting_for_message_text)) + + dp.callback_query.register(edit_user_message_start, F.data.startswith('edit_user_message:')) + + dp.message.register(process_edit_message_text, StateFilter(UserMessageStates.waiting_for_edit_text)) + + dp.callback_query.register(list_user_messages, F.data.startswith('list_user_messages')) + + dp.callback_query.register(view_user_message, F.data.startswith('view_user_message:')) + + dp.callback_query.register(toggle_message_status, F.data.startswith('toggle_user_message:')) + + dp.callback_query.register(delete_message_confirm, F.data.startswith('delete_user_message:')) + + dp.callback_query.register(show_messages_stats, F.data == 'user_messages_stats') diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 7e7e7c3e..6ba6c31e 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -1,54 +1,57 @@ import logging import re -from datetime import datetime, timedelta -from typing import Optional, List, Tuple, Dict, Any, Callable -from enum import Enum from dataclasses import dataclass -from aiogram import Dispatcher, types, F -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from datetime import datetime, timedelta +from enum import Enum +from typing import Any + +from aiogram import Dispatcher, F, types +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.fsm.context import FSMContext +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import AdminStates -from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType -from app.database.crud.user import ( - get_user_by_id, - get_user_by_telegram_id, - get_user_by_username, - get_referrals, -) from app.database.crud.campaign import ( get_campaign_registration_by_user, get_campaign_statistics, ) -from app.keyboards.admin import ( - get_admin_users_keyboard, get_user_management_keyboard, - get_admin_pagination_keyboard, get_confirmation_keyboard, - get_admin_users_filters_keyboard, get_user_promo_group_keyboard, - get_user_restrictions_keyboard -) -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.utils.user_utils import get_effective_referral_commission_percent -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, get_server_ids_by_uuids, + get_server_squad_by_id, + get_server_squad_by_uuid, ) from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id +from app.database.crud.user import ( + get_referrals, + get_user_by_id, + get_user_by_telegram_id, + get_user_by_username, +) +from app.database.models import Subscription, SubscriptionStatus, TransactionType, User, UserStatus +from app.keyboards.admin import ( + get_admin_pagination_keyboard, + get_admin_users_filters_keyboard, + get_admin_users_keyboard, + get_confirmation_keyboard, + get_user_management_keyboard, + get_user_promo_group_keyboard, + get_user_restrictions_keyboard, +) +from app.localization.texts import get_texts +from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService +from app.services.user_service import UserService +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.utils.formatters import format_datetime, format_time_ago from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) +from app.utils.user_utils import get_effective_referral_commission_percent + logger = logging.getLogger(__name__) @@ -57,19 +60,22 @@ logger = logging.getLogger(__name__) # Конфигурация фильтров пользователей # ============================================================================= + class UserFilterType(Enum): """Типы фильтрации пользователей.""" - BALANCE = "balance" - TRAFFIC = "traffic" - ACTIVITY = "activity" - SPENDING = "spending" - PURCHASES = "purchases" - CAMPAIGN = "campaign" + + BALANCE = 'balance' + TRAFFIC = 'traffic' + ACTIVITY = 'activity' + SPENDING = 'spending' + PURCHASES = 'purchases' + CAMPAIGN = 'campaign' @dataclass class UserFilterConfig: """Конфигурация для типа фильтра.""" + fsm_state: Any # State из AdminStates title: str empty_message: str @@ -78,48 +84,48 @@ class UserFilterConfig: # Конфигурация для каждого типа фильтра -USER_FILTER_CONFIGS: Dict[UserFilterType, UserFilterConfig] = { +USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = { UserFilterType.BALANCE: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_balance_list, - title="👥 Список пользователей по балансу", - empty_message="👥 Пользователи не найдены", - pagination_prefix="admin_users_balance_list", - order_param="order_by_balance", + title='👥 Список пользователей по балансу', + empty_message='👥 Пользователи не найдены', + pagination_prefix='admin_users_balance_list', + order_param='order_by_balance', ), UserFilterType.TRAFFIC: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_traffic_list, - title="👥 Список пользователей по использованному трафику", - empty_message="📶 Пользователи с трафиком не найдены", - pagination_prefix="admin_users_traffic_list", - order_param="order_by_traffic", + title='👥 Список пользователей по использованному трафику', + empty_message='📶 Пользователи с трафиком не найдены', + pagination_prefix='admin_users_traffic_list', + order_param='order_by_traffic', ), UserFilterType.ACTIVITY: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_last_activity_list, - title="👥 Пользователи по активности", - empty_message="🕒 Пользователи с активностью не найдены", - pagination_prefix="admin_users_activity_list", - order_param="order_by_last_activity", + title='👥 Пользователи по активности', + empty_message='🕒 Пользователи с активностью не найдены', + pagination_prefix='admin_users_activity_list', + order_param='order_by_last_activity', ), UserFilterType.SPENDING: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_spending_list, - title="👥 Пользователи по сумме трат", - empty_message="💳 Пользователи с тратами не найдены", - pagination_prefix="admin_users_spending_list", - order_param="order_by_total_spent", + title='👥 Пользователи по сумме трат', + empty_message='💳 Пользователи с тратами не найдены', + pagination_prefix='admin_users_spending_list', + order_param='order_by_total_spent', ), UserFilterType.PURCHASES: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_purchases_list, - title="👥 Пользователи по количеству покупок", - empty_message="🛒 Пользователи с покупками не найдены", - pagination_prefix="admin_users_purchases_list", - order_param="order_by_purchase_count", + title='👥 Пользователи по количеству покупок', + empty_message='🛒 Пользователи с покупками не найдены', + pagination_prefix='admin_users_purchases_list', + order_param='order_by_purchase_count', ), UserFilterType.CAMPAIGN: UserFilterConfig( fsm_state=AdminStates.viewing_user_from_campaign_list, - title="👥 Пользователи по кампании регистрации", - empty_message="📢 Пользователи с кампанией не найдены", - pagination_prefix="admin_users_campaign_list", - order_param="", # использует специальный метод + title='👥 Пользователи по кампании регистрации', + empty_message='📢 Пользователи с кампанией не найдены', + pagination_prefix='admin_users_campaign_list', + order_param='', # использует специальный метод ), } @@ -127,28 +133,25 @@ USER_FILTER_CONFIGS: Dict[UserFilterType, UserFilterConfig] = { def _get_user_status_emoji(user: User) -> str: """Возвращает эмодзи статуса пользователя.""" if user.status == UserStatus.ACTIVE.value: - return "✅" - elif user.status == UserStatus.BLOCKED.value: - return "🚫" - return "🗑️" + return '✅' + if user.status == UserStatus.BLOCKED.value: + return '🚫' + return '🗑️' def _get_subscription_emoji(user: User) -> str: """Возвращает эмодзи подписки пользователя.""" if not user.subscription: - return "❌" + return '❌' if user.subscription.is_trial: - return "🎁" + return '🎁' if user.subscription.is_active: - return "💎" - return "⏰" + return '💎' + return '⏰' def _build_user_button_text( - user: User, - filter_type: UserFilterType, - extra_data: Optional[Dict[str, Any]] = None, - language: str = "ru" + user: User, filter_type: UserFilterType, extra_data: dict[str, Any] | None = None, language: str = 'ru' ) -> str: """ Формирует текст кнопки пользователя в зависимости от типа фильтра. @@ -163,77 +166,73 @@ def _build_user_button_text( sub_emoji = _get_subscription_emoji(user) if filter_type == UserFilterType.BALANCE: - button_text = f"{status_emoji} {sub_emoji} {user.full_name}" + button_text = f'{status_emoji} {sub_emoji} {user.full_name}' if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' if user.subscription and user.subscription.end_date: days_left = (user.subscription.end_date - datetime.utcnow()).days - button_text += f" | 📅 {days_left}д" + button_text += f' | 📅 {days_left}д' elif filter_type == UserFilterType.TRAFFIC: if user.subscription: sub = user.subscription used = sub.traffic_used_gb or 0.0 if sub.traffic_limit_gb and sub.traffic_limit_gb > 0: - limit_display = f"{sub.traffic_limit_gb}" + limit_display = f'{sub.traffic_limit_gb}' else: - limit_display = "♾️" - traffic_display = f"{used:.1f}/{limit_display} ГБ" + limit_display = '♾️' + traffic_display = f'{used:.1f}/{limit_display} ГБ' else: - traffic_display = "нет подписки" - button_text = f"{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}" + traffic_display = 'нет подписки' + button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}' if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' elif filter_type == UserFilterType.ACTIVITY: - activity_display = ( - format_time_ago(user.last_activity, language) - if user.last_activity - else "неизвестно" - ) - button_text = f"{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}" + activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно' + button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}' elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES): - stats = extra_data.get(user.id, {"total_spent": 0, "purchase_count": 0}) if extra_data else {} - total_spent = stats.get("total_spent", 0) - purchases = stats.get("purchase_count", 0) + stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {} + total_spent = stats.get('total_spent', 0) + purchases = stats.get('purchase_count', 0) if filter_type == UserFilterType.SPENDING: - button_text = f"{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}" + button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}' else: - button_text = f"{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}" + button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}' elif filter_type == UserFilterType.CAMPAIGN: info = extra_data.get(user.id, {}) if extra_data else {} - campaign_name = info.get("campaign_name") or "Без кампании" - registered_at = info.get("registered_at") - registered_display = format_datetime(registered_at) if registered_at else "неизвестно" - button_text = f"{status_emoji} {user.full_name} | 📢 {campaign_name} | 📅 {registered_display}" + campaign_name = info.get('campaign_name') or 'Без кампании' + registered_at = info.get('registered_at') + registered_display = format_datetime(registered_at) if registered_at else 'неизвестно' + button_text = f'{status_emoji} {user.full_name} | 📢 {campaign_name} | 📅 {registered_display}' else: - button_text = f"{status_emoji} {sub_emoji} {user.full_name}" + button_text = f'{status_emoji} {sub_emoji} {user.full_name}' # Обрезка длинных имён if len(button_text) > 60: - short_name = user.full_name[:17] + "..." if len(user.full_name) > 20 else user.full_name + short_name = user.full_name[:17] + '...' if len(user.full_name) > 20 else user.full_name # Пересобираем с коротким именем if filter_type == UserFilterType.BALANCE: - button_text = f"{status_emoji} {sub_emoji} {short_name}" + button_text = f'{status_emoji} {sub_emoji} {short_name}' if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' elif filter_type == UserFilterType.TRAFFIC: if user.subscription: sub = user.subscription used = sub.traffic_used_gb or 0.0 if sub.traffic_limit_gb and sub.traffic_limit_gb > 0: - limit_display = f"{sub.traffic_limit_gb}" + limit_display = f'{sub.traffic_limit_gb}' else: - limit_display = "♾️" - traffic_display = f"{used:.1f}/{limit_display} ГБ" + limit_display = '♾️' + traffic_display = f'{used:.1f}/{limit_display} ГБ' else: - traffic_display = "нет" - button_text = f"{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}" + traffic_display = 'нет' + button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}' else: - button_text = f"{status_emoji} {short_name}" + button_text = f'{status_emoji} {short_name}' return button_text @@ -244,7 +243,7 @@ async def _show_users_list_filtered( db: AsyncSession, state: FSMContext, filter_type: UserFilterType, - page: int = 1 + page: int = 1, ) -> None: """ Универсальная функция отображения отфильтрованного списка пользователей. @@ -263,88 +262,70 @@ async def _show_users_list_filtered( await state.set_state(config.fsm_state) user_service = UserService() - extra_data: Optional[Dict[str, Any]] = None + extra_data: dict[str, Any] | None = None # Получаем данные в зависимости от типа фильтра if filter_type == UserFilterType.CAMPAIGN: users_data = await user_service.get_users_by_campaign_page(db, page=page, limit=10) - extra_data = users_data.get("campaigns", {}) + extra_data = users_data.get('campaigns', {}) else: - kwargs = {"db": db, "page": page, "limit": 10, config.order_param: True} + kwargs = {'db': db, 'page': page, 'limit': 10, config.order_param: True} users_data = await user_service.get_users_page(**kwargs) - users = users_data.get("users", []) + users = users_data.get('users', []) # Если нет пользователей if not users: - await callback.message.edit_text( - config.empty_message, - reply_markup=get_admin_users_keyboard(db_user.language) - ) + await callback.message.edit_text(config.empty_message, reply_markup=get_admin_users_keyboard(db_user.language)) await callback.answer() return # Для spending/purchases нужны дополнительные данные if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES): - extra_data = await user_service.get_user_spending_stats_map( - db, [user.id for user in users] - ) + extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users]) # Формируем текст заголовка - text = f"{config.title} (стр. {page}/{users_data['total_pages']})\n\n" - text += "Нажмите на пользователя для управления:" + text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n' + text += 'Нажмите на пользователя для управления:' # Формируем клавиатуру keyboard = [] for user in users: button_text = _build_user_button_text(user, filter_type, extra_data, db_user.language) - keyboard.append([ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_user_manage_{user.id}" - ) - ]) + keyboard.append([types.InlineKeyboardButton(text=button_text, callback_data=f'admin_user_manage_{user.id}')]) # Пагинация - if users_data["total_pages"] > 1: + if users_data['total_pages'] > 1: pagination_row = get_admin_pagination_keyboard( - users_data["current_page"], - users_data["total_pages"], + users_data['current_page'], + users_data['total_pages'], config.pagination_prefix, - "admin_users", - db_user.language + 'admin_users', + db_user.language, ).inline_keyboard[0] keyboard.append(pagination_row) # Дополнительные кнопки - keyboard.extend([ + keyboard.extend( [ - types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"), - types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats") - ], - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + [ + types.InlineKeyboardButton(text='🔍 Поиск', callback_data='admin_users_search'), + types.InlineKeyboardButton(text='📊 Статистика', callback_data='admin_users_stats'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')], ] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def show_users_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_users_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_service = UserService() stats = await user_service.get_user_statistics(db) - + text = f""" 👥 Управление пользователями @@ -360,137 +341,104 @@ async def show_users_menu( Выберите действие: """ - - await callback.message.edit_text( - text, - reply_markup=get_admin_users_keyboard(db_user.language) - ) + + await callback.message.edit_text(text, reply_markup=get_admin_users_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler -async def show_users_filters( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - - text = ("⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:\n") - - await callback.message.edit_text( - text, - reply_markup=get_admin_users_filters_keyboard(db_user.language) - ) +async def show_users_filters(callback: types.CallbackQuery, db_user: User, state: FSMContext): + text = '⚙️ Фильтры пользователей\n\nВыберите фильтр для отображения пользователей:\n' + + await callback.message.edit_text(text, reply_markup=get_admin_users_filters_keyboard(db_user.language)) await callback.answer() @admin_required @error_handler async def show_users_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): - # Сбрасываем состояние, так как мы в обычном списке await state.set_state(None) - + user_service = UserService() users_data = await user_service.get_users_page(db, page=page, limit=10) - - if not users_data["users"]: + + if not users_data['users']: await callback.message.edit_text( - "👥 Пользователи не найдены", - reply_markup=get_admin_users_keyboard(db_user.language) + '👥 Пользователи не найдены', reply_markup=get_admin_users_keyboard(db_user.language) ) await callback.answer() return - - text = f"👥 Список пользователей (стр. {page}/{users_data['total_pages']})\n\n" - text += "Нажмите на пользователя для управления:" - + + text = f'👥 Список пользователей (стр. {page}/{users_data["total_pages"]})\n\n' + text += 'Нажмите на пользователя для управления:' + keyboard = [] - - for user in users_data["users"]: + + for user in users_data['users']: if user.status == UserStatus.ACTIVE.value: - status_emoji = "✅" + status_emoji = '✅' elif user.status == UserStatus.BLOCKED.value: - status_emoji = "🚫" + status_emoji = '🚫' else: - status_emoji = "🗑️" - - subscription_emoji = "" + status_emoji = '🗑️' + + subscription_emoji = '' if user.subscription: if user.subscription.is_trial: - subscription_emoji = "🎁" + subscription_emoji = '🎁' elif user.subscription.is_active: - subscription_emoji = "💎" + subscription_emoji = '💎' else: - subscription_emoji = "⏰" + subscription_emoji = '⏰' else: - subscription_emoji = "❌" - - button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" - + subscription_emoji = '❌' + + button_text = f'{status_emoji} {subscription_emoji} {user.full_name}' + if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" - - button_text += f" | 📅 {format_time_ago(user.created_at, db_user.language)}" - + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' + + button_text += f' | 📅 {format_time_ago(user.created_at, db_user.language)}' + if len(button_text) > 60: short_name = user.full_name if len(short_name) > 20: - short_name = short_name[:17] + "..." - - button_text = f"{status_emoji} {subscription_emoji} {short_name}" + short_name = short_name[:17] + '...' + + button_text = f'{status_emoji} {subscription_emoji} {short_name}' if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" - - keyboard.append([ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_user_manage_{user.id}" - ) - ]) - - if users_data["total_pages"] > 1: + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' + + keyboard.append([types.InlineKeyboardButton(text=button_text, callback_data=f'admin_user_manage_{user.id}')]) + + if users_data['total_pages'] > 1: pagination_row = get_admin_pagination_keyboard( - users_data["current_page"], - users_data["total_pages"], - "admin_users_list", - "admin_users", - db_user.language + users_data['current_page'], users_data['total_pages'], 'admin_users_list', 'admin_users', db_user.language ).inline_keyboard[0] keyboard.append(pagination_row) - - keyboard.extend([ + + keyboard.extend( [ - types.InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search"), - types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats") - ], - [ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") + [ + types.InlineKeyboardButton(text='🔍 Поиск', callback_data='admin_users_search'), + types.InlineKeyboardButton(text='📊 Статистика', callback_data='admin_users_stats'), + ], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')], ] - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler async def show_users_list_by_balance( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей, отсортированный по балансу (убывание).""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.BALANCE, page) @@ -499,11 +447,7 @@ async def show_users_list_by_balance( @admin_required @error_handler async def show_users_ready_to_renew( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Показывает пользователей с истекшей подпиской и балансом >= порога.""" await state.set_state(AdminStates.viewing_user_from_ready_to_renew_list) @@ -511,7 +455,7 @@ async def show_users_ready_to_renew( texts = get_texts(db_user.language) threshold = getattr( settings, - "SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS", + 'SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS', 20000, ) @@ -525,100 +469,103 @@ async def show_users_ready_to_renew( amount_text = settings.format_price(threshold) header = texts.t( - "ADMIN_USERS_FILTER_RENEW_READY_TITLE", - "♻️ Пользователи готовы к продлению", + 'ADMIN_USERS_FILTER_RENEW_READY_TITLE', + '♻️ Пользователи готовы к продлению', ) description = texts.t( - "ADMIN_USERS_FILTER_RENEW_READY_DESC", - "Подписка истекла, а на балансе осталось {amount} или больше.", + 'ADMIN_USERS_FILTER_RENEW_READY_DESC', + 'Подписка истекла, а на балансе осталось {amount} или больше.', ).format(amount=amount_text) - if not users_data["users"]: + if not users_data['users']: empty_text = texts.t( - "ADMIN_USERS_FILTER_RENEW_READY_EMPTY", - "Сейчас нет пользователей, которые подходят под этот фильтр.", + 'ADMIN_USERS_FILTER_RENEW_READY_EMPTY', + 'Сейчас нет пользователей, которые подходят под этот фильтр.', ) await callback.message.edit_text( - f"{header}\n\n{description}\n\n{empty_text}", + f'{header}\n\n{description}\n\n{empty_text}', reply_markup=get_admin_users_keyboard(db_user.language), ) await callback.answer() return - text = f"{header}\n\n{description}\n\n" - text += "Нажмите на пользователя для управления:" + text = f'{header}\n\n{description}\n\n' + text += 'Нажмите на пользователя для управления:' keyboard = [] current_time = datetime.utcnow() - for user in users_data["users"]: + for user in users_data['users']: subscription = user.subscription - status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" - subscription_emoji = "❌" - expired_days = "?" + status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫' + subscription_emoji = '❌' + expired_days = '?' if subscription: if subscription.is_trial: - subscription_emoji = "🎁" + subscription_emoji = '🎁' elif subscription.is_active: - subscription_emoji = "💎" + subscription_emoji = '💎' else: - subscription_emoji = "⏰" + subscription_emoji = '⏰' if subscription.end_date: delta = current_time - subscription.end_date expired_days = delta.days button_text = ( - f"{status_emoji} {subscription_emoji} {user.full_name}" - f" | 💰 {settings.format_price(user.balance_kopeks)}" - f" | ⏰ {expired_days}д ист." + f'{status_emoji} {subscription_emoji} {user.full_name}' + f' | 💰 {settings.format_price(user.balance_kopeks)}' + f' | ⏰ {expired_days}д ист.' ) if len(button_text) > 60: short_name = user.full_name if len(short_name) > 20: - short_name = short_name[:17] + "..." + short_name = short_name[:17] + '...' button_text = ( - f"{status_emoji} {subscription_emoji} {short_name}" - f" | 💰 {settings.format_price(user.balance_kopeks)}" + f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}' ) - keyboard.append([ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_user_manage_{user.id}", - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, + callback_data=f'admin_user_manage_{user.id}', + ) + ] + ) - if users_data["total_pages"] > 1: + if users_data['total_pages'] > 1: pagination_row = get_admin_pagination_keyboard( - users_data["current_page"], - users_data["total_pages"], - "admin_users_ready_to_renew_list", - "admin_users_ready_to_renew_filter", + users_data['current_page'], + users_data['total_pages'], + 'admin_users_ready_to_renew_list', + 'admin_users_ready_to_renew_filter', db_user.language, ).inline_keyboard[0] keyboard.append(pagination_row) - keyboard.extend([ + keyboard.extend( [ - types.InlineKeyboardButton( - text="🔍 Поиск", - callback_data="admin_users_search", - ), - types.InlineKeyboardButton( - text="📊 Статистика", - callback_data="admin_users_stats", - ), - ], - [ - types.InlineKeyboardButton( - text="⬅️ Назад", - callback_data="admin_users", - ) - ], - ]) + [ + types.InlineKeyboardButton( + text='🔍 Поиск', + callback_data='admin_users_search', + ), + types.InlineKeyboardButton( + text='📊 Статистика', + callback_data='admin_users_stats', + ), + ], + [ + types.InlineKeyboardButton( + text='⬅️ Назад', + callback_data='admin_users', + ) + ], + ] + ) await callback.message.edit_text( text, @@ -630,11 +577,7 @@ async def show_users_ready_to_renew( @admin_required @error_handler async def show_users_list_by_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей, отсортированный по использованному трафику (убывание).""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page) @@ -643,11 +586,7 @@ async def show_users_list_by_traffic( @admin_required @error_handler async def show_users_list_by_last_activity( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей, отсортированный по последней активности.""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page) @@ -656,11 +595,7 @@ async def show_users_list_by_last_activity( @admin_required @error_handler async def show_users_list_by_spending( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей, отсортированный по сумме трат (убывание).""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page) @@ -669,11 +604,7 @@ async def show_users_list_by_spending( @admin_required @error_handler async def show_users_list_by_purchases( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей, отсортированный по количеству покупок (убывание).""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page) @@ -682,11 +613,7 @@ async def show_users_list_by_purchases( @admin_required @error_handler async def show_users_list_by_campaign( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext, - page: int = 1 + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1 ): """Список пользователей по кампании регистрации.""" await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.CAMPAIGN, page) @@ -695,173 +622,140 @@ async def show_users_list_by_campaign( @admin_required @error_handler async def handle_users_list_pagination_fixed( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') page = int(callback_parts[-1]) await show_users_list(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_balance_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_balance(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_balance(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_traffic_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_traffic(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_traffic(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_activity_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_last_activity(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_last_activity(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_spending_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_spending(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_spending(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_purchases_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_purchases(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_purchases(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_ready_to_renew_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: page = int(callback.data.split('_')[-1]) await show_users_ready_to_renew(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_ready_to_renew(callback, db_user, db, state, 1) @admin_required @error_handler async def handle_users_campaign_list_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): try: callback_parts = callback.data.split('_') - page = int(callback_parts[-1]) + page = int(callback_parts[-1]) await show_users_list_by_campaign(callback, db_user, db, state, page) except (ValueError, IndexError) as e: - logger.error(f"Ошибка парсинга номера страницы: {e}") + logger.error(f'Ошибка парсинга номера страницы: {e}') await show_users_list_by_campaign(callback, db_user, db, state, 1) @admin_required @error_handler -async def start_user_search( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - +async def start_user_search(callback: types.CallbackQuery, db_user: User, state: FSMContext): await callback.message.edit_text( - "🔍 Поиск пользователя\n\n" - "Введите для поиска:\n" - "• Telegram ID\n" - "• Username (без @)\n" - "• Имя или фамилию\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")] - ]) + '🔍 Поиск пользователя\n\n' + 'Введите для поиска:\n' + '• Telegram ID\n' + '• Username (без @)\n' + '• Имя или фамилию\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_users')]] + ), ) - + await state.set_state(AdminStates.waiting_for_user_search) await callback.answer() + @admin_required @error_handler -async def show_users_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_users_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_service = UserService() stats = await user_service.get_user_statistics(db) - - from sqlalchemy import select, func, or_ + + from sqlalchemy import func, or_, select current_time = datetime.utcnow() @@ -879,9 +773,7 @@ async def show_users_statistics( Subscription.end_date > current_time, ) ) - users_with_subscription = ( - await db.execute(active_subscription_query) - ).scalar() or 0 + users_with_subscription = (await db.execute(active_subscription_query)).scalar() or 0 trial_subscription_query = ( select(func.count(Subscription.id)) @@ -898,16 +790,15 @@ async def show_users_statistics( trial_users = (await db.execute(trial_subscription_query)).scalar() or 0 users_without_subscription = max( - stats["active_users"] - users_with_subscription, + stats['active_users'] - users_with_subscription, 0, ) - + avg_balance_result = await db.execute( - select(func.avg(User.balance_kopeks)) - .where(User.status == UserStatus.ACTIVE.value) + select(func.avg(User.balance_kopeks)).where(User.status == UserStatus.ACTIVE.value) ) avg_balance = avg_balance_result.scalar() or 0 - + text = f""" 📊 Детальная статистика пользователей @@ -933,204 +824,159 @@ async def show_users_statistics( • Конверсия в подписку: {(users_with_subscription / max(stats['active_users'], 1) * 100):.1f}% • Доля триальных: {(trial_users / max(users_with_subscription, 1) * 100):.1f}% """ - + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_users_stats")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_users_stats')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')], + ] + ), ) await callback.answer() -async def _render_user_subscription_overview( - callback: types.CallbackQuery, - db: AsyncSession, - user_id: int -) -> bool: +async def _render_user_subscription_overview(callback: types.CallbackQuery, db: AsyncSession, user_id: int) -> bool: user_service = UserService() profile = await user_service.get_user_profile(db, user_id) if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return False - user = profile["user"] - subscription = profile["subscription"] + user = profile['user'] + subscription = profile['subscription'] - text = "📱 Подписка и настройки пользователя\n\n" + text = '📱 Подписка и настройки пользователя\n\n' if user.telegram_id: user_link = f'{user.full_name}' user_id_display = user.telegram_id else: - user_link = f"{user.full_name}" - user_id_display = user.email or f"#{user.id}" - text += f"👤 {user_link} (ID: {user_id_display})\n\n" + user_link = f'{user.full_name}' + user_id_display = user.email or f'#{user.id}' + text += f'👤 {user_link} (ID: {user_id_display})\n\n' keyboard = [] if subscription: - status_emoji = "✅" if subscription.is_active else "❌" - type_emoji = "🎁" if subscription.is_trial else "💎" + status_emoji = '✅' if subscription.is_active else '❌' + type_emoji = '🎁' if subscription.is_trial else '💎' - traffic_display = f"{subscription.traffic_used_gb:.1f}/" + traffic_display = f'{subscription.traffic_used_gb:.1f}/' if subscription.traffic_limit_gb == 0: - traffic_display += "♾️ ГБ" + traffic_display += '♾️ ГБ' else: - traffic_display += f"{subscription.traffic_limit_gb} ГБ" + traffic_display += f'{subscription.traffic_limit_gb} ГБ' - text += f"Статус: {status_emoji} {'Активна' if subscription.is_active else 'Неактивна'}\n" - text += f"Тип: {type_emoji} {'Триал' if subscription.is_trial else 'Платная'}\n" + text += f'Статус: {status_emoji} {"Активна" if subscription.is_active else "Неактивна"}\n' + text += f'Тип: {type_emoji} {"Триал" if subscription.is_trial else "Платная"}\n' # Отображение тарифа if subscription.tariff_id: tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - text += f"Тариф: 📦 {tariff.name}\n" + text += f'Тариф: 📦 {tariff.name}\n' else: - text += f"Тариф: ID {subscription.tariff_id} (удалён)\n" + text += f'Тариф: ID {subscription.tariff_id} (удалён)\n' - text += f"Начало: {format_datetime(subscription.start_date)}\n" - text += f"Окончание: {format_datetime(subscription.end_date)}\n" - text += f"Трафик: {traffic_display}\n" - text += f"Устройства: {subscription.device_limit}\n" + text += f'Начало: {format_datetime(subscription.start_date)}\n' + text += f'Окончание: {format_datetime(subscription.end_date)}\n' + text += f'Трафик: {traffic_display}\n' + text += f'Устройства: {subscription.device_limit}\n' if subscription.is_active: days_left = (subscription.end_date - datetime.utcnow()).days - text += f"Осталось дней: {days_left}\n" + text += f'Осталось дней: {days_left}\n' current_squads = subscription.connected_squads or [] if current_squads: - text += "\nПодключенные серверы:\n" + text += '\nПодключенные серверы:\n' for squad_uuid in current_squads: try: server = await get_server_squad_by_uuid(db, squad_uuid) if server: - text += f"• {server.display_name}\n" + text += f'• {server.display_name}\n' else: - text += f"• {squad_uuid[:8]}... (неизвестный)\n" + text += f'• {squad_uuid[:8]}... (неизвестный)\n' except Exception as e: - logger.error(f"Ошибка получения сервера {squad_uuid}: {e}") - text += f"• {squad_uuid[:8]}... (ошибка загрузки)\n" + logger.error(f'Ошибка получения сервера {squad_uuid}: {e}') + text += f'• {squad_uuid[:8]}... (ошибка загрузки)\n' else: - text += "\nПодключенные серверы: отсутствуют\n" + text += '\nПодключенные серверы: отсутствуют\n' keyboard = [ [ - types.InlineKeyboardButton( - text="⏰ Продлить", - callback_data=f"admin_sub_extend_{user_id}" - ), - types.InlineKeyboardButton( - text="💳 Купить подписку", - callback_data=f"admin_sub_buy_{user_id}" - ) + types.InlineKeyboardButton(text='⏰ Продлить', callback_data=f'admin_sub_extend_{user_id}'), + types.InlineKeyboardButton(text='💳 Купить подписку', callback_data=f'admin_sub_buy_{user_id}'), + ], + [ + types.InlineKeyboardButton(text='🔄 Тип подписки', callback_data=f'admin_sub_change_type_{user_id}'), + types.InlineKeyboardButton(text='📊 Добавить трафик', callback_data=f'admin_sub_traffic_{user_id}'), ], [ types.InlineKeyboardButton( - text="🔄 Тип подписки", - callback_data=f"admin_sub_change_type_{user_id}" + text='🌍 Сменить сервер', callback_data=f'admin_user_change_server_{user_id}' ), - types.InlineKeyboardButton( - text="📊 Добавить трафик", - callback_data=f"admin_sub_traffic_{user_id}" - ) + types.InlineKeyboardButton(text='📱 Устройства', callback_data=f'admin_user_devices_{user_id}'), ], [ + types.InlineKeyboardButton(text='🛠️ Лимит трафика', callback_data=f'admin_user_traffic_{user_id}'), types.InlineKeyboardButton( - text="🌍 Сменить сервер", - callback_data=f"admin_user_change_server_{user_id}" + text='🔄 Сбросить устройства', callback_data=f'admin_user_reset_devices_{user_id}' ), - types.InlineKeyboardButton( - text="📱 Устройства", - callback_data=f"admin_user_devices_{user_id}" - ) ], - [ - types.InlineKeyboardButton( - text="🛠️ Лимит трафика", - callback_data=f"admin_user_traffic_{user_id}" - ), - types.InlineKeyboardButton( - text="🔄 Сбросить устройства", - callback_data=f"admin_user_reset_devices_{user_id}" - ) - ] ] if settings.is_modem_enabled(): - modem_status = "✅" if getattr(subscription, 'modem_enabled', False) else "❌" - keyboard.append([ - types.InlineKeyboardButton( - text=f"📡 Модем ({modem_status})", - callback_data=f"admin_user_modem_{user_id}" - ) - ]) + modem_status = '✅' if getattr(subscription, 'modem_enabled', False) else '❌' + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'📡 Модем ({modem_status})', callback_data=f'admin_user_modem_{user_id}' + ) + ] + ) # Кнопки тарифов в режиме тарифов if settings.is_tariffs_mode(): - keyboard.append([ - types.InlineKeyboardButton( - text="📦 Сменить тариф", - callback_data=f"admin_sub_change_tariff_{user_id}" - ), - types.InlineKeyboardButton( - text="💳 Купить тариф", - callback_data=f"admin_tariff_buy_{user_id}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text='📦 Сменить тариф', callback_data=f'admin_sub_change_tariff_{user_id}' + ), + types.InlineKeyboardButton(text='💳 Купить тариф', callback_data=f'admin_tariff_buy_{user_id}'), + ] + ) if subscription.is_active: - keyboard.append([ - types.InlineKeyboardButton( - text="🚫 Деактивировать", - callback_data=f"admin_sub_deactivate_{user_id}" - ) - ]) + keyboard.append( + [types.InlineKeyboardButton(text='🚫 Деактивировать', callback_data=f'admin_sub_deactivate_{user_id}')] + ) else: - keyboard.append([ - types.InlineKeyboardButton( - text="✅ Активировать", - callback_data=f"admin_sub_activate_{user_id}" - ) - ]) + keyboard.append( + [types.InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}')] + ) else: - text += "❌ Подписка отсутствует\n\n" - text += "Пользователь еще не активировал подписку." + text += '❌ Подписка отсутствует\n\n' + text += 'Пользователь еще не активировал подписку.' keyboard = [ [ - types.InlineKeyboardButton( - text="🎁 Выдать триал", - callback_data=f"admin_sub_grant_trial_{user_id}" - ), - types.InlineKeyboardButton( - text="💎 Выдать подписку", - callback_data=f"admin_sub_grant_{user_id}" - ) + types.InlineKeyboardButton(text='🎁 Выдать триал', callback_data=f'admin_sub_grant_trial_{user_id}'), + types.InlineKeyboardButton(text='💎 Выдать подписку', callback_data=f'admin_sub_grant_{user_id}'), ] ] - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}") - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ К пользователю', callback_data=f'admin_user_manage_{user_id}')]) - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) return True @admin_required @error_handler -async def show_user_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) if await _render_user_subscription_overview(callback, db, user_id): @@ -1139,204 +985,172 @@ async def show_user_subscription( @admin_required @error_handler -async def show_user_transactions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_user_transactions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + from app.database.crud.transaction import get_user_transactions - + user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - + transactions = await get_user_transactions(db, user_id, limit=10) - - text = f"💳 Транзакции пользователя\n\n" + + text = '💳 Транзакции пользователя\n\n' if user.telegram_id: user_link = f'{user.full_name}' user_id_display = user.telegram_id else: - user_link = f"{user.full_name}" - user_id_display = user.email or f"#{user.id}" - text += f"👤 {user_link} (ID: {user_id_display})\n" - text += f"💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - + user_link = f'{user.full_name}' + user_id_display = user.email or f'#{user.id}' + text += f'👤 {user_link} (ID: {user_id_display})\n' + text += f'💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' + if transactions: - text += "Последние транзакции:\n\n" - + text += 'Последние транзакции:\n\n' + for transaction in transactions: - type_emoji = "📈" if transaction.amount_kopeks > 0 else "📉" - text += f"{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n" - text += f"📋 {transaction.description}\n" - text += f"📅 {format_datetime(transaction.created_at)}\n\n" + type_emoji = '📈' if transaction.amount_kopeks > 0 else '📉' + text += f'{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n' + text += f'📋 {transaction.description}\n' + text += f'📅 {format_datetime(transaction.created_at)}\n\n' else: - text += "📭 Транзакции отсутствуют" - + text += '📭 Транзакции отсутствуют' + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) await callback.answer() @admin_required @error_handler -async def confirm_user_delete( - callback: types.CallbackQuery, - db_user: User -): - +async def confirm_user_delete(callback: types.CallbackQuery, db_user: User): user_id = int(callback.data.split('_')[-1]) - + await callback.message.edit_text( - "🗑️ Удаление пользователя\n\n" - "⚠️ ВНИМАНИЕ!\n" - "Вы уверены, что хотите удалить этого пользователя?\n\n" - "Это действие:\n" - "• Пометит пользователя как удаленного\n" - "• Деактивирует его подписку\n" - "• Заблокирует доступ к боту\n\n" - "Данное действие необратимо!", + '🗑️ Удаление пользователя\n\n' + '⚠️ ВНИМАНИЕ!\n' + 'Вы уверены, что хотите удалить этого пользователя?\n\n' + 'Это действие:\n' + '• Пометит пользователя как удаленного\n' + '• Деактивирует его подписку\n' + '• Заблокирует доступ к боту\n\n' + 'Данное действие необратимо!', reply_markup=get_confirmation_keyboard( - f"admin_user_delete_confirm_{user_id}", - f"admin_user_manage_{user_id}", - db_user.language - ) + f'admin_user_delete_confirm_{user_id}', f'admin_user_manage_{user_id}', db_user.language + ), ) await callback.answer() @admin_required @error_handler -async def delete_user_account( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def delete_user_account(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() success = await user_service.delete_user_account(db, user_id, db_user.id) - + if success: await callback.message.edit_text( - "✅ Пользователь успешно удален", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👥 К списку пользователей", callback_data="admin_users_list")] - ]) + '✅ Пользователь успешно удален', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👥 К списку пользователей', callback_data='admin_users_list')] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка удаления пользователя", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + '❌ Ошибка удаления пользователя', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def process_user_search( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): - +async def process_user_search(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): query = message.text.strip() - + if not query: - await message.answer("❌ Введите корректный запрос для поиска") + await message.answer('❌ Введите корректный запрос для поиска') return - + user_service = UserService() search_results = await user_service.search_users(db, query, page=1, limit=10) - - if not search_results["users"]: + + if not search_results['users']: await message.answer( f"🔍 По запросу '{query}' ничего не найдено", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]] + ), ) await state.clear() return - + text = f"🔍 Результаты поиска: '{query}'\n\n" - text += "Выберите пользователя:" - + text += 'Выберите пользователя:' + keyboard = [] - - for user in search_results["users"]: + + for user in search_results['users']: if user.status == UserStatus.ACTIVE.value: - status_emoji = "✅" + status_emoji = '✅' elif user.status == UserStatus.BLOCKED.value: - status_emoji = "🚫" + status_emoji = '🚫' else: - status_emoji = "🗑️" - - subscription_emoji = "" + status_emoji = '🗑️' + + subscription_emoji = '' if user.subscription: if user.subscription.is_trial: - subscription_emoji = "🎁" + subscription_emoji = '🎁' elif user.subscription.is_active: - subscription_emoji = "💎" + subscription_emoji = '💎' else: - subscription_emoji = "⏰" + subscription_emoji = '⏰' else: - subscription_emoji = "❌" - - button_text = f"{status_emoji} {subscription_emoji} {user.full_name}" + subscription_emoji = '❌' - user_id_display = user.telegram_id or user.email or f"#{user.id}" - button_text += f" | 🆔 {user_id_display}" + button_text = f'{status_emoji} {subscription_emoji} {user.full_name}' + + user_id_display = user.telegram_id or user.email or f'#{user.id}' + button_text += f' | 🆔 {user_id_display}' if user.balance_kopeks > 0: - button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}" + button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}' if len(button_text) > 60: short_name = user.full_name if len(short_name) > 15: - short_name = short_name[:12] + "..." - button_text = f"{status_emoji} {subscription_emoji} {short_name} | 🆔 {user_id_display}" - - keyboard.append([ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_user_manage_{user.id}" - ) - ]) - - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users") - ]) - - await message.answer( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + short_name = short_name[:12] + '...' + button_text = f'{status_emoji} {subscription_emoji} {short_name} | 🆔 {user_id_display}' + + keyboard.append([types.InlineKeyboardButton(text=button_text, callback_data=f'admin_user_manage_{user.id}')]) + + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]) + + await message.answer(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await state.clear() @admin_required @error_handler -async def show_user_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - +async def show_user_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): # Поддерживаем переход "из тикета": admin_user_manage_{userId}_from_ticket_{ticketId} parts = callback.data.split('_') try: @@ -1344,7 +1158,7 @@ async def show_user_management( except Exception: user_id = int(callback.data.split('_')[-1]) origin_ticket_id = None - if "from" in parts and "ticket" in parts: + if 'from' in parts and 'ticket' in parts: try: origin_ticket_id = int(parts[-1]) except Exception: @@ -1359,26 +1173,26 @@ async def show_user_management( 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") + 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" - + back_callback = 'admin_users_list' + # Если callback_data содержит информацию о том, что мы пришли из списка по балансу # В реальности это сложно определить, поэтому будем использовать состояние - + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) - + if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - - user = profile["user"] - subscription = profile["subscription"] + + user = profile['user'] + subscription = profile['subscription'] texts = get_texts(db_user.language) @@ -1389,9 +1203,7 @@ async def show_user_management( } status_text = status_map.get(user.status, texts.ADMIN_USER_STATUS_UNKNOWN) - username_display = ( - f"@{user.username}" if user.username else texts.ADMIN_USER_USERNAME_NOT_SET - ) + username_display = f'@{user.username}' if user.username else texts.ADMIN_USER_USERNAME_NOT_SET last_activity = ( format_time_ago(user.last_activity, db_user.language) if user.last_activity @@ -1406,10 +1218,10 @@ async def show_user_management( status=status_text, language=user.language, balance=settings.format_price(user.balance_kopeks), - transactions=profile["transactions_count"], + transactions=profile['transactions_count'], registration=format_datetime(user.created_at), last_activity=last_activity, - registration_days=profile["registration_days"], + registration_days=profile['registration_days'], ) ] @@ -1425,7 +1237,7 @@ async def show_user_management( else texts.ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE ) traffic_usage = texts.ADMIN_USER_TRAFFIC_USAGE.format( - used=f"{subscription.traffic_used_gb:.1f}", + used=f'{subscription.traffic_used_gb:.1f}', limit=subscription.traffic_limit_gb, ) sections.append( @@ -1446,9 +1258,9 @@ async def show_user_management( if primary_group: sections.append( texts.t( - "ADMIN_USER_PROMO_GROUPS_PRIMARY", - "⭐ Основная: {name} (Priority: {priority})", - ).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0)) + 'ADMIN_USER_PROMO_GROUPS_PRIMARY', + '⭐ Основная: {name} (Priority: {priority})', + ).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0)) ) sections.append( texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format( @@ -1462,20 +1274,19 @@ async def show_user_management( # Show additional groups if any if user.user_promo_groups and len(user.user_promo_groups) > 1: additional_groups = [ - upg.promo_group for upg in user.user_promo_groups + upg.promo_group + for upg in user.user_promo_groups if upg.promo_group and upg.promo_group.id != primary_group.id ] if additional_groups: sections.append( texts.t( - "ADMIN_USER_PROMO_GROUPS_ADDITIONAL", - "Дополнительные группы:", + 'ADMIN_USER_PROMO_GROUPS_ADDITIONAL', + 'Дополнительные группы:', ) ) for group in additional_groups: - sections.append( - f" • {group.name} (Priority: {getattr(group, 'priority', 0)})" - ) + sections.append(f' • {group.name} (Priority: {getattr(group, "priority", 0)})') else: sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE) @@ -1483,52 +1294,48 @@ async def show_user_management( restriction_topup = getattr(user, 'restriction_topup', False) restriction_subscription = getattr(user, 'restriction_subscription', False) if restriction_topup or restriction_subscription: - restriction_lines = ["⚠️ Ограничения:"] + restriction_lines = ['⚠️ Ограничения:'] if restriction_topup: - restriction_lines.append(" • 🚫 Пополнение запрещено") + restriction_lines.append(' • 🚫 Пополнение запрещено') if restriction_subscription: - restriction_lines.append(" • 🚫 Продление/покупка запрещена") + restriction_lines.append(' • 🚫 Продление/покупка запрещена') restriction_reason = getattr(user, 'restriction_reason', None) if restriction_reason: - restriction_lines.append(f" 📝 Причина: {restriction_reason}") - sections.append("\n".join(restriction_lines)) + restriction_lines.append(f' 📝 Причина: {restriction_reason}') + sections.append('\n'.join(restriction_lines)) - text = "\n\n".join(sections) + text = '\n\n'.join(sections) # Проверяем состояние, чтобы определить, откуда пришел пользователь current_state = await state.get_state() if current_state == AdminStates.viewing_user_from_balance_list: - back_callback = "admin_users_balance_filter" + back_callback = 'admin_users_balance_filter' elif current_state == AdminStates.viewing_user_from_traffic_list: - back_callback = "admin_users_traffic_filter" + back_callback = 'admin_users_traffic_filter' elif current_state == AdminStates.viewing_user_from_last_activity_list: - back_callback = "admin_users_activity_filter" + back_callback = 'admin_users_activity_filter' elif current_state == AdminStates.viewing_user_from_spending_list: - back_callback = "admin_users_spending_filter" + back_callback = 'admin_users_spending_filter' elif current_state == AdminStates.viewing_user_from_purchases_list: - back_callback = "admin_users_purchases_filter" + back_callback = 'admin_users_purchases_filter' elif current_state == AdminStates.viewing_user_from_campaign_list: - back_callback = "admin_users_campaign_filter" + back_callback = 'admin_users_campaign_filter' elif current_state == AdminStates.viewing_user_from_ready_to_renew_list: - back_callback = "admin_users_ready_to_renew_filter" - + back_callback = 'admin_users_ready_to_renew_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}" + 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=kb - ) + await callback.message.edit_text(text, reply_markup=kb) await callback.answer() @@ -1537,7 +1344,7 @@ async def _build_user_referrals_view( language: str, user_id: int, limit: int = 30, -) -> Optional[Tuple[str, InlineKeyboardMarkup]]: +) -> tuple[str, InlineKeyboardMarkup] | None: texts = get_texts(language) user = await get_user_by_id(db, user_id) @@ -1550,32 +1357,32 @@ async def _build_user_referrals_view( default_percent = settings.REFERRAL_COMMISSION_PERCENT header = texts.t( - "ADMIN_USER_REFERRALS_TITLE", - "🤝 Рефералы пользователя", + 'ADMIN_USER_REFERRALS_TITLE', + '🤝 Рефералы пользователя', ) summary = texts.t( - "ADMIN_USER_REFERRALS_SUMMARY", - "👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}", + 'ADMIN_USER_REFERRALS_SUMMARY', + '👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}', ).format( name=user.full_name, telegram_id=user.telegram_id, count=len(referrals), ) - lines: List[str] = [header, summary] + lines: list[str] = [header, summary] if user.referral_commission_percent is None: lines.append( texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_DEFAULT", - "• Процент комиссии: {percent}% (стандартное значение)", + 'ADMIN_USER_REFERRAL_COMMISSION_DEFAULT', + '• Процент комиссии: {percent}% (стандартное значение)', ).format(percent=effective_percent) ) else: lines.append( texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_CUSTOM", - "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)", + 'ADMIN_USER_REFERRAL_COMMISSION_CUSTOM', + '• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)', ).format( percent=user.referral_commission_percent, default_percent=default_percent, @@ -1585,27 +1392,23 @@ async def _build_user_referrals_view( if referrals: lines.append( texts.t( - "ADMIN_USER_REFERRALS_LIST_HEADER", - "Список рефералов:", + 'ADMIN_USER_REFERRALS_LIST_HEADER', + 'Список рефералов:', ) ) items = [] for referral in referrals[:limit]: - username_part = ( - f", @{referral.username}" - if referral.username - else "" - ) + username_part = f', @{referral.username}' if referral.username else '' if referral.telegram_id: referral_link = f'{referral.full_name}' referral_id_display = referral.telegram_id else: - referral_link = f"{referral.full_name}" - referral_id_display = referral.email or f"#{referral.id}" + referral_link = f'{referral.full_name}' + referral_id_display = referral.email or f'#{referral.id}' items.append( texts.t( - "ADMIN_USER_REFERRALS_LIST_ITEM", - "• {name} (ID: {telegram_id}{username_part})", + 'ADMIN_USER_REFERRALS_LIST_ITEM', + '• {name} (ID: {telegram_id}{username_part})', ).format( name=referral_link, telegram_id=referral_id_display, @@ -1613,28 +1416,28 @@ async def _build_user_referrals_view( ) ) - lines.append("\n".join(items)) + lines.append('\n'.join(items)) if len(referrals) > limit: remaining = len(referrals) - limit lines.append( texts.t( - "ADMIN_USER_REFERRALS_LIST_TRUNCATED", - "• … и ещё {count} рефералов", + 'ADMIN_USER_REFERRALS_LIST_TRUNCATED', + '• … и ещё {count} рефералов', ).format(count=remaining) ) else: lines.append( texts.t( - "ADMIN_USER_REFERRALS_EMPTY", - "Рефералов пока нет.", + 'ADMIN_USER_REFERRALS_EMPTY', + 'Рефералов пока нет.', ) ) lines.append( texts.t( - "ADMIN_USER_REFERRALS_EDIT_HINT", - "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.", + 'ADMIN_USER_REFERRALS_EDIT_HINT', + '✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.', ) ) @@ -1643,31 +1446,31 @@ async def _build_user_referrals_view( [ InlineKeyboardButton( text=texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON", - "📈 Изменить процент", + 'ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON', + '📈 Изменить процент', ), - callback_data=f"admin_user_referral_percent_{user_id}", + callback_data=f'admin_user_referral_percent_{user_id}', ) ], [ InlineKeyboardButton( text=texts.t( - "ADMIN_USER_REFERRALS_EDIT_BUTTON", - "✏️ Редактировать", + 'ADMIN_USER_REFERRALS_EDIT_BUTTON', + '✏️ Редактировать', ), - callback_data=f"admin_user_referrals_edit_{user_id}", + callback_data=f'admin_user_referrals_edit_{user_id}', ) ], [ InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_user_manage_{user_id}", + callback_data=f'admin_user_manage_{user_id}', ) ], ] ) - return "\n\n".join(lines), keyboard + return '\n\n'.join(lines), keyboard @admin_required @@ -1686,7 +1489,7 @@ async def show_user_referrals( preserved_data = { key: value for key, value in data.items() - if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"} + if key not in {'editing_referrals_user_id', 'referrals_message_id', 'editing_referral_percent_user_id'} } await state.clear() if preserved_data: @@ -1694,7 +1497,7 @@ async def show_user_referrals( view = await _build_user_referrals_view(db, db_user.language, user_id) if not view: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return text, keyboard = view @@ -1718,7 +1521,7 @@ async def start_edit_referral_percent( user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return texts = get_texts(db_user.language) @@ -1727,11 +1530,11 @@ async def start_edit_referral_percent( default_percent = settings.REFERRAL_COMMISSION_PERCENT prompt = texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_PROMPT", + 'ADMIN_USER_REFERRAL_COMMISSION_PROMPT', ( - "📈 Индивидуальный процент реферальной комиссии\n\n" - "Текущее значение: {current}%\n" - "Стандартное значение: {default}%\n\n" + '📈 Индивидуальный процент реферальной комиссии\n\n' + 'Текущее значение: {current}%\n' + 'Стандартное значение: {default}%\n\n' "Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса." ), ).format(current=effective_percent, default=default_percent) @@ -1740,37 +1543,37 @@ async def start_edit_referral_percent( inline_keyboard=[ [ InlineKeyboardButton( - text="5%", - callback_data=f"admin_user_referral_percent_set_{user_id}_5", + text='5%', + callback_data=f'admin_user_referral_percent_set_{user_id}_5', ), InlineKeyboardButton( - text="10%", - callback_data=f"admin_user_referral_percent_set_{user_id}_10", + text='10%', + callback_data=f'admin_user_referral_percent_set_{user_id}_10', ), ], [ InlineKeyboardButton( - text="15%", - callback_data=f"admin_user_referral_percent_set_{user_id}_15", + text='15%', + callback_data=f'admin_user_referral_percent_set_{user_id}_15', ), InlineKeyboardButton( - text="20%", - callback_data=f"admin_user_referral_percent_set_{user_id}_20", + text='20%', + callback_data=f'admin_user_referral_percent_set_{user_id}_20', ), ], [ InlineKeyboardButton( text=texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON", - "♻️ Сбросить на стандартный", + 'ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON', + '♻️ Сбросить на стандартный', ), - callback_data=f"admin_user_referral_percent_reset_{user_id}", + callback_data=f'admin_user_referral_percent_reset_{user_id}', ) ], [ InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_user_referrals_{user_id}", + callback_data=f'admin_user_referrals_{user_id}', ) ], ] @@ -1789,9 +1592,9 @@ async def start_edit_referral_percent( async def _update_referral_commission_percent( db: AsyncSession, user_id: int, - percent: Optional[int], + percent: int | None, admin_id: int, -) -> Tuple[bool, Optional[int]]: +) -> tuple[bool, int | None]: try: user = await get_user_by_id(db, user_id) if not user: @@ -1805,7 +1608,7 @@ async def _update_referral_commission_percent( effective = get_effective_referral_commission_percent(user) logger.info( - "Админ %s обновил реферальный процент пользователя %s: %s", + 'Админ %s обновил реферальный процент пользователя %s: %s', admin_id, user_id, percent, @@ -1814,14 +1617,14 @@ async def _update_referral_commission_percent( return True, effective except Exception as e: logger.error( - "Ошибка обновления реферального процента пользователя %s: %s", + 'Ошибка обновления реферального процента пользователя %s: %s', user_id, e, ) try: await db.rollback() except Exception as rollback_error: - logger.error("Ошибка отката транзакции: %s", rollback_error) + logger.error('Ошибка отката транзакции: %s', rollback_error) return False, None @@ -1835,7 +1638,7 @@ async def _render_referrals_after_update( view = await _build_user_referrals_view(db, db_user.language, user_id) if view: text, keyboard = view - text = f"{success_message}\n\n" + text + text = f'{success_message}\n\n' + text await callback.message.edit_text(text, reply_markup=keyboard) else: await callback.message.edit_text(success_message) @@ -1851,9 +1654,9 @@ async def set_referral_percent_button( ): parts = callback.data.split('_') - if "reset" in parts: + if 'reset' in parts: user_id = int(parts[-1]) - percent_value: Optional[int] = None + percent_value: int | None = None else: user_id = int(parts[-2]) percent_value = int(parts[-1]) @@ -1868,14 +1671,14 @@ async def set_referral_percent_button( ) if not success: - await callback.answer("❌ Не удалось обновить процент", show_alert=True) + await callback.answer('❌ Не удалось обновить процент', show_alert=True) return await state.clear() success_message = texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_UPDATED", - "✅ Процент обновлён: {percent}%", + 'ADMIN_USER_REFERRAL_COMMISSION_UPDATED', + '✅ Процент обновлён: {percent}%', ).format(percent=effective_percent) await _render_referrals_after_update(callback, db, db_user, user_id, success_message) @@ -1891,18 +1694,18 @@ async def process_referral_percent_input( db: AsyncSession, ): data = await state.get_data() - user_id = data.get("editing_referral_percent_user_id") + user_id = data.get('editing_referral_percent_user_id') if not user_id: - await message.answer("❌ Не удалось определить пользователя") + await message.answer('❌ Не удалось определить пользователя') return raw_text = message.text.strip() normalized = raw_text.lower() - percent_value: Optional[int] + percent_value: int | None - if normalized in {"стандарт", "standard", "default"}: + if normalized in {'стандарт', 'standard', 'default'}: percent_value = None else: normalized_number = raw_text.replace(',', '.').strip() @@ -1911,7 +1714,7 @@ async def process_referral_percent_input( except (TypeError, ValueError): await message.answer( get_texts(db_user.language).t( - "ADMIN_USER_REFERRAL_COMMISSION_INVALID", + 'ADMIN_USER_REFERRAL_COMMISSION_INVALID', "❌ Введите число от 0 до 100 или слово 'стандарт'", ) ) @@ -1922,7 +1725,7 @@ async def process_referral_percent_input( if percent_value < 0 or percent_value > 100: await message.answer( get_texts(db_user.language).t( - "ADMIN_USER_REFERRAL_COMMISSION_INVALID", + 'ADMIN_USER_REFERRAL_COMMISSION_INVALID', "❌ Введите число от 0 до 100 или слово 'стандарт'", ) ) @@ -1938,20 +1741,20 @@ async def process_referral_percent_input( ) if not success: - await message.answer("❌ Не удалось обновить процент") + await message.answer('❌ Не удалось обновить процент') return await state.clear() success_message = texts.t( - "ADMIN_USER_REFERRAL_COMMISSION_UPDATED", - "✅ Процент обновлён: {percent}%", + 'ADMIN_USER_REFERRAL_COMMISSION_UPDATED', + '✅ Процент обновлён: {percent}%', ).format(percent=effective_percent) view = await _build_user_referrals_view(db, db_user.language, int(user_id)) if view: text, keyboard = view - await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard) + await message.answer(f'{success_message}\n\n{text}', reply_markup=keyboard) else: await message.answer(success_message) @@ -1968,20 +1771,20 @@ async def start_edit_user_referrals( user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return texts = get_texts(db_user.language) prompt = texts.t( - "ADMIN_USER_REFERRALS_EDIT_PROMPT", + 'ADMIN_USER_REFERRALS_EDIT_PROMPT', ( - "✏️ Редактирование рефералов\n\n" - "Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n" - "• Используйте TG ID или @username\n" - "• Значения можно указывать через запятую, пробел или с новой строки\n" + '✏️ Редактирование рефералов\n\n' + 'Отправьте список рефералов для пользователя {name} (ID: {telegram_id}):\n' + '• Используйте TG ID или @username\n' + '• Значения можно указывать через запятую, пробел или с новой строки\n' "• Чтобы очистить список, отправьте 0 или слово 'нет'\n\n" - "Или нажмите кнопку ниже, чтобы отменить." + 'Или нажмите кнопку ниже, чтобы отменить.' ), ).format( name=user.full_name, @@ -2000,7 +1803,7 @@ async def start_edit_user_referrals( [ InlineKeyboardButton( text=texts.BACK, - callback_data=f"admin_user_referrals_{user_id}", + callback_data=f'admin_user_referrals_{user_id}', ) ] ] @@ -2022,12 +1825,12 @@ async def process_edit_user_referrals( texts = get_texts(db_user.language) data = await state.get_data() - user_id = data.get("editing_referrals_user_id") + user_id = data.get('editing_referrals_user_id') if not user_id: await message.answer( texts.t( - "ADMIN_USER_REFERRALS_STATE_LOST", - "❌ Не удалось определить пользователя. Попробуйте начать сначала.", + 'ADMIN_USER_REFERRALS_STATE_LOST', + '❌ Не удалось определить пользователя. Попробуйте начать сначала.', ) ) await state.clear() @@ -2035,22 +1838,22 @@ async def process_edit_user_referrals( raw_text = message.text.strip() lower_text = raw_text.lower() - clear_keywords = {"0", "нет", "none", "пусто", "clear"} + clear_keywords = {'0', 'нет', 'none', 'пусто', 'clear'} clear_requested = lower_text in clear_keywords - tokens: List[str] = [] + tokens: list[str] = [] if not clear_requested: - parts = re.split(r"[,\n]+", raw_text) + parts = re.split(r'[,\n]+', raw_text) for part in parts: for token in part.split(): cleaned = token.strip() if cleaned and cleaned not in tokens: tokens.append(cleaned) - found_users: List[User] = [] - not_found: List[str] = [] - skipped_self: List[str] = [] - duplicate_tokens: List[str] = [] + found_users: list[User] = [] + not_found: list[str] = [] + skipped_self: list[str] = [] + duplicate_tokens: list[str] = [] seen_ids = set() @@ -2059,8 +1862,7 @@ async def process_edit_user_referrals( if not normalized: continue - if normalized.startswith("@"): - normalized = normalized[1:] + normalized = normalized.removeprefix('@') user = None if normalized.isdigit(): @@ -2089,25 +1891,25 @@ async def process_edit_user_referrals( if not found_users and not clear_requested: error_lines = [ texts.t( - "ADMIN_USER_REFERRALS_NO_VALID", - "❌ Не удалось найти ни одного пользователя по введённым данным.", + 'ADMIN_USER_REFERRALS_NO_VALID', + '❌ Не удалось найти ни одного пользователя по введённым данным.', ) ] if not_found: error_lines.append( texts.t( - "ADMIN_USER_REFERRALS_INVALID_ENTRIES", - "Не найдены: {values}", - ).format(values=", ".join(not_found)) + 'ADMIN_USER_REFERRALS_INVALID_ENTRIES', + 'Не найдены: {values}', + ).format(values=', '.join(not_found)) ) if skipped_self: error_lines.append( texts.t( - "ADMIN_USER_REFERRALS_SELF_SKIPPED", - "Пропущены значения пользователя: {values}", - ).format(values=", ".join(skipped_self)) + 'ADMIN_USER_REFERRALS_SELF_SKIPPED', + 'Пропущены значения пользователя: {values}', + ).format(values=', '.join(skipped_self)) ) - await message.answer("\n".join(error_lines)) + await message.answer('\n'.join(error_lines)) return user_service = UserService() @@ -2124,72 +1926,72 @@ async def process_edit_user_referrals( if not success: await message.answer( texts.t( - "ADMIN_USER_REFERRALS_UPDATE_ERROR", - "❌ Не удалось обновить рефералов. Попробуйте позже.", + 'ADMIN_USER_REFERRALS_UPDATE_ERROR', + '❌ Не удалось обновить рефералов. Попробуйте позже.', ) ) return response_lines = [ texts.t( - "ADMIN_USER_REFERRALS_UPDATED", - "✅ Список рефералов обновлён.", + 'ADMIN_USER_REFERRALS_UPDATED', + '✅ Список рефералов обновлён.', ) ] - total_referrals = details.get("total", len(new_referral_ids)) - added = details.get("added", 0) - removed = details.get("removed", 0) + total_referrals = details.get('total', len(new_referral_ids)) + added = details.get('added', 0) + removed = details.get('removed', 0) response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_UPDATED_TOTAL", - "• Текущий список: {total}", + 'ADMIN_USER_REFERRALS_UPDATED_TOTAL', + '• Текущий список: {total}', ).format(total=total_referrals) ) if added > 0: response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_UPDATED_ADDED", - "• Добавлено: {count}", + 'ADMIN_USER_REFERRALS_UPDATED_ADDED', + '• Добавлено: {count}', ).format(count=added) ) if removed > 0: response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_UPDATED_REMOVED", - "• Удалено: {count}", + 'ADMIN_USER_REFERRALS_UPDATED_REMOVED', + '• Удалено: {count}', ).format(count=removed) ) if not_found: response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_INVALID_ENTRIES", - "Не найдены: {values}", - ).format(values=", ".join(not_found)) + 'ADMIN_USER_REFERRALS_INVALID_ENTRIES', + 'Не найдены: {values}', + ).format(values=', '.join(not_found)) ) if skipped_self: response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_SELF_SKIPPED", - "Пропущены значения пользователя: {values}", - ).format(values=", ".join(skipped_self)) + 'ADMIN_USER_REFERRALS_SELF_SKIPPED', + 'Пропущены значения пользователя: {values}', + ).format(values=', '.join(skipped_self)) ) if duplicate_tokens: response_lines.append( texts.t( - "ADMIN_USER_REFERRALS_DUPLICATES", - "Игнорированы дубли: {values}", - ).format(values=", ".join(duplicate_tokens)) + 'ADMIN_USER_REFERRALS_DUPLICATES', + 'Игнорированы дубли: {values}', + ).format(values=', '.join(duplicate_tokens)) ) view = await _build_user_referrals_view(db, db_user.language, user_id) - message_id = data.get("referrals_message_id") + message_id = data.get('referrals_message_id') if view and message_id: try: @@ -2204,15 +2006,11 @@ async def process_edit_user_referrals( elif view: await message.answer(view[0], reply_markup=view[1]) - await message.answer("\n".join(response_lines)) + await message.answer('\n'.join(response_lines)) await state.clear() -async def _render_user_promo_group( - message: types.Message, - language: str, - user: User, - promo_groups: list -) -> None: + +async def _render_user_promo_group(message: types.Message, language: str, user: User, promo_groups: list) -> None: texts = get_texts(language) # Get primary and all user groups @@ -2222,9 +2020,9 @@ async def _render_user_promo_group( # Build current groups section if primary_group: current_line = texts.t( - "ADMIN_USER_PROMO_GROUPS_PRIMARY", - "⭐ Основная: {name} (Priority: {priority})", - ).format(name=primary_group.name, priority=getattr(primary_group, "priority", 0)) + 'ADMIN_USER_PROMO_GROUPS_PRIMARY', + '⭐ Основная: {name} (Priority: {priority})', + ).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0)) discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format( servers=primary_group.server_discount_percent, @@ -2235,29 +2033,34 @@ async def _render_user_promo_group( # Show additional groups if any if len(user_group_ids) > 1: additional_groups = [ - upg.promo_group for upg in user.user_promo_groups + upg.promo_group + for upg in user.user_promo_groups if upg.promo_group and upg.promo_group.id != primary_group.id ] if additional_groups: - additional_line = "\n" + texts.t( - "ADMIN_USER_PROMO_GROUPS_ADDITIONAL", - "Дополнительные группы:", - ) + "\n" + additional_line = ( + '\n' + + texts.t( + 'ADMIN_USER_PROMO_GROUPS_ADDITIONAL', + 'Дополнительные группы:', + ) + + '\n' + ) for group in additional_groups: - additional_line += f" • {group.name} (Priority: {getattr(group, 'priority', 0)})\n" + additional_line += f' • {group.name} (Priority: {getattr(group, "priority", 0)})\n' discount_line += additional_line else: current_line = texts.t( - "ADMIN_USER_PROMO_GROUPS_NONE", - "У пользователя нет промогрупп", + 'ADMIN_USER_PROMO_GROUPS_NONE', + 'У пользователя нет промогрупп', ) - discount_line = "" + discount_line = '' text = ( - f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n" - f"{current_line}\n" - f"{discount_line}\n\n" - f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}" + f'{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n' + f'{current_line}\n' + f'{discount_line}\n\n' + f'{texts.ADMIN_USER_PROMO_GROUP_SELECT}' ) await message.edit_text( @@ -2266,24 +2069,19 @@ async def _render_user_promo_group( promo_groups, user.id, user_group_ids, # Pass list of all group IDs - language - ) + language, + ), ) @admin_required @error_handler -async def show_user_promo_group( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_user_promo_group(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return promo_groups = await get_promo_groups_with_counts(db) @@ -2298,18 +2096,14 @@ async def show_user_promo_group( @admin_required @error_handler -async def set_user_promo_group( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - from app.database.crud.user_promo_group import ( - has_user_promo_group, - add_user_to_promo_group, - remove_user_from_promo_group, - count_user_promo_groups - ) +async def set_user_promo_group(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.database.crud.promo_group import get_promo_group_by_id + from app.database.crud.user_promo_group import ( + add_user_to_promo_group, + count_user_promo_groups, + has_user_promo_group, + remove_user_from_promo_group, + ) parts = callback.data.split('_') user_id = int(parts[-2]) @@ -2319,7 +2113,7 @@ async def set_user_promo_group( user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return # Check if user already has this group @@ -2332,10 +2126,10 @@ async def set_user_promo_group( if groups_count <= 1: await callback.answer( texts.t( - "ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST", - "❌ Нельзя удалить последнюю промогруппу", + 'ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST', + '❌ Нельзя удалить последнюю промогруппу', ), - show_alert=True + show_alert=True, ) return @@ -2343,10 +2137,10 @@ async def set_user_promo_group( await remove_user_from_promo_group(db, user_id, group_id) await callback.answer( texts.t( - "ADMIN_USER_PROMO_GROUP_REMOVED", - "🗑 Группа «{name}» удалена", - ).format(name=group.name if group else ""), - show_alert=True + 'ADMIN_USER_PROMO_GROUP_REMOVED', + '🗑 Группа «{name}» удалена', + ).format(name=group.name if group else ''), + show_alert=True, ) else: # Add group @@ -2355,13 +2149,13 @@ async def set_user_promo_group( await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True) return - await add_user_to_promo_group(db, user_id, group_id, assigned_by="admin") + await add_user_to_promo_group(db, user_id, group_id, assigned_by='admin') await callback.answer( texts.t( - "ADMIN_USER_PROMO_GROUP_ADDED", - "✅ Группа «{name}» добавлена", + 'ADMIN_USER_PROMO_GROUP_ADDED', + '✅ Группа «{name}» добавлена', ).format(name=group.name), - show_alert=True + show_alert=True, ) # Refresh user data and show updated list @@ -2370,31 +2164,27 @@ async def set_user_promo_group( await _render_user_promo_group(callback.message, db_user.language, user, promo_groups) - @admin_required @error_handler -async def start_balance_edit( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): - +async def start_balance_edit(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(editing_user_id=user_id) - + await callback.message.edit_text( - "💰 Изменение баланса\n\n" - "Введите сумму для изменения баланса:\n" - "• Положительное число для пополнения\n" - "• Отрицательное число для списания\n" - "• Примеры: 100, -50, 25.5\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")] - ]) + '💰 Изменение баланса\n\n' + 'Введите сумму для изменения баланса:\n' + '• Положительное число для пополнения\n' + '• Отрицательное число для списания\n' + '• Примеры: 100, -50, 25.5\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) - + await state.set_state(AdminStates.editing_user_balance) await callback.answer() @@ -2411,27 +2201,27 @@ async def start_send_user_message( target_user = await get_user_by_id(db, user_id) if not target_user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return await state.update_data(direct_message_user_id=user_id) texts = get_texts(db_user.language) - prompt = ( - texts.t("ADMIN_USER_SEND_MESSAGE_PROMPT", - "✉️ Отправка сообщения пользователю\n\n" - "Введите текст, который бот отправит пользователю." - "\n\nВы можете отменить действие командой /cancel или кнопкой ниже." ) + prompt = texts.t( + 'ADMIN_USER_SEND_MESSAGE_PROMPT', + '✉️ Отправка сообщения пользователю\n\n' + 'Введите текст, который бот отправит пользователю.' + '\n\nВы можете отменить действие командой /cancel или кнопкой ниже.', ) await callback.message.edit_text( prompt, reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ - [types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_manage_{user_id}")] + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_manage_{user_id}')] ] ), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(AdminStates.sending_user_message) @@ -2448,60 +2238,74 @@ async def process_send_user_message( ): texts = get_texts(db_user.language) data = await state.get_data() - user_id = data.get("direct_message_user_id") + user_id = data.get('direct_message_user_id') if not user_id: - await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь для отправки сообщения не найден")) + await message.answer( + texts.t('ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND', '❌ Пользователь для отправки сообщения не найден') + ) await state.clear() return target_user = await get_user_by_id(db, int(user_id)) if not target_user: - await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND", "❌ Пользователь не найден или был удалён")) + await message.answer( + texts.t('ADMIN_USER_SEND_MESSAGE_ERROR_NOT_FOUND', '❌ Пользователь не найден или был удалён') + ) await state.clear() return - text = (message.text or "").strip() + text = (message.text or '').strip() if not text: - await message.answer(texts.t("ADMIN_USER_SEND_MESSAGE_EMPTY", "❌ Пожалуйста, введите непустое сообщение")) + await message.answer(texts.t('ADMIN_USER_SEND_MESSAGE_EMPTY', '❌ Пожалуйста, введите непустое сообщение')) return confirmation_keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")]] + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] ) # Check if user has telegram_id (email-only users cannot receive Telegram messages) if not target_user.telegram_id: await message.answer( - texts.t("ADMIN_USER_NO_TELEGRAM_ID", "❌ Этот пользователь зарегистрирован только по email и не может получать сообщения в Telegram."), + texts.t( + 'ADMIN_USER_NO_TELEGRAM_ID', + '❌ Этот пользователь зарегистрирован только по email и не может получать сообщения в Telegram.', + ), reply_markup=confirmation_keyboard, ) await state.clear() return try: - await message.bot.send_message(target_user.telegram_id, text, parse_mode="HTML") + await message.bot.send_message(target_user.telegram_id, text, parse_mode='HTML') await message.answer( - texts.t("ADMIN_USER_SEND_MESSAGE_SUCCESS", "✅ Сообщение отправлено пользователю"), + texts.t('ADMIN_USER_SEND_MESSAGE_SUCCESS', '✅ Сообщение отправлено пользователю'), reply_markup=confirmation_keyboard, ) except TelegramForbiddenError: await message.answer( - texts.t("ADMIN_USER_SEND_MESSAGE_FORBIDDEN", "⚠️ Пользователь заблокировал бота или не может получить сообщения."), + texts.t( + 'ADMIN_USER_SEND_MESSAGE_FORBIDDEN', '⚠️ Пользователь заблокировал бота или не может получить сообщения.' + ), reply_markup=confirmation_keyboard, ) except TelegramBadRequest as err: - logger.error("Ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err) + logger.error('Ошибка отправки сообщения пользователю %s: %s', target_user.telegram_id, err) await message.answer( - texts.t("ADMIN_USER_SEND_MESSAGE_BAD_REQUEST", "❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз."), + texts.t( + 'ADMIN_USER_SEND_MESSAGE_BAD_REQUEST', + '❌ Telegram отклонил сообщение. Проверьте текст и попробуйте ещё раз.', + ), reply_markup=confirmation_keyboard, ) await state.clear() return except Exception as err: - logger.error("Неожиданная ошибка отправки сообщения пользователю %s: %s", target_user.telegram_id, err) + logger.error('Неожиданная ошибка отправки сообщения пользователю %s: %s', target_user.telegram_id, err) await message.answer( - texts.t("ADMIN_USER_SEND_MESSAGE_ERROR", "❌ Не удалось отправить сообщение. Попробуйте позже."), + texts.t('ADMIN_USER_SEND_MESSAGE_ERROR', '❌ Не удалось отправить сообщение. Попробуйте позже.'), reply_markup=confirmation_keyboard, ) await state.clear() @@ -2512,133 +2316,120 @@ async def process_send_user_message( @admin_required @error_handler -async def process_balance_edit( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): - +async def process_balance_edit(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - user_id = data.get("editing_user_id") - + user_id = data.get('editing_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: amount_rubles = float(message.text.replace(',', '.')) amount_kopeks = int(amount_rubles * 100) - - if abs(amount_kopeks) > 10000000: - await message.answer("❌ Слишком большая сумма (максимум 100,000 ₽)") + + if abs(amount_kopeks) > 10000000: + await message.answer('❌ Слишком большая сумма (максимум 100,000 ₽)') return - + user_service = UserService() - - description = f"Изменение баланса администратором {db_user.full_name}" + + description = f'Изменение баланса администратором {db_user.full_name}' if amount_kopeks > 0: - description = f"Пополнение администратором: +{int(amount_rubles)} ₽" + description = f'Пополнение администратором: +{int(amount_rubles)} ₽' else: - description = f"Списание администратором: {int(amount_rubles)} ₽" - + description = f'Списание администратором: {int(amount_rubles)} ₽' + success = await user_service.update_user_balance( - db, user_id, amount_kopeks, description, db_user.id, - bot=message.bot, admin_name=db_user.full_name + db, user_id, amount_kopeks, description, db_user.id, bot=message.bot, admin_name=db_user.full_name ) - + if success: - action = "пополнен" if amount_kopeks > 0 else "списан" + action = 'пополнен' if amount_kopeks > 0 else 'списан' await message.answer( - f"✅ Баланс пользователя {action} на {settings.format_price(abs(amount_kopeks))}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + f'✅ Баланс пользователя {action} на {settings.format_price(abs(amount_kopeks))}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка изменения баланса (возможно, недостаточно средств для списания)") - + await message.answer('❌ Ошибка изменения баланса (возможно, недостаточно средств для списания)') + except ValueError: - await message.answer("❌ Введите корректную сумму (например: 100 или -50)") + await message.answer('❌ Введите корректную сумму (например: 100 или -50)') return - + await state.clear() @admin_required @error_handler -async def confirm_user_block( - callback: types.CallbackQuery, - db_user: User -): - +async def confirm_user_block(callback: types.CallbackQuery, db_user: User): user_id = int(callback.data.split('_')[-1]) - + await callback.message.edit_text( - "🚫 Блокировка пользователя\n\n" - "Вы уверены, что хотите заблокировать этого пользователя?\n" - "Пользователь потеряет доступ к боту.", + '🚫 Блокировка пользователя\n\n' + 'Вы уверены, что хотите заблокировать этого пользователя?\n' + 'Пользователь потеряет доступ к боту.', reply_markup=get_confirmation_keyboard( - f"admin_user_block_confirm_{user_id}", - f"admin_user_manage_{user_id}", - db_user.language - ) + f'admin_user_block_confirm_{user_id}', f'admin_user_manage_{user_id}', db_user.language + ), ) await callback.answer() @admin_required @error_handler -async def block_user( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def block_user(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() - success = await user_service.block_user( - db, user_id, db_user.id, "Заблокирован администратором" - ) - + success = await user_service.block_user(db, user_id, db_user.id, 'Заблокирован администратором') + if success: await callback.message.edit_text( - "✅ Пользователь заблокирован", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + '✅ Пользователь заблокирован', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка блокировки пользователя", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + '❌ Ошибка блокировки пользователя', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) - + await callback.answer() # ============ УПРАВЛЕНИЕ ОГРАНИЧЕНИЯМИ ПОЛЬЗОВАТЕЛЯ ============ + @admin_required @error_handler -async def show_user_restrictions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показать меню управления ограничениями пользователя.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("Пользователь не найден", show_alert=True) + await callback.answer('Пользователь не найден', show_alert=True) return - texts = get_texts(db_user.language) + get_texts(db_user.language) # Формируем текст с информацией об ограничениях restriction_topup = getattr(user, 'restriction_topup', False) @@ -2646,46 +2437,39 @@ async def show_user_restrictions( restriction_reason = getattr(user, 'restriction_reason', None) text_lines = [ - f"⚠️ Ограничения пользователя", - f"👤 {user.full_name}", - "", - f"✅ — разрешено, 🚫 — запрещено", - "", - f"{'🚫' if restriction_topup else '✅'} Пополнение баланса", - f"{'🚫' if restriction_subscription else '✅'} Продление/покупка подписки", + '⚠️ Ограничения пользователя', + f'👤 {user.full_name}', + '', + '✅ — разрешено, 🚫 — запрещено', + '', + f'{"🚫" if restriction_topup else "✅"} Пополнение баланса', + f'{"🚫" if restriction_subscription else "✅"} Продление/покупка подписки', ] if restriction_reason: - text_lines.append("") - text_lines.append(f"📝 Причина: {restriction_reason}") + text_lines.append('') + text_lines.append(f'📝 Причина: {restriction_reason}') keyboard = get_user_restrictions_keyboard( user_id=user_id, restriction_topup=restriction_topup, restriction_subscription=restriction_subscription, - language=db_user.language + language=db_user.language, ) - await callback.message.edit_text( - "\n".join(text_lines), - reply_markup=keyboard - ) + await callback.message.edit_text('\n'.join(text_lines), reply_markup=keyboard) await callback.answer() @admin_required @error_handler -async def toggle_user_restriction_topup( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_user_restriction_topup(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Переключить ограничение на пополнение баланса.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("Пользователь не найден", show_alert=True) + await callback.answer('Пользователь не найден', show_alert=True) return # Переключаем ограничение @@ -2693,8 +2477,8 @@ async def toggle_user_restriction_topup( user.restriction_topup = not current_value await db.commit() - action = "установлено" if user.restriction_topup else "снято" - await callback.answer(f"Ограничение на пополнение {action}", show_alert=False) + action = 'установлено' if user.restriction_topup else 'снято' + await callback.answer(f'Ограничение на пополнение {action}', show_alert=False) # Обновляем меню await show_user_restrictions(callback, db_user, db) @@ -2702,17 +2486,13 @@ async def toggle_user_restriction_topup( @admin_required @error_handler -async def toggle_user_restriction_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_user_restriction_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Переключить ограничение на продление/покупку подписки.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("Пользователь не найден", show_alert=True) + await callback.answer('Пользователь не найден', show_alert=True) return # Переключаем ограничение @@ -2720,8 +2500,8 @@ async def toggle_user_restriction_subscription( user.restriction_subscription = not current_value await db.commit() - action = "установлено" if user.restriction_subscription else "снято" - await callback.answer(f"Ограничение на подписку {action}", show_alert=False) + action = 'установлено' if user.restriction_subscription else 'снято' + await callback.answer(f'Ограничение на подписку {action}', show_alert=False) # Обновляем меню await show_user_restrictions(callback, db_user, db) @@ -2729,63 +2509,55 @@ async def toggle_user_restriction_subscription( @admin_required @error_handler -async def ask_restriction_reason( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def ask_restriction_reason(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): """Запросить ввод причины ограничения.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("Пользователь не найден", show_alert=True) + await callback.answer('Пользователь не найден', show_alert=True) return - current_reason = getattr(user, 'restriction_reason', None) or "" + current_reason = getattr(user, 'restriction_reason', None) or '' await state.set_state(AdminStates.editing_user_restriction_reason) await state.update_data(restriction_user_id=user_id) text = ( - "📝 Введите причину ограничения\n\n" - "Эта причина будет показана пользователю при попытке " - "выполнить запрещённое действие.\n\n" + '📝 Введите причину ограничения\n\n' + 'Эта причина будет показана пользователю при попытке ' + 'выполнить запрещённое действие.\n\n' ) if current_reason: - text += f"Текущая причина: {current_reason}\n\n" - text += "Отправьте новую причину или /cancel для отмены:" + text += f'Текущая причина: {current_reason}\n\n' + text += 'Отправьте новую причину или /cancel для отмены:' await callback.message.edit_text( text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_restrictions_{user_id}")] - ]) + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_restrictions_{user_id}')] + ] + ), ) await callback.answer() @admin_required @error_handler -async def save_restriction_reason( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def save_restriction_reason(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): """Сохранить причину ограничения.""" data = await state.get_data() - user_id = data.get("restriction_user_id") + user_id = data.get('restriction_user_id') if not user_id: - await message.answer("Ошибка: пользователь не найден") + await message.answer('Ошибка: пользователь не найден') await state.clear() return user = await get_user_by_id(db, user_id) if not user: - await message.answer("Ошибка: пользователь не найден") + await message.answer('Ошибка: пользователь не найден') await state.clear() return @@ -2800,43 +2572,36 @@ async def save_restriction_reason( restriction_subscription = getattr(user, 'restriction_subscription', False) text_lines = [ - f"✅ Причина ограничения сохранена", - "", - f"⚠️ Ограничения пользователя", - f"👤 {user.full_name}", - "", - f"{'🚫' if restriction_topup else '✅'} Пополнение баланса", - f"{'🚫' if restriction_subscription else '✅'} Продление/покупка подписки", - "", - f"📝 Причина: {reason}", + '✅ Причина ограничения сохранена', + '', + '⚠️ Ограничения пользователя', + f'👤 {user.full_name}', + '', + f'{"🚫" if restriction_topup else "✅"} Пополнение баланса', + f'{"🚫" if restriction_subscription else "✅"} Продление/покупка подписки', + '', + f'📝 Причина: {reason}', ] keyboard = get_user_restrictions_keyboard( user_id=user_id, restriction_topup=restriction_topup, restriction_subscription=restriction_subscription, - language=db_user.language + language=db_user.language, ) - await message.answer( - "\n".join(text_lines), - reply_markup=keyboard - ) + await message.answer('\n'.join(text_lines), reply_markup=keyboard) @admin_required @error_handler -async def clear_user_restrictions( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def clear_user_restrictions(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Снять все ограничения с пользователя.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("Пользователь не найден", show_alert=True) + await callback.answer('Пользователь не найден', show_alert=True) return # Снимаем все ограничения @@ -2845,7 +2610,7 @@ async def clear_user_restrictions( user.restriction_reason = None await db.commit() - await callback.answer("Все ограничения сняты", show_alert=True) + await callback.answer('Все ограничения сняты', show_alert=True) # Обновляем меню await show_user_restrictions(callback, db_user, db) @@ -2853,792 +2618,779 @@ async def clear_user_restrictions( @admin_required @error_handler -async def show_inactive_users( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - - user_service = UserService() - +async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + UserService() + from app.database.crud.user import get_inactive_users + inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS) - + if not inactive_users: await callback.message.edit_text( - f"✅ Неактивных пользователей (более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев) не найдено", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] - ]) + f'✅ Неактивных пользователей (более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев) не найдено', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]] + ), ) await callback.answer() return - - text = f"🗑️ Неактивные пользователи\n" - text += f"Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n" + + text = '🗑️ Неактивные пользователи\n' + text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n' for user in inactive_users[:10]: if user.telegram_id: user_link = f'{user.full_name}' user_id_display = user.telegram_id else: - user_link = f"{user.full_name}" - user_id_display = user.email or f"#{user.id}" - text += f"👤 {user_link}\n" - text += f"🆔 {user_id_display}\n" + user_link = f'{user.full_name}' + user_id_display = user.email or f'#{user.id}' + text += f'👤 {user_link}\n' + text += f'🆔 {user_id_display}\n' last_activity_display = ( - format_time_ago(user.last_activity, db_user.language) - if user.last_activity - else "Никогда" + format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда' ) - text += f"📅 {last_activity_display}\n\n" - + text += f'📅 {last_activity_display}\n\n' + if len(inactive_users) > 10: - text += f"... и еще {len(inactive_users) - 10} пользователей" - + text += f'... и еще {len(inactive_users) - 10} пользователей' + keyboard = [ - [types.InlineKeyboardButton(text="🗑️ Очистить всех", callback_data="admin_cleanup_inactive")], - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] + [types.InlineKeyboardButton(text='🗑️ Очистить всех', callback_data='admin_cleanup_inactive')], + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def confirm_user_unblock( - callback: types.CallbackQuery, - db_user: User -): - +async def confirm_user_unblock(callback: types.CallbackQuery, db_user: User): user_id = int(callback.data.split('_')[-1]) - + await callback.message.edit_text( - "✅ Разблокировка пользователя\n\n" - "Вы уверены, что хотите разблокировать этого пользователя?\n" - "Пользователь снова получит доступ к боту.", + '✅ Разблокировка пользователя\n\n' + 'Вы уверены, что хотите разблокировать этого пользователя?\n' + 'Пользователь снова получит доступ к боту.', reply_markup=get_confirmation_keyboard( - f"admin_user_unblock_confirm_{user_id}", - f"admin_user_manage_{user_id}", - db_user.language - ) + f'admin_user_unblock_confirm_{user_id}', f'admin_user_manage_{user_id}', db_user.language + ), ) await callback.answer() @admin_required @error_handler -async def unblock_user( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def unblock_user(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() success = await user_service.unblock_user(db, user_id, db_user.id) - + if success: await callback.message.edit_text( - "✅ Пользователь разблокирован", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + '✅ Пользователь разблокирован', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка разблокировки пользователя", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="👤 К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + '❌ Ошибка разблокировки пользователя', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='👤 К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) - + await callback.answer() + @admin_required @error_handler -async def show_user_statistics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) - + if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - - user = profile["user"] - subscription = profile["subscription"] - + + user = profile['user'] + subscription = profile['subscription'] + referral_stats = await get_detailed_referral_stats(db, user.id) campaign_registration = await get_campaign_registration_by_user(db, user.id) campaign_stats = None if campaign_registration: campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) - - text = f"📊 Статистика пользователя\n\n" + + text = '📊 Статистика пользователя\n\n' if user.telegram_id: user_link = f'{user.full_name}' user_id_display = user.telegram_id else: - user_link = f"{user.full_name}" - user_id_display = user.email or f"#{user.id}" - text += f"👤 {user_link} (ID: {user_id_display})\n\n" - - text += f"Основная информация:\n" - text += f"• Дней с регистрации: {profile['registration_days']}\n" - text += f"• Баланс: {settings.format_price(user.balance_kopeks)}\n" - text += f"• Транзакций: {profile['transactions_count']}\n" - text += f"• Язык: {user.language}\n\n" - - text += f"Подписка:\n" + user_link = f'{user.full_name}' + user_id_display = user.email or f'#{user.id}' + text += f'👤 {user_link} (ID: {user_id_display})\n\n' + + text += 'Основная информация:\n' + text += f'• Дней с регистрации: {profile["registration_days"]}\n' + text += f'• Баланс: {settings.format_price(user.balance_kopeks)}\n' + text += f'• Транзакций: {profile["transactions_count"]}\n' + text += f'• Язык: {user.language}\n\n' + + text += 'Подписка:\n' if subscription: - sub_status = "✅ Активна" if subscription.is_active else "❌ Неактивна" - sub_type = " (пробная)" if subscription.is_trial else " (платная)" - text += f"• Статус: {sub_status}{sub_type}\n" - text += f"• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n" - text += f"• Устройства: {subscription.device_limit}\n" - text += f"• Стран: {len(subscription.connected_squads)}\n" + sub_status = '✅ Активна' if subscription.is_active else '❌ Неактивна' + sub_type = ' (пробная)' if subscription.is_trial else ' (платная)' + text += f'• Статус: {sub_status}{sub_type}\n' + text += f'• Трафик: {subscription.traffic_used_gb:.1f}/{subscription.traffic_limit_gb} ГБ\n' + text += f'• Устройства: {subscription.device_limit}\n' + text += f'• Стран: {len(subscription.connected_squads)}\n' else: - text += f"• Отсутствует\n" - - text += f"\nРеферальная программа:\n" + text += '• Отсутствует\n' + + text += '\nРеферальная программа:\n' if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: - text += f"• Пришел по реферальной ссылке от {referrer.full_name}\n" + text += f'• Пришел по реферальной ссылке от {referrer.full_name}\n' else: - text += "• Пришел по реферальной ссылке (реферер не найден)\n" + text += '• Пришел по реферальной ссылке (реферер не найден)\n' if campaign_registration and campaign_registration.campaign: - text += ( - "• Дополнительно зарегистрирован через кампанию " - f"{campaign_registration.campaign.name}\n" - ) + text += f'• Дополнительно зарегистрирован через кампанию {campaign_registration.campaign.name}\n' elif campaign_registration and campaign_registration.campaign: - text += ( - "• Регистрация через рекламную кампанию " - f"{campaign_registration.campaign.name}\n" - ) + text += f'• Регистрация через рекламную кампанию {campaign_registration.campaign.name}\n' if campaign_registration.created_at: - text += ( - "• Дата регистрации по кампании: " - f"{campaign_registration.created_at.strftime('%d.%m.%Y %H:%M')}\n" - ) + text += f'• Дата регистрации по кампании: {campaign_registration.created_at.strftime("%d.%m.%Y %H:%M")}\n' else: - text += "• Прямая регистрация\n" + text += '• Прямая регистрация\n' - text += f"• Реферальный код: {user.referral_code}\n\n" + text += f'• Реферальный код: {user.referral_code}\n\n' if campaign_registration and campaign_registration.campaign and campaign_stats: - text += "Рекламная кампания:\n" - text += ( - "• Название: " - f"{campaign_registration.campaign.name}" - ) + text += 'Рекламная кампания:\n' + text += f'• Название: {campaign_registration.campaign.name}' if campaign_registration.campaign.start_parameter: - text += ( - " (параметр: " - f"{campaign_registration.campaign.start_parameter})" - ) - text += "\n" + text += f' (параметр: {campaign_registration.campaign.start_parameter})' + text += '\n' + text += f'• Всего регистраций: {campaign_stats["registrations"]}\n' + text += f'• Суммарный доход: {settings.format_price(campaign_stats["total_revenue_kopeks"])}\n' text += ( - "• Всего регистраций: " - f"{campaign_stats['registrations']}\n" + '• Получили триал: ' + f'{campaign_stats["trial_users_count"]}' + f' (активно: {campaign_stats["active_trials_count"]})\n' ) text += ( - "• Суммарный доход: " - f"{settings.format_price(campaign_stats['total_revenue_kopeks'])}\n" + '• Конверсий в оплату: ' + f'{campaign_stats["conversion_count"]}' + f' (оплативших пользователей: {campaign_stats["paid_users_count"]})\n' ) + text += f'• Конверсия в оплату: {campaign_stats["conversion_rate"]:.1f}%\n' + text += f'• Конверсия триала: {campaign_stats["trial_conversion_rate"]:.1f}%\n' text += ( - "• Получили триал: " - f"{campaign_stats['trial_users_count']}" - f" (активно: {campaign_stats['active_trials_count']})\n" + f'• Средний доход на пользователя: {settings.format_price(campaign_stats["avg_revenue_per_user_kopeks"])}\n' ) - text += ( - "• Конверсий в оплату: " - f"{campaign_stats['conversion_count']}" - f" (оплативших пользователей: {campaign_stats['paid_users_count']})\n" - ) - text += ( - "• Конверсия в оплату: " - f"{campaign_stats['conversion_rate']:.1f}%\n" - ) - text += ( - "• Конверсия триала: " - f"{campaign_stats['trial_conversion_rate']:.1f}%\n" - ) - text += ( - "• Средний доход на пользователя: " - f"{settings.format_price(campaign_stats['avg_revenue_per_user_kopeks'])}\n" - ) - text += ( - "• Средний первый платеж: " - f"{settings.format_price(campaign_stats['avg_first_payment_kopeks'])}\n" - ) - text += "\n" - + text += f'• Средний первый платеж: {settings.format_price(campaign_stats["avg_first_payment_kopeks"])}\n' + text += '\n' + if referral_stats['invited_count'] > 0: - text += f"Доходы от рефералов:\n" - text += f"• Всего приглашено: {referral_stats['invited_count']}\n" - text += f"• Активных рефералов: {referral_stats['active_referrals']}\n" - text += f"• Общий доход: {settings.format_price(referral_stats['total_earned_kopeks'])}\n" - text += f"• Доход за месяц: {settings.format_price(referral_stats['month_earned_kopeks'])}\n" - + text += 'Доходы от рефералов:\n' + text += f'• Всего приглашено: {referral_stats["invited_count"]}\n' + text += f'• Активных рефералов: {referral_stats["active_referrals"]}\n' + text += f'• Общий доход: {settings.format_price(referral_stats["total_earned_kopeks"])}\n' + text += f'• Доход за месяц: {settings.format_price(referral_stats["month_earned_kopeks"])}\n' + if referral_stats['referrals_detail']: - text += f"\nДетали по рефералам:\n" - for detail in referral_stats['referrals_detail'][:5]: + text += '\nДетали по рефералам:\n' + for detail in referral_stats['referrals_detail'][:5]: referral_name = detail['referral_name'] earned = settings.format_price(detail['total_earned_kopeks']) - status = "🟢" if detail['is_active'] else "🔴" - text += f"• {status} {referral_name}: {earned}\n" - + status = '🟢' if detail['is_active'] else '🔴' + text += f'• {status} {referral_name}: {earned}\n' + if len(referral_stats['referrals_detail']) > 5: - text += f"• ... и еще {len(referral_stats['referrals_detail']) - 5} рефералов\n" + text += f'• ... и еще {len(referral_stats["referrals_detail"]) - 5} рефералов\n' else: - text += f"Реферальная программа:\n" - text += f"• Рефералов нет\n" - text += f"• Доходов нет\n" - + text += 'Реферальная программа:\n' + text += '• Рефералов нет\n' + text += '• Доходов нет\n' + await callback.message.edit_text( text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ К пользователю", callback_data=f"admin_user_manage_{user_id}")] - ]) + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ К пользователю', callback_data=f'admin_user_manage_{user_id}')] + ] + ), ) await callback.answer() async def get_detailed_referral_stats(db: AsyncSession, user_id: int) -> dict: - from app.database.crud.referral import get_user_referral_stats, get_referral_earnings_by_user - from sqlalchemy import select, func + from sqlalchemy import select from sqlalchemy.orm import selectinload - + + from app.database.crud.referral import get_referral_earnings_by_user, get_user_referral_stats + base_stats = await get_user_referral_stats(db, user_id) - - referrals_query = select(User).options( - selectinload(User.subscription) - ).where(User.referred_by_id == user_id) - + + referrals_query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user_id) + referrals_result = await db.execute(referrals_query) referrals = referrals_result.scalars().all() - + earnings_by_referral = {} all_earnings = await get_referral_earnings_by_user(db, user_id) - + for earning in all_earnings: referral_id = earning.referral_id if referral_id not in earnings_by_referral: earnings_by_referral[referral_id] = 0 earnings_by_referral[referral_id] += earning.amount_kopeks - + referrals_detail = [] current_time = datetime.utcnow() - + for referral in referrals: earned = earnings_by_referral.get(referral.id, 0) - + is_active = False if referral.subscription: from app.database.models import SubscriptionStatus + is_active = ( - referral.subscription.status == SubscriptionStatus.ACTIVE.value and - referral.subscription.end_date > current_time + referral.subscription.status == SubscriptionStatus.ACTIVE.value + and referral.subscription.end_date > current_time ) - - referrals_detail.append({ - 'referral_id': referral.id, - 'referral_name': referral.full_name, - 'referral_telegram_id': referral.telegram_id, - 'total_earned_kopeks': earned, - 'is_active': is_active, - 'registration_date': referral.created_at, - 'has_subscription': bool(referral.subscription) - }) - + + referrals_detail.append( + { + 'referral_id': referral.id, + 'referral_name': referral.full_name, + 'referral_telegram_id': referral.telegram_id, + 'total_earned_kopeks': earned, + 'is_active': is_active, + 'registration_date': referral.created_at, + 'has_subscription': bool(referral.subscription), + } + ) + referrals_detail.sort(key=lambda x: x['total_earned_kopeks'], reverse=True) - + return { 'invited_count': base_stats['invited_count'], - 'active_referrals': base_stats['active_referrals'], + 'active_referrals': base_stats['active_referrals'], 'total_earned_kopeks': base_stats['total_earned_kopeks'], 'month_earned_kopeks': base_stats['month_earned_kopeks'], - 'referrals_detail': referrals_detail + 'referrals_detail': referrals_detail, } - + + @admin_required @error_handler -async def extend_user_subscription( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def extend_user_subscription(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(extending_user_id=user_id) - + await callback.message.edit_text( - "⏰ Продление подписки\n\n" - "Введите количество дней для изменения:\n" - "• Положительные значения продлят подписку\n" - "• Отрицательные сократят срок подписки\n" - "• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"), - types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30") - ], - [ - types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"), - types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30") - ], - [ - types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_extend_days_{user_id}_90"), - types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_extend_days_{user_id}_180") - ], - [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") + '⏰ Продление подписки\n\n' + 'Введите количество дней для изменения:\n' + '• Положительные значения продлят подписку\n' + '• Отрицательные сократят срок подписки\n' + '• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='-7 дней', callback_data=f'admin_sub_extend_days_{user_id}_-7'), + types.InlineKeyboardButton(text='-30 дней', callback_data=f'admin_sub_extend_days_{user_id}_-30'), + ], + [ + types.InlineKeyboardButton(text='7 дней', callback_data=f'admin_sub_extend_days_{user_id}_7'), + types.InlineKeyboardButton(text='30 дней', callback_data=f'admin_sub_extend_days_{user_id}_30'), + ], + [ + types.InlineKeyboardButton(text='90 дней', callback_data=f'admin_sub_extend_days_{user_id}_90'), + types.InlineKeyboardButton(text='180 дней', callback_data=f'admin_sub_extend_days_{user_id}_180'), + ], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')], ] - ]) + ), ) - + await state.set_state(AdminStates.extending_subscription) await callback.answer() @admin_required @error_handler -async def process_subscription_extension_days( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def process_subscription_extension_days(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[-2]) days = int(parts[-1]) - + if days == 0 or days < -365 or days > 365: - await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True) + await callback.answer('❌ Количество дней должно быть от -365 до 365, исключая 0', show_alert=True) return success = await _extend_subscription_by_days(db, user_id, days, db_user.id) if success: if days > 0: - action_text = f"продлена на {days} дней" + action_text = f'продлена на {days} дней' else: - action_text = f"уменьшена на {abs(days)} дней" + action_text = f'уменьшена на {abs(days)} дней' await callback.message.edit_text( - f"✅ Подписка пользователя {action_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Подписка пользователя {action_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка продления подписки", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка продления подписки', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler async def process_subscription_extension_text( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession + message: types.Message, db_user: User, state: FSMContext, db: AsyncSession ): data = await state.get_data() - user_id = data.get("extending_user_id") - + user_id = data.get('extending_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: days = int(message.text.strip()) - + if days == 0 or days < -365 or days > 365: - await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0") + await message.answer('❌ Количество дней должно быть от -365 до 365, исключая 0') return success = await _extend_subscription_by_days(db, user_id, days, db_user.id) if success: if days > 0: - action_text = f"продлена на {days} дней" + action_text = f'продлена на {days} дней' else: - action_text = f"уменьшена на {abs(days)} дней" + action_text = f'уменьшена на {abs(days)} дней' await message.answer( - f"✅ Подписка пользователя {action_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Подписка пользователя {action_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка продления подписки") - + await message.answer('❌ Ошибка продления подписки') + except ValueError: - await message.answer("❌ Введите корректное число дней") + await message.answer('❌ Введите корректное число дней') return - + await state.clear() @admin_required @error_handler -async def add_subscription_traffic( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def add_subscription_traffic(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(traffic_user_id=user_id) - + await callback.message.edit_text( - "📊 Добавление трафика\n\n" - "Введите количество ГБ для добавления:\n" - "• Например: 50, 100, 500\n" - "• Максимум: 10000 ГБ\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_50"), - types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_100") - ], - [ - types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_500"), - types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_sub_traffic_add_{user_id}_1000") - ], - [ - types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_sub_traffic_add_{user_id}_0"), - ], - [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") + '📊 Добавление трафика\n\n' + 'Введите количество ГБ для добавления:\n' + '• Например: 50, 100, 500\n' + '• Максимум: 10000 ГБ\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='50 ГБ', callback_data=f'admin_sub_traffic_add_{user_id}_50'), + types.InlineKeyboardButton(text='100 ГБ', callback_data=f'admin_sub_traffic_add_{user_id}_100'), + ], + [ + types.InlineKeyboardButton(text='500 ГБ', callback_data=f'admin_sub_traffic_add_{user_id}_500'), + types.InlineKeyboardButton(text='1000 ГБ', callback_data=f'admin_sub_traffic_add_{user_id}_1000'), + ], + [ + types.InlineKeyboardButton(text='♾️ Безлимит', callback_data=f'admin_sub_traffic_add_{user_id}_0'), + ], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')], ] - ]) + ), ) - + await state.set_state(AdminStates.adding_traffic) await callback.answer() @admin_required @error_handler -async def process_traffic_addition_button( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def process_traffic_addition_button(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[-2]) gb = int(parts[-1]) - + success = await _add_subscription_traffic(db, user_id, gb, db_user.id) - + if success: - traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ" + traffic_text = '♾️ безлимитный' if gb == 0 else f'{gb} ГБ' await callback.message.edit_text( - f"✅ К подписке пользователя добавлен трафик: {traffic_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ К подписке пользователя добавлен трафик: {traffic_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка добавления трафика", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка добавления трафика', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def process_traffic_addition_text( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_traffic_addition_text(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - user_id = data.get("traffic_user_id") - + user_id = data.get('traffic_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: gb = int(message.text.strip()) - + if gb < 0 or gb > 10000: - await message.answer("❌ Количество ГБ должно быть от 0 до 10000 (0 = безлимит)") + await message.answer('❌ Количество ГБ должно быть от 0 до 10000 (0 = безлимит)') return - + success = await _add_subscription_traffic(db, user_id, gb, db_user.id) - + if success: - traffic_text = "♾️ безлимитный" if gb == 0 else f"{gb} ГБ" + traffic_text = '♾️ безлимитный' if gb == 0 else f'{gb} ГБ' await message.answer( - f"✅ К подписке пользователя добавлен трафик: {traffic_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ К подписке пользователя добавлен трафик: {traffic_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка добавления трафика") - + await message.answer('❌ Ошибка добавления трафика') + except ValueError: - await message.answer("❌ Введите корректное число ГБ") + await message.answer('❌ Введите корректное число ГБ') return - + await state.clear() @admin_required @error_handler -async def deactivate_user_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def deactivate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + await callback.message.edit_text( - "🚫 Деактивация подписки\n\n" - "Вы уверены, что хотите деактивировать подписку этого пользователя?\n" - "Пользователь потеряет доступ к сервису.", + '🚫 Деактивация подписки\n\n' + 'Вы уверены, что хотите деактивировать подписку этого пользователя?\n' + 'Пользователь потеряет доступ к сервису.', reply_markup=get_confirmation_keyboard( - f"admin_sub_deactivate_confirm_{user_id}", - f"admin_user_subscription_{user_id}", - db_user.language - ) + f'admin_sub_deactivate_confirm_{user_id}', f'admin_user_subscription_{user_id}', db_user.language + ), ) await callback.answer() @admin_required @error_handler -async def confirm_subscription_deactivation( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_subscription_deactivation(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + success = await _deactivate_user_subscription(db, user_id, db_user.id) - + if success: await callback.message.edit_text( - "✅ Подписка пользователя деактивирована", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '✅ Подписка пользователя деактивирована', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка деактивации подписки", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка деактивации подписки', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def activate_user_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def activate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + success = await _activate_user_subscription(db, user_id, db_user.id) - + if success: await callback.message.edit_text( - "✅ Подписка пользователя активирована", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '✅ Подписка пользователя активирована', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка активации подписки", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка активации подписки', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def grant_trial_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def grant_trial_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + success = await _grant_trial_subscription(db, user_id, db_user.id) - + if success: await callback.message.edit_text( - "✅ Пользователю выдан триальный период", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '✅ Пользователю выдан триальный период', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка выдачи триального периода", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка выдачи триального периода', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def grant_paid_subscription( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def grant_paid_subscription(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(granting_user_id=user_id) - + await callback.message.edit_text( - "💎 Выдача подписки\n\n" - "Введите количество дней подписки:\n" - "• Например: 30, 90, 180, 365\n" - "• Максимум: 730 дней\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_grant_days_{user_id}_30"), - types.InlineKeyboardButton(text="90 дней", callback_data=f"admin_sub_grant_days_{user_id}_90") - ], - [ - types.InlineKeyboardButton(text="180 дней", callback_data=f"admin_sub_grant_days_{user_id}_180"), - types.InlineKeyboardButton(text="365 дней", callback_data=f"admin_sub_grant_days_{user_id}_365") - ], - [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") + '💎 Выдача подписки\n\n' + 'Введите количество дней подписки:\n' + '• Например: 30, 90, 180, 365\n' + '• Максимум: 730 дней\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='30 дней', callback_data=f'admin_sub_grant_days_{user_id}_30'), + types.InlineKeyboardButton(text='90 дней', callback_data=f'admin_sub_grant_days_{user_id}_90'), + ], + [ + types.InlineKeyboardButton(text='180 дней', callback_data=f'admin_sub_grant_days_{user_id}_180'), + types.InlineKeyboardButton(text='365 дней', callback_data=f'admin_sub_grant_days_{user_id}_365'), + ], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')], ] - ]) + ), ) - + await state.set_state(AdminStates.granting_subscription) await callback.answer() @admin_required @error_handler -async def process_subscription_grant_days( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def process_subscription_grant_days(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[-2]) days = int(parts[-1]) - + success = await _grant_paid_subscription(db, user_id, days, db_user.id) - + if success: await callback.message.edit_text( - f"✅ Пользователю выдана подписка на {days} дней", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Пользователю выдана подписка на {days} дней', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка выдачи подписки", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка выдачи подписки', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def process_subscription_grant_text( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_subscription_grant_text(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - user_id = data.get("granting_user_id") - + user_id = data.get('granting_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: days = int(message.text.strip()) - + if days <= 0 or days > 730: - await message.answer("❌ Количество дней должно быть от 1 до 730") + await message.answer('❌ Количество дней должно быть от 1 до 730') return - + success = await _grant_paid_subscription(db, user_id, days, db_user.id) - + if success: await message.answer( - f"✅ Пользователю выдана подписка на {days} дней", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Пользователю выдана подписка на {days} дней', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка выдачи подписки") - + await message.answer('❌ Ошибка выдачи подписки') + except ValueError: - await message.answer("❌ Введите корректное число дней") + await message.answer('❌ Введите корректное число дней') return - + await state.clear() + @admin_required @error_handler -async def show_user_servers_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_user_servers_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) if await _render_user_subscription_overview(callback, db, user_id): @@ -3647,129 +3399,121 @@ async def show_user_servers_management( @admin_required @error_handler -async def show_server_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_server_selection(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) await _show_servers_for_user(callback, user_id, db) await callback.answer() -async def _show_servers_for_user( - callback: types.CallbackQuery, - user_id: int, - db: AsyncSession -): + +async def _show_servers_for_user(callback: types.CallbackQuery, user_id: int, db: AsyncSession): try: user = await get_user_by_id(db, user_id) current_squads = [] if user and user.subscription: current_squads = user.subscription.connected_squads or [] - + all_servers, _ = await get_all_server_squads(db, available_only=False) - + servers_to_show = [] for server in all_servers: if server.is_available or server.squad_uuid in current_squads: servers_to_show.append(server) - + if not servers_to_show: await callback.message.edit_text( - "❌ Доступные серверы не найдены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Доступные серверы не найдены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) return - - text = f"🌍 Управление серверами\n\n" - text += f"Нажмите на сервер чтобы добавить/убрать:\n" - text += f"✅ - выбранный сервер\n" - text += f"⚪ - доступный сервер\n" - text += f"🔒 - неактивный (только для уже назначенных)\n\n" - + + text = '🌍 Управление серверами\n\n' + text += 'Нажмите на сервер чтобы добавить/убрать:\n' + text += '✅ - выбранный сервер\n' + text += '⚪ - доступный сервер\n' + text += '🔒 - неактивный (только для уже назначенных)\n\n' + keyboard = [] selected_servers = [s for s in servers_to_show if s.squad_uuid in current_squads] available_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and s.is_available] inactive_servers = [s for s in servers_to_show if s.squad_uuid not in current_squads and not s.is_available] - + sorted_servers = selected_servers + available_servers + inactive_servers - - for server in sorted_servers[:20]: + + for server in sorted_servers[:20]: is_selected = server.squad_uuid in current_squads - + if is_selected: - emoji = "✅" + emoji = '✅' elif server.is_available: - emoji = "⚪" + emoji = '⚪' else: - emoji = "🔒" - + emoji = '🔒' + display_name = server.display_name if not server.is_available and not is_selected: - display_name += " (неактивный)" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {display_name}", - callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" - ) - ]) - + display_name += ' (неактивный)' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {display_name}', callback_data=f'admin_user_toggle_server_{user_id}_{server.id}' + ) + ] + ) + if len(servers_to_show) > 20: - text += f"\n📝 Показано первых 20 из {len(servers_to_show)} серверов" - - keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + text += f'\n📝 Показано первых 20 из {len(servers_to_show)} серверов' + + keyboard.append( + [ + types.InlineKeyboardButton(text='✅ Готово', callback_data=f'admin_user_subscription_{user_id}'), + types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}'), + ] ) - + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + except Exception as e: - logger.error(f"Ошибка показа серверов: {e}") + logger.error(f'Ошибка показа серверов: {e}') + @admin_required @error_handler -async def toggle_user_server( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_user_server(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') - user_id = int(parts[4]) + user_id = int(parts[4]) server_id = int(parts[5]) - + try: user = await get_user_by_id(db, user_id) if not user or not user.subscription: - await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True) + await callback.answer('❌ Пользователь или подписка не найдены', show_alert=True) return - + server = await get_server_squad_by_id(db, server_id) if not server: - await callback.answer("❌ Сервер не найден", show_alert=True) + await callback.answer('❌ Сервер не найден', show_alert=True) return - + subscription = user.subscription current_squads = list(subscription.connected_squads or []) - + if server.squad_uuid in current_squads: current_squads.remove(server.squad_uuid) - action_text = "удален" + action_text = 'удален' else: current_squads.append(server.squad_uuid) - action_text = "добавлен" - + action_text = 'добавлен' + subscription.connected_squads = current_squads subscription.updated_at = datetime.utcnow() await db.commit() await db.refresh(subscription) - + if user.remnawave_uuid: try: remnawave_service = RemnaWaveService() @@ -3778,444 +3522,453 @@ async def toggle_user_server( uuid=user.remnawave_uuid, active_internal_squads=current_squads, description=settings.format_remnawave_user_description( - full_name=user.full_name, - username=user.username, - telegram_id=user.telegram_id - ) + full_name=user.full_name, username=user.username, telegram_id=user.telegram_id + ), ) - logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}") + logger.info(f'✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}') except Exception as rw_error: - logger.error(f"❌ Ошибка обновления RemnaWave: {rw_error}") - - logger.info(f"Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}") - - await refresh_server_selection_screen(callback, user_id, db_user, db) - - except Exception as e: - logger.error(f"Ошибка переключения сервера: {e}") - await callback.answer("❌ Ошибка изменения сервера", show_alert=True) + logger.error(f'❌ Ошибка обновления RemnaWave: {rw_error}') -async def refresh_server_selection_screen( - callback: types.CallbackQuery, - user_id: int, - db_user: User, - db: AsyncSession -): + logger.info(f'Админ {db_user.id}: сервер {server.display_name} {action_text} для пользователя {user_id}') + + await refresh_server_selection_screen(callback, user_id, db_user, db) + + except Exception as e: + logger.error(f'Ошибка переключения сервера: {e}') + await callback.answer('❌ Ошибка изменения сервера', show_alert=True) + + +async def refresh_server_selection_screen(callback: types.CallbackQuery, user_id: int, db_user: User, db: AsyncSession): try: user = await get_user_by_id(db, user_id) current_squads = [] if user and user.subscription: current_squads = user.subscription.connected_squads or [] - + servers, _ = await get_all_server_squads(db, available_only=True) - + if not servers: await callback.message.edit_text( - "❌ Доступные серверы не найдены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Доступные серверы не найдены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) return - - text = f"🌍 Управление серверами\n\n" - text += f"Нажмите на сервер чтобы добавить/убрать:\n\n" - + + text = '🌍 Управление серверами\n\n' + text += 'Нажмите на сервер чтобы добавить/убрать:\n\n' + keyboard = [] for server in servers[:15]: is_selected = server.squad_uuid in current_squads - emoji = "✅" if is_selected else "⚪" - - keyboard.append([ - types.InlineKeyboardButton( - text=f"{emoji} {server.display_name}", - callback_data=f"admin_user_toggle_server_{user_id}_{server.id}" - ) - ]) - + emoji = '✅' if is_selected else '⚪' + + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{emoji} {server.display_name}', + callback_data=f'admin_user_toggle_server_{user_id}_{server.id}', + ) + ] + ) + if len(servers) > 15: - text += f"\n📝 Показано первых 15 из {len(servers)} серверов" - - keyboard.append([ - types.InlineKeyboardButton(text="✅ Готово", callback_data=f"admin_user_subscription_{user_id}"), - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + text += f'\n📝 Показано первых 15 из {len(servers)} серверов' + + keyboard.append( + [ + types.InlineKeyboardButton(text='✅ Готово', callback_data=f'admin_user_subscription_{user_id}'), + types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}'), + ] ) - + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) + except Exception as e: - logger.error(f"Ошибка обновления экрана серверов: {e}") + logger.error(f'Ошибка обновления экрана серверов: {e}') @admin_required @error_handler -async def start_devices_edit( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_devices_edit(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(editing_devices_user_id=user_id) - + await callback.message.edit_text( - "📱 Изменение количества устройств\n\n" - "Введите новое количество устройств (от 1 до 10):\n" - "• Текущее значение будет заменено\n" - "• Примеры: 1, 2, 5, 10\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="1", callback_data=f"admin_user_devices_set_{user_id}_1"), - types.InlineKeyboardButton(text="2", callback_data=f"admin_user_devices_set_{user_id}_2"), - types.InlineKeyboardButton(text="3", callback_data=f"admin_user_devices_set_{user_id}_3") - ], - [ - types.InlineKeyboardButton(text="5", callback_data=f"admin_user_devices_set_{user_id}_5"), - types.InlineKeyboardButton(text="10", callback_data=f"admin_user_devices_set_{user_id}_10") - ], - [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") + '📱 Изменение количества устройств\n\n' + 'Введите новое количество устройств (от 1 до 10):\n' + '• Текущее значение будет заменено\n' + '• Примеры: 1, 2, 5, 10\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='1', callback_data=f'admin_user_devices_set_{user_id}_1'), + types.InlineKeyboardButton(text='2', callback_data=f'admin_user_devices_set_{user_id}_2'), + types.InlineKeyboardButton(text='3', callback_data=f'admin_user_devices_set_{user_id}_3'), + ], + [ + types.InlineKeyboardButton(text='5', callback_data=f'admin_user_devices_set_{user_id}_5'), + types.InlineKeyboardButton(text='10', callback_data=f'admin_user_devices_set_{user_id}_10'), + ], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')], ] - ]) + ), ) - + await state.set_state(AdminStates.editing_user_devices) await callback.answer() @admin_required @error_handler -async def set_user_devices_button( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def set_user_devices_button(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[-2]) devices = int(parts[-1]) - + success = await _update_user_devices(db, user_id, devices, db_user.id) - + if success: await callback.message.edit_text( - f"✅ Количество устройств изменено на: {devices}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Количество устройств изменено на: {devices}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка изменения количества устройств", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка изменения количества устройств', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def toggle_user_modem( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_user_modem(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Переключение модема для пользователя в админке.""" user_id = int(callback.data.split('_')[-1]) - + user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - + subscription = user.subscription if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return - + modem_enabled = getattr(subscription, 'modem_enabled', False) or False - + if modem_enabled: # Отключаем модем subscription.modem_enabled = False if subscription.device_limit and subscription.device_limit > 1: subscription.device_limit = subscription.device_limit - 1 - action_text = "отключен" + action_text = 'отключен' else: # Включаем модем subscription.modem_enabled = True subscription.device_limit = (subscription.device_limit or 1) + 1 - action_text = "подключен" - + action_text = 'подключен' + subscription.updated_at = datetime.utcnow() await db.commit() - + # Обновляем в RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}") - + logger.error(f'Ошибка обновления RemnaWave при переключении модема: {e}') + await db.refresh(subscription) - - modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен" - + + modem_status = '✅ Подключен' if subscription.modem_enabled else '❌ Отключен' + await callback.message.edit_text( - f"📡 Модем {action_text}\n\n" - f"Статус модема: {modem_status}\n" - f"Лимит устройств: {subscription.device_limit}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]), - parse_mode="HTML" + f'📡 Модем {action_text}\n\nСтатус модема: {modem_status}\nЛимит устройств: {subscription.device_limit}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), + parse_mode='HTML', ) - - logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}") + + logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}') await callback.answer() @admin_required @error_handler -async def process_devices_edit_text( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_devices_edit_text(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - user_id = data.get("editing_devices_user_id") - + user_id = data.get('editing_devices_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: devices = int(message.text.strip()) - + if devices <= 0 or devices > 10: - await message.answer("❌ Количество устройств должно быть от 1 до 10") + await message.answer('❌ Количество устройств должно быть от 1 до 10') return - + success = await _update_user_devices(db, user_id, devices, db_user.id) - + if success: await message.answer( - f"✅ Количество устройств изменено на: {devices}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Количество устройств изменено на: {devices}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка изменения количества устройств") - + await message.answer('❌ Ошибка изменения количества устройств') + except ValueError: - await message.answer("❌ Введите корректное число устройств") + await message.answer('❌ Введите корректное число устройств') return - + await state.clear() @admin_required @error_handler -async def start_traffic_edit( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_traffic_edit(callback: types.CallbackQuery, db_user: User, state: FSMContext): user_id = int(callback.data.split('_')[-1]) - + await state.update_data(editing_traffic_user_id=user_id) - + await callback.message.edit_text( - "📊 Изменение лимита трафика\n\n" - "Введите новый лимит трафика в ГБ:\n" - "• 0 - безлимитный трафик\n" - "• Примеры: 50, 100, 500, 1000\n" - "• Максимум: 10000 ГБ\n\n" - "Или нажмите /cancel для отмены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton(text="50 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_50"), - types.InlineKeyboardButton(text="100 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_100") - ], - [ - types.InlineKeyboardButton(text="500 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_500"), - types.InlineKeyboardButton(text="1000 ГБ", callback_data=f"admin_user_traffic_set_{user_id}_1000") - ], - [ - types.InlineKeyboardButton(text="♾️ Безлимит", callback_data=f"admin_user_traffic_set_{user_id}_0") - ], - [ - types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_subscription_{user_id}") + '📊 Изменение лимита трафика\n\n' + 'Введите новый лимит трафика в ГБ:\n' + '• 0 - безлимитный трафик\n' + '• Примеры: 50, 100, 500, 1000\n' + '• Максимум: 10000 ГБ\n\n' + 'Или нажмите /cancel для отмены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton(text='50 ГБ', callback_data=f'admin_user_traffic_set_{user_id}_50'), + types.InlineKeyboardButton(text='100 ГБ', callback_data=f'admin_user_traffic_set_{user_id}_100'), + ], + [ + types.InlineKeyboardButton(text='500 ГБ', callback_data=f'admin_user_traffic_set_{user_id}_500'), + types.InlineKeyboardButton(text='1000 ГБ', callback_data=f'admin_user_traffic_set_{user_id}_1000'), + ], + [types.InlineKeyboardButton(text='♾️ Безлимит', callback_data=f'admin_user_traffic_set_{user_id}_0')], + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')], ] - ]) + ), ) - + await state.set_state(AdminStates.editing_user_traffic) await callback.answer() @admin_required @error_handler -async def set_user_traffic_button( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def set_user_traffic_button(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[-2]) traffic_gb = int(parts[-1]) - + success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id) - + if success: - traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ" + traffic_text = '♾️ безлимитный' if traffic_gb == 0 else f'{traffic_gb} ГБ' await callback.message.edit_text( - f"✅ Лимит трафика изменен на: {traffic_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Лимит трафика изменен на: {traffic_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка изменения лимита трафика", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка изменения лимита трафика', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + await callback.answer() @admin_required @error_handler -async def process_traffic_edit_text( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_traffic_edit_text(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): data = await state.get_data() - user_id = data.get("editing_traffic_user_id") - + user_id = data.get('editing_traffic_user_id') + if not user_id: - await message.answer("❌ Ошибка: пользователь не найден") + await message.answer('❌ Ошибка: пользователь не найден') await state.clear() return - + try: traffic_gb = int(message.text.strip()) - + if traffic_gb < 0 or traffic_gb > 10000: - await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ (0 = безлимит)") + await message.answer('❌ Лимит трафика должен быть от 0 до 10000 ГБ (0 = безлимит)') return - + success = await _update_user_traffic(db, user_id, traffic_gb, db_user.id) - + if success: - traffic_text = "♾️ безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ" + traffic_text = '♾️ безлимитный' if traffic_gb == 0 else f'{traffic_gb} ГБ' await message.answer( - f"✅ Лимит трафика изменен на: {traffic_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Лимит трафика изменен на: {traffic_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) else: - await message.answer("❌ Ошибка изменения лимита трафика") - + await message.answer('❌ Ошибка изменения лимита трафика') + except ValueError: - await message.answer("❌ Введите корректное число ГБ") + await message.answer('❌ Введите корректное число ГБ') return - + await state.clear() @admin_required @error_handler -async def confirm_reset_devices( - callback: types.CallbackQuery, - db_user: User -): +async def confirm_reset_devices(callback: types.CallbackQuery, db_user: User): user_id = int(callback.data.split('_')[-1]) - + await callback.message.edit_text( - "🔄 Сброс устройств пользователя\n\n" - "⚠️ ВНИМАНИЕ!\n" - "Вы уверены, что хотите сбросить все HWID устройства этого пользователя?\n\n" - "Это действие:\n" - "• Удалит все привязанные устройства\n" - "• Пользователь сможет заново подключить устройства\n" - "• Действие необратимо!\n\n" - "Продолжить?", + '🔄 Сброс устройств пользователя\n\n' + '⚠️ ВНИМАНИЕ!\n' + 'Вы уверены, что хотите сбросить все HWID устройства этого пользователя?\n\n' + 'Это действие:\n' + '• Удалит все привязанные устройства\n' + '• Пользователь сможет заново подключить устройства\n' + '• Действие необратимо!\n\n' + 'Продолжить?', reply_markup=get_confirmation_keyboard( - f"admin_user_reset_devices_confirm_{user_id}", - f"admin_user_subscription_{user_id}", - db_user.language - ) + f'admin_user_reset_devices_confirm_{user_id}', f'admin_user_subscription_{user_id}', db_user.language + ), ) await callback.answer() @admin_required @error_handler -async def reset_user_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def reset_user_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + try: user = await get_user_by_id(db, user_id) if not user or not user.remnawave_uuid: - await callback.answer("❌ Пользователь не найден или не связан с RemnaWave", show_alert=True) + await callback.answer('❌ Пользователь не найден или не связан с RemnaWave', show_alert=True) return - + remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: success = await api.reset_user_devices(user.remnawave_uuid) - + if success: await callback.message.edit_text( - "✅ Устройства пользователя успешно сброшены", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '✅ Устройства пользователя успешно сброшены', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - logger.info(f"Админ {db_user.id} сбросил устройства пользователя {user_id}") + logger.info(f'Админ {db_user.id} сбросил устройства пользователя {user_id}') else: await callback.message.edit_text( - "❌ Ошибка сброса устройств", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка сброса устройств', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) - + except Exception as e: - logger.error(f"Ошибка сброса устройств: {e}") - await callback.answer("❌ Ошибка сброса устройств", show_alert=True) + logger.error(f'Ошибка сброса устройств: {e}') + await callback.answer('❌ Ошибка сброса устройств', show_alert=True) + async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, admin_id: int) -> bool: try: user = await get_user_by_id(db, user_id) if not user or not user.subscription: - logger.error(f"Пользователь {user_id} или подписка не найдены") + logger.error(f'Пользователь {user_id} или подписка не найдены') return False - + subscription = user.subscription old_devices = subscription.device_limit subscription.device_limit = devices subscription.updated_at = datetime.utcnow() - + await db.commit() - + if user.remnawave_uuid: try: remnawave_service = RemnaWaveService() @@ -4224,20 +3977,18 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm uuid=user.remnawave_uuid, hwid_device_limit=devices, description=settings.format_remnawave_user_description( - full_name=user.full_name, - username=user.username, - telegram_id=user.telegram_id - ) + full_name=user.full_name, username=user.username, telegram_id=user.telegram_id + ), ) - logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}") + logger.info(f'✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}') except Exception as rw_error: - logger.error(f"❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}") - - logger.info(f"Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}") + logger.error(f'❌ Ошибка обновления лимита устройств в RemnaWave: {rw_error}') + + logger.info(f'Админ {admin_id} изменил лимит устройств пользователя {user_id}: {old_devices} -> {devices}') return True - + except Exception as e: - logger.error(f"Ошибка обновления лимита устройств: {e}") + logger.error(f'Ошибка обновления лимита устройств: {e}') await db.rollback() return False @@ -4246,20 +3997,20 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, try: user = await get_user_by_id(db, user_id) if not user or not user.subscription: - logger.error(f"Пользователь {user_id} или подписка не найдены") + logger.error(f'Пользователь {user_id} или подписка не найдены') return False - + subscription = user.subscription old_traffic = subscription.traffic_limit_gb subscription.traffic_limit_gb = traffic_gb subscription.updated_at = datetime.utcnow() - + await db.commit() - + if user.remnawave_uuid: try: from app.external.remnawave_api import TrafficLimitStrategy - + remnawave_service = RemnaWaveService() async with remnawave_service.get_api_client() as api: await api.update_user( @@ -4267,145 +4018,146 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int, traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=settings.format_remnawave_user_description( - full_name=user.full_name, - username=user.username, - telegram_id=user.telegram_id - ) + full_name=user.full_name, username=user.username, telegram_id=user.telegram_id + ), ) - logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}") + logger.info(f'✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}') except Exception as rw_error: - logger.error(f"❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}") - - traffic_text_old = "безлимитный" if old_traffic == 0 else f"{old_traffic} ГБ" - traffic_text_new = "безлимитный" if traffic_gb == 0 else f"{traffic_gb} ГБ" - logger.info(f"Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}") + logger.error(f'❌ Ошибка обновления лимита трафика в RemnaWave: {rw_error}') + + traffic_text_old = 'безлимитный' if old_traffic == 0 else f'{old_traffic} ГБ' + traffic_text_new = 'безлимитный' if traffic_gb == 0 else f'{traffic_gb} ГБ' + logger.info( + f'Админ {admin_id} изменил лимит трафика пользователя {user_id}: {traffic_text_old} -> {traffic_text_new}' + ) return True - + except Exception as e: - logger.error(f"Ошибка обновления лимита трафика: {e}") + logger.error(f'Ошибка обновления лимита трафика: {e}') await db.rollback() return False async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription + from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService - + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f"Подписка не найдена для пользователя {user_id}") + logger.error(f'Подписка не найдена для пользователя {user_id}') return False - + await extend_subscription(db, subscription, days) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - + if days > 0: - logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней") + logger.info(f'Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней') else: - logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней") + logger.info(f'Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней') return True except Exception as e: - logger.error(f"Ошибка продления подписки: {e}") + logger.error(f'Ошибка продления подписки: {e}') return False async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id, add_subscription_traffic + from app.database.crud.subscription import add_subscription_traffic, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService - + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f"Подписка не найдена для пользователя {user_id}") + logger.error(f'Подписка не найдена для пользователя {user_id}') return False - - if gb == 0: + + if gb == 0: subscription.traffic_limit_gb = 0 await db.commit() else: await add_subscription_traffic(db, subscription, gb) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - - traffic_text = "безлимитный" if gb == 0 else f"{gb} ГБ" - logger.info(f"Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}") + + traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ' + logger.info(f'Админ {admin_id} добавил трафик {traffic_text} пользователю {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка добавления трафика: {e}") + logger.error(f'Ошибка добавления трафика: {e}') return False async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id, deactivate_subscription + from app.database.crud.subscription import deactivate_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService - + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f"Подписка не найдена для пользователя {user_id}") + logger.error(f'Подписка не найдена для пользователя {user_id}') return False - + await deactivate_subscription(db, subscription) - + user = await get_user_by_id(db, user_id) if user and user.remnawave_uuid: subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - - logger.info(f"Админ {admin_id} деактивировал подписку пользователя {user_id}") + + logger.info(f'Админ {admin_id} деактивировал подписку пользователя {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка деактивации подписки: {e}") + logger.error(f'Ошибка деактивации подписки: {e}') return False async def _activate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id - from app.services.subscription_service import SubscriptionService - from app.database.models import SubscriptionStatus from datetime import datetime - + + from app.database.crud.subscription import get_subscription_by_user_id + from app.database.models import SubscriptionStatus + from app.services.subscription_service import SubscriptionService + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f"Подписка не найдена для пользователя {user_id}") + logger.error(f'Подписка не найдена для пользователя {user_id}') return False - + subscription.status = SubscriptionStatus.ACTIVE.value if subscription.end_date <= datetime.utcnow(): subscription.end_date = datetime.utcnow() + timedelta(days=1) - + await db.commit() await db.refresh(subscription) - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - - logger.info(f"Админ {admin_id} активировал подписку пользователя {user_id}") + + logger.info(f'Админ {admin_id} активировал подписку пользователя {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка активации подписки: {e}") + logger.error(f'Ошибка активации подписки: {e}') return False async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id, create_trial_subscription + from app.database.crud.subscription import create_trial_subscription, get_subscription_by_user_id from app.services.subscription_service import SubscriptionService - + existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: - logger.error(f"У пользователя {user_id} уже есть подписка") + logger.error(f'У пользователя {user_id} уже есть подписка') return False - + forced_devices = None if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() @@ -4415,29 +4167,29 @@ async def _grant_trial_subscription(db: AsyncSession, user_id: int, admin_id: in user_id, device_limit=forced_devices, ) - + subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) - - logger.info(f"Админ {admin_id} выдал триальную подписку пользователю {user_id}") + + logger.info(f'Админ {admin_id} выдал триальную подписку пользователю {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка выдачи триальной подписки: {e}") + logger.error(f'Ошибка выдачи триальной подписки: {e}') return False async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, admin_id: int) -> bool: try: - from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription - from app.services.subscription_service import SubscriptionService from app.config import settings - + from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id + from app.services.subscription_service import SubscriptionService + existing_subscription = await get_subscription_by_user_id(db, user_id) if existing_subscription: - logger.error(f"У пользователя {user_id} уже есть подписка") + logger.error(f'У пользователя {user_id} уже есть подписка') return False - + trial_squads: list[str] = [] try: @@ -4448,7 +4200,7 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad trial_squads = [trial_uuid] except Exception as error: logger.error( - "Не удалось подобрать сквад при выдаче подписки админом %s: %s", + 'Не удалось подобрать сквад при выдаче подписки админом %s: %s', admin_id, error, ) @@ -4470,15 +4222,15 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad connected_squads=trial_squads, update_server_counters=True, ) - + subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) - - logger.info(f"Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}") + + logger.info(f'Админ {admin_id} выдал платную подписку на {days} дней пользователю {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка выдачи платной подписки: {e}") + logger.error(f'Ошибка выдачи платной подписки: {e}') return False @@ -4487,7 +4239,7 @@ async def _calculate_subscription_period_price( target_user: User, subscription: Subscription, period_days: int, - subscription_service: Optional[SubscriptionService] = None, + subscription_service: SubscriptionService | None = None, ) -> int: """Рассчитывает стоимость подписки для администратора с учётом всех параметров.""" @@ -4501,12 +4253,12 @@ async def _calculate_subscription_period_price( server_ids = await get_server_ids_by_uuids(db, connected_squads) if len(server_ids) != len(connected_squads): logger.warning( - "Не удалось сопоставить все сервера подписки пользователя %s для расчёта цены", + 'Не удалось сопоставить все сервера подписки пользователя %s для расчёта цены', target_user.telegram_id, ) except Exception as e: logger.error( - "Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя %s: %s", + 'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя %s: %s', target_user.telegram_id, e, ) @@ -4531,103 +4283,78 @@ async def _calculate_subscription_period_price( return total_price + @admin_required @error_handler -async def cleanup_inactive_users( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - +async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_service = UserService() deleted_count = await user_service.cleanup_inactive_users(db) - + await callback.message.edit_text( - f"✅ Очистка завершена\n\n" - f"Удалено неактивных пользователей: {deleted_count}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")] - ]) + f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]] + ), ) await callback.answer() + @admin_required @error_handler -async def change_subscription_type( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def change_subscription_type(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) - - if not profile or not profile["subscription"]: - await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True) + + if not profile or not profile['subscription']: + await callback.answer('❌ Пользователь или подписка не найдены', show_alert=True) return - - subscription = profile["subscription"] - current_type = "🎁 Триал" if subscription.is_trial else "💎 Платная" - - text = f"🔄 Смена типа подписки\n\n" - text += f"👤 {profile['user'].full_name}\n" - text += f"📱 Текущий тип: {current_type}\n\n" - text += f"Выберите новый тип подписки:" - + + subscription = profile['subscription'] + current_type = '🎁 Триал' if subscription.is_trial else '💎 Платная' + + text = '🔄 Смена типа подписки\n\n' + text += f'👤 {profile["user"].full_name}\n' + text += f'📱 Текущий тип: {current_type}\n\n' + text += 'Выберите новый тип подписки:' + keyboard = [] - + if subscription.is_trial: - keyboard.append([ - InlineKeyboardButton( - text="💎 Сделать платной", - callback_data=f"admin_sub_type_paid_{user_id}" - ) - ]) - else: - keyboard.append([ - InlineKeyboardButton( - text="🎁 Сделать триальной", - callback_data=f"admin_sub_type_trial_{user_id}" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text="⬅️ Назад", - callback_data=f"admin_user_subscription_{user_id}" + keyboard.append( + [InlineKeyboardButton(text='💎 Сделать платной', callback_data=f'admin_sub_type_paid_{user_id}')] ) - ]) - - await callback.message.edit_text( - text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + else: + keyboard.append( + [InlineKeyboardButton(text='🎁 Сделать триальной', callback_data=f'admin_sub_type_trial_{user_id}')] + ) + + keyboard.append([InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')]) + + await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() + @admin_required @error_handler -async def admin_buy_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): user_id = int(callback.data.split('_')[-1]) - + user_service = UserService() profile = await user_service.get_user_profile(db, user_id) - + if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - - target_user = profile["user"] - subscription = profile["subscription"] - + + target_user = profile['user'] + subscription = profile['subscription'] + if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return - + available_periods = settings.get_available_subscription_periods() subscription_service = SubscriptionService() @@ -4644,81 +4371,73 @@ async def admin_buy_subscription( ) except Exception as e: logger.error( - "Ошибка расчёта стоимости подписки для пользователя %s и периода %s дней: %s", + 'Ошибка расчёта стоимости подписки для пользователя %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}" - ) - ]) + 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) + await callback.answer('❌ Не удалось рассчитать стоимость подписки', show_alert=True) return - period_buttons.append([ - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_user_subscription_{user_id}" - ) - ]) + period_buttons.append( + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_user_subscription_{user_id}')] + ) - text = f"💳 Покупка подписки для пользователя\n\n" + text = '💳 Покупка подписки для пользователя\n\n' if target_user.telegram_id: target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - text += f"👤 {target_user_link} (ID: {target_user_id_display})\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} ГБ" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + text += f'👤 {target_user_link} (ID: {target_user_id_display})\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 if devices_limit is None: devices_limit = 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( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons) - ) + text += f'📶 Трафик: {traffic_text}\n' + text += f'📱 Устройства: {devices_limit}\n' + text += f'🌐 Серверов: {servers_count}\n\n' + text += 'Выберите период подписки:\n' + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons)) await callback.answer() @admin_required @error_handler -async def admin_buy_subscription_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_subscription_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[4]) period_days = int(parts[5]) 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) - + if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - - target_user = profile["user"] - subscription = profile["subscription"] + + target_user = profile['user'] + subscription = profile['subscription'] if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return subscription_service = SubscriptionService() @@ -4733,16 +4452,16 @@ async def admin_buy_subscription_confirm( ) except Exception as e: logger.error( - "Ошибка расчёта стоимости подписки при подтверждении админом для пользователя %s: %s", + 'Ошибка расчёта стоимости подписки при подтверждении админом для пользователя %s: %s', target_user.telegram_id, e, ) - await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True) + 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 при подтверждении", + 'Стоимость подписки для пользователя %s изменилась с %s до %s при подтверждении', target_user.telegram_id, price_kopeks_from_callback, price_kopeks, @@ -4751,88 +4470,78 @@ async def admin_buy_subscription_confirm( if target_user.balance_kopeks < price_kopeks: missing_kopeks = price_kopeks - target_user.balance_kopeks await callback.message.edit_text( - f"❌ Недостаточно средств на балансе пользователя\n\n" - f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n" - f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n" - f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n" - f"Пополните баланс пользователя перед покупкой.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="⬅️ Назад к подписке", - callback_data=f"admin_user_subscription_{user_id}" - )] - ]) + f'❌ Недостаточно средств на балансе пользователя\n\n' + f'💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n' + f'💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n' + f'📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n' + f'Пополните баланс пользователя перед покупкой.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='⬅️ Назад к подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) await callback.answer() return - - text = f"💳 Подтверждение покупки подписки\n\n" + + text = '💳 Подтверждение покупки подписки\n\n' if target_user.telegram_id: target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - text += f"👤 {target_user_link} (ID: {target_user_id_display})\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} ГБ" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + text += f'👤 {target_user_link} (ID: {target_user_id_display})\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 if devices_limit is None: devices_limit = 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 += "Вы уверены, что хотите купить подписку для этого пользователя?" - + text += f'📶 Трафик: {traffic_text}\n' + text += f'📱 Устройства: {devices_limit}\n' + text += f'🌐 Серверов: {servers_count}\n\n' + text += 'Вы уверены, что хотите купить подписку для этого пользователя?' + keyboard = [ [ types.InlineKeyboardButton( - text="✅ Подтвердить", - callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}" + text='✅ Подтвердить', callback_data=f'admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}' ) ], - [ - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_sub_buy_{user_id}" - ) - ] + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_sub_buy_{user_id}')], ] - - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def admin_buy_subscription_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') user_id = int(parts[4]) period_days = int(parts[5]) 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) - + if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - - target_user = profile["user"] - subscription = profile["subscription"] + + target_user = profile['user'] + subscription = profile['subscription'] if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return subscription_service = SubscriptionService() @@ -4847,50 +4556,46 @@ async def admin_buy_subscription_execute( ) except Exception as e: logger.error( - "Ошибка расчёта стоимости подписки при списании средств админом для пользователя %s: %s", + 'Ошибка расчёта стоимости подписки при списании средств админом для пользователя %s: %s', target_user.telegram_id, e, ) - await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True) + 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 перед списанием", + 'Стоимость подписки для пользователя %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) + await callback.answer('❌ Недостаточно средств на балансе пользователя', show_alert=True) return - + try: from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( - db, target_user, price_kopeks, - f"Покупка подписки на {period_days} дней (администратор)" + db, target_user, price_kopeks, f'Покупка подписки на {period_days} дней (администратор)' ) - + if not success: - await callback.answer("❌ Ошибка списания средств", show_alert=True) + await callback.answer('❌ Ошибка списания средств', show_alert=True) return - + 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 - ): + 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", + 'Админ продлевает подписку: добавляем оставшееся время триала (%s) пользователю %s', bonus_period, target_user.telegram_id, ) @@ -4913,24 +4618,26 @@ async def admin_buy_subscription_execute( subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT if was_trial: subscription.traffic_used_gb = 0.0 - + await db.commit() await db.refresh(subscription) - + from app.database.crud.transaction import create_transaction - transaction = await create_transaction( + + await create_transaction( db=db, user_id=target_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_kopeks, - description=f"Продление подписки на {period_days} дней (администратор)" + description=f'Продление подписки на {period_days} дней (администратор)', ) - + try: + from app.external.remnawave_api import TrafficLimitStrategy, UserStatus from app.services.remnawave_service import RemnaWaveService - from app.external.remnawave_api import UserStatus, TrafficLimitStrategy + remnawave_service = RemnaWaveService() - + hwid_limit = resolve_hwid_device_limit_for_payload(subscription) if target_user.remnawave_uuid: @@ -4939,12 +4646,14 @@ async def admin_buy_subscription_execute( uuid=target_user.remnawave_uuid, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, expire_at=subscription.end_date, - traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) + if subscription.traffic_limit_gb > 0 + else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=settings.format_remnawave_user_description( full_name=target_user.full_name, username=target_user.username, - telegram_id=target_user.telegram_id + telegram_id=target_user.telegram_id, ), active_internal_squads=subscription.connected_squads, ) @@ -4964,13 +4673,15 @@ async def admin_buy_subscription_execute( username=username, expire_at=subscription.end_date, status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED, - traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0, + traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) + if subscription.traffic_limit_gb > 0 + else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, telegram_id=target_user.telegram_id, description=settings.format_remnawave_user_description( full_name=target_user.full_name, username=target_user.username, - telegram_id=target_user.telegram_id + telegram_id=target_user.telegram_id, ), active_internal_squads=subscription.connected_squads, ) @@ -4979,74 +4690,74 @@ async def admin_buy_subscription_execute( create_kwargs['hwid_device_limit'] = hwid_limit remnawave_user = await api.create_user(**create_kwargs) - + if remnawave_user and hasattr(remnawave_user, 'uuid'): target_user.remnawave_uuid = remnawave_user.uuid await db.commit() - + if remnawave_user: - logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave") + logger.info(f'Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave') else: - logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave") + logger.error(f'Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave') except Exception as e: - logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}") - - message = f"✅ Подписка пользователя продлена на {period_days} дней" + logger.error(f'Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}') + + message = f'✅ Подписка пользователя продлена на {period_days} дней' else: - message = "❌ Ошибка: у пользователя нет существующей подписки" - + message = '❌ Ошибка: у пользователя нет существующей подписки' + if target_user.telegram_id: target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' await callback.message.edit_text( - f"{message}\n\n" - f"👤 {target_user_link} (ID: {target_user_id_display})\n" - f"💰 Списано: {settings.format_price(price_kopeks)}\n" - f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="⬅️ Назад к подписке", - callback_data=f"admin_user_subscription_{user_id}" - )] - ]), - parse_mode="HTML" + f'{message}\n\n' + f'👤 {target_user_link} (ID: {target_user_id_display})\n' + f'💰 Списано: {settings.format_price(price_kopeks)}\n' + f'📅 Подписка действительна до: {format_datetime(subscription.end_date)}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='⬅️ Назад к подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), + parse_mode='HTML', ) - + try: if callback.bot and target_user.telegram_id: await callback.bot.send_message( chat_id=target_user.telegram_id, - text=f"💳 Администратор продлил вашу подписку\n\n" - f"📅 Подписка продлена на {period_days} дней\n" - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" - f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}", - parse_mode="HTML" + text=f'💳 Администратор продлил вашу подписку\n\n' + f'📅 Подписка продлена на {period_days} дней\n' + f'💰 Списано с баланса: {settings.format_price(price_kopeks)}\n' + f'📅 Подписка действительна до: {format_datetime(subscription.end_date)}', + parse_mode='HTML', ) except Exception as e: - user_id_display = target_user.telegram_id or target_user.email or f"#{target_user.id}" - logger.error(f"Ошибка отправки уведомления пользователю {user_id_display}: {e}") - + user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' + logger.error(f'Ошибка отправки уведомления пользователю {user_id_display}: {e}') + await callback.answer() - + except Exception as e: - logger.error(f"Ошибка покупки подписки администратором: {e}") - await callback.answer("❌ Ошибка при покупке подписки", show_alert=True) - + logger.error(f'Ошибка покупки подписки администратором: {e}') + await callback.answer('❌ Ошибка при покупке подписки', show_alert=True) + await db.rollback() # ==================== Покупка тарифа администратором ==================== + @admin_required @error_handler -async def admin_buy_tariff( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает список тарифов для покупки админом.""" user_id = int(callback.data.split('_')[-1]) @@ -5054,22 +4765,24 @@ async def admin_buy_tariff( profile = await user_service.get_user_profile(db, user_id) if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - target_user = profile["user"] + target_user = profile['user'] # Получаем доступные тарифы from app.database.crud.tariff import get_tariffs_for_user + tariffs = await get_tariffs_for_user(db, target_user) if not tariffs: await callback.message.edit_text( - "❌ Нет доступных тарифов\n\n" - "Создайте тарифы в разделе управления тарифами.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Нет доступных тарифов\n\nСоздайте тарифы в разделе управления тарифами.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) await callback.answer() return @@ -5078,47 +4791,40 @@ async def admin_buy_tariff( target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - text = f"💳 Покупка тарифа для пользователя\n\n" - text += f"👤 {target_user_link} (ID: {target_user_id_display})\n" - text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" - text += "📦 Выберите тариф:\n\n" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + text = '💳 Покупка тарифа для пользователя\n\n' + text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' + text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' + text += '📦 Выберите тариф:\n\n' for tariff in tariffs: - traffic = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + traffic = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' prices = tariff.period_prices or {} min_price = min(prices.values()) if prices else 0 - text += f"{tariff.name} — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n" + text += f'{tariff.name} — {traffic}/{tariff.device_limit}📱 от {settings.format_price(min_price)}\n' keyboard = [] for tariff in tariffs: - keyboard.append([ - types.InlineKeyboardButton( - text=tariff.name, - callback_data=f"admin_tariff_buy_select_{user_id}_{tariff.id}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text=tariff.name, callback_data=f'admin_tariff_buy_select_{user_id}_{tariff.id}' + ) + ] + ) - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')]) await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def admin_buy_tariff_period( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_tariff_period(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает выбор периода для тарифа.""" parts = callback.data.split('_') user_id = int(parts[4]) @@ -5128,66 +4834,61 @@ async def admin_buy_tariff_period( profile = await user_service.get_user_profile(db, user_id) if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - target_user = profile["user"] + target_user = profile['user'] from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("❌ Тариф недоступен", show_alert=True) + await callback.answer('❌ Тариф недоступен', show_alert=True) return if target_user.telegram_id: target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' - text = f"💳 Покупка тарифа для пользователя\n\n" - text += f"👤 {target_user_link} (ID: {target_user_id_display})\n" - text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" - text += f"📦 Тариф: {tariff.name}\n" - text += f"📊 Трафик: {traffic}\n" - text += f"📱 Устройств: {tariff.device_limit}\n" - text += f"🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n" - text += "Выберите период:" + text = '💳 Покупка тарифа для пользователя\n\n' + text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' + text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' + text += f'📦 Тариф: {tariff.name}\n' + text += f'📊 Трафик: {traffic}\n' + text += f'📱 Устройств: {tariff.device_limit}\n' + text += f'🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n' + text += 'Выберите период:' prices = tariff.period_prices or {} keyboard = [] for period_str, price in sorted(prices.items(), key=lambda x: int(x[0])): period = int(period_str) - keyboard.append([ - types.InlineKeyboardButton( - text=f"{period} дней — {settings.format_price(price)}", - callback_data=f"admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text=f'{period} дней — {settings.format_price(price)}', + callback_data=f'admin_tariff_buy_confirm_{user_id}_{tariff_id}_{period}_{price}', + ) + ] + ) - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ К тарифам", callback_data=f"admin_tariff_buy_{user_id}") - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ К тарифам', callback_data=f'admin_tariff_buy_{user_id}')]) await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def admin_buy_tariff_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_tariff_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Подтверждение покупки тарифа.""" parts = callback.data.split('_') user_id = int(parts[4]) @@ -5199,31 +4900,38 @@ async def admin_buy_tariff_confirm( profile = await user_service.get_user_profile(db, user_id) if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - target_user = profile["user"] + target_user = profile['user'] from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("❌ Тариф недоступен", show_alert=True) + await callback.answer('❌ Тариф недоступен', show_alert=True) return # Проверяем баланс if target_user.balance_kopeks < price_kopeks: missing = price_kopeks - target_user.balance_kopeks await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n" - f"💳 Стоимость: {settings.format_price(price_kopeks)}\n" - f"📉 Не хватает: {settings.format_price(missing)}\n\n" - f"Пополните баланс пользователя перед покупкой.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}")] - ]), - parse_mode="HTML" + f'❌ Недостаточно средств\n\n' + f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n' + f'💳 Стоимость: {settings.format_price(price_kopeks)}\n' + f'📉 Не хватает: {settings.format_price(missing)}\n\n' + f'Пополните баланс пользователя перед покупкой.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='⬅️ Назад', callback_data=f'admin_tariff_buy_select_{user_id}_{tariff_id}' + ) + ] + ] + ), + parse_mode='HTML', ) await callback.answer() return @@ -5232,50 +4940,39 @@ async def admin_buy_tariff_confirm( target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' - text = f"💳 Подтверждение покупки тарифа\n\n" - text += f"👤 {target_user_link} (ID: {target_user_id_display})\n" - text += f"💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n" - text += f"📦 Тариф: {tariff.name}\n" - text += f"📊 Трафик: {traffic}\n" - text += f"📱 Устройств: {tariff.device_limit}\n" - text += f"📅 Период: {period} дней\n" - text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n\n" - text += "Подтвердить покупку?" + text = '💳 Подтверждение покупки тарифа\n\n' + text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' + text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' + text += f'📦 Тариф: {tariff.name}\n' + text += f'📊 Трафик: {traffic}\n' + text += f'📱 Устройств: {tariff.device_limit}\n' + text += f'📅 Период: {period} дней\n' + text += f'💰 Стоимость: {settings.format_price(price_kopeks)}\n\n' + text += 'Подтвердить покупку?' keyboard = [ [ types.InlineKeyboardButton( - text="✅ Подтвердить", - callback_data=f"admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}" + text='✅ Подтвердить', + callback_data=f'admin_tariff_buy_exec_{user_id}_{tariff_id}_{period}_{price_kopeks}', ) ], - [ - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_tariff_buy_select_{user_id}_{tariff_id}" - ) - ] + [types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_tariff_buy_select_{user_id}_{tariff_id}')], ] await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @admin_required @error_handler -async def admin_buy_tariff_execute( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Выполняет покупку тарифа для пользователя.""" parts = callback.data.split('_') user_id = int(parts[4]) @@ -5287,37 +4984,41 @@ async def admin_buy_tariff_execute( profile = await user_service.get_user_profile(db, user_id) if not profile: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return - target_user = profile["user"] + target_user = profile['user'] from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("❌ Тариф недоступен", show_alert=True) + await callback.answer('❌ Тариф недоступен', show_alert=True) return # Проверяем баланс ещё раз if target_user.balance_kopeks < price_kopeks: - await callback.answer("❌ Недостаточно средств на балансе", show_alert=True) + await callback.answer('❌ Недостаточно средств на балансе', show_alert=True) return try: - from app.database.crud.user import subtract_user_balance - from app.database.crud.subscription import extend_subscription, create_paid_subscription, get_subscription_by_user_id + from app.database.crud.subscription import ( + create_paid_subscription, + extend_subscription, + get_subscription_by_user_id, + ) from app.database.crud.transaction import create_transaction + from app.database.crud.user import subtract_user_balance from app.services.subscription_service import SubscriptionService # Списываем баланс success = await subtract_user_balance( - db, target_user, price_kopeks, - f"Покупка тарифа {tariff.name} на {period} дней (администратор)" + db, target_user, price_kopeks, f'Покупка тарифа {tariff.name} на {period} дней (администратор)' ) if not success: - await callback.answer("❌ Ошибка списания средств", show_alert=True) + await callback.answer('❌ Ошибка списания средств', show_alert=True) return # Получаем серверы из тарифа @@ -5356,10 +5057,10 @@ async def admin_buy_tariff_execute( db, subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="покупка тарифа (администратор)", + reset_reason='покупка тарифа (администратор)', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -5367,30 +5068,36 @@ async def admin_buy_tariff_execute( user_id=target_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-price_kopeks, - description=f"Покупка тарифа {tariff.name} на {period} дней (администратор)", + description=f'Покупка тарифа {tariff.name} на {period} дней (администратор)', ) if target_user.telegram_id: target_user_link = f'{target_user.full_name}' target_user_id_display = target_user.telegram_id else: - target_user_link = f"{target_user.full_name}" - target_user_id_display = target_user.email or f"#{target_user.id}" - traffic = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + target_user_link = f'{target_user.full_name}' + target_user_id_display = target_user.email or f'#{target_user.id}' + traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' await callback.message.edit_text( - f"✅ Тариф успешно куплен!\n\n" - f"👤 {target_user_link} (ID: {target_user_id_display})\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {period} дней\n" - f"💰 Списано: {settings.format_price(price_kopeks)}\n" - f"📅 Действует до: {format_datetime(subscription.end_date)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]), - parse_mode="HTML" + f'✅ Тариф успешно куплен!\n\n' + f'👤 {target_user_link} (ID: {target_user_id_display})\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {period} дней\n' + f'💰 Списано: {settings.format_price(price_kopeks)}\n' + f'📅 Действует до: {format_datetime(subscription.end_date)}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), + parse_mode='HTML', ) # Уведомляем пользователя @@ -5398,55 +5105,55 @@ async def admin_buy_tariff_execute( if callback.bot and target_user.telegram_id: await callback.bot.send_message( chat_id=target_user.telegram_id, - text=f"💳 Администратор оформил вам тариф\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {period} дней\n" - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n" - f"📅 Действует до: {format_datetime(subscription.end_date)}", - parse_mode="HTML" + text=f'💳 Администратор оформил вам тариф\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {period} дней\n' + f'💰 Списано с баланса: {settings.format_price(price_kopeks)}\n' + f'📅 Действует до: {format_datetime(subscription.end_date)}', + parse_mode='HTML', ) except Exception as e: - logger.error(f"Ошибка отправки уведомления пользователю: {e}") + logger.error(f'Ошибка отправки уведомления пользователю: {e}') - await callback.answer("✅ Тариф куплен!", show_alert=True) + await callback.answer('✅ Тариф куплен!', show_alert=True) except Exception as e: - logger.error(f"Ошибка покупки тарифа администратором: {e}", exc_info=True) - await callback.answer("❌ Ошибка при покупке тарифа", show_alert=True) + logger.error(f'Ошибка покупки тарифа администратором: {e}', exc_info=True) + await callback.answer('❌ Ошибка при покупке тарифа', show_alert=True) await db.rollback() @admin_required @error_handler -async def change_subscription_type_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def change_subscription_type_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): parts = callback.data.split('_') new_type = parts[-2] # 'paid' или 'trial' user_id = int(parts[-1]) - + success = await _change_subscription_type(db, user_id, new_type, db_user.id) - + if success: - type_text = "платной" if new_type == "paid" else "триальной" + type_text = 'платной' if new_type == 'paid' else 'триальной' await callback.message.edit_text( - f"✅ Тип подписки успешно изменен на {type_text}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Тип подписки успешно изменен на {type_text}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) else: await callback.message.edit_text( - "❌ Ошибка изменения типа подписки", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Ошибка изменения типа подписки', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) - + await callback.answer() @@ -5454,39 +5161,39 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st try: from app.database.crud.subscription import get_subscription_by_user_id from app.services.subscription_service import SubscriptionService - + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - logger.error(f"Подписка не найдена для пользователя {user_id}") + logger.error(f'Подписка не найдена для пользователя {user_id}') return False - - new_is_trial = (new_type == "trial") - + + new_is_trial = new_type == 'trial' + if subscription.is_trial == new_is_trial: - logger.info(f"Тип подписки уже установлен корректно для пользователя {user_id}") + logger.info(f'Тип подписки уже установлен корректно для пользователя {user_id}') return True - - old_type = "триальной" if subscription.is_trial else "платной" - new_type_text = "триальной" if new_is_trial else "платной" - + + old_type = 'триальной' if subscription.is_trial else 'платной' + new_type_text = 'триальной' if new_is_trial else 'платной' + subscription.is_trial = new_is_trial subscription.updated_at = datetime.utcnow() - + if not new_is_trial and subscription.is_trial: user = await get_user_by_id(db, user_id) if user: user.has_had_paid_subscription = True - + await db.commit() - + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - - logger.info(f"Админ {admin_id} изменил тип подписки пользователя {user_id}: {old_type} -> {new_type_text}") + + logger.info(f'Админ {admin_id} изменил тип подписки пользователя {user_id}: {old_type} -> {new_type_text}') return True - + except Exception as e: - logger.error(f"Ошибка изменения типа подписки: {e}") + logger.error(f'Ошибка изменения типа подписки: {e}') await db.rollback() return False @@ -5495,26 +5202,24 @@ async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: st # Смена тарифа пользователя администратором # ============================================================================= + @admin_required @error_handler -async def show_admin_tariff_change( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_admin_tariff_change(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает список доступных тарифов для смены.""" user_id = int(callback.data.split('_')[-1]) user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return # Получаем все активные тарифы @@ -5522,11 +5227,12 @@ async def show_admin_tariff_change( if not tariffs: await callback.message.edit_text( - "❌ Нет доступных тарифов\n\n" - "Создайте тарифы в разделе управления тарифами.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}")] - ]) + '❌ Нет доступных тарифов\n\nСоздайте тарифы в разделе управления тарифами.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')] + ] + ), ) await callback.answer() return @@ -5536,56 +5242,48 @@ async def show_admin_tariff_change( if subscription.tariff_id: current_tariff = await get_tariff_by_id(db, subscription.tariff_id) - text = "📦 Смена тарифа пользователя\n\n" + text = '📦 Смена тарифа пользователя\n\n' if user.telegram_id: user_link = f'{user.full_name}' else: - user_link = f"{user.full_name} ({user.email or f'#{user.id}'})" - text += f"👤 {user_link}\n\n" + user_link = f'{user.full_name} ({user.email or f"#{user.id}"})' + text += f'👤 {user_link}\n\n' if current_tariff: - text += f"Текущий тариф: {current_tariff.name}\n\n" + text += f'Текущий тариф: {current_tariff.name}\n\n' else: - text += "Текущий тариф: не установлен\n\n" + text += 'Текущий тариф: не установлен\n\n' - text += "Выберите новый тариф:\n" + text += 'Выберите новый тариф:\n' keyboard = [] for tariff in tariffs: # Отмечаем текущий тариф - prefix = "✅ " if current_tariff and tariff.id == current_tariff.id else "" + prefix = '✅ ' if current_tariff and tariff.id == current_tariff.id else '' # Описание тарифа - traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + traffic_str = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 - button_text = f"{prefix}{tariff.name} ({tariff.device_limit} устр., {traffic_str}, {servers_count} серв.)" + button_text = f'{prefix}{tariff.name} ({tariff.device_limit} устр., {traffic_str}, {servers_count} серв.)' - keyboard.append([ - types.InlineKeyboardButton( - text=button_text, - callback_data=f"admin_sub_tariff_select_{tariff.id}_{user_id}" - ) - ]) + keyboard.append( + [ + types.InlineKeyboardButton( + text=button_text, callback_data=f'admin_sub_tariff_select_{tariff.id}_{user_id}' + ) + ] + ) - keyboard.append([ - types.InlineKeyboardButton(text="⬅️ Назад", callback_data=f"admin_user_subscription_{user_id}") - ]) + keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_user_subscription_{user_id}')]) - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def select_admin_tariff_change( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def select_admin_tariff_change(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Подтверждение выбора тарифа.""" parts = callback.data.split('_') tariff_id = int(parts[-2]) @@ -5593,69 +5291,59 @@ async def select_admin_tariff_change( user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("❌ Тариф не найден", show_alert=True) + await callback.answer('❌ Тариф не найден', show_alert=True) return from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return # Проверяем, если это тот же тариф if subscription.tariff_id == tariff_id: - await callback.answer("ℹ️ Этот тариф уже установлен", show_alert=True) + await callback.answer('ℹ️ Этот тариф уже установлен', show_alert=True) return - traffic_str = "♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ" + traffic_str = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 - text = f"📦 Подтверждение смены тарифа\n\n" + text = '📦 Подтверждение смены тарифа\n\n' if user.telegram_id: user_link = f'{user.full_name}' else: - user_link = f"{user.full_name} ({user.email or f'#{user.id}'})" - text += f"👤 {user_link}\n\n" - text += f"Новый тариф: {tariff.name}\n" - text += f"• Устройства: {tariff.device_limit}\n" - text += f"• Трафик: {traffic_str}\n" - text += f"• Серверы: {servers_count}\n\n" - text += "⚠️ Параметры подписки будут обновлены в соответствии с тарифом.\n" - text += "Дата окончания подписки не изменится." + user_link = f'{user.full_name} ({user.email or f"#{user.id}"})' + text += f'👤 {user_link}\n\n' + text += f'Новый тариф: {tariff.name}\n' + text += f'• Устройства: {tariff.device_limit}\n' + text += f'• Трафик: {traffic_str}\n' + text += f'• Серверы: {servers_count}\n\n' + text += '⚠️ Параметры подписки будут обновлены в соответствии с тарифом.\n' + text += 'Дата окончания подписки не изменится.' keyboard = [ [ types.InlineKeyboardButton( - text="✅ Подтвердить", - callback_data=f"admin_sub_tariff_confirm_{tariff_id}_{user_id}" + text='✅ Подтвердить', callback_data=f'admin_sub_tariff_confirm_{tariff_id}_{user_id}' ), - types.InlineKeyboardButton( - text="❌ Отмена", - callback_data=f"admin_sub_change_tariff_{user_id}" - ) + types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_sub_change_tariff_{user_id}'), ] ] - await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await callback.message.edit_text(text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() @admin_required @error_handler -async def confirm_admin_tariff_change( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Применяет смену тарифа.""" parts = callback.data.split('_') tariff_id = int(parts[-2]) @@ -5663,19 +5351,20 @@ async def confirm_admin_tariff_change( user = await get_user_by_id(db, user_id) if not user: - await callback.answer("❌ Пользователь не найден", show_alert=True) + await callback.answer('❌ Пользователь не найден', show_alert=True) return tariff = await get_tariff_by_id(db, tariff_id) if not tariff: - await callback.answer("❌ Тариф не найден", show_alert=True) + await callback.answer('❌ Тариф не найден', show_alert=True) return from app.database.crud.subscription import get_subscription_by_user_id + subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - await callback.answer("❌ У пользователя нет подписки", show_alert=True) + await callback.answer('❌ У пользователя нет подписки', show_alert=True) return try: @@ -5689,8 +5378,10 @@ async def confirm_admin_tariff_change( subscription.updated_at = datetime.utcnow() # Сбрасываем докупленный трафик при смене тарифа - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None @@ -5702,220 +5393,149 @@ async def confirm_admin_tariff_change( await subscription_service.update_remnawave_user(db, subscription) logger.info( - f"Админ {db_user.id} изменил тариф пользователя {user_id}: " - f"{old_tariff_id} -> {tariff_id} ({tariff.name})" + f'Админ {db_user.id} изменил тариф пользователя {user_id}: {old_tariff_id} -> {tariff_id} ({tariff.name})' ) await callback.message.edit_text( - f"✅ Тариф успешно изменен\n\n" - f"Новый тариф: {tariff.name}\n" - f"• Устройства: {tariff.device_limit}\n" - f"• Трафик: {'♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ'}\n" - f"• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'✅ Тариф успешно изменен\n\n' + f'Новый тариф: {tariff.name}\n' + f'• Устройства: {tariff.device_limit}\n' + f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n' + f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) except Exception as e: - logger.error(f"Ошибка смены тарифа: {e}") + logger.error(f'Ошибка смены тарифа: {e}') await db.rollback() await callback.message.edit_text( - "❌ Ошибка смены тарифа\n\n" - f"Детали: {str(e)}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")] - ]) + f'❌ Ошибка смены тарифа\n\nДетали: {e!s}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 К подписке', callback_data=f'admin_user_subscription_{user_id}' + ) + ] + ] + ), ) await callback.answer() def register_handlers(dp: Dispatcher): - + dp.callback_query.register(show_users_menu, F.data == 'admin_users') + + dp.callback_query.register(show_users_list, F.data == 'admin_users_list') + + dp.callback_query.register(show_users_statistics, F.data == 'admin_users_stats') + + dp.callback_query.register(show_user_subscription, F.data.startswith('admin_user_subscription_')) + + dp.callback_query.register(show_user_transactions, F.data.startswith('admin_user_transactions_')) + + dp.callback_query.register(show_user_statistics, F.data.startswith('admin_user_statistics_')) + + dp.callback_query.register(block_user, F.data.startswith('admin_user_block_confirm_')) + + dp.callback_query.register(delete_user_account, F.data.startswith('admin_user_delete_confirm_')) + + dp.callback_query.register(confirm_user_block, F.data.startswith('admin_user_block_') & ~F.data.contains('confirm')) + + dp.callback_query.register(unblock_user, F.data.startswith('admin_user_unblock_confirm_')) + dp.callback_query.register( - show_users_menu, - F.data == "admin_users" - ) - - dp.callback_query.register( - show_users_list, - F.data == "admin_users_list" - ) - - dp.callback_query.register( - show_users_statistics, - F.data == "admin_users_stats" - ) - - dp.callback_query.register( - show_user_subscription, - F.data.startswith("admin_user_subscription_") + confirm_user_unblock, F.data.startswith('admin_user_unblock_') & ~F.data.contains('confirm') ) dp.callback_query.register( - show_user_transactions, - F.data.startswith("admin_user_transactions_") - ) - - dp.callback_query.register( - show_user_statistics, - F.data.startswith("admin_user_statistics_") - ) - - dp.callback_query.register( - block_user, - F.data.startswith("admin_user_block_confirm_") - ) - - dp.callback_query.register( - delete_user_account, - F.data.startswith("admin_user_delete_confirm_") - ) - - dp.callback_query.register( - confirm_user_block, - F.data.startswith("admin_user_block_") & ~F.data.contains("confirm") - ) - - dp.callback_query.register( - unblock_user, - F.data.startswith("admin_user_unblock_confirm_") - ) - - dp.callback_query.register( - confirm_user_unblock, - F.data.startswith("admin_user_unblock_") & ~F.data.contains("confirm") - ) - - dp.callback_query.register( - confirm_user_delete, - F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm") + confirm_user_delete, F.data.startswith('admin_user_delete_') & ~F.data.contains('confirm') ) # Регистрация хендлеров ограничений пользователя + dp.callback_query.register(show_user_restrictions, F.data.startswith('admin_user_restrictions_')) + + dp.callback_query.register(toggle_user_restriction_topup, F.data.startswith('admin_user_restriction_toggle_topup_')) + dp.callback_query.register( - show_user_restrictions, - F.data.startswith("admin_user_restrictions_") + toggle_user_restriction_subscription, F.data.startswith('admin_user_restriction_toggle_sub_') + ) + + dp.callback_query.register(ask_restriction_reason, F.data.startswith('admin_user_restriction_reason_')) + + dp.callback_query.register(clear_user_restrictions, F.data.startswith('admin_user_restriction_clear_')) + + dp.message.register(save_restriction_reason, AdminStates.editing_user_restriction_reason) + + dp.callback_query.register(handle_users_list_pagination_fixed, F.data.startswith('admin_users_list_page_')) + + dp.callback_query.register( + handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_') ) dp.callback_query.register( - toggle_user_restriction_topup, - F.data.startswith("admin_user_restriction_toggle_topup_") + handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_') ) dp.callback_query.register( - toggle_user_restriction_subscription, - F.data.startswith("admin_user_restriction_toggle_sub_") + handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_') ) dp.callback_query.register( - ask_restriction_reason, - F.data.startswith("admin_user_restriction_reason_") + handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_') ) dp.callback_query.register( - clear_user_restrictions, - F.data.startswith("admin_user_restriction_clear_") - ) - - dp.message.register( - save_restriction_reason, - AdminStates.editing_user_restriction_reason + handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_') ) dp.callback_query.register( - handle_users_list_pagination_fixed, - F.data.startswith("admin_users_list_page_") - ) - - dp.callback_query.register( - handle_users_balance_list_pagination, - F.data.startswith("admin_users_balance_list_page_") - ) - - dp.callback_query.register( - handle_users_traffic_list_pagination, - F.data.startswith("admin_users_traffic_list_page_") + handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_') ) dp.callback_query.register( - handle_users_activity_list_pagination, - F.data.startswith("admin_users_activity_list_page_") + handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_') ) - dp.callback_query.register( - handle_users_spending_list_pagination, - F.data.startswith("admin_users_spending_list_page_") - ) + dp.callback_query.register(start_user_search, F.data == 'admin_users_search') - dp.callback_query.register( - handle_users_purchases_list_pagination, - F.data.startswith("admin_users_purchases_list_page_") - ) + dp.message.register(process_user_search, AdminStates.waiting_for_user_search) - dp.callback_query.register( - handle_users_ready_to_renew_pagination, - F.data.startswith("admin_users_ready_to_renew_list_page_") - ) - - dp.callback_query.register( - handle_users_campaign_list_pagination, - F.data.startswith("admin_users_campaign_list_page_") - ) - - dp.callback_query.register( - start_user_search, - F.data == "admin_users_search" - ) - - dp.message.register( - process_user_search, - AdminStates.waiting_for_user_search - ) - - dp.callback_query.register( - show_user_management, - F.data.startswith("admin_user_manage_") - ) + dp.callback_query.register(show_user_management, F.data.startswith('admin_user_manage_')) dp.callback_query.register( show_user_promo_group, - F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_") & ~F.data.contains("_toggle_") + F.data.startswith('admin_user_promo_group_') & ~F.data.contains('_set_') & ~F.data.contains('_toggle_'), ) + dp.callback_query.register(set_user_promo_group, F.data.startswith('admin_user_promo_group_toggle_')) + + dp.callback_query.register(start_balance_edit, F.data.startswith('admin_user_balance_')) + + dp.message.register(process_balance_edit, AdminStates.editing_user_balance) + dp.callback_query.register( - set_user_promo_group, - F.data.startswith("admin_user_promo_group_toggle_") - ) - - dp.callback_query.register( - start_balance_edit, - F.data.startswith("admin_user_balance_") - ) - - dp.message.register( - process_balance_edit, - AdminStates.editing_user_balance - ) - - dp.callback_query.register( - show_user_referrals, - F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit") + show_user_referrals, F.data.startswith('admin_user_referrals_') & ~F.data.contains('_edit') ) dp.callback_query.register( start_edit_referral_percent, - F.data.startswith("admin_user_referral_percent_") - & ~F.data.contains("_set_") - & ~F.data.contains("_reset") + F.data.startswith('admin_user_referral_percent_') & ~F.data.contains('_set_') & ~F.data.contains('_reset'), ) dp.callback_query.register( set_referral_percent_button, - F.data.startswith("admin_user_referral_percent_set_") - | F.data.startswith("admin_user_referral_percent_reset_") + F.data.startswith('admin_user_referral_percent_set_') | F.data.startswith('admin_user_referral_percent_reset_'), ) dp.message.register( @@ -5923,264 +5543,130 @@ def register_handlers(dp: Dispatcher): AdminStates.editing_user_referral_percent, ) - dp.callback_query.register( - start_edit_user_referrals, - F.data.startswith("admin_user_referrals_edit_") - ) + dp.callback_query.register(start_edit_user_referrals, F.data.startswith('admin_user_referrals_edit_')) - dp.message.register( - process_edit_user_referrals, - AdminStates.editing_user_referrals - ) + dp.message.register(process_edit_user_referrals, AdminStates.editing_user_referrals) - dp.callback_query.register( - start_send_user_message, - F.data.startswith("admin_user_send_message_") - ) + dp.callback_query.register(start_send_user_message, F.data.startswith('admin_user_send_message_')) - dp.message.register( - process_send_user_message, - AdminStates.sending_user_message - ) - - dp.callback_query.register( - show_inactive_users, - F.data == "admin_users_inactive" - ) - - dp.callback_query.register( - cleanup_inactive_users, - F.data == "admin_cleanup_inactive" - ) + dp.message.register(process_send_user_message, AdminStates.sending_user_message) + + dp.callback_query.register(show_inactive_users, F.data == 'admin_users_inactive') + + dp.callback_query.register(cleanup_inactive_users, F.data == 'admin_cleanup_inactive') - dp.callback_query.register( extend_user_subscription, - F.data.startswith("admin_sub_extend_") & ~F.data.contains("days") & ~F.data.contains("confirm") + F.data.startswith('admin_sub_extend_') & ~F.data.contains('days') & ~F.data.contains('confirm'), ) - + + dp.callback_query.register(process_subscription_extension_days, F.data.startswith('admin_sub_extend_days_')) + + dp.message.register(process_subscription_extension_text, AdminStates.extending_subscription) + dp.callback_query.register( - process_subscription_extension_days, - F.data.startswith("admin_sub_extend_days_") + add_subscription_traffic, F.data.startswith('admin_sub_traffic_') & ~F.data.contains('add') ) - - dp.message.register( - process_subscription_extension_text, - AdminStates.extending_subscription - ) - + + dp.callback_query.register(process_traffic_addition_button, F.data.startswith('admin_sub_traffic_add_')) + + dp.message.register(process_traffic_addition_text, AdminStates.adding_traffic) + dp.callback_query.register( - add_subscription_traffic, - F.data.startswith("admin_sub_traffic_") & ~F.data.contains("add") + deactivate_user_subscription, F.data.startswith('admin_sub_deactivate_') & ~F.data.contains('confirm') ) - - dp.callback_query.register( - process_traffic_addition_button, - F.data.startswith("admin_sub_traffic_add_") - ) - - dp.message.register( - process_traffic_addition_text, - AdminStates.adding_traffic - ) - - dp.callback_query.register( - deactivate_user_subscription, - F.data.startswith("admin_sub_deactivate_") & ~F.data.contains("confirm") - ) - - dp.callback_query.register( - confirm_subscription_deactivation, - F.data.startswith("admin_sub_deactivate_confirm_") - ) - - dp.callback_query.register( - activate_user_subscription, - F.data.startswith("admin_sub_activate_") - ) - - dp.callback_query.register( - grant_trial_subscription, - F.data.startswith("admin_sub_grant_trial_") - ) - + + dp.callback_query.register(confirm_subscription_deactivation, F.data.startswith('admin_sub_deactivate_confirm_')) + + dp.callback_query.register(activate_user_subscription, F.data.startswith('admin_sub_activate_')) + + dp.callback_query.register(grant_trial_subscription, F.data.startswith('admin_sub_grant_trial_')) + dp.callback_query.register( grant_paid_subscription, - F.data.startswith("admin_sub_grant_") & ~F.data.contains("trial") & ~F.data.contains("days") - ) - - dp.callback_query.register( - process_subscription_grant_days, - F.data.startswith("admin_sub_grant_days_") - ) - - dp.message.register( - process_subscription_grant_text, - AdminStates.granting_subscription + F.data.startswith('admin_sub_grant_') & ~F.data.contains('trial') & ~F.data.contains('days'), ) - dp.callback_query.register( - show_user_servers_management, - F.data.startswith("admin_user_servers_") - ) - - dp.callback_query.register( - show_server_selection, - F.data.startswith("admin_user_change_server_") - ) - + dp.callback_query.register(process_subscription_grant_days, F.data.startswith('admin_sub_grant_days_')) + + dp.message.register(process_subscription_grant_text, AdminStates.granting_subscription) + + dp.callback_query.register(show_user_servers_management, F.data.startswith('admin_user_servers_')) + + dp.callback_query.register(show_server_selection, F.data.startswith('admin_user_change_server_')) + dp.callback_query.register( toggle_user_server, - F.data.startswith("admin_user_toggle_server_") & ~F.data.endswith("_add") & ~F.data.endswith("_remove") - ) - - dp.callback_query.register( - start_devices_edit, - F.data.startswith("admin_user_devices_") & ~F.data.contains("set") - ) - - dp.callback_query.register( - set_user_devices_button, - F.data.startswith("admin_user_devices_set_") - ) - - dp.callback_query.register( - toggle_user_modem, - F.data.startswith("admin_user_modem_") + F.data.startswith('admin_user_toggle_server_') & ~F.data.endswith('_add') & ~F.data.endswith('_remove'), ) + dp.callback_query.register(start_devices_edit, F.data.startswith('admin_user_devices_') & ~F.data.contains('set')) + + dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_')) + + dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_')) + # Смена тарифа пользователя - dp.callback_query.register( - show_admin_tariff_change, - F.data.startswith("admin_sub_change_tariff_") - ) + dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_')) + + dp.callback_query.register(select_admin_tariff_change, F.data.startswith('admin_sub_tariff_select_')) + + dp.callback_query.register(confirm_admin_tariff_change, F.data.startswith('admin_sub_tariff_confirm_')) + + dp.message.register(process_devices_edit_text, AdminStates.editing_user_devices) + + dp.callback_query.register(start_traffic_edit, F.data.startswith('admin_user_traffic_') & ~F.data.contains('set')) + + dp.callback_query.register(set_user_traffic_button, F.data.startswith('admin_user_traffic_set_')) + + dp.message.register(process_traffic_edit_text, AdminStates.editing_user_traffic) dp.callback_query.register( - select_admin_tariff_change, - F.data.startswith("admin_sub_tariff_select_") + confirm_reset_devices, F.data.startswith('admin_user_reset_devices_') & ~F.data.contains('confirm') ) - dp.callback_query.register( - confirm_admin_tariff_change, - F.data.startswith("admin_sub_tariff_confirm_") - ) + dp.callback_query.register(reset_user_devices, F.data.startswith('admin_user_reset_devices_confirm_')) - dp.message.register( - process_devices_edit_text, - AdminStates.editing_user_devices - ) - - dp.callback_query.register( - start_traffic_edit, - F.data.startswith("admin_user_traffic_") & ~F.data.contains("set") - ) - - dp.callback_query.register( - set_user_traffic_button, - F.data.startswith("admin_user_traffic_set_") - ) - - dp.message.register( - process_traffic_edit_text, - AdminStates.editing_user_traffic - ) - - dp.callback_query.register( - confirm_reset_devices, - F.data.startswith("admin_user_reset_devices_") & ~F.data.contains("confirm") - ) - - dp.callback_query.register( - reset_user_devices, - F.data.startswith("admin_user_reset_devices_confirm_") - ) + dp.callback_query.register(change_subscription_type, F.data.startswith('admin_sub_change_type_')) + + dp.callback_query.register(change_subscription_type_confirm, F.data.startswith('admin_sub_type_')) - dp.callback_query.register( - change_subscription_type, - F.data.startswith("admin_sub_change_type_") - ) - - dp.callback_query.register( - change_subscription_type_confirm, - F.data.startswith("admin_sub_type_") - ) - # Регистрация обработчика покупки подписки администратором - dp.callback_query.register( - admin_buy_subscription, - F.data.startswith("admin_sub_buy_") - ) - + dp.callback_query.register(admin_buy_subscription, F.data.startswith('admin_sub_buy_')) + # Регистрация дополнительных обработчиков для покупки подписки - dp.callback_query.register( - admin_buy_subscription_confirm, - F.data.startswith("admin_buy_sub_confirm_") - ) - - dp.callback_query.register( - admin_buy_subscription_execute, - F.data.startswith("admin_buy_sub_execute_") - ) + dp.callback_query.register(admin_buy_subscription_confirm, F.data.startswith('admin_buy_sub_confirm_')) + + dp.callback_query.register(admin_buy_subscription_execute, F.data.startswith('admin_buy_sub_execute_')) # Регистрация обработчиков для покупки тарифа администратором dp.callback_query.register( admin_buy_tariff, - F.data.startswith("admin_tariff_buy_") & ~F.data.startswith("admin_tariff_buy_select_") & ~F.data.startswith("admin_tariff_buy_confirm_") & ~F.data.startswith("admin_tariff_buy_exec_") + F.data.startswith('admin_tariff_buy_') + & ~F.data.startswith('admin_tariff_buy_select_') + & ~F.data.startswith('admin_tariff_buy_confirm_') + & ~F.data.startswith('admin_tariff_buy_exec_'), ) - dp.callback_query.register( - admin_buy_tariff_period, - F.data.startswith("admin_tariff_buy_select_") - ) + dp.callback_query.register(admin_buy_tariff_period, F.data.startswith('admin_tariff_buy_select_')) - dp.callback_query.register( - admin_buy_tariff_confirm, - F.data.startswith("admin_tariff_buy_confirm_") - ) + dp.callback_query.register(admin_buy_tariff_confirm, F.data.startswith('admin_tariff_buy_confirm_')) - dp.callback_query.register( - admin_buy_tariff_execute, - F.data.startswith("admin_tariff_buy_exec_") - ) + dp.callback_query.register(admin_buy_tariff_execute, F.data.startswith('admin_tariff_buy_exec_')) # Регистрация обработчиков для фильтрации пользователей - dp.callback_query.register( - show_users_filters, - F.data == "admin_users_filters" - ) - - dp.callback_query.register( - show_users_list_by_balance, - F.data == "admin_users_balance_filter" - ) - - dp.callback_query.register( - show_users_list_by_traffic, - F.data == "admin_users_traffic_filter" - ) + dp.callback_query.register(show_users_filters, F.data == 'admin_users_filters') - dp.callback_query.register( - show_users_list_by_last_activity, - F.data == "admin_users_activity_filter" - ) + dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter') - dp.callback_query.register( - show_users_list_by_spending, - F.data == "admin_users_spending_filter" - ) + dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter') - dp.callback_query.register( - show_users_list_by_purchases, - F.data == "admin_users_purchases_filter" - ) - - dp.callback_query.register( - show_users_ready_to_renew, - F.data == "admin_users_ready_to_renew_filter" - ) + dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter') - dp.callback_query.register( - show_users_list_by_campaign, - F.data == "admin_users_campaign_filter" - ) - + dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter') + + dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter') + + dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter') + + dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter') diff --git a/app/handlers/admin/welcome_text.py b/app/handlers/admin/welcome_text.py index 1c383005..2d371716 100644 --- a/app/handlers/admin/welcome_text.py +++ b/app/handlers/admin/welcome_text.py @@ -1,21 +1,22 @@ import logging import re -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User -from app.states import AdminStates -from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard -from app.utils.decorators import admin_required, error_handler from app.database.crud.welcome_text import ( - get_active_welcome_text, - set_welcome_text, - get_current_welcome_text_or_default, get_available_placeholders, + get_current_welcome_text_or_default, get_current_welcome_text_settings, - toggle_welcome_text_status + set_welcome_text, + toggle_welcome_text_status, ) +from app.database.models import User +from app.keyboards.admin import get_welcome_text_keyboard +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + logger = logging.getLogger(__name__) @@ -23,88 +24,99 @@ logger = logging.getLogger(__name__) def validate_html_tags(text: str) -> tuple[bool, str]: """ Проверяет HTML-теги в тексте на соответствие требованиям Telegram API. - + Args: text: Текст для проверки - + Returns: Кортеж из (валидно ли, сообщение об ошибке или None) """ # Поддерживаемые теги в parse_mode="HTML" для Telegram API allowed_tags = { - 'b', 'strong', # жирный - 'i', 'em', # курсив - 'u', 'ins', # подчеркнуто - 's', 'strike', 'del', # зачеркнуто - 'code', # моноширинный для коротких фрагментов - 'pre', # моноширинный блок кода - 'a' # ссылки + 'b', + 'strong', # жирный + 'i', + 'em', # курсив + 'u', + 'ins', # подчеркнуто + 's', + 'strike', + 'del', # зачеркнуто + 'code', # моноширинный для коротких фрагментов + 'pre', # моноширинный блок кода + 'a', # ссылки } - + # Убираем плейсхолдеры из строки перед проверкой тегов # Плейсхолдеры имеют формат {ключ}, и не являются тегами placeholder_pattern = r'\{[^{}]+\}' clean_text = re.sub(placeholder_pattern, '', text) - + # Находим все открывающие и закрывающие теги tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>' - tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)] - + tags_with_pos = [ + (m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text) + ] + for closing, tag, attrs, start_pos, end_pos in tags_with_pos: tag_lower = tag.lower() - + # Проверяем, является ли тег поддерживаемым if tag_lower not in allowed_tags: - return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}" - + return ( + False, + f'Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {", ".join(sorted(allowed_tags))}', + ) + # Проверяем атрибуты для тега if tag_lower == 'a': if closing: continue # Для закрывающего тега не нужно проверять атрибуты if not attrs: return False, "Тег должен содержать атрибут href, например: ссылка" - + # Проверяем, что есть атрибут href if 'href=' not in attrs.lower(): return False, "Тег должен содержать атрибут href, например: ссылка" - + # Проверяем формат URL href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE) if href_match: url = href_match.group(1) # Проверяем, что URL начинается с поддерживаемой схемы if not re.match(r'^https?://|^tg://', url, re.IGNORECASE): - return False, f"URL в теге должен начинаться с http://, https:// или tg://. Найдено: {url}" + return False, f'URL в теге должен начинаться с http://, https:// или tg://. Найдено: {url}' else: - return False, "Не удалось извлечь URL из атрибута href тега " - + return False, 'Не удалось извлечь URL из атрибута href тега ' + # Проверяем парность тегов с использованием стека stack = [] for closing, tag, attrs, start_pos, end_pos in tags_with_pos: tag_lower = tag.lower() - + if tag_lower not in allowed_tags: continue - + if closing: # Это закрывающий тег if not stack: - return False, f"Лишний закрывающий тег: " - + return False, f'Лишний закрывающий тег: ' + last_opening_tag = stack.pop() if last_opening_tag.lower() != tag_lower: - return False, f"Тег не соответствует открывающему тегу <{last_opening_tag}>" + return False, f'Тег не соответствует открывающему тегу <{last_opening_tag}>' else: # Это открывающий тег stack.append(tag) - + # Если остались незакрытые теги if stack: - unclosed_tags = ", ".join([f"<{tag}>" for tag in stack]) - return False, f"Незакрытые теги: {unclosed_tags}" - + unclosed_tags = ', '.join([f'<{tag}>' for tag in stack]) + return False, f'Незакрытые теги: {unclosed_tags}' + return True, None + def get_telegram_formatting_info() -> str: return """ 📝 Поддерживаемые теги форматирования: @@ -123,319 +135,264 @@ def get_telegram_formatting_info() -> str: ❌ НЕ используйте: <div>, <span>, <p>, <br>, <h1>-<h6>, <img> и другие HTML-теги. """ + @admin_required @error_handler -async def show_welcome_text_panel( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_welcome_text_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" - + status_emoji = '🟢' if welcome_settings['is_enabled'] else '🔴' + status_text = 'включено' if welcome_settings['is_enabled'] else 'отключено' + await callback.message.edit_text( - f"👋 Управление приветственным текстом\n\n" - f"{status_emoji} Статус: {status_text}\n\n" - f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n" - f"💡 Доступные плейсхолдеры для автозамены:", + f'👋 Управление приветственным текстом\n\n' + f'{status_emoji} Статус: {status_text}\n\n' + f'Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n' + f'💡 Доступные плейсхолдеры для автозамены:', reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def toggle_welcome_text( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def toggle_welcome_text(callback: types.CallbackQuery, db_user: User, db: AsyncSession): new_status = await toggle_welcome_text_status(db, db_user.id) - - status_emoji = "🟢" if new_status else "🔴" - status_text = "включено" if new_status else "отключено" - action_text = "включены" if new_status else "отключены" - + + status_emoji = '🟢' if new_status else '🔴' + status_text = 'включено' if new_status else 'отключено' + action_text = 'включены' if new_status else 'отключены' + await callback.message.edit_text( - f"👋 Управление приветственным текстом\n\n" - f"{status_emoji} Статус: {status_text}\n\n" - f"✅ Приветственные сообщения {action_text}!\n\n" - f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n" - f"💡 Доступные плейсхолдеры для автозамены:", + f'👋 Управление приветственным текстом\n\n' + f'{status_emoji} Статус: {status_text}\n\n' + f'✅ Приветственные сообщения {action_text}!\n\n' + f'Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n' + f'💡 Доступные плейсхолдеры для автозамены:', reply_markup=get_welcome_text_keyboard(db_user.language, new_status), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def show_current_welcome_text( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_current_welcome_text(callback: types.CallbackQuery, db_user: User, db: AsyncSession): welcome_settings = await get_current_welcome_text_settings(db) current_text = welcome_settings['text'] is_enabled = welcome_settings['is_enabled'] if not welcome_settings['id']: - status = "📝 Используется стандартный текст:" + status = '📝 Используется стандартный текст:' else: - status = "📝 Текущий приветственный текст:" - - status_emoji = "🟢" if is_enabled else "🔴" - status_text = "включено" if is_enabled else "отключено" - + status = '📝 Текущий приветственный текст:' + + status_emoji = '🟢' if is_enabled else '🔴' + status_text = 'включено' if is_enabled else 'отключено' + placeholders = get_available_placeholders() - placeholders_text = "\n".join([f"• {key} - {desc}" for key, desc in placeholders.items()]) - + placeholders_text = '\n'.join([f'• {key} - {desc}' for key, desc in placeholders.items()]) + await callback.message.edit_text( - f"{status_emoji} Статус: {status_text}\n\n" - f"{status}\n\n" - f"{current_text}\n\n" - f"💡 Доступные плейсхолдеры:\n{placeholders_text}", + f'{status_emoji} Статус: {status_text}\n\n' + f'{status}\n\n' + f'{current_text}\n\n' + f'💡 Доступные плейсхолдеры:\n{placeholders_text}', reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def show_placeholders_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_placeholders_help(callback: types.CallbackQuery, db_user: User, db: AsyncSession): welcome_settings = await get_current_welcome_text_settings(db) placeholders = get_available_placeholders() - placeholders_text = "\n".join([f"• {key}\n {desc}" for key, desc in placeholders.items()]) - + placeholders_text = '\n'.join([f'• {key}\n {desc}' for key, desc in placeholders.items()]) + help_text = ( - "💡 Доступные плейсхолдеры для автозамены:\n\n" - f"{placeholders_text}\n\n" - "📌 Примеры использования:\n" - "• Привет, {user_name}! Добро пожаловать!\n" - "• Здравствуйте, {first_name}! Рады видеть вас!\n" - "• Привет, {username}! Спасибо за регистрацию!\n\n" + '💡 Доступные плейсхолдеры для автозамены:\n\n' + f'{placeholders_text}\n\n' + '📌 Примеры использования:\n' + '• Привет, {user_name}! Добро пожаловать!\n' + '• Здравствуйте, {first_name}! Рады видеть вас!\n' + '• Привет, {username}! Спасибо за регистрацию!\n\n' "При отсутствии данных пользователя используется слово 'друг'." ) - + await callback.message.edit_text( help_text, reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def show_formatting_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_formatting_help(callback: types.CallbackQuery, db_user: User, db: AsyncSession): welcome_settings = await get_current_welcome_text_settings(db) formatting_info = get_telegram_formatting_info() - + await callback.message.edit_text( formatting_info, reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + @admin_required @error_handler -async def start_edit_welcome_text( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def start_edit_welcome_text(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): welcome_settings = await get_current_welcome_text_settings(db) current_text = welcome_settings['text'] - + placeholders = get_available_placeholders() - placeholders_text = "\n".join([f"• {key} - {desc}" for key, desc in placeholders.items()]) - + placeholders_text = '\n'.join([f'• {key} - {desc}' for key, desc in placeholders.items()]) + await callback.message.edit_text( - f"📝 Редактирование приветственного текста\n\n" - f"Текущий текст:\n" - f"{current_text}\n\n" - f"💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n" - f"Отправьте новый текст:", - parse_mode="HTML" + f'📝 Редактирование приветственного текста\n\n' + f'Текущий текст:\n' + f'{current_text}\n\n' + f'💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n' + f'Отправьте новый текст:', + parse_mode='HTML', ) - + await state.set_state(AdminStates.editing_welcome_text) await callback.answer() + @admin_required @error_handler -async def process_welcome_text_edit( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def process_welcome_text_edit(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): new_text = message.text.strip() - + if len(new_text) < 10: - await message.answer("❌ Текст слишком короткий! Минимум 10 символов.") + await message.answer('❌ Текст слишком короткий! Минимум 10 символов.') return - + if len(new_text) > 4000: - await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.") + await message.answer('❌ Текст слишком длинный! Максимум 4000 символов.') return - + # Проверяем HTML-теги на валидность is_valid, error_msg = validate_html_tags(new_text) if not is_valid: - await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}") + await message.answer(f'❌ Ошибка в HTML-разметке:\n\n{error_msg}') return - + success = await set_welcome_text(db, new_text, db_user.id) - + if success: welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" - + status_emoji = '🟢' if welcome_settings['is_enabled'] else '🔴' + status_text = 'включено' if welcome_settings['is_enabled'] else 'отключено' + placeholders = get_available_placeholders() - placeholders_text = "\n".join([f"• {key}" for key in placeholders.keys()]) - + placeholders_text = '\n'.join([f'• {key}' for key in placeholders.keys()]) + await message.answer( - f"✅ Приветственный текст успешно обновлен!\n\n" - f"{status_emoji} Статус: {status_text}\n\n" - f"Новый текст:\n" - f"{new_text}\n\n" - f"💡 Будут заменяться плейсхолдеры: {placeholders_text}", + f'✅ Приветственный текст успешно обновлен!\n\n' + f'{status_emoji} Статус: {status_text}\n\n' + f'Новый текст:\n' + f'{new_text}\n\n' + f'💡 Будут заменяться плейсхолдеры: {placeholders_text}', reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) else: welcome_settings = await get_current_welcome_text_settings(db) await message.answer( - "❌ Ошибка при сохранении текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + '❌ Ошибка при сохранении текста. Попробуйте еще раз.', + reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), ) - + await state.clear() + @admin_required @error_handler -async def reset_welcome_text( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def reset_welcome_text(callback: types.CallbackQuery, db_user: User, db: AsyncSession): default_text = await get_current_welcome_text_or_default() success = await set_welcome_text(db, default_text, db_user.id) - + if success: welcome_settings = await get_current_welcome_text_settings(db) - status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴" - status_text = "включено" if welcome_settings['is_enabled'] else "отключено" - + status_emoji = '🟢' if welcome_settings['is_enabled'] else '🔴' + status_text = 'включено' if welcome_settings['is_enabled'] else 'отключено' + await callback.message.edit_text( - f"✅ Приветственный текст сброшен на стандартный!\n\n" - f"{status_emoji} Статус: {status_text}\n\n" - f"Стандартный текст:\n" - f"{default_text}\n\n" - f"💡 Плейсхолдер {{user_name}} будет заменяться на имя пользователя", + f'✅ Приветственный текст сброшен на стандартный!\n\n' + f'{status_emoji} Статус: {status_text}\n\n' + f'Стандартный текст:\n' + f'{default_text}\n\n' + f'💡 Плейсхолдер {{user_name}} будет заменяться на имя пользователя', reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) else: welcome_settings = await get_current_welcome_text_settings(db) await callback.message.edit_text( - "❌ Ошибка при сбросе текста. Попробуйте еще раз.", - reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']) + '❌ Ошибка при сбросе текста. Попробуйте еще раз.', + reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), ) - + await callback.answer() + @admin_required @error_handler -async def show_preview_welcome_text( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_preview_welcome_text(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.database.crud.welcome_text import get_welcome_text_for_user - + class TestUser: def __init__(self): - self.first_name = "Иван" - self.username = "test_user" - + self.first_name = 'Иван' + self.username = 'test_user' + test_user = TestUser() preview_text = await get_welcome_text_for_user(db, test_user) - + welcome_settings = await get_current_welcome_text_settings(db) - + if preview_text: await callback.message.edit_text( - f"👁️ Предварительный просмотр\n\n" + f'👁️ Предварительный просмотр\n\n' f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n" - f"{preview_text}", + f'{preview_text}', reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) else: await callback.message.edit_text( - f"👁️ Предварительный просмотр\n\n" - f"🔴 Приветственные сообщения отключены.\n" - f"Новые пользователи не будут получать приветственный текст после регистрации.", + '👁️ Предварительный просмотр\n\n' + '🔴 Приветственные сообщения отключены.\n' + 'Новые пользователи не будут получать приветственный текст после регистрации.', reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']), - parse_mode="HTML" + parse_mode='HTML', ) - + await callback.answer() + def register_welcome_text_handlers(dp: Dispatcher): - dp.callback_query.register( - show_welcome_text_panel, - F.data == "welcome_text_panel" - ) - - dp.callback_query.register( - toggle_welcome_text, - F.data == "toggle_welcome_text" - ) - - dp.callback_query.register( - show_current_welcome_text, - F.data == "show_welcome_text" - ) - - dp.callback_query.register( - show_placeholders_help, - F.data == "show_placeholders_help" - ) - - dp.callback_query.register( - show_formatting_help, - F.data == "show_formatting_help" - ) - - dp.callback_query.register( - show_preview_welcome_text, - F.data == "preview_welcome_text" - ) - - dp.callback_query.register( - start_edit_welcome_text, - F.data == "edit_welcome_text" - ) - - dp.callback_query.register( - reset_welcome_text, - F.data == "reset_welcome_text" - ) - - dp.message.register( - process_welcome_text_edit, - AdminStates.editing_welcome_text - ) + dp.callback_query.register(show_welcome_text_panel, F.data == 'welcome_text_panel') + + dp.callback_query.register(toggle_welcome_text, F.data == 'toggle_welcome_text') + + dp.callback_query.register(show_current_welcome_text, F.data == 'show_welcome_text') + + dp.callback_query.register(show_placeholders_help, F.data == 'show_placeholders_help') + + dp.callback_query.register(show_formatting_help, F.data == 'show_formatting_help') + + dp.callback_query.register(show_preview_welcome_text, F.data == 'preview_welcome_text') + + dp.callback_query.register(start_edit_welcome_text, F.data == 'edit_welcome_text') + + dp.callback_query.register(reset_welcome_text, F.data == 'reset_welcome_text') + + dp.message.register(process_welcome_text_edit, AdminStates.editing_welcome_text) diff --git a/app/handlers/balance/__init__.py b/app/handlers/balance/__init__.py index 268702f6..c681b683 100644 --- a/app/handlers/balance/__init__.py +++ b/app/handlers/balance/__init__.py @@ -1,3 +1,4 @@ from .main import register_balance_handlers -__all__ = ["register_balance_handlers"] \ No newline at end of file + +__all__ = ['register_balance_handlers'] diff --git a/app/handlers/balance/cloudpayments.py b/app/handlers/balance/cloudpayments.py index 928a40fe..d49b06a1 100644 --- a/app/handlers/balance/cloudpayments.py +++ b/app/handlers/balance/cloudpayments.py @@ -4,7 +4,7 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,6 +15,7 @@ from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -57,23 +58,23 @@ async def _create_cloudpayments_payment_and_respond( if not result: error_text = texts.t( - "PAYMENT_CREATE_ERROR", - "Не удалось создать платёж. Попробуйте позже.", + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', ) if edit_message: await message_or_callback.edit_text( error_text, reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( error_text, - parse_mode="HTML", + parse_mode='HTML', ) return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') # Create keyboard with payment button keyboard = InlineKeyboardMarkup( @@ -81,44 +82,44 @@ async def _create_cloudpayments_payment_and_respond( [ InlineKeyboardButton( text=texts.t( - "PAY_BUTTON", - "💳 Оплатить {amount}₽", - ).format(amount=f"{amount_rub:.0f}"), + 'PAY_BUTTON', + '💳 Оплатить {amount}₽', + ).format(amount=f'{amount_rub:.0f}'), url=payment_url, ) ], [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ], ] ) response_text = texts.t( - "CLOUDPAYMENTS_PAYMENT_CREATED", - "💳 Оплата банковской картой\n\n" - "Сумма: {amount}₽\n\n" - "Нажмите кнопку ниже для оплаты.\n" - "После успешной оплаты баланс будет пополнен автоматически.", - ).format(amount=f"{amount_rub:.2f}") + 'CLOUDPAYMENTS_PAYMENT_CREATED', + '💳 Оплата банковской картой\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(amount=f'{amount_rub:.2f}') if edit_message: await message_or_callback.edit_text( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "CloudPayments payment created: user=%s, amount=%s₽", + 'CloudPayments payment created: user=%s, amount=%s₽', db_user.telegram_id, amount_rub, ) @@ -141,25 +142,25 @@ async def process_cloudpayments_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return if not settings.is_cloudpayments_enabled(): await message.answer( - texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"), + texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'), ) return @@ -168,8 +169,8 @@ async def process_cloudpayments_payment_amount( min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100 await message.answer( texts.t( - "AMOUNT_TOO_LOW", - "Минимальная сумма пополнения: {min_amount:.0f}₽", + 'AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount:.0f}₽', ).format(min_amount=min_rub), ) return @@ -178,8 +179,8 @@ async def process_cloudpayments_payment_amount( max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100 await message.answer( texts.t( - "AMOUNT_TOO_HIGH", - "Максимальная сумма пополнения: {max_amount:,.0f}₽", + 'AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount:,.0f}₽', ).format(max_amount=max_rub), ) return @@ -187,9 +188,7 @@ async def process_cloudpayments_payment_amount( # Clear state await state.clear() - await _create_cloudpayments_payment_and_respond( - message, db_user, db, amount_kopeks, edit_message=False - ) + await _create_cloudpayments_payment_and_respond(message, db_user, db, amount_kopeks, edit_message=False) @error_handler @@ -207,24 +206,24 @@ async def start_cloudpayments_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_cloudpayments_enabled(): await callback.answer( - texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"), + texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'), show_alert=True, ) return @@ -233,9 +232,9 @@ async def start_cloudpayments_payment( max_amount_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100 message_text = texts.t( - "CLOUDPAYMENTS_ENTER_AMOUNT", - "💳 Оплата банковской картой (CloudPayments)\n\n" - "Введите сумму для пополнения от {min_amount:.0f} до {max_amount:,.0f} рублей:", + 'CLOUDPAYMENTS_ENTER_AMOUNT', + '💳 Оплата банковской картой (CloudPayments)\n\n' + 'Введите сумму для пополнения от {min_amount:.0f} до {max_amount:,.0f} рублей:', ).format(min_amount=min_amount_rub, max_amount=max_amount_rub) keyboard = get_back_keyboard(db_user.language) @@ -243,11 +242,11 @@ async def start_cloudpayments_payment( await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="cloudpayments") + await state.update_data(payment_method='cloudpayments') await state.update_data( cloudpayments_prompt_message_id=callback.message.message_id, cloudpayments_prompt_chat_id=callback.message.chat.id, @@ -271,20 +270,20 @@ async def process_cloudpayments_amount( # Get state data state_data = await state.get_data() - payment_method = state_data.get("payment_method") + payment_method = state_data.get('payment_method') - if payment_method != "cloudpayments": + if payment_method != 'cloudpayments': return # Not our payment method # Parse amount try: - amount_text = message.text.strip().replace(",", ".").replace(" ", "") + amount_text = message.text.strip().replace(',', '.').replace(' ', '') amount_rub = float(amount_text) amount_kopeks = int(amount_rub * 100) except (ValueError, TypeError): await message.answer( - texts.t("INVALID_AMOUNT", "Введите корректную сумму числом"), - parse_mode="HTML", + texts.t('INVALID_AMOUNT', 'Введите корректную сумму числом'), + parse_mode='HTML', ) return @@ -293,10 +292,10 @@ async def process_cloudpayments_amount( min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100 await message.answer( texts.t( - "AMOUNT_TOO_LOW", - "Минимальная сумма пополнения: {min_amount:.0f}₽", + 'AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount:.0f}₽', ).format(min_amount=min_rub), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -304,10 +303,10 @@ async def process_cloudpayments_amount( max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100 await message.answer( texts.t( - "AMOUNT_TOO_HIGH", - "Максимальная сумма пополнения: {max_amount:,.0f}₽", + 'AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount:,.0f}₽', ).format(max_amount=max_rub), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -334,14 +333,14 @@ async def process_cloudpayments_amount( if not result: await message.answer( texts.t( - "PAYMENT_CREATE_ERROR", - "Не удалось создать платёж. Попробуйте позже.", + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', ), - parse_mode="HTML", + parse_mode='HTML', ) return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') # Create keyboard with payment button keyboard = InlineKeyboardMarkup( @@ -349,16 +348,16 @@ async def process_cloudpayments_amount( [ InlineKeyboardButton( text=texts.t( - "PAY_BUTTON", - "💳 Оплатить {amount}₽", - ).format(amount=f"{amount_rub:.0f}"), + 'PAY_BUTTON', + '💳 Оплатить {amount}₽', + ).format(amount=f'{amount_rub:.0f}'), url=payment_url, ) ], [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ], ] @@ -366,18 +365,18 @@ async def process_cloudpayments_amount( await message.answer( texts.t( - "CLOUDPAYMENTS_PAYMENT_CREATED", - "💳 Оплата банковской картой\n\n" - "Сумма: {amount}₽\n\n" - "Нажмите кнопку ниже для оплаты.\n" - "После успешной оплаты баланс будет пополнен автоматически.", - ).format(amount=f"{amount_rub:.2f}"), + 'CLOUDPAYMENTS_PAYMENT_CREATED', + '💳 Оплата банковской картой\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(amount=f'{amount_rub:.2f}'), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "CloudPayments payment created: user=%s, amount=%s₽", + 'CloudPayments payment created: user=%s, amount=%s₽', db_user.telegram_id, amount_rub, ) @@ -399,21 +398,21 @@ async def handle_cloudpayments_quick_amount( if not settings.is_cloudpayments_enabled(): await callback.answer( - texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"), + texts.t('CLOUDPAYMENTS_NOT_AVAILABLE', 'CloudPayments временно недоступен'), show_alert=True, ) return # Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks} try: - parts = callback.data.split("|") + parts = callback.data.split('|') if len(parts) >= 3: amount_kopeks = int(parts[2]) else: - await callback.answer("Invalid callback data", show_alert=True) + await callback.answer('Invalid callback data', show_alert=True) return except (ValueError, IndexError): - await callback.answer("Invalid amount", show_alert=True) + await callback.answer('Invalid amount', show_alert=True) return amount_rub = amount_kopeks / 100 @@ -421,14 +420,14 @@ async def handle_cloudpayments_quick_amount( # Validate amount if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: await callback.answer( - texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"), + texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True, ) return if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: await callback.answer( - texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"), + texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True, ) return @@ -455,15 +454,15 @@ async def handle_cloudpayments_quick_amount( if not result: await callback.message.edit_text( texts.t( - "PAYMENT_CREATE_ERROR", - "Не удалось создать платёж. Попробуйте позже.", + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', ), reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') # Create keyboard with payment button keyboard = InlineKeyboardMarkup( @@ -471,16 +470,16 @@ async def handle_cloudpayments_quick_amount( [ InlineKeyboardButton( text=texts.t( - "PAY_BUTTON", - "💳 Оплатить {amount}₽", - ).format(amount=f"{amount_rub:.0f}"), + 'PAY_BUTTON', + '💳 Оплатить {amount}₽', + ).format(amount=f'{amount_rub:.0f}'), url=payment_url, ) ], [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ], ] @@ -488,18 +487,18 @@ async def handle_cloudpayments_quick_amount( await callback.message.edit_text( texts.t( - "CLOUDPAYMENTS_PAYMENT_CREATED", - "💳 Оплата банковской картой\n\n" - "Сумма: {amount}₽\n\n" - "Нажмите кнопку ниже для оплаты.\n" - "После успешной оплаты баланс будет пополнен автоматически.", - ).format(amount=f"{amount_rub:.2f}"), + 'CLOUDPAYMENTS_PAYMENT_CREATED', + '💳 Оплата банковской картой\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(amount=f'{amount_rub:.2f}'), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "CloudPayments payment created (quick): user=%s, amount=%s₽", + 'CloudPayments payment created (quick): user=%s, amount=%s₽', db_user.telegram_id, amount_rub, ) diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index aa149425..1af27f9f 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -1,4 +1,5 @@ import logging + from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -9,96 +10,91 @@ from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService -from app.utils.decorators import error_handler from app.states import BalanceStates +from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @error_handler -async def start_cryptobot_payment( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User, state: FSMContext): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_cryptobot_enabled(): - await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True) + await callback.answer('❌ Оплата криптовалютой временно недоступна', show_alert=True) return - + from app.utils.currency_converter import currency_converter + try: current_rate = await currency_converter.get_usd_to_rub_rate() - rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f} ₽" + rate_text = f'💱 Текущий курс: 1 USD = {current_rate:.2f} ₽' except Exception as e: - logger.warning(f"Не удалось получить курс валют: {e}") + logger.warning(f'Не удалось получить курс валют: {e}') current_rate = 95.0 - rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f} ₽" - + rate_text = f'💱 Курс: 1 USD ≈ {current_rate:.0f} ₽' + available_assets = settings.get_cryptobot_assets() - assets_text = ", ".join(available_assets) - + assets_text = ', '.join(available_assets) + # Формируем текст сообщения в зависимости от настройки if settings.is_quick_amount_buttons_enabled(): message_text = ( - f"🪙 Пополнение криптовалютой\n\n" - f"Выберите сумму пополнения или введите вручную сумму " - f"от 100 до 100,000 ₽:\n\n" - f"💰 Доступные активы: {assets_text}\n" - f"⚡ Мгновенное зачисление на баланс\n" - f"🔒 Безопасная оплата через CryptoBot\n\n" - f"{rate_text}\n" - f"Сумма будет автоматически конвертирована в USD для оплаты." + f'🪙 Пополнение криптовалютой\n\n' + f'Выберите сумму пополнения или введите вручную сумму ' + f'от 100 до 100,000 ₽:\n\n' + f'💰 Доступные активы: {assets_text}\n' + f'⚡ Мгновенное зачисление на баланс\n' + f'🔒 Безопасная оплата через CryptoBot\n\n' + f'{rate_text}\n' + f'Сумма будет автоматически конвертирована в USD для оплаты.' ) else: message_text = ( - f"🪙 Пополнение криптовалютой\n\n" - f"Введите сумму для пополнения от 100 до 100,000 ₽:\n\n" - f"💰 Доступные активы: {assets_text}\n" - f"⚡ Мгновенное зачисление на баланс\n" - f"🔒 Безопасная оплата через CryptoBot\n\n" - f"{rate_text}\n" - f"Сумма будет автоматически конвертирована в USD для оплаты." + f'🪙 Пополнение криптовалютой\n\n' + f'Введите сумму для пополнения от 100 до 100,000 ₽:\n\n' + f'💰 Доступные активы: {assets_text}\n' + f'⚡ Мгновенное зачисление на баланс\n' + f'🔒 Безопасная оплата через CryptoBot\n\n' + f'{rate_text}\n' + f'Сумма будет автоматически конвертирована в USD для оплаты.' ) - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) - + + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') + await state.set_state(BalanceStates.waiting_for_amount) await state.update_data( - payment_method="cryptobot", + payment_method='cryptobot', current_rate=current_rate, cryptobot_prompt_message_id=callback.message.message_id, cryptobot_prompt_chat_id=callback.message.chat.id, @@ -108,127 +104,130 @@ async def start_cryptobot_payment( @error_handler async def process_cryptobot_payment_amount( - message: types.Message, - db_user: User, - db: AsyncSession, - amount_kopeks: int, - state: FSMContext + message: types.Message, db_user: User, db: AsyncSession, amount_kopeks: int, state: FSMContext ): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Оплата невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Оплата невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) if not settings.is_cryptobot_enabled(): - await message.answer("❌ Оплата криптовалютой временно недоступна") + await message.answer('❌ Оплата криптовалютой временно недоступна') return - + amount_rubles = amount_kopeks / 100 - + if amount_rubles < 100: - await message.answer("Минимальная сумма пополнения: 100 ₽") + await message.answer('Минимальная сумма пополнения: 100 ₽') return - + if amount_rubles > 100000: - await message.answer("Максимальная сумма пополнения: 100,000 ₽") + await message.answer('Максимальная сумма пополнения: 100,000 ₽') return - + try: data = await state.get_data() current_rate = data.get('current_rate') - + if not current_rate: from app.utils.currency_converter import currency_converter + current_rate = await currency_converter.get_usd_to_rub_rate() - + amount_usd = amount_rubles / current_rate - + amount_usd = round(amount_usd, 2) - + if amount_usd < 1: - await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD") + await message.answer('❌ Минимальная сумма для оплаты в USD: 1.00 USD') return - + if amount_usd > 1000: - await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD") + await message.answer('❌ Максимальная сумма для оплаты в USD: 1,000 USD') return - + payment_service = PaymentService(message.bot) - + payment_result = await payment_service.create_cryptobot_payment( db=db, user_id=db_user.id, amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)", - payload=f"balance_{db_user.id}_{amount_kopeks}" + description=f'Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)', + payload=f'balance_{db_user.id}_{amount_kopeks}', ) - + if not payment_result: - await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.") + await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() return - - bot_invoice_url = payment_result.get("bot_invoice_url") - mini_app_invoice_url = payment_result.get("mini_app_invoice_url") - + + bot_invoice_url = payment_result.get('bot_invoice_url') + mini_app_invoice_url = payment_result.get('mini_app_invoice_url') + payment_url = bot_invoice_url or mini_app_invoice_url - + if not payment_url: - await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.") + await message.answer('❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.') await state.clear() return - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)], - [types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🪙 Оплатить', url=payment_url)], + [ + types.InlineKeyboardButton( + text='📊 Проверить статус', + callback_data=f'check_cryptobot_{payment_result["local_payment_id"]}', + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], + ] + ) state_data = await state.get_data() - prompt_message_id = state_data.get("cryptobot_prompt_message_id") - prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('cryptobot_prompt_message_id') + prompt_chat_id = state_data.get('cryptobot_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - "Не удалось удалить сообщение с суммой CryptoBot: %s", + 'Не удалось удалить сообщение с суммой CryptoBot: %s', delete_error, ) @@ -237,27 +236,27 @@ async def process_cryptobot_payment_amount( await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostics logger.warning( - "Не удалось удалить сообщение с запросом суммы CryptoBot: %s", + 'Не удалось удалить сообщение с запросом суммы CryptoBot: %s', delete_error, ) invoice_message = await message.answer( - f"🪙 Оплата криптовалютой\n\n" - f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n" - f"💵 К оплате: {amount_usd:.2f} USD\n" - f"🪙 Актив: {payment_result['asset']}\n" - f"💱 Курс: 1 USD = {current_rate:.2f} ₽\n" - f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n" - f"📱 Инструкция:\n" + f'🪙 Оплата криптовалютой\n\n' + f'💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n' + f'💵 К оплате: {amount_usd:.2f} USD\n' + f'🪙 Актив: {payment_result["asset"]}\n' + f'💱 Курс: 1 USD = {current_rate:.2f} ₽\n' + f'🆔 ID платежа: {payment_result["invoice_id"][:8]}...\n\n' + f'📱 Инструкция:\n' f"1. Нажмите кнопку 'Оплатить'\n" - f"2. Выберите удобный актив\n" - f"3. Переведите указанную сумму\n" - f"4. Деньги поступят на баланс автоматически\n\n" - f"🔒 Оплата проходит через защищенную систему CryptoBot\n" - f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", + f'2. Выберите удобный актив\n' + f'3. Переведите указанную сумму\n' + f'4. Деньги поступят на баланс автоматически\n\n' + f'🔒 Оплата проходит через защищенную систему CryptoBot\n' + f'⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n' + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}', reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -266,63 +265,55 @@ async def process_cryptobot_payment_amount( ) await state.clear() - - logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: " - f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}") - + + logger.info( + f'Создан CryptoBot платеж для пользователя {db_user.telegram_id}: ' + f'{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result["invoice_id"]}' + ) + except Exception as e: - logger.error(f"Ошибка создания CryptoBot платежа: {e}") - await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.") + logger.error(f'Ошибка создания CryptoBot платежа: {e}') + await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() @error_handler -async def check_cryptobot_payment_status( - callback: types.CallbackQuery, - db: AsyncSession -): +async def check_cryptobot_payment_status(callback: types.CallbackQuery, db: AsyncSession): try: local_payment_id = int(callback.data.split('_')[-1]) - + from app.database.crud.cryptobot import get_cryptobot_payment_by_id + payment = await get_cryptobot_payment_by_id(db, local_payment_id) - + if not payment: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - - status_emoji = { - "active": "⏳", - "paid": "✅", - "expired": "❌" - } - - status_text = { - "active": "Ожидает оплаты", - "paid": "Оплачен", - "expired": "Истек" - } - - emoji = status_emoji.get(payment.status, "❓") - status = status_text.get(payment.status, "Неизвестно") - - message_text = (f"🪙 Статус платежа:\n\n" - f"🆔 ID: {payment.invoice_id[:8]}...\n" - f"💰 Сумма: {payment.amount} {payment.asset}\n" - f"📊 Статус: {emoji} {status}\n" - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n") - + + status_emoji = {'active': '⏳', 'paid': '✅', 'expired': '❌'} + + status_text = {'active': 'Ожидает оплаты', 'paid': 'Оплачен', 'expired': 'Истек'} + + emoji = status_emoji.get(payment.status, '❓') + status = status_text.get(payment.status, 'Неизвестно') + + message_text = ( + f'🪙 Статус платежа:\n\n' + f'🆔 ID: {payment.invoice_id[:8]}...\n' + f'💰 Сумма: {payment.amount} {payment.asset}\n' + f'📊 Статус: {emoji} {status}\n' + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M")}\n' + ) + if payment.is_paid: - message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс." + message_text += '\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс.' elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_expired: - message_text += ( - f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}" - ) - + message_text += f'\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}' + await callback.answer(message_text, show_alert=True) - + except Exception as e: - logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}") - await callback.answer("❌ Ошибка проверки статуса", show_alert=True) \ No newline at end of file + logger.error(f'Ошибка проверки статуса CryptoBot платежа: {e}') + await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/freekassa.py b/app/handlers/balance/freekassa.py index 0f304b86..5506f061 100644 --- a/app/handlers/balance/freekassa.py +++ b/app/handlers/balance/freekassa.py @@ -4,7 +4,7 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,6 +15,7 @@ from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ async def _create_freekassa_payment_and_respond( description = settings.PAYMENT_BALANCE_TEMPLATE.format( service_name=settings.PAYMENT_SERVICE_NAME, - description="Пополнение баланса", + description='Пополнение баланса', ) result = await payment_service.create_freekassa_payment( @@ -51,29 +52,29 @@ async def _create_freekassa_payment_and_respond( user_id=db_user.id, amount_kopeks=amount_kopeks, description=description, - email=getattr(db_user, "email", None), + email=getattr(db_user, 'email', None), language=db_user.language, ) if not result: error_text = texts.t( - "PAYMENT_CREATE_ERROR", - "Не удалось создать платёж. Попробуйте позже.", + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', ) if edit_message: await message_or_callback.edit_text( error_text, reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( error_text, - parse_mode="HTML", + parse_mode='HTML', ) return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') display_name = settings.get_freekassa_display_name() # Create keyboard with payment button @@ -82,44 +83,44 @@ async def _create_freekassa_payment_and_respond( [ InlineKeyboardButton( text=texts.t( - "PAY_BUTTON", - "💳 Оплатить {amount}₽", - ).format(amount=f"{amount_rub:.0f}"), + 'PAY_BUTTON', + '💳 Оплатить {amount}₽', + ).format(amount=f'{amount_rub:.0f}'), url=payment_url, ) ], [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ], ] ) response_text = texts.t( - "FREEKASSA_PAYMENT_CREATED", - "💳 Оплата через {name}\n\n" - "Сумма: {amount}₽\n\n" - "Нажмите кнопку ниже для оплаты.\n" - "После успешной оплаты баланс будет пополнен автоматически.", - ).format(name=display_name, amount=f"{amount_rub:.2f}") + 'FREEKASSA_PAYMENT_CREATED', + '💳 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') if edit_message: await message_or_callback.edit_text( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "Freekassa payment created: user=%s, amount=%s₽", + 'Freekassa payment created: user=%s, amount=%s₽', db_user.telegram_id, amount_rub, ) @@ -139,24 +140,17 @@ async def process_freekassa_payment_amount( texts = get_texts(db_user.language) # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) await state.clear() @@ -169,20 +163,20 @@ async def process_freekassa_payment_amount( if amount_kopeks < min_amount: await message.answer( texts.t( - "PAYMENT_AMOUNT_TOO_LOW", - "Минимальная сумма пополнения: {min_amount}₽", + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', ).format(min_amount=min_amount // 100), - parse_mode="HTML", + parse_mode='HTML', ) return if amount_kopeks > max_amount: await message.answer( texts.t( - "PAYMENT_AMOUNT_TOO_HIGH", - "Максимальная сумма пополнения: {max_amount}₽", + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', ).format(max_amount=max_amount // 100), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -210,30 +204,23 @@ async def start_freekassa_topup( texts = get_texts(db_user.language) # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) return await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="freekassa") + await state.update_data(payment_method='freekassa') min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100 max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100 @@ -243,8 +230,8 @@ async def start_freekassa_topup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ] ] @@ -252,17 +239,17 @@ async def start_freekassa_topup( await callback.message.edit_text( texts.t( - "FREEKASSA_ENTER_AMOUNT", - "💳 Пополнение через {name}\n\n" - "Введите сумму пополнения в рублях.\n\n" - "Минимум: {min_amount}₽\n" - "Максимум: {max_amount}₽", + 'FREEKASSA_ENTER_AMOUNT', + '💳 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', ).format( name=display_name, min_amount=min_amount, - max_amount=f"{max_amount:,}".replace(",", " "), + max_amount=f'{max_amount:,}'.replace(',', ' '), ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) @@ -278,22 +265,22 @@ async def process_freekassa_custom_amount( Process custom amount input for Freekassa payment. """ data = await state.get_data() - if data.get("payment_method") != "freekassa": + if data.get('payment_method') != 'freekassa': return texts = get_texts(db_user.language) try: - amount_text = message.text.replace(",", ".").replace(" ", "").strip() + amount_text = message.text.replace(',', '.').replace(' ', '').strip() amount_rubles = float(amount_text) amount_kopeks = int(amount_rubles * 100) except (ValueError, TypeError): await message.answer( texts.t( - "PAYMENT_INVALID_AMOUNT", - "Введите корректную сумму числом.", + 'PAYMENT_INVALID_AMOUNT', + 'Введите корректную сумму числом.', ), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -321,42 +308,35 @@ async def process_freekassa_quick_amount( if not settings.is_freekassa_enabled(): await callback.answer( - texts.t("FREEKASSA_NOT_AVAILABLE", "Freekassa временно недоступен"), + texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'), show_alert=True, ) return # Extract amount from callback data: topup_amount|freekassa|{amount_kopeks} try: - parts = callback.data.split("|") + parts = callback.data.split('|') if len(parts) >= 3: amount_kopeks = int(parts[2]) else: - await callback.answer("Invalid callback data", show_alert=True) + await callback.answer('Invalid callback data', show_alert=True) return except (ValueError, IndexError): - await callback.answer("Invalid amount", show_alert=True) + await callback.answer('Invalid amount', show_alert=True) return # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) return @@ -367,14 +347,14 @@ async def process_freekassa_quick_amount( if amount_kopeks < min_amount: await callback.answer( - texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"), + texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True, ) return if amount_kopeks > max_amount: await callback.answer( - texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"), + texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True, ) return diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 2fa48814..7cf5512f 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,6 +1,5 @@ import logging from datetime import datetime -from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext @@ -15,6 +14,7 @@ from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -28,46 +28,47 @@ async def start_heleket_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_heleket_enabled(): - await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True) + await callback.answer('❌ Оплата через Heleket недоступна', show_alert=True) return markup = settings.get_heleket_markup_percent() - markup_text: Optional[str] + markup_text: str | None if markup > 0: - label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера") - markup_text = f"{label}: {markup:.0f}%" + label = texts.t('PAYMENT_HELEKET_MARKUP_LABEL', 'Наценка провайдера') + markup_text = f'{label}: {markup:.0f}%' elif markup < 0: - label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера") - markup_text = f"{label}: {abs(markup):.0f}%" + label = texts.t('PAYMENT_HELEKET_DISCOUNT_LABEL', 'Скидка провайдера') + markup_text = f'{label}: {abs(markup):.0f}%' else: markup_text = None message_lines = [ - "🪙 Пополнение через Heleket", - "\n", - "Введите сумму пополнения от 100 до 100,000 ₽:", - "", - "⚡ Мгновенное зачисление", "🔒 Безопасная оплата", + '🪙 Пополнение через Heleket', + '\n', + 'Введите сумму пополнения от 100 до 100,000 ₽:', + '', + '⚡ Мгновенное зачисление', + '🔒 Безопасная оплата', ] if markup_text: - message_lines.extend(["", markup_text]) + message_lines.extend(['', markup_text]) keyboard = get_back_keyboard(db_user.language) @@ -79,14 +80,14 @@ async def start_heleket_payment( keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard await callback.message.edit_text( - "\n".join(filter(None, message_lines)), + '\n'.join(filter(None, message_lines)), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) await state.update_data( - payment_method="heleket", + payment_method='heleket', heleket_prompt_message_id=callback.message.message_id, heleket_prompt_chat_id=callback.message.chat.id, ) @@ -105,34 +106,34 @@ async def process_heleket_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return if not settings.is_heleket_enabled(): - await message.answer("❌ Оплата через Heleket недоступна") + await message.answer('❌ Оплата через Heleket недоступна') return amount_rubles = amount_kopeks / 100 if amount_rubles < 100: - await message.answer("Минимальная сумма пополнения: 100 ₽") + await message.answer('Минимальная сумма пополнения: 100 ₽') return if amount_rubles > 100000: - await message.answer("Максимальная сумма пополнения: 100,000 ₽") + await message.answer('Максимальная сумма пополнения: 100,000 ₽') return payment_service = PaymentService(message.bot) @@ -141,38 +142,36 @@ async def process_heleket_payment_amount( db=db, user_id=db_user.id, amount_kopeks=amount_kopeks, - description=f"Пополнение баланса на {amount_rubles:.0f} ₽", + description=f'Пополнение баланса на {amount_rubles:.0f} ₽', language=db_user.language, ) if not result: - await message.answer( - "❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку." - ) + await message.answer('❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку.') await state.clear() return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') if not payment_url: - await message.answer("❌ Не удалось получить ссылку для оплаты Heleket") + await message.answer('❌ Не удалось получить ссылку для оплаты Heleket') await state.clear() return - payer_amount = result.get("payer_amount") - payer_currency = result.get("payer_currency") - exchange_rate = result.get("exchange_rate") - discount_percent = result.get("discount_percent") + payer_amount = result.get('payer_amount') + payer_currency = result.get('payer_currency') + result.get('exchange_rate') + discount_percent = result.get('discount_percent') details = [ - "🪙 Оплата через Heleket", - "", - f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽", + '🪙 Оплата через Heleket', + '', + f'💰 Сумма к зачислению: {amount_rubles:.0f} ₽', ] if payer_amount and payer_currency: - details.append(f"🪙 К оплате: {payer_amount} {payer_currency}") + details.append(f'🪙 К оплате: {payer_amount} {payer_currency}') - markup_percent: Optional[float] = None + markup_percent: float | None = None if discount_percent is not None: try: discount_int = int(discount_percent) @@ -181,78 +180,76 @@ async def process_heleket_payment_amount( markup_percent = None if markup_percent: - label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера") - label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера") + label_markup = texts.t('PAYMENT_HELEKET_MARKUP_LABEL', 'Наценка провайдера') + label_discount = texts.t('PAYMENT_HELEKET_DISCOUNT_LABEL', 'Скидка провайдера') absolute = abs(markup_percent) if markup_percent > 0: - details.append(f"📈 {label_markup}: +{absolute}%") + details.append(f'📈 {label_markup}: +{absolute}%') else: - details.append(f"📉 {label_discount}: {absolute}%") + details.append(f'📉 {label_discount}: {absolute}%') if payer_amount and payer_currency: try: payer_amount_float = float(payer_amount) if payer_amount_float > 0: rub_per_currency = amount_rubles / payer_amount_float - details.append( - f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽" - ) + details.append(f'💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽') except (TypeError, ValueError, ZeroDivisionError): pass details.extend( [ - "", - "📱 Инструкция:", + '', + '📱 Инструкция:', "1. Нажмите кнопку 'Оплатить'", - "2. Перейдите на страницу Heleket", - "3. Оплатите указанную сумму", - "4. Баланс пополнится автоматически", + '2. Перейдите на страницу Heleket', + '3. Оплатите указанную сумму', + '4. Баланс пополнится автоматически', ] ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)], - [ - types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_heleket_{result['local_payment_id']}" - ) - ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.t('PAY_WITH_COINS_BUTTON', '🪙 Оплатить'), url=payment_url)], + [ + types.InlineKeyboardButton( + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_heleket_{result["local_payment_id"]}', + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], + ] + ) state_data = await state.get_data() - prompt_message_id = state_data.get("heleket_prompt_message_id") - prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('heleket_prompt_message_id') + prompt_chat_id = state_data.get('heleket_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой Heleket: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic logger.warning( - "Не удалось удалить сообщение с запросом суммы Heleket: %s", + 'Не удалось удалить сообщение с запросом суммы Heleket: %s', delete_error, ) - invoice_message = await message.answer( - "\n".join(details), parse_mode="HTML", reply_markup=keyboard - ) + invoice_message = await message.answer('\n'.join(details), parse_mode='HTML', reply_markup=keyboard) try: from app.services import payment_service as payment_module - payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"]) + payment = await payment_module.get_heleket_payment_by_id(db, result['local_payment_id']) if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await db.execute( update(payment.__class__) @@ -261,7 +258,7 @@ async def process_heleket_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение Heleket: %s", error) + logger.warning('Не удалось сохранить сообщение Heleket: %s', error) await state.update_data( heleket_invoice_message_id=invoice_message.message_id, @@ -277,23 +274,23 @@ async def check_heleket_payment_status( db: AsyncSession, ) -> None: try: - local_payment_id = int(callback.data.split("_")[-1]) + local_payment_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("Некорректный идентификатор платежа", show_alert=True) + await callback.answer('Некорректный идентификатор платежа', show_alert=True) return from app.database.crud.heleket import get_heleket_payment_by_id payment = await get_heleket_payment_by_id(db, local_payment_id) if not payment: - await callback.answer("Платёж не найден", show_alert=True) + await callback.answer('Платёж не найден', show_alert=True) return - language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE + language = getattr(payment.user, 'language', None) or settings.DEFAULT_LANGUAGE texts = get_texts(language) if payment.is_paid: - message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен") + message = texts.t('HELEKET_PAYMENT_ALREADY_PAID', '✅ Платёж уже зачислен') await callback.answer(message, show_alert=True) return @@ -307,38 +304,38 @@ async def check_heleket_payment_status( payment = updated_payment if payment.is_paid: - message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс") + message = texts.t('HELEKET_PAYMENT_SUCCESS', '✅ Платёж зачислен на баланс') await callback.answer(message, show_alert=True) return - status_normalized = (payment.status or "").lower() + status_normalized = (payment.status or '').lower() status_messages = { - "check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"), - "process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"), - "confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"), - "wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"), - "wrong_amount_waiting": texts.t( - "HELEKET_STATUS_WRONG_AMOUNT_WAITING", - "❗️ Недостаточная сумма, ожидаем доплату", + 'check': texts.t('HELEKET_STATUS_CHECK', '⏳ Ожидание оплаты'), + 'process': texts.t('HELEKET_STATUS_PROCESS', '⚙️ Платёж обрабатывается'), + 'confirm_check': texts.t('HELEKET_STATUS_CONFIRM_CHECK', '⛓ Ожидание подтверждений сети'), + 'wrong_amount': texts.t('HELEKET_STATUS_WRONG_AMOUNT', '❗️ Оплачена неверная сумма'), + 'wrong_amount_waiting': texts.t( + 'HELEKET_STATUS_WRONG_AMOUNT_WAITING', + '❗️ Недостаточная сумма, ожидаем доплату', ), - "paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"), - "paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"), - "cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"), - "fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"), - "system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"), - "refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"), - "refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"), - "refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"), - "locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"), + 'paid_over': texts.t('HELEKET_STATUS_PAID_OVER', '✅ Платёж зачислен (с переплатой)'), + 'paid': texts.t('HELEKET_STATUS_PAID', '✅ Платёж зачислен'), + 'cancel': texts.t('HELEKET_STATUS_CANCEL', '🚫 Платёж отменён'), + 'fail': texts.t('HELEKET_STATUS_FAIL', '❌ Ошибка при оплате'), + 'system_fail': texts.t('HELEKET_STATUS_SYSTEM_FAIL', '❌ Системная ошибка Heleket'), + 'refund_process': texts.t('HELEKET_STATUS_REFUND_PROCESS', '↩️ Возврат обрабатывается'), + 'refund_fail': texts.t('HELEKET_STATUS_REFUND_FAIL', '⚠️ Ошибка возврата'), + 'refund_paid': texts.t('HELEKET_STATUS_REFUND_PAID', '✅ Возврат выполнен'), + 'locked': texts.t('HELEKET_STATUS_LOCKED', '🔒 Средства заблокированы'), } message = status_messages.get(status_normalized) if message is None: - template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}") - status_value = payment.status or status_normalized or "—" + template = texts.t('HELEKET_STATUS_UNKNOWN', 'ℹ️ Статус платежа: {status}') + status_value = payment.status or status_normalized or '—' try: message = template.format(status=status_value) except Exception: # pragma: no cover - defensive formatting - message = f"ℹ️ Статус платежа: {status_value}" + message = f'ℹ️ Статус платежа: {status_value}' await callback.answer(message, show_alert=True) diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py index 75c71a83..c75b16ef 100644 --- a/app/handlers/balance/kassa_ai.py +++ b/app/handlers/balance/kassa_ai.py @@ -4,7 +4,7 @@ import logging from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -15,6 +15,7 @@ from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -43,7 +44,7 @@ async def _create_kassa_ai_payment_and_respond( description = settings.PAYMENT_BALANCE_TEMPLATE.format( service_name=settings.PAYMENT_SERVICE_NAME, - description="Пополнение баланса", + description='Пополнение баланса', ) result = await payment_service.create_kassa_ai_payment( @@ -51,29 +52,29 @@ async def _create_kassa_ai_payment_and_respond( user_id=db_user.id, amount_kopeks=amount_kopeks, description=description, - email=getattr(db_user, "email", None), + email=getattr(db_user, 'email', None), language=db_user.language, ) if not result: error_text = texts.t( - "PAYMENT_CREATE_ERROR", - "Не удалось создать платёж. Попробуйте позже.", + 'PAYMENT_CREATE_ERROR', + 'Не удалось создать платёж. Попробуйте позже.', ) if edit_message: await message_or_callback.edit_text( error_text, reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( error_text, - parse_mode="HTML", + parse_mode='HTML', ) return - payment_url = result.get("payment_url") + payment_url = result.get('payment_url') display_name = settings.get_kassa_ai_display_name() # Create keyboard with payment button @@ -82,44 +83,44 @@ async def _create_kassa_ai_payment_and_respond( [ InlineKeyboardButton( text=texts.t( - "PAY_BUTTON", - "💳 Оплатить {amount}₽", - ).format(amount=f"{amount_rub:.0f}"), + 'PAY_BUTTON', + '💳 Оплатить {amount}₽', + ).format(amount=f'{amount_rub:.0f}'), url=payment_url, ) ], [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ], ] ) response_text = texts.t( - "KASSA_AI_PAYMENT_CREATED", - "💳 Оплата через {name}\n\n" - "Сумма: {amount}₽\n\n" - "Нажмите кнопку ниже для оплаты.\n" - "После успешной оплаты баланс будет пополнен автоматически.", - ).format(name=display_name, amount=f"{amount_rub:.2f}") + 'KASSA_AI_PAYMENT_CREATED', + '💳 Оплата через {name}\n\n' + 'Сумма: {amount}₽\n\n' + 'Нажмите кнопку ниже для оплаты.\n' + 'После успешной оплаты баланс будет пополнен автоматически.', + ).format(name=display_name, amount=f'{amount_rub:.2f}') if edit_message: await message_or_callback.edit_text( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message_or_callback.answer( response_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "KassaAI payment created: user=%s, amount=%s₽", + 'KassaAI payment created: user=%s, amount=%s₽', db_user.telegram_id, amount_rub, ) @@ -139,24 +140,17 @@ async def process_kassa_ai_payment_amount( texts = get_texts(db_user.language) # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) await state.clear() @@ -169,20 +163,20 @@ async def process_kassa_ai_payment_amount( if amount_kopeks < min_amount: await message.answer( texts.t( - "PAYMENT_AMOUNT_TOO_LOW", - "Минимальная сумма пополнения: {min_amount}₽", + 'PAYMENT_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {min_amount}₽', ).format(min_amount=min_amount // 100), - parse_mode="HTML", + parse_mode='HTML', ) return if amount_kopeks > max_amount: await message.answer( texts.t( - "PAYMENT_AMOUNT_TOO_HIGH", - "Максимальная сумма пополнения: {max_amount}₽", + 'PAYMENT_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {max_amount}₽', ).format(max_amount=max_amount // 100), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -210,30 +204,23 @@ async def start_kassa_ai_topup( texts = get_texts(db_user.language) # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) return await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="kassa_ai") + await state.update_data(payment_method='kassa_ai') min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100 max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100 @@ -243,8 +230,8 @@ async def start_kassa_ai_topup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("BACK_BUTTON", "◀️ Назад"), - callback_data="menu_balance", + text=texts.t('BACK_BUTTON', '◀️ Назад'), + callback_data='menu_balance', ) ] ] @@ -252,17 +239,17 @@ async def start_kassa_ai_topup( await callback.message.edit_text( texts.t( - "KASSA_AI_ENTER_AMOUNT", - "💳 Пополнение через {name}\n\n" - "Введите сумму пополнения в рублях.\n\n" - "Минимум: {min_amount}₽\n" - "Максимум: {max_amount}₽", + 'KASSA_AI_ENTER_AMOUNT', + '💳 Пополнение через {name}\n\n' + 'Введите сумму пополнения в рублях.\n\n' + 'Минимум: {min_amount}₽\n' + 'Максимум: {max_amount}₽', ).format( name=display_name, min_amount=min_amount, - max_amount=f"{max_amount:,}".replace(",", " "), + max_amount=f'{max_amount:,}'.replace(',', ' '), ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) @@ -278,22 +265,22 @@ async def process_kassa_ai_custom_amount( Process custom amount input for KassaAI payment. """ data = await state.get_data() - if data.get("payment_method") != "kassa_ai": + if data.get('payment_method') != 'kassa_ai': return texts = get_texts(db_user.language) try: - amount_text = message.text.replace(",", ".").replace(" ", "").strip() + amount_text = message.text.replace(',', '.').replace(' ', '').strip() amount_rubles = float(amount_text) amount_kopeks = int(amount_rubles * 100) except (ValueError, TypeError): await message.answer( texts.t( - "PAYMENT_INVALID_AMOUNT", - "Введите корректную сумму числом.", + 'PAYMENT_INVALID_AMOUNT', + 'Введите корректную сумму числом.', ), - parse_mode="HTML", + parse_mode='HTML', ) return @@ -321,42 +308,35 @@ async def process_kassa_ai_quick_amount( if not settings.is_kassa_ai_enabled(): await callback.answer( - texts.t("KASSA_AI_NOT_AVAILABLE", "KassaAI временно недоступен"), + texts.t('KASSA_AI_NOT_AVAILABLE', 'KassaAI временно недоступен'), show_alert=True, ) return # Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks} try: - parts = callback.data.split("|") + parts = callback.data.split('|') if len(parts) >= 3: amount_kopeks = int(parts[2]) else: - await callback.answer("Invalid callback data", show_alert=True) + await callback.answer('Invalid callback data', show_alert=True) return except (ValueError, IndexError): - await callback.answer("Invalid amount", show_alert=True) + await callback.answer('Invalid amount', show_alert=True) return # Проверка ограничения на пополнение - if getattr(db_user, "restriction_topup", False): - reason = ( - getattr(db_user, "restriction_reason", None) - or "Действие ограничено администратором" - ) + if getattr(db_user, 'restriction_topup', False): + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append( - [InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)] - ) - keyboard.append( - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")] - ) + keyboard.append([InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}", - parse_mode="HTML", + f'🚫 Пополнение ограничено\n\n{reason}', + parse_mode='HTML', reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), ) return @@ -367,14 +347,14 @@ async def process_kassa_ai_quick_amount( if amount_kopeks < min_amount: await callback.answer( - texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"), + texts.t('AMOUNT_TOO_LOW_SHORT', 'Сумма слишком мала'), show_alert=True, ) return if amount_kopeks > max_amount: await callback.answer( - texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"), + texts.t('AMOUNT_TOO_HIGH_SHORT', 'Сумма слишком велика'), show_alert=True, ) return diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index fd1a0aec..7cb6fc00 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -1,29 +1,25 @@ -import html import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from aiogram.types import InaccessibleMessage from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import BalanceStates -from app.database.crud.user import add_user_balance -from app.utils.price_display import calculate_user_price, format_price_button -from app.utils.pricing_utils import format_period_description -from app.database.crud.transaction import ( - get_user_transactions, get_user_transactions_count, - create_transaction -) -from app.database.models import User, TransactionType, PaymentMethod +from app.database.crud.transaction import get_user_transactions +from app.database.models import TransactionType, User from app.keyboards.inline import ( - get_balance_keyboard, get_payment_methods_keyboard, - get_back_keyboard, get_pagination_keyboard + get_back_keyboard, + get_balance_keyboard, + get_pagination_keyboard, + get_payment_methods_keyboard, ) from app.localization.texts import get_texts -from app.services.payment_service import PaymentService -from app.utils.pagination import paginate_list +from app.states import BalanceStates from app.utils.decorators import error_handler +from app.utils.price_display import calculate_user_price + logger = logging.getLogger(__name__) @@ -31,11 +27,7 @@ TRANSACTIONS_PER_PAGE = 10 async def route_payment_by_method( - message: types.Message, - db_user: User, - amount_kopeks: int, - state: FSMContext, - payment_method: str + message: types.Message, db_user: User, amount_kopeks: int, state: FSMContext, payment_method: str ) -> bool: """ Роутер платежей по методу оплаты. @@ -50,76 +42,88 @@ async def route_payment_by_method( Returns: True если платеж обработан, False если метод неизвестен """ - if payment_method == "stars": + if payment_method == 'stars': from .stars import process_stars_payment_amount + await process_stars_payment_amount(message, db_user, amount_kopeks, state) return True # Все остальные методы требуют сессию БД from app.database.database import AsyncSessionLocal - if payment_method == "yookassa": + if payment_method == 'yookassa': from .yookassa import process_yookassa_payment_amount + async with AsyncSessionLocal() as db: await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "yookassa_sbp": + if payment_method == 'yookassa_sbp': from .yookassa import process_yookassa_sbp_payment_amount + async with AsyncSessionLocal() as db: await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "mulenpay": + if payment_method == 'mulenpay': from .mulenpay import process_mulenpay_payment_amount + async with AsyncSessionLocal() as db: await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "platega": + if payment_method == 'platega': from .platega import process_platega_payment_amount + async with AsyncSessionLocal() as db: await process_platega_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "wata": + if payment_method == 'wata': from .wata import process_wata_payment_amount + async with AsyncSessionLocal() as db: await process_wata_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "pal24": + if payment_method == 'pal24': from .pal24 import process_pal24_payment_amount + async with AsyncSessionLocal() as db: await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "cryptobot": + if payment_method == 'cryptobot': from .cryptobot import process_cryptobot_payment_amount + async with AsyncSessionLocal() as db: await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "heleket": + if payment_method == 'heleket': from .heleket import process_heleket_payment_amount + async with AsyncSessionLocal() as db: await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "cloudpayments": + if payment_method == 'cloudpayments': from .cloudpayments import process_cloudpayments_payment_amount + async with AsyncSessionLocal() as db: await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "freekassa": + if payment_method == 'freekassa': from .freekassa import process_freekassa_payment_amount + async with AsyncSessionLocal() as db: await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state) return True - if payment_method == "kassa_ai": + if payment_method == 'kassa_ai': from .kassa_ai import process_kassa_ai_payment_amount + async with AsyncSessionLocal() as db: await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state) return True @@ -141,17 +145,18 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: if not settings.is_quick_amount_buttons_enabled(): return [] - from app.localization.texts import get_texts from app.config import PERIOD_PRICES + from app.localization.texts import get_texts + texts = get_texts(language) # В режиме тарифов получаем цены из тарифа пользователя tariff_prices = None tariff_periods = None if settings.is_tariffs_mode(): - from app.database.database import AsyncSessionLocal from app.database.crud.subscription import get_subscription_by_user_id from app.database.crud.tariff import get_tariff_by_id + from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: subscription = await get_subscription_by_user_id(db, user.id) @@ -178,43 +183,34 @@ async def get_quick_amount_buttons(language: str, user: User) -> list: if base_price_kopeks > 0: # Calculate price with user's promo group discount using unified system - price_info = calculate_user_price(user, base_price_kopeks, period, "period") + price_info = calculate_user_price(user, base_price_kopeks, period, 'period') - callback_data = f"quick_amount_{price_info.final_price}" + callback_data = f'quick_amount_{price_info.final_price}' # Format button text with discount display - period_label = f"{period} дней" + period_label = f'{period} дней' # For balance buttons, use simpler format without emoji and period label prefix if price_info.has_discount: button_text = ( - f"{texts.format_price(price_info.base_price)} ➜ " - f"{texts.format_price(price_info.final_price)} " - f"(-{price_info.discount_percent}%) • {period_label}" + f'{texts.format_price(price_info.base_price)} ➜ ' + f'{texts.format_price(price_info.final_price)} ' + f'(-{price_info.discount_percent}%) • {period_label}' ) else: - button_text = f"{texts.format_price(price_info.final_price)} • {period_label}" + button_text = f'{texts.format_price(price_info.final_price)} • {period_label}' - buttons.append( - types.InlineKeyboardButton( - text=button_text, - callback_data=callback_data - ) - ) + buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data)) keyboard_rows = [] for i in range(0, len(buttons), 2): - keyboard_rows.append(buttons[i:i + 2]) + keyboard_rows.append(buttons[i : i + 2]) return keyboard_rows @error_handler -async def show_balance_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_balance_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -222,167 +218,122 @@ async def show_balance_menu( texts = get_texts(db_user.language) - balance_text = texts.BALANCE_INFO.format( - balance=texts.format_price(db_user.balance_kopeks) - ) + balance_text = texts.BALANCE_INFO.format(balance=texts.format_price(db_user.balance_kopeks)) reply_markup = get_balance_keyboard(db_user.language) try: if callback.message and callback.message.text: - await callback.message.edit_text( - balance_text, - reply_markup=reply_markup - ) + await callback.message.edit_text(balance_text, reply_markup=reply_markup) elif callback.message and callback.message.caption: - await callback.message.edit_caption( - balance_text, - reply_markup=reply_markup - ) + await callback.message.edit_caption(balance_text, reply_markup=reply_markup) else: - await callback.message.answer( - balance_text, - reply_markup=reply_markup - ) + await callback.message.answer(balance_text, reply_markup=reply_markup) except TelegramBadRequest as error: logger.warning( - "Failed to edit balance message, sending a new one instead: %s", + 'Failed to edit balance message, sending a new one instead: %s', error, ) - await callback.message.answer( - balance_text, - reply_markup=reply_markup - ) + await callback.message.answer(balance_text, reply_markup=reply_markup) await callback.answer() @error_handler -async def show_balance_history( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - page: int = 1 -): +async def show_balance_history(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1): texts = get_texts(db_user.language) - + offset = (page - 1) * TRANSACTIONS_PER_PAGE - - raw_transactions = await get_user_transactions( - db, db_user.id, - limit=TRANSACTIONS_PER_PAGE * 3, - offset=offset - ) - + + raw_transactions = await get_user_transactions(db, db_user.id, limit=TRANSACTIONS_PER_PAGE * 3, offset=offset) + seen_transactions = set() unique_transactions = [] - + for transaction in raw_transactions: rounded_time = transaction.created_at.replace(second=0, microsecond=0) - transaction_key = ( - transaction.amount_kopeks, - transaction.description, - rounded_time - ) - + transaction_key = (transaction.amount_kopeks, transaction.description, rounded_time) + if transaction_key not in seen_transactions: seen_transactions.add(transaction_key) unique_transactions.append(transaction) - + if len(unique_transactions) >= TRANSACTIONS_PER_PAGE: break - + all_transactions = await get_user_transactions(db, db_user.id, limit=1000) seen_all = set() total_unique = 0 - + for transaction in all_transactions: rounded_time = transaction.created_at.replace(second=0, microsecond=0) - transaction_key = ( - transaction.amount_kopeks, - transaction.description, - rounded_time - ) + transaction_key = (transaction.amount_kopeks, transaction.description, rounded_time) if transaction_key not in seen_all: seen_all.add(transaction_key) total_unique += 1 - + if not unique_transactions: - await callback.message.edit_text( - "📊 История операций пуста", - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text('📊 История операций пуста', reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return - - text = "📊 История операций\n\n" - + + text = '📊 История операций\n\n' + for transaction in unique_transactions: - emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸" - amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}" - - text += f"{emoji} {amount_text}\n" - text += f"📝 {transaction.description}\n" - text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" - + emoji = '💰' if transaction.type == TransactionType.DEPOSIT.value else '💸' + amount_text = ( + f'+{texts.format_price(transaction.amount_kopeks)}' + if transaction.type == TransactionType.DEPOSIT.value + else f'-{texts.format_price(transaction.amount_kopeks)}' + ) + + text += f'{emoji} {amount_text}\n' + text += f'📝 {transaction.description}\n' + text += f'📅 {transaction.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' + keyboard = [] total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE - + if total_pages > 1: - pagination_row = get_pagination_keyboard( - page, total_pages, "balance_history", db_user.language - ) + pagination_row = get_pagination_keyboard(page, total_pages, 'balance_history', db_user.language) keyboard.extend(pagination_row) - - keyboard.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") - ]) - + + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + await callback.message.edit_text( - text, - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + text, reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode='HTML' ) await callback.answer() @error_handler -async def handle_balance_history_pagination( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_balance_history_pagination(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[-1]) await show_balance_history(callback, db_user, db, page) @error_handler -async def show_payment_methods( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - from app.utils.payment_utils import get_payment_methods_text - from app.database.crud.subscription import get_subscription_by_user_id - from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount +async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): from app.config import settings + from app.database.crud.subscription import get_subscription_by_user_id from app.services.subscription_service import SubscriptionService + from app.utils.payment_utils import get_payment_methods_text + from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return @@ -391,7 +342,7 @@ async def show_payment_methods( # Добавляем информацию о текущем тарифе пользователя subscription = await get_subscription_by_user_id(db, db_user.id) - tariff_info = "" + tariff_info = '' if subscription and not subscription.is_trial: # Рассчитываем приблизительную стоимость продления на 30 дней duration_days = 30 # Берем для примера 30 дней @@ -415,7 +366,7 @@ async def show_payment_methods( if base_price_original <= 0: base_price_original = PERIOD_PRICES.get(duration_days, 0) - period_discount_percent = db_user.get_promo_discount("period", duration_days) + period_discount_percent = db_user.get_promo_discount('period', duration_days) base_price, base_discount_total = apply_percentage_discount( base_price_original, period_discount_percent, @@ -423,13 +374,17 @@ async def show_payment_methods( # Рассчитываем стоимость серверов from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() - servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids( + ( + servers_price_per_month, + per_server_monthly_prices, + ) = await subscription_service.get_countries_price_by_uuids( current_connected_squads, db, promo_group_id=db_user.promo_group_id, ) - servers_discount_percent = db_user.get_promo_discount("servers", duration_days) + servers_discount_percent = db_user.get_promo_discount('servers', duration_days) total_servers_price = 0 for server_price in per_server_monthly_prices: discounted_per_month, discount_per_month = apply_percentage_discount( @@ -440,7 +395,7 @@ async def show_payment_methods( # Рассчитываем стоимость трафика traffic_price_per_month = settings.get_traffic_price(current_traffic) - traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days) + traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days) traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount( traffic_price_per_month, traffic_discount_percent, @@ -449,7 +404,7 @@ async def show_payment_methods( # Рассчитываем стоимость устройств additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE - devices_discount_percent = db_user.get_promo_discount("devices", duration_days) + devices_discount_percent = db_user.get_promo_discount('devices', duration_days) devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount( devices_price_per_month, devices_discount_percent, @@ -458,28 +413,30 @@ async def show_payment_methods( # Общая стоимость months_in_period = calculate_months_from_days(duration_days) total_price = ( - base_price + - total_servers_price * months_in_period + - traffic_discounted_per_month * months_in_period + - devices_discounted_per_month * months_in_period + base_price + + total_servers_price * months_in_period + + traffic_discounted_per_month * months_in_period + + devices_discounted_per_month * months_in_period ) - + traffic_value = current_traffic or 0 if traffic_value <= 0: - traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит") + traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит') else: traffic_display = texts.format_traffic(traffic_value) current_tariff_desc = ( - f"📱 Подписка: {len(current_connected_squads)} серверов, " - f"{traffic_display}, {current_device_limit} устр." + f'📱 Подписка: {len(current_connected_squads)} серверов, ' + f'{traffic_display}, {current_device_limit} устр.' ) - estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней" - - tariff_info = f"\n\n📋 Ваш текущий тариф:\n{current_tariff_desc}\n{estimated_price_info}" + estimated_price_info = ( + f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней' + ) + + tariff_info = f'\n\n📋 Ваш текущий тариф:\n{current_tariff_desc}\n{estimated_price_info}' except Exception as e: - logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}") - tariff_info = "" + logger.warning(f'Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}') + tariff_info = '' full_text = payment_text + tariff_info @@ -487,68 +444,44 @@ async def show_payment_methods( # Если сообщение недоступно, отправляем новое if isinstance(callback.message, InaccessibleMessage): - await callback.message.answer( - full_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(full_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() return try: - await callback.message.edit_text( - full_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(full_text, reply_markup=keyboard, parse_mode='HTML') except TelegramBadRequest: try: - await callback.message.edit_caption( - full_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_caption(full_text, reply_markup=keyboard, parse_mode='HTML') except TelegramBadRequest: try: await callback.message.delete() except TelegramBadRequest: pass - await callback.message.answer( - full_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(full_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() @error_handler -async def handle_payment_methods_unavailable( - callback: types.CallbackQuery, - db_user: User -): +async def handle_payment_methods_unavailable(callback: types.CallbackQuery, db_user: User): texts = get_texts(db_user.language) - + await callback.answer( texts.t( - "PAYMENT_METHODS_UNAVAILABLE_ALERT", - "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + 'PAYMENT_METHODS_UNAVAILABLE_ALERT', + '⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.', ), - show_alert=True + show_alert=True, ) @error_handler -async def handle_successful_topup_with_cart( - user_id: int, - amount_kopeks: int, - bot, - db: AsyncSession -): - from app.database.crud.user import get_user_by_id - from aiogram.fsm.context import FSMContext +async def handle_successful_topup_with_cart(user_id: int, amount_kopeks: int, bot, db: AsyncSession): from aiogram.fsm.storage.base import StorageKey + from app.bot import dp + from app.database.crud.user import get_user_by_id user = await get_user_by_id(db, user_id) if not user: @@ -556,78 +489,67 @@ async def handle_successful_topup_with_cart( # Email-only users don't have telegram_id - skip Telegram notification if not user.telegram_id: - logger.info(f"Skipping cart notification for email-only user {user_id}") + logger.info(f'Skipping cart notification for email-only user {user_id}') return storage = dp.storage key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id) - + try: state_data = await storage.get_data(key) current_state = await storage.get_state(key) - - if (current_state == "SubscriptionStates:cart_saved_for_topup" and - state_data.get('saved_cart')): - + + if current_state == 'SubscriptionStates:cart_saved_for_topup' and state_data.get('saved_cart'): texts = get_texts(user.language) total_price = state_data.get('total_price', 0) - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="🛒 Вернуться к оформлению подписки", - callback_data="return_to_saved_cart" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu" - )] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='🛒 Вернуться к оформлению подписки', callback_data='return_to_saved_cart' + ) + ], + [types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')], + ] + ) + success_text = ( - f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n" - f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n" - f"🛒 У вас есть сохраненная корзина подписки\n" - f"Стоимость: {texts.format_price(total_price)}\n\n" - f"Хотите продолжить оформление?" + f'✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n' + f'💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n' + f'🛒 У вас есть сохраненная корзина подписки\n' + f'Стоимость: {texts.format_price(total_price)}\n\n' + f'Хотите продолжить оформление?' ) - + await bot.send_message( - chat_id=user.telegram_id, - text=success_text, - reply_markup=keyboard, - parse_mode="HTML" + chat_id=user.telegram_id, text=success_text, reply_markup=keyboard, parse_mode='HTML' ) - + except Exception as e: - logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}") + logger.error(f'Ошибка обработки успешного пополнения с корзиной: {e}') @error_handler -async def request_support_topup( - callback: types.CallbackQuery, - db_user: User -): +async def request_support_topup(callback: types.CallbackQuery, db_user: User): texts = get_texts(db_user.language) if not settings.is_support_topup_enabled(): await callback.answer( texts.t( - "SUPPORT_TOPUP_DISABLED", - "Пополнение через поддержку отключено. Попробуйте другой способ оплаты.", + 'SUPPORT_TOPUP_DISABLED', + 'Пополнение через поддержку отключено. Попробуйте другой способ оплаты.', ), show_alert=True, ) return - user_id_display = db_user.telegram_id or db_user.email or f"#{db_user.id}" + user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' support_text = f""" 🛠️ Пополнение через поддержку @@ -646,143 +568,125 @@ async def request_support_topup( • Переводы между банками • Другие платежные системы """ - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="💬 Написать в поддержку", - url=settings.get_support_contact_url() or "https://t.me/" - )], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) - - await callback.message.edit_text( - support_text, - reply_markup=keyboard, - parse_mode="HTML" + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='💬 Написать в поддержку', url=settings.get_support_contact_url() or 'https://t.me/' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], + ] ) + + await callback.message.edit_text(support_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() @error_handler -async def process_topup_amount( - message: types.Message, - db_user: User, - state: FSMContext -): +async def process_topup_amount(message: types.Message, db_user: User, state: FSMContext): texts = get_texts(db_user.language) try: if not message.text: if message.successful_payment: logger.info( - "Получено сообщение об успешном платеже без текста, " - "обработчик суммы пополнения завершает работу" + 'Получено сообщение об успешном платеже без текста, обработчик суммы пополнения завершает работу' ) await state.clear() return - await message.answer( - texts.INVALID_AMOUNT, - reply_markup=get_back_keyboard(db_user.language) - ) + await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language)) return amount_text = message.text.strip() if not amount_text: - await message.answer( - texts.INVALID_AMOUNT, - reply_markup=get_back_keyboard(db_user.language) - ) + await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language)) return amount_rubles = float(amount_text.replace(',', '.')) if amount_rubles < 1: - await message.answer("Минимальная сумма пополнения: 1 ₽") + await message.answer('Минимальная сумма пополнения: 1 ₽') return - + if amount_rubles > 50000: - await message.answer("Максимальная сумма пополнения: 50,000 ₽") + await message.answer('Максимальная сумма пополнения: 50,000 ₽') return - + amount_kopeks = int(amount_rubles * 100) data = await state.get_data() - payment_method = data.get("payment_method", "stars") - - if payment_method in ["yookassa", "yookassa_sbp"]: + payment_method = data.get('payment_method', 'stars') + + if payment_method in ['yookassa', 'yookassa_sbp']: if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽") + await message.answer(f'❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽') return - + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽".replace(',', ' ')) + await message.answer( + f'❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽'.replace(',', ' ') + ) return - + if not await route_payment_by_method(message, db_user, amount_kopeks, state, payment_method): - await message.answer("Неизвестный способ оплаты") - + await message.answer('Неизвестный способ оплаты') + except ValueError: - await message.answer( - texts.INVALID_AMOUNT, - reply_markup=get_back_keyboard(db_user.language) - ) + await message.answer(texts.INVALID_AMOUNT, reply_markup=get_back_keyboard(db_user.language)) @error_handler -async def handle_sbp_payment( - callback: types.CallbackQuery, - db: AsyncSession -): +async def handle_sbp_payment(callback: types.CallbackQuery, db: AsyncSession): try: local_payment_id = int(callback.data.split('_')[-1]) - + from app.database.crud.yookassa import get_yookassa_payment_by_local_id + payment = await get_yookassa_payment_by_local_id(db, local_payment_id) - + if not payment: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - + import json + metadata = json.loads(payment.metadata_json) if payment.metadata_json else {} - confirmation_token = metadata.get("confirmation_token") - + confirmation_token = metadata.get('confirmation_token') + if not confirmation_token: - await callback.answer("❌ Токен подтверждения не найден", show_alert=True) + await callback.answer('❌ Токен подтверждения не найден', show_alert=True) return - + await callback.message.answer( - f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n" - f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n" - f"1. Скопировать этот токен: {confirmation_token}\\n" - f"2. Открыть приложение вашего банка\\n" - f"3. Найти функцию оплаты по токену\\n" - f"4. Вставить токен и подтвердить платеж", - parse_mode="HTML" + f'Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n' + f'Если у вас не открылось банковское приложение автоматически, вы можете:\\n' + f'1. Скопировать этот токен: {confirmation_token}\\n' + f'2. Открыть приложение вашего банка\\n' + f'3. Найти функцию оплаты по токену\\n' + f'4. Вставить токен и подтвердить платеж', + parse_mode='HTML', ) - - await callback.answer("Информация об оплате отправлена", show_alert=True) - + + await callback.answer('Информация об оплате отправлена', show_alert=True) + except Exception as e: - logger.error(f"Ошибка обработки embedded платежа СБП: {e}") - await callback.answer("❌ Ошибка обработки платежа", show_alert=True) + logger.error(f'Ошибка обработки embedded платежа СБП: {e}') + await callback.answer('❌ Ошибка обработки платежа', show_alert=True) @error_handler -async def handle_quick_amount_selection( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def handle_quick_amount_selection(callback: types.CallbackQuery, db_user: User, state: FSMContext): """ Обработчик выбора суммы через кнопки быстрого выбора """ # Проверяем, что пользователь в правильном состоянии FSM current_state = await state.get_state() if current_state != BalanceStates.waiting_for_amount: - await callback.answer("❌ Сначала выберите способ оплаты", show_alert=True) + await callback.answer('❌ Сначала выберите способ оплаты', show_alert=True) return # Извлекаем сумму из callback_data @@ -791,20 +695,18 @@ async def handle_quick_amount_selection( # Получаем метод оплаты из состояния data = await state.get_data() - payment_method = data.get("payment_method", "yookassa") + payment_method = data.get('payment_method', 'yookassa') # Роутим платеж на соответствующий обработчик - if not await route_payment_by_method( - callback.message, db_user, amount_kopeks, state, payment_method - ): - await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) + if not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, payment_method): + await callback.answer('❌ Неизвестный способ оплаты', show_alert=True) return except ValueError: - await callback.answer("❌ Ошибка обработки суммы", show_alert=True) + await callback.answer('❌ Ошибка обработки суммы', show_alert=True) except Exception as e: - logger.error(f"Ошибка обработки быстрого выбора суммы: {e}") - await callback.answer("❌ Ошибка обработки запроса", show_alert=True) + logger.error(f'Ошибка обработки быстрого выбора суммы: {e}') + await callback.answer('❌ Ошибка обработки запроса', show_alert=True) @error_handler @@ -814,240 +716,159 @@ async def handle_topup_amount_callback( state: FSMContext, ): try: - _, method, amount_str = callback.data.split("|", 2) + _, method, amount_str = callback.data.split('|', 2) amount_kopeks = int(amount_str) except ValueError: - await callback.answer("❌ Некорректный запрос", show_alert=True) + await callback.answer('❌ Некорректный запрос', show_alert=True) return if amount_kopeks <= 0: - await callback.answer("❌ Некорректная сумма", show_alert=True) + await callback.answer('❌ Некорректная сумма', show_alert=True) return try: # Особые случаи, требующие специальной логики - if method == "platega": + if method == 'platega': from app.database.database import AsyncSessionLocal + from .platega import process_platega_payment_amount, start_platega_payment data = await state.get_data() - method_code = int(data.get("platega_method", 0)) if data else 0 + method_code = int(data.get('platega_method', 0)) if data else 0 if method_code > 0: async with AsyncSessionLocal() as db: - await process_platega_payment_amount( - callback.message, db_user, db, amount_kopeks, state - ) + await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state) else: await state.update_data(platega_pending_amount=amount_kopeks) await start_platega_payment(callback, db_user, state) - elif method == "tribute": + elif method == 'tribute': from .tribute import start_tribute_payment + await start_tribute_payment(callback, db_user) return # Стандартные методы через роутер - elif not await route_payment_by_method( - callback.message, db_user, amount_kopeks, state, method - ): - await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) + elif not await route_payment_by_method(callback.message, db_user, amount_kopeks, state, method): + await callback.answer('❌ Неизвестный способ оплаты', show_alert=True) return await callback.answer() except Exception as error: - logger.error(f"Ошибка быстрого пополнения: {error}") - await callback.answer("❌ Ошибка обработки запроса", show_alert=True) + logger.error(f'Ошибка быстрого пополнения: {error}') + await callback.answer('❌ Ошибка обработки запроса', show_alert=True) def register_balance_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_balance_menu, - F.data == "menu_balance" - ) - - dp.callback_query.register( - show_balance_history, - F.data == "balance_history" - ) - - dp.callback_query.register( - handle_balance_history_pagination, - F.data.startswith("balance_history_page_") - ) - - dp.callback_query.register( - show_payment_methods, - F.data == "balance_topup" - ) - + dp.callback_query.register(show_balance_menu, F.data == 'menu_balance') + + dp.callback_query.register(show_balance_history, F.data == 'balance_history') + + dp.callback_query.register(handle_balance_history_pagination, F.data.startswith('balance_history_page_')) + + dp.callback_query.register(show_payment_methods, F.data == 'balance_topup') + from .stars import start_stars_payment - dp.callback_query.register( - start_stars_payment, - F.data == "topup_stars" - ) - + + dp.callback_query.register(start_stars_payment, F.data == 'topup_stars') + from .yookassa import start_yookassa_payment - dp.callback_query.register( - start_yookassa_payment, - F.data == "topup_yookassa" - ) - + + dp.callback_query.register(start_yookassa_payment, F.data == 'topup_yookassa') + from .yookassa import start_yookassa_sbp_payment - dp.callback_query.register( - start_yookassa_sbp_payment, - F.data == "topup_yookassa_sbp" - ) + + dp.callback_query.register(start_yookassa_sbp_payment, F.data == 'topup_yookassa_sbp') from .mulenpay import start_mulenpay_payment - dp.callback_query.register( - start_mulenpay_payment, - F.data == "topup_mulenpay" - ) + + dp.callback_query.register(start_mulenpay_payment, F.data == 'topup_mulenpay') from .wata import start_wata_payment - dp.callback_query.register( - start_wata_payment, - F.data == "topup_wata" - ) + + dp.callback_query.register(start_wata_payment, F.data == 'topup_wata') from .pal24 import start_pal24_payment - dp.callback_query.register( - start_pal24_payment, - F.data == "topup_pal24" - ) + + dp.callback_query.register(start_pal24_payment, F.data == 'topup_pal24') from .pal24 import handle_pal24_method_selection + dp.callback_query.register( handle_pal24_method_selection, - F.data.startswith("pal24_method_"), + F.data.startswith('pal24_method_'), ) - from .platega import start_platega_payment, handle_platega_method_selection - dp.callback_query.register( - start_platega_payment, - F.data == "topup_platega" - ) + from .platega import handle_platega_method_selection, start_platega_payment + + dp.callback_query.register(start_platega_payment, F.data == 'topup_platega') dp.callback_query.register( handle_platega_method_selection, - F.data.startswith("platega_method_"), + F.data.startswith('platega_method_'), ) from .yookassa import check_yookassa_payment_status - dp.callback_query.register( - check_yookassa_payment_status, - F.data.startswith("check_yookassa_") - ) + + dp.callback_query.register(check_yookassa_payment_status, F.data.startswith('check_yookassa_')) from .tribute import start_tribute_payment - dp.callback_query.register( - start_tribute_payment, - F.data == "topup_tribute" - ) - - dp.callback_query.register( - request_support_topup, - F.data == "topup_support" - ) - + + dp.callback_query.register(start_tribute_payment, F.data == 'topup_tribute') + + dp.callback_query.register(request_support_topup, F.data == 'topup_support') + from .yookassa import check_yookassa_payment_status - dp.callback_query.register( - check_yookassa_payment_status, - F.data.startswith("check_yookassa_") - ) - - dp.message.register( - process_topup_amount, - BalanceStates.waiting_for_amount - ) + + dp.callback_query.register(check_yookassa_payment_status, F.data.startswith('check_yookassa_')) + + dp.message.register(process_topup_amount, BalanceStates.waiting_for_amount) from .cryptobot import start_cryptobot_payment - dp.callback_query.register( - start_cryptobot_payment, - F.data == "topup_cryptobot" - ) - + + dp.callback_query.register(start_cryptobot_payment, F.data == 'topup_cryptobot') + from .cryptobot import check_cryptobot_payment_status - dp.callback_query.register( - check_cryptobot_payment_status, - F.data.startswith("check_cryptobot_") - ) - from .heleket import start_heleket_payment, check_heleket_payment_status - dp.callback_query.register( - start_heleket_payment, - F.data == "topup_heleket" - ) - dp.callback_query.register( - check_heleket_payment_status, - F.data.startswith("check_heleket_") - ) + dp.callback_query.register(check_cryptobot_payment_status, F.data.startswith('check_cryptobot_')) - from .cloudpayments import start_cloudpayments_payment, handle_cloudpayments_quick_amount - dp.callback_query.register( - start_cloudpayments_payment, - F.data == "topup_cloudpayments" - ) - dp.callback_query.register( - handle_cloudpayments_quick_amount, - F.data.startswith("topup_amount|cloudpayments|") - ) + from .heleket import check_heleket_payment_status, start_heleket_payment - from .freekassa import start_freekassa_topup, process_freekassa_quick_amount - dp.callback_query.register( - start_freekassa_topup, - F.data == "topup_freekassa" - ) - dp.callback_query.register( - process_freekassa_quick_amount, - F.data.startswith("topup_amount|freekassa|") - ) + dp.callback_query.register(start_heleket_payment, F.data == 'topup_heleket') + dp.callback_query.register(check_heleket_payment_status, F.data.startswith('check_heleket_')) - from .kassa_ai import start_kassa_ai_topup, process_kassa_ai_quick_amount - dp.callback_query.register( - start_kassa_ai_topup, - F.data == "topup_kassa_ai" - ) - dp.callback_query.register( - process_kassa_ai_quick_amount, - F.data.startswith("topup_amount|kassa_ai|") - ) + from .cloudpayments import handle_cloudpayments_quick_amount, start_cloudpayments_payment + + dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments') + dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|')) + + from .freekassa import process_freekassa_quick_amount, start_freekassa_topup + + dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa') + dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|')) + + from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup + + dp.callback_query.register(start_kassa_ai_topup, F.data == 'topup_kassa_ai') + dp.callback_query.register(process_kassa_ai_quick_amount, F.data.startswith('topup_amount|kassa_ai|')) from .mulenpay import check_mulenpay_payment_status - dp.callback_query.register( - check_mulenpay_payment_status, - F.data.startswith("check_mulenpay_") - ) + + dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_')) from .wata import check_wata_payment_status - dp.callback_query.register( - check_wata_payment_status, - F.data.startswith("check_wata_") - ) + + dp.callback_query.register(check_wata_payment_status, F.data.startswith('check_wata_')) from .pal24 import check_pal24_payment_status - dp.callback_query.register( - check_pal24_payment_status, - F.data.startswith("check_pal24_") - ) + + dp.callback_query.register(check_pal24_payment_status, F.data.startswith('check_pal24_')) from .platega import check_platega_payment_status - dp.callback_query.register( - check_platega_payment_status, - F.data.startswith("check_platega_") - ) - dp.callback_query.register( - handle_payment_methods_unavailable, - F.data == "payment_methods_unavailable" - ) - + dp.callback_query.register(check_platega_payment_status, F.data.startswith('check_platega_')) + + dp.callback_query.register(handle_payment_methods_unavailable, F.data == 'payment_methods_unavailable') + # Регистрируем обработчик для кнопок быстрого выбора суммы - dp.callback_query.register( - handle_quick_amount_selection, - F.data.startswith("quick_amount_") - ) + dp.callback_query.register(handle_quick_amount_selection, F.data.startswith('quick_amount_')) - dp.callback_query.register( - handle_topup_amount_callback, - F.data.startswith("topup_amount|") - ) + dp.callback_query.register(handle_topup_amount_callback, F.data.startswith('topup_amount|')) diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index f1ded7e5..c6470bdb 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -1,4 +1,5 @@ import logging + from aiogram import types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -8,8 +9,9 @@ from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService -from app.utils.decorators import error_handler from app.states import BalanceStates +from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -24,17 +26,17 @@ async def start_mulenpay_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return @@ -44,17 +46,17 @@ async def start_mulenpay_payment( if not settings.is_mulenpay_enabled(): await callback.answer( - f"❌ Оплата через {mulenpay_name} временно недоступна", + f'❌ Оплата через {mulenpay_name} временно недоступна', show_alert=True, ) return message_template = texts.t( - "MULENPAY_TOPUP_PROMPT", + 'MULENPAY_TOPUP_PROMPT', ( - "💳 Оплата через {mulenpay_name_html}\n\n" - "Введите сумму для пополнения от 100 до 100 000 ₽.\n" - "Оплата происходит через защищенную платформу {mulenpay_name}." + '💳 Оплата через {mulenpay_name_html}\n\n' + 'Введите сумму для пополнения от 100 до 100 000 ₽.\n' + 'Оплата происходит через защищенную платформу {mulenpay_name}.' ), ) message_text = message_template.format( @@ -66,6 +68,7 @@ async def start_mulenpay_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -73,12 +76,12 @@ async def start_mulenpay_payment( await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) await state.update_data( - payment_method="mulenpay", + payment_method='mulenpay', mulenpay_prompt_message_id=callback.message.message_id, mulenpay_prompt_chat_id=callback.message.chat.id, ) @@ -97,18 +100,18 @@ async def process_mulenpay_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return @@ -117,40 +120,38 @@ async def process_mulenpay_payment_amount( mulenpay_name_html = settings.get_mulenpay_display_name_html() if not settings.is_mulenpay_enabled(): - await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна") + await message.answer(f'❌ Оплата через {mulenpay_name} временно недоступна') return if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: await message.answer( - f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}" + f'Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}' ) return if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: await message.answer( - f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}" + f'Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}' ) return amount_rubles = amount_kopeks / 100 state_data = await state.get_data() - prompt_message_id = state_data.get("mulenpay_prompt_message_id") - prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('mulenpay_prompt_message_id') + prompt_chat_id = state_data.get('mulenpay_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot permissions - logger.warning( - "Не удалось удалить сообщение с суммой MulenPay: %s", delete_error - ) + logger.warning('Не удалось удалить сообщение с суммой MulenPay: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic logger.warning( - "Не удалось удалить сообщение с запросом суммы MulenPay: %s", + 'Не удалось удалить сообщение с запросом суммы MulenPay: %s', delete_error, ) @@ -164,55 +165,55 @@ async def process_mulenpay_payment_amount( language=db_user.language, ) - if not payment_result or not payment_result.get("payment_url"): + if not payment_result or not payment_result.get('payment_url'): await message.answer( texts.t( - "MULENPAY_PAYMENT_ERROR", - "❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.", + 'MULENPAY_PAYMENT_ERROR', + '❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.', ).format(mulenpay_name=mulenpay_name) ) await state.clear() return - payment_url = payment_result.get("payment_url") - mulen_payment_id = payment_result.get("mulen_payment_id") - local_payment_id = payment_result.get("local_payment_id") + payment_url = payment_result.get('payment_url') + mulen_payment_id = payment_result.get('mulen_payment_id') + local_payment_id = payment_result.get('local_payment_id') keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( text=texts.t( - "MULENPAY_PAY_BUTTON", - "💳 Оплатить через {mulenpay_name}", + 'MULENPAY_PAY_BUTTON', + '💳 Оплатить через {mulenpay_name}', ).format(mulenpay_name=mulenpay_name), url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_mulenpay_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_mulenpay_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] ) payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id message_template = texts.t( - "MULENPAY_PAYMENT_INSTRUCTIONS", + 'MULENPAY_PAYMENT_INSTRUCTIONS', ( - "💳 Оплата через {mulenpay_name_html}\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID платежа: {payment_id}\n\n" - "📱 Инструкция:\n" + '💳 Оплата через {mulenpay_name_html}\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID платежа: {payment_id}\n\n' + '📱 Инструкция:\n' "1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n" - "2. Следуйте подсказкам платежной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '2. Следуйте подсказкам платежной системы\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) @@ -227,22 +228,18 @@ async def process_mulenpay_payment_amount( invoice_message = await message.answer( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) try: from app.services import payment_service as payment_module - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id - ) + payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id) if payment: - payment_metadata = dict( - getattr(payment, "metadata_json", {}) or {} - ) - payment_metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + payment_metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await payment_module.update_mulenpay_payment_metadata( db, @@ -250,7 +247,7 @@ async def process_mulenpay_payment_amount( metadata=payment_metadata, ) except Exception as error: # pragma: no cover - diagnostic logging only - logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error) + logger.warning('Не удалось сохранить данные сообщения MulenPay: %s', error) await state.update_data( mulenpay_invoice_message_id=invoice_message.message_id, @@ -260,7 +257,7 @@ async def process_mulenpay_payment_amount( await state.clear() logger.info( - "Создан %s платеж для пользователя %s: %s₽, ID: %s", + 'Создан %s платеж для пользователя %s: %s₽, ID: %s', mulenpay_name, db_user.telegram_id, amount_rubles, @@ -268,76 +265,69 @@ async def process_mulenpay_payment_amount( ) except Exception as e: - logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}") + logger.error(f'Ошибка создания {mulenpay_name} платежа: {e}') await message.answer( texts.t( - "MULENPAY_PAYMENT_ERROR", - "❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.", + 'MULENPAY_PAYMENT_ERROR', + '❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.', ).format(mulenpay_name=mulenpay_name) ) await state.clear() @error_handler -async def check_mulenpay_payment_status( - callback: types.CallbackQuery, - db: AsyncSession -): +async def check_mulenpay_payment_status(callback: types.CallbackQuery, db: AsyncSession): try: local_payment_id = int(callback.data.split('_')[-1]) payment_service = PaymentService(callback.bot) status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] status_labels = { - "created": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "success": ("✅", "Оплачен"), - "canceled": ("❌", "Отменен"), - "error": ("⚠️", "Ошибка"), - "hold": ("🔒", "Холд"), - "unknown": ("❓", "Неизвестно"), + 'created': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачен'), + 'canceled': ('❌', 'Отменен'), + 'error': ('⚠️', 'Ошибка'), + 'hold': ('🔒', 'Холд'), + 'unknown': ('❓', 'Неизвестно'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) mulenpay_name = settings.get_mulenpay_display_name() message_lines = [ - f"💳 Статус платежа {mulenpay_name}:\n\n", - f"🆔 ID: {payment.mulen_payment_id or payment.id}\n", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n", - f"📊 Статус: {emoji} {status_text}\n", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n", + f'💳 Статус платежа {mulenpay_name}:\n\n', + f'🆔 ID: {payment.mulen_payment_id or payment.id}\n', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n', + f'📊 Статус: {emoji} {status_text}\n', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M")}\n', ] if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") - elif payment.status in {"created", "processing"}: - message_lines.append( - "\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже." - ) + message_lines.append('\n✅ Платеж успешно завершен! Средства уже на балансе.') + elif payment.status in {'created', 'processing'}: + message_lines.append('\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже.') if payment.payment_url: - message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}") - elif payment.status in {"canceled", "error"}: + message_lines.append(f'\n🔗 Ссылка на оплату: {payment.payment_url}') + elif payment.status in {'canceled', 'error'}: message_lines.append( - f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}" + f'\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}' ) - message_text = "".join(message_lines) + message_text = ''.join(message_lines) if len(message_text) > 190: await callback.message.answer(message_text) - await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True) + await callback.answer('ℹ️ Статус платежа отправлен в чат', show_alert=True) else: await callback.answer(message_text, show_alert=True) except Exception as e: - logger.error( - f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}" - ) - await callback.answer("❌ Ошибка проверки статуса", show_alert=True) \ No newline at end of file + logger.error(f'Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}') + await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index b3de04f8..39b1eb3b 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -1,7 +1,7 @@ import html import logging from datetime import datetime -from typing import Any, Optional +from typing import Any from aiogram import types from aiogram.exceptions import TelegramBadRequest @@ -15,8 +15,9 @@ from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.payment_service import PaymentService -from app.utils.decorators import error_handler from app.states import BalanceStates +from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -24,11 +25,11 @@ logger = logging.getLogger(__name__) def _get_available_pal24_methods() -> list[str]: methods: list[str] = [] if settings.is_pal24_sbp_button_visible(): - methods.append("sbp") + methods.append('sbp') if settings.is_pal24_card_button_visible(): - methods.append("card") + methods.append('card') if not methods: - methods.append("sbp") + methods.append('sbp') return methods @@ -56,28 +57,22 @@ async def _send_pal24_payment_message( if not payment_result: await message.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ) ) await state.clear() return - sbp_url = ( - payment_result.get("sbp_url") - or payment_result.get("transfer_url") - ) - card_url = payment_result.get("card_url") - fallback_url = ( - payment_result.get("link_page_url") - or payment_result.get("link_url") - ) + sbp_url = payment_result.get('sbp_url') or payment_result.get('transfer_url') + card_url = payment_result.get('card_url') + fallback_url = payment_result.get('link_page_url') or payment_result.get('link_url') if not (sbp_url or card_url or fallback_url): await message.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ) ) await state.clear() @@ -86,16 +81,16 @@ async def _send_pal24_payment_message( if not sbp_url: sbp_url = fallback_url - bill_id = payment_result.get("bill_id") - local_payment_id = payment_result.get("local_payment_id") + bill_id = payment_result.get('bill_id') + local_payment_id = payment_result.get('local_payment_id') pay_buttons: list[list[types.InlineKeyboardButton]] = [] steps: list[str] = [] step_counter = 1 default_sbp_text = texts.t( - "PAL24_SBP_PAY_BUTTON", - "🏦 Оплатить через PayPalych (СБП)", + 'PAL24_SBP_PAY_BUTTON', + '🏦 Оплатить через PayPalych (СБП)', ) sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) @@ -110,15 +105,15 @@ async def _send_pal24_payment_message( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(sbp_button_text)) ) step_counter += 1 default_card_text = texts.t( - "PAL24_CARD_PAY_BUTTON", - "💳 Оплатить банковской картой (PayPalych)", + 'PAL24_CARD_PAY_BUTTON', + '💳 Оплатить банковской картой (PayPalych)', ) card_button_text = settings.get_pal24_card_button_text(default_card_text) @@ -133,8 +128,8 @@ async def _send_pal24_payment_message( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(card_button_text)) ) step_counter += 1 @@ -150,51 +145,51 @@ async def _send_pal24_payment_message( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(sbp_button_text)) ) step_counter += 1 follow_template = texts.t( - "PAL24_INSTRUCTION_FOLLOW", - "{step}. Следуйте подсказкам платёжной системы", + 'PAL24_INSTRUCTION_FOLLOW', + '{step}. Следуйте подсказкам платёжной системы', ) steps.append(follow_template.format(step=step_counter)) step_counter += 1 confirm_template = texts.t( - "PAL24_INSTRUCTION_CONFIRM", - "{step}. Подтвердите перевод", + 'PAL24_INSTRUCTION_CONFIRM', + '{step}. Подтвердите перевод', ) steps.append(confirm_template.format(step=step_counter)) step_counter += 1 success_template = texts.t( - "PAL24_INSTRUCTION_COMPLETE", - "{step}. Средства зачислятся автоматически", + 'PAL24_INSTRUCTION_COMPLETE', + '{step}. Средства зачислятся автоматически', ) steps.append(success_template.format(step=step_counter)) message_template = texts.t( - "PAL24_PAYMENT_INSTRUCTIONS", + 'PAL24_PAYMENT_INSTRUCTIONS', ( - "🏦 Оплата через PayPalych\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID счета: {bill_id}\n\n" - "📱 Инструкция:\n{steps}\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '🏦 Оплата через PayPalych\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID счета: {bill_id}\n\n' + '📱 Инструкция:\n{steps}\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) keyboard_rows = pay_buttons + [ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_pal24_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_pal24_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) @@ -202,14 +197,14 @@ async def _send_pal24_payment_message( message_text = message_template.format( amount=settings.format_price(amount_kopeks), bill_id=bill_id, - steps="\n".join(steps), + steps='\n'.join(steps), support=settings.get_support_contact_display_html(), ) invoice_message = await message.answer( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) try: @@ -217,10 +212,10 @@ async def _send_pal24_payment_message( payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await db.execute( update(payment.__class__) @@ -229,7 +224,7 @@ async def _send_pal24_payment_message( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение PayPalych: %s", error) + logger.warning('Не удалось сохранить сообщение PayPalych: %s', error) await state.update_data( pal24_invoice_message_id=invoice_message.message_id, @@ -239,7 +234,7 @@ async def _send_pal24_payment_message( await state.clear() logger.info( - "Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s", + 'Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s', db_user.telegram_id, amount_kopeks / 100, bill_id, @@ -247,15 +242,16 @@ async def _send_pal24_payment_message( ) except Exception as error: - logger.error(f"Ошибка создания PayPalych платежа: {error}") + logger.error(f'Ошибка создания PayPalych платежа: {error}') await message.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ) ) await state.clear() + @error_handler async def start_pal24_payment( callback: types.CallbackQuery, @@ -266,42 +262,42 @@ async def start_pal24_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_pal24_enabled(): - await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через PayPalych временно недоступна', show_alert=True) return # Формируем текст сообщения в зависимости от доступных способов оплаты if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible(): - payment_methods_text = "СБП и банковской картой" + payment_methods_text = 'СБП и банковской картой' elif settings.is_pal24_sbp_button_visible(): - payment_methods_text = "СБП" + payment_methods_text = 'СБП' elif settings.is_pal24_card_button_visible(): - payment_methods_text = "банковской картой" + payment_methods_text = 'банковской картой' else: # Если обе кнопки отключены, используем общий текст - payment_methods_text = "доступными способами" + payment_methods_text = 'доступными способами' message_text = texts.t( - "PAL24_TOPUP_PROMPT", + 'PAL24_TOPUP_PROMPT', ( - f"🏦 Оплата через PayPalych ({payment_methods_text})\n\n" - "Введите сумму для пополнения от 100 до 1 000 000 ₽.\n" - f"Оплата проходит через PayPalych ({payment_methods_text})." + f'🏦 Оплата через PayPalych ({payment_methods_text})\n\n' + 'Введите сумму для пополнения от 100 до 1 000 000 ₽.\n' + f'Оплата проходит через PayPalych ({payment_methods_text}).' ), ) @@ -309,6 +305,7 @@ async def start_pal24_payment( if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard @@ -316,12 +313,12 @@ async def start_pal24_payment( await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) await state.update_data( - payment_method="pal24", + payment_method='pal24', pal24_prompt_message_id=callback.message.message_id, pal24_prompt_chat_id=callback.message.chat.id, ) @@ -340,55 +337,53 @@ async def process_pal24_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return if not settings.is_pal24_enabled(): - await message.answer("❌ Оплата через PayPalych временно недоступна") + await message.answer('❌ Оплата через PayPalych временно недоступна') return if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽") + await message.answer(f'❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽') return if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100 - await message.answer( - f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ') - ) + await message.answer(f'❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽'.replace(',', ' ')) return available_methods = _get_available_pal24_methods() state_data = await state.get_data() - prompt_message_id = state_data.get("pal24_prompt_message_id") - prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('pal24_prompt_message_id') + prompt_chat_id = state_data.get('pal24_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой PayPalych: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic logger.warning( - "Не удалось удалить сообщение с запросом суммы PayPalych: %s", + 'Не удалось удалить сообщение с запросом суммы PayPalych: %s', delete_error, ) @@ -407,35 +402,35 @@ async def process_pal24_payment_amount( await state.set_state(BalanceStates.waiting_for_pal24_method) method_buttons: list[list[types.InlineKeyboardButton]] = [] - if "sbp" in available_methods: + if 'sbp' in available_methods: method_buttons.append( [ types.InlineKeyboardButton( text=settings.get_pal24_sbp_button_text( - texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)") + texts.t('PAL24_SBP_PAY_BUTTON', '🏦 Оплатить через PayPalych (СБП)') ), - callback_data="pal24_method_sbp", + callback_data='pal24_method_sbp', ) ] ) - if "card" in available_methods: + if 'card' in available_methods: method_buttons.append( [ types.InlineKeyboardButton( text=settings.get_pal24_card_button_text( - texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)") + texts.t('PAL24_CARD_PAY_BUTTON', '💳 Оплатить банковской картой (PayPalych)') ), - callback_data="pal24_method_card", + callback_data='pal24_method_card', ) ] ) - method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) + method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')]) await message.answer( texts.t( - "PAL24_SELECT_PAYMENT_METHOD", - "Выберите способ оплаты PayPalych:", + 'PAL24_SELECT_PAYMENT_METHOD', + 'Выберите способ оплаты PayPalych:', ), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons), ) @@ -448,20 +443,20 @@ async def handle_pal24_method_selection( state: FSMContext, ): data = await state.get_data() - amount_kopeks = data.get("pal24_amount_kopeks") + amount_kopeks = data.get('pal24_amount_kopeks') if not amount_kopeks: texts = get_texts(db_user.language) await callback.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ), show_alert=True, ) await state.clear() return - method = "sbp" if callback.data.endswith("_sbp") else "card" + method = 'sbp' if callback.data.endswith('_sbp') else 'card' await callback.answer() @@ -487,30 +482,30 @@ async def check_pal24_payment_status( status_info = await payment_service.get_pal24_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] status_labels = { - "NEW": ("⏳", "Ожидает оплаты"), - "PROCESS": ("⌛", "Обрабатывается"), - "SUCCESS": ("✅", "Оплачен"), - "FAIL": ("❌", "Отменен"), - "UNDERPAID": ("⚠️", "Недоплата"), - "OVERPAID": ("⚠️", "Переплата"), + 'NEW': ('⏳', 'Ожидает оплаты'), + 'PROCESS': ('⌛', 'Обрабатывается'), + 'SUCCESS': ('✅', 'Оплачен'), + 'FAIL': ('❌', 'Отменен'), + 'UNDERPAID': ('⚠️', 'Недоплата'), + 'OVERPAID': ('⚠️', 'Переплата'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) metadata = payment.metadata_json or {} - links_meta = metadata.get("links") if isinstance(metadata, dict) else None + links_meta = metadata.get('links') if isinstance(metadata, dict) else None if not isinstance(links_meta, dict): links_meta = {} - links_info = status_info.get("links") or {} + links_info = status_info.get('links') or {} - def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]: + def _extract_link(source: Any, keys: tuple[str, ...]) -> str | None: stack: list[Any] = [source] while stack: current = stack.pop() @@ -524,101 +519,89 @@ async def check_pal24_payment_status( stack.extend(current) return None - raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None - remote_data = status_info.get("remote_data") + raw_response = metadata.get('raw_response') if isinstance(metadata, dict) else None + remote_data = status_info.get('remote_data') transfer_keys = ( - "transfer_url", - "transferUrl", - "transfer_link", - "transferLink", - "transfer", - "sbp_url", - "sbpUrl", - "sbp_link", - "sbpLink", + 'transfer_url', + 'transferUrl', + 'transfer_link', + 'transferLink', + 'transfer', + 'sbp_url', + 'sbpUrl', + 'sbp_link', + 'sbpLink', ) card_keys = ( - "link_url", - "linkUrl", - "link", - "card_url", - "cardUrl", - "card_link", - "cardLink", - "payment_url", - "paymentUrl", - "url", + 'link_url', + 'linkUrl', + 'link', + 'card_url', + 'cardUrl', + 'card_link', + 'cardLink', + 'payment_url', + 'paymentUrl', + 'url', ) - extra_sbp_link = ( - _extract_link(raw_response, transfer_keys) - if raw_response - else None - ) + extra_sbp_link = _extract_link(raw_response, transfer_keys) if raw_response else None if not extra_sbp_link and remote_data: extra_sbp_link = _extract_link(remote_data, transfer_keys) - extra_card_link = ( - _extract_link(raw_response, card_keys) - if raw_response - else None - ) + extra_card_link = _extract_link(raw_response, card_keys) if raw_response else None if not extra_card_link and remote_data: extra_card_link = _extract_link(remote_data, card_keys) sbp_link = ( - links_info.get("sbp") - or links_meta.get("sbp") - or status_info.get("sbp_url") + links_info.get('sbp') + or links_meta.get('sbp') + or status_info.get('sbp_url') or extra_sbp_link or payment.link_url ) - card_link = ( - links_info.get("card") - or links_meta.get("card") - or status_info.get("card_url") - or extra_card_link - ) + card_link = links_info.get('card') or links_meta.get('card') or status_info.get('card_url') or extra_card_link if not card_link and payment.link_page_url and payment.link_page_url != sbp_link: card_link = payment.link_page_url message_lines = [ - "🏦 Статус платежа PayPalych:", - "", - f"🆔 ID счета: {payment.bill_id}", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}", + '🏦 Статус платежа PayPalych:', + '', + f'🆔 ID счета: {payment.bill_id}', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M")}', ] if payment.is_paid: - message_lines.append("") - message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.") - elif payment.status in {"NEW", "PROCESS"}: - message_lines.append("") - message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.") + message_lines.append('') + message_lines.append('✅ Платеж успешно завершен! Средства уже на балансе.') + elif payment.status in {'NEW', 'PROCESS'}: + message_lines.append('') + message_lines.append('⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.') if sbp_link: - message_lines.append("") - message_lines.append(f"🏦 СБП: {sbp_link}") + message_lines.append('') + message_lines.append(f'🏦 СБП: {sbp_link}') if card_link and card_link != sbp_link: - message_lines.append(f"💳 Банковская карта: {card_link}") - elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}: - message_lines.append("") + message_lines.append(f'💳 Банковская карта: {card_link}') + elif payment.status in {'FAIL', 'UNDERPAID', 'OVERPAID'}: + message_lines.append('') message_lines.append( - f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + f'❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}' ) from app.localization.texts import get_texts + db_user = getattr(callback, 'db_user', None) texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru') pay_rows: list[list[types.InlineKeyboardButton]] = [] - if not payment.is_paid and payment.status in {"NEW", "PROCESS"}: + if not payment.is_paid and payment.status in {'NEW', 'PROCESS'}: default_sbp_text = texts.t( - "PAL24_SBP_PAY_BUTTON", - "🏦 Оплатить через PayPalych (СБП)", + 'PAL24_SBP_PAY_BUTTON', + '🏦 Оплатить через PayPalych (СБП)', ) sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) @@ -633,8 +616,8 @@ async def check_pal24_payment_status( ) default_card_text = texts.t( - "PAL24_CARD_PAY_BUTTON", - "💳 Оплатить банковской картой (PayPalych)", + 'PAL24_CARD_PAY_BUTTON', + '💳 Оплатить банковской картой (PayPalych)', ) card_button_text = settings.get_pal24_card_button_text(default_card_text) @@ -652,28 +635,28 @@ async def check_pal24_payment_status( keyboard_rows = pay_rows + [ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_pal24_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_pal24_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - + await callback.answer() try: await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, disable_web_page_preview=True, ) except TelegramBadRequest as error: - if "message is not modified" in str(error).lower(): - await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился")) + if 'message is not modified' in str(error).lower(): + await callback.answer(texts.t('CHECK_STATUS_NO_CHANGES', 'Статус не изменился')) else: raise except Exception as e: - logger.error(f"Ошибка проверки статуса PayPalych: {e}") - await callback.answer("❌ Ошибка проверки статуса", show_alert=True) \ No newline at end of file + logger.error(f'Ошибка проверки статуса PayPalych: {e}') + await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 40336525..79041ace 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -1,7 +1,6 @@ """Handlers for Platega balance interactions.""" import logging -from typing import List from aiogram import types from aiogram.fsm.context import FSMContext @@ -15,10 +14,11 @@ from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) -def _get_active_methods() -> List[int]: +def _get_active_methods() -> list[int]: methods = settings.get_platega_active_methods() return [code for code in methods if code in {2, 10, 11, 12, 13}] @@ -33,10 +33,10 @@ async def _prompt_amount( method_name = settings.get_platega_method_display_title(method_code) # Всегда фиксируем выбранный метод для последующей обработки - await state.update_data(payment_method="platega", platega_method=method_code) + await state.update_data(payment_method='platega', platega_method=method_code) data = await state.get_data() - pending_amount = int(data.get("platega_pending_amount") or 0) + pending_amount = int(data.get('platega_pending_amount') or 0) if pending_amount > 0: # Если сумма уже известна (например, после быстрого выбора), @@ -57,25 +57,17 @@ async def _prompt_amount( min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS) max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS - max_amount_label = ( - settings.format_price(max_amount_kopeks) - if max_amount_kopeks and max_amount_kopeks > 0 - else "" - ) + max_amount_label = settings.format_price(max_amount_kopeks) if max_amount_kopeks and max_amount_kopeks > 0 else '' default_prompt_body = ( - "Введите сумму для пополнения от {min_amount} до {max_amount}.\n" + 'Введите сумму для пополнения от {min_amount} до {max_amount}.\n' if max_amount_kopeks and max_amount_kopeks > 0 - else "Введите сумму для пополнения от {min_amount}.\n" + else 'Введите сумму для пополнения от {min_amount}.\n' ) prompt_template = texts.t( - "PLATEGA_TOPUP_PROMPT", - ( - "💳 Оплата через Platega ({method_name})\n\n" - f"{default_prompt_body}" - "Оплата происходит через Platega." - ), + 'PLATEGA_TOPUP_PROMPT', + (f'💳 Оплата через Platega ({{method_name}})\n\n{default_prompt_body}Оплата происходит через Platega.'), ) keyboard = get_back_keyboard(db_user.language) @@ -94,7 +86,7 @@ async def _prompt_amount( max_amount=max_amount_label, ), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) @@ -114,17 +106,17 @@ async def start_platega_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return @@ -132,8 +124,8 @@ async def start_platega_payment( if not settings.is_platega_enabled(): await callback.answer( texts.t( - "PLATEGA_TEMPORARILY_UNAVAILABLE", - "❌ Оплата через Platega временно недоступна", + 'PLATEGA_TEMPORARILY_UNAVAILABLE', + '❌ Оплата через Platega временно недоступна', ), show_alert=True, ) @@ -143,16 +135,16 @@ async def start_platega_payment( if not active_methods: await callback.answer( texts.t( - "PLATEGA_METHODS_NOT_CONFIGURED", - "⚠️ На стороне Platega нет доступных методов оплаты", + 'PLATEGA_METHODS_NOT_CONFIGURED', + '⚠️ На стороне Platega нет доступных методов оплаты', ), show_alert=True, ) return - await state.update_data(payment_method="platega") + await state.update_data(payment_method='platega') data = await state.get_data() - has_pending_amount = bool(int(data.get("platega_pending_amount") or 0)) + has_pending_amount = bool(int(data.get('platega_pending_amount') or 0)) if len(active_methods) == 1: await _prompt_amount(callback.message, db_user, state, active_methods[0]) @@ -166,19 +158,17 @@ async def start_platega_payment( [ types.InlineKeyboardButton( text=label, - callback_data=f"platega_method_{method_code}", + callback_data=f'platega_method_{method_code}', ) ] ) - method_buttons.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ) + method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')]) await callback.message.edit_text( texts.t( - "PLATEGA_SELECT_PAYMENT_METHOD", - "Выберите способ оплаты Platega:", + 'PLATEGA_SELECT_PAYMENT_METHOD', + 'Выберите способ оплаты Platega:', ), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons), ) @@ -194,13 +184,13 @@ async def handle_platega_method_selection( state: FSMContext, ): try: - method_code = int(callback.data.rsplit("_", 1)[-1]) + method_code = int(callback.data.rsplit('_', 1)[-1]) except ValueError: - await callback.answer("❌ Некорректный способ оплаты", show_alert=True) + await callback.answer('❌ Некорректный способ оплаты', show_alert=True) return if method_code not in _get_active_methods(): - await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True) + await callback.answer('⚠️ Этот способ сейчас недоступен', show_alert=True) return await _prompt_amount(callback.message, db_user, state, method_code) @@ -219,18 +209,18 @@ async def process_platega_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return @@ -238,19 +228,19 @@ async def process_platega_payment_amount( if not settings.is_platega_enabled(): await message.answer( texts.t( - "PLATEGA_TEMPORARILY_UNAVAILABLE", - "❌ Оплата через Platega временно недоступна", + 'PLATEGA_TEMPORARILY_UNAVAILABLE', + '❌ Оплата через Platega временно недоступна', ) ) return data = await state.get_data() - method_code = int(data.get("platega_method", 0)) + method_code = int(data.get('platega_method', 0)) if method_code not in _get_active_methods(): await message.answer( texts.t( - "PLATEGA_METHOD_SELECTION_REQUIRED", - "⚠️ Выберите способ оплаты Platega перед вводом суммы", + 'PLATEGA_METHOD_SELECTION_REQUIRED', + '⚠️ Выберите способ оплаты Platega перед вводом суммы', ) ) await state.set_state(BalanceStates.waiting_for_platega_method) @@ -259,8 +249,8 @@ async def process_platega_payment_amount( if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS: await message.answer( texts.t( - "PLATEGA_AMOUNT_TOO_LOW", - "Минимальная сумма для оплаты через Platega: {amount}", + 'PLATEGA_AMOUNT_TOO_LOW', + 'Минимальная сумма для оплаты через Platega: {amount}', ).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)) ) return @@ -268,8 +258,8 @@ async def process_platega_payment_amount( if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS: await message.answer( texts.t( - "PLATEGA_AMOUNT_TOO_HIGH", - "Максимальная сумма для оплаты через Platega: {amount}", + 'PLATEGA_AMOUNT_TOO_HIGH', + 'Максимальная сумма для оплаты через Platega: {amount}', ).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS)) ) return @@ -285,22 +275,22 @@ async def process_platega_payment_amount( payment_method_code=method_code, ) except Exception as error: - logger.exception("Ошибка создания платежа Platega: %s", error) + logger.exception('Ошибка создания платежа Platega: %s', error) payment_result = None - if not payment_result or not payment_result.get("redirect_url"): + if not payment_result or not payment_result.get('redirect_url'): await message.answer( texts.t( - "PLATEGA_PAYMENT_ERROR", - "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.", + 'PLATEGA_PAYMENT_ERROR', + '❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.', ) ) await state.clear() return - redirect_url = payment_result.get("redirect_url") - local_payment_id = payment_result.get("local_payment_id") - transaction_id = payment_result.get("transaction_id") + redirect_url = payment_result.get('redirect_url') + local_payment_id = payment_result.get('local_payment_id') + transaction_id = payment_result.get('transaction_id') method_title = settings.get_platega_method_display_title(method_code) keyboard = types.InlineKeyboardMarkup( @@ -308,52 +298,52 @@ async def process_platega_payment_amount( [ types.InlineKeyboardButton( text=texts.t( - "PLATEGA_PAY_BUTTON", - "💳 Оплатить через {method}", + 'PLATEGA_PAY_BUTTON', + '💳 Оплатить через {method}', ).format(method=method_title), url=redirect_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_platega_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_platega_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] ) instructions_template = texts.t( - "PLATEGA_PAYMENT_INSTRUCTIONS", + 'PLATEGA_PAYMENT_INSTRUCTIONS', ( - "💳 Оплата через Platega ({method})\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID транзакции: {transaction}\n\n" - "📱 Инструкция:\n" - "1. Нажмите кнопку «Оплатить»\n" - "2. Следуйте подсказкам платёжной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '💳 Оплата через Platega ({method})\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID транзакции: {transaction}\n\n' + '📱 Инструкция:\n' + '1. Нажмите кнопку «Оплатить»\n' + '2. Следуйте подсказкам платёжной системы\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) state_data = await state.get_data() - prompt_message_id = state_data.get("platega_prompt_message_id") - prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('platega_prompt_message_id') + prompt_chat_id = state_data.get('platega_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой Platega: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось удалить сообщение с запросом суммы Platega: %s", + 'Не удалось удалить сообщение с запросом суммы Platega: %s', delete_error, ) @@ -365,7 +355,7 @@ async def process_platega_payment_amount( support=settings.get_support_contact_display_html(), ), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) try: @@ -373,10 +363,10 @@ async def process_platega_payment_amount( payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) if payment: - payment_metadata = dict(getattr(payment, "metadata_json", {}) or {}) - payment_metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + payment_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + payment_metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await payment_module.update_platega_payment( db, @@ -384,7 +374,7 @@ async def process_platega_payment_amount( metadata=payment_metadata, ) except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить данные сообщения Platega: %s", error) + logger.warning('Не удалось сохранить данные сообщения Platega: %s', error) await state.update_data( platega_invoice_message_id=invoice_message.message_id, @@ -400,9 +390,9 @@ async def check_platega_payment_status( db: AsyncSession, ): try: - local_payment_id = int(callback.data.split("_")[-1]) + local_payment_id = int(callback.data.split('_')[-1]) except ValueError: - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return payment_service = PaymentService(callback.bot) @@ -410,29 +400,29 @@ async def check_platega_payment_status( try: status_info = await payment_service.get_platega_payment_status(db, local_payment_id) except Exception as error: - logger.exception("Ошибка проверки статуса Platega: %s", error) - await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True) + logger.exception('Ошибка проверки статуса Platega: %s', error) + await callback.answer('⚠️ Ошибка проверки статуса', show_alert=True) return if not status_info: - await callback.answer("⚠️ Платёж не найден", show_alert=True) + await callback.answer('⚠️ Платёж не найден', show_alert=True) return - payment = status_info.get("payment") - status = status_info.get("status") - is_paid = status_info.get("is_paid") + payment = status_info.get('payment') + status = status_info.get('status') + is_paid = status_info.get('is_paid') - language = "ru" - user = getattr(payment, "user", None) - if user and getattr(user, "language", None): + language = 'ru' + user = getattr(payment, 'user', None) + if user and getattr(user, 'language', None): language = user.language texts = get_texts(language) if is_paid: - await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True) + await callback.answer(texts.t('PLATEGA_PAYMENT_ALREADY_CONFIRMED', '✅ Платёж уже зачислен'), show_alert=True) else: await callback.answer( - texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status), + texts.t('PLATEGA_PAYMENT_STATUS', 'Текущий статус платежа: {status}').format(status=status), show_alert=True, ) diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index 93d67739..4b4df7f6 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,55 +1,50 @@ import logging + from aiogram import types from aiogram.fsm.context import FSMContext from app.config import settings from app.database.models import User +from app.external.telegram_stars import TelegramStarsService from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler -from app.external.telegram_stars import TelegramStarsService + logger = logging.getLogger(__name__) @error_handler -async def start_stars_payment( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_stars_payment(callback: types.CallbackQuery, db_user: User, state: FSMContext): texts = get_texts(db_user.language) if not settings.TELEGRAM_STARS_ENABLED: - await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True) + await callback.answer('❌ Пополнение через Stars временно недоступно', show_alert=True) return # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return # Формируем текст сообщения в зависимости от настройки if settings.is_quick_amount_buttons_enabled(): - message_text = ( - f"⭐ Пополнение через Telegram Stars\n\n" - f"Выберите сумму пополнения или введите вручную:" - ) + message_text = '⭐ Пополнение через Telegram Stars\n\nВыберите сумму пополнения или введите вручную:' else: message_text = texts.TOP_UP_AMOUNT @@ -59,15 +54,13 @@ async def start_stars_payment( # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - await callback.message.edit_text( - message_text, - reply_markup=keyboard - ) + await callback.message.edit_text(message_text, reply_markup=keyboard) await state.update_data( stars_prompt_message_id=callback.message.message_id, @@ -75,59 +68,53 @@ async def start_stars_payment( ) await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="stars") + await state.update_data(payment_method='stars') await callback.answer() @error_handler -async def process_stars_payment_amount( - message: types.Message, - db_user: User, - amount_kopeks: int, - state: FSMContext -): +async def process_stars_payment_amount(message: types.Message, db_user: User, amount_kopeks: int, state: FSMContext): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Оплата невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Оплата невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) if not settings.TELEGRAM_STARS_ENABLED: - await message.answer("⚠️ Оплата Stars временно недоступна") + await message.answer('⚠️ Оплата Stars временно недоступна') return try: @@ -138,42 +125,44 @@ async def process_stars_payment_amount( payment_service = PaymentService(message.bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, - description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}", - payload=f"balance_{db_user.id}_{amount_kopeks}" + description=f'Пополнение баланса на {texts.format_price(amount_kopeks)}', + payload=f'balance_{db_user.id}_{amount_kopeks}', ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='⭐ Оплатить', url=invoice_link)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], + ] + ) state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('stars_prompt_message_id') + prompt_chat_id = state_data.get('stars_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой Stars: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось удалить сообщение с запросом суммы Stars: %s", + 'Не удалось удалить сообщение с запросом суммы Stars: %s', delete_error, ) invoice_message = await message.answer( - f"⭐ Оплата через Telegram Stars\n\n" - f"💰 Сумма: {texts.format_price(amount_kopeks)}\n" - f"⭐ К оплате: {stars_amount} звезд\n" - f"📊 Курс: {stars_rate}₽ за звезду\n\n" - f"Нажмите кнопку ниже для оплаты:", + f'⭐ Оплата через Telegram Stars\n\n' + f'💰 Сумма: {texts.format_price(amount_kopeks)}\n' + f'⭐ К оплате: {stars_amount} звезд\n' + f'📊 Курс: {stars_rate}₽ за звезду\n\n' + f'Нажмите кнопку ниже для оплаты:', reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -184,5 +173,5 @@ async def process_stars_payment_amount( await state.set_state(None) except Exception as e: - logger.error(f"Ошибка создания Stars invoice: {e}") - await message.answer("⚠️ Ошибка создания платежа") + logger.error(f'Ошибка создания Stars invoice: {e}') + await message.answer('⚠️ Ошибка создания платежа') diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index 74b75fd8..425fd764 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -1,12 +1,13 @@ import logging + from aiogram import types from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -19,23 +20,23 @@ async def start_tribute_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.TRIBUTE_ENABLED: - await callback.answer("❌ Оплата картой временно недоступна", show_alert=True) + await callback.answer('❌ Оплата картой временно недоступна', show_alert=True) return try: @@ -45,34 +46,34 @@ async def start_tribute_payment( payment_url = await tribute_service.create_payment_link( user_id=db_user.telegram_id, amount_kopeks=0, - description="Пополнение баланса VPN", + description='Пополнение баланса VPN', ) if not payment_url: - await callback.answer("❌ Ошибка создания платежа", show_alert=True) + await callback.answer('❌ Ошибка создания платежа', show_alert=True) return keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ - [types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text='💳 Перейти к оплате', url=payment_url)], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] ) message_text = ( - "💳 Пополнение банковской картой\n\n" - "• Введите любую сумму от 100₽\n" - "• Безопасная оплата через Tribute\n" - "• Мгновенное зачисление на баланс\n" - "• Принимаем карты Visa, MasterCard, МИР\n\n" - "• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n" - "Нажмите кнопку для перехода к оплате:" + '💳 Пополнение банковской картой\n\n' + '• Введите любую сумму от 100₽\n' + '• Безопасная оплата через Tribute\n' + '• Мгновенное зачисление на баланс\n' + '• Принимаем карты Visa, MasterCard, МИР\n\n' + '• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n' + 'Нажмите кнопку для перехода к оплате:' ) await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) TributeService.remember_invoice_message( @@ -82,7 +83,7 @@ async def start_tribute_payment( ) except Exception as e: - logger.error(f"Ошибка создания Tribute платежа: {e}") - await callback.answer("❌ Ошибка создания платежа", show_alert=True) + logger.error(f'Ошибка создания Tribute платежа: {e}') + await callback.answer('❌ Ошибка создания платежа', show_alert=True) await callback.answer() diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index 16c76b21..bfce4090 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,6 +1,5 @@ import logging from datetime import datetime -from typing import Dict from aiogram import types from aiogram.fsm.context import FSMContext @@ -15,6 +14,7 @@ from app.services.payment_service import PaymentService, get_user_by_id as fetch from app.states import BalanceStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @@ -28,31 +28,31 @@ async def start_wata_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_wata_enabled(): - await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через WATA временно недоступна', show_alert=True) return message_text = texts.t( - "WATA_TOPUP_PROMPT", + 'WATA_TOPUP_PROMPT', ( - "💳 Оплата через WATA\n\n" - "Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n" - "Оплата происходит через защищенную форму WATA." + '💳 Оплата через WATA\n\n' + 'Введите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\n' + 'Оплата происходит через защищенную форму WATA.' ), ).format( min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS), @@ -71,12 +71,12 @@ async def start_wata_payment( await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(BalanceStates.waiting_for_amount) await state.update_data( - payment_method="wata", + payment_method='wata', wata_prompt_message_id=callback.message.message_id, wata_prompt_chat_id=callback.message.chat.id, ) @@ -95,31 +95,31 @@ async def process_wata_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return if not settings.is_wata_enabled(): - await message.answer("❌ Оплата через WATA временно недоступна") + await message.answer('❌ Оплата через WATA временно недоступна') return if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: await message.answer( texts.t( - "WATA_AMOUNT_TOO_LOW", - "Минимальная сумма пополнения: {amount}", + 'WATA_AMOUNT_TOO_LOW', + 'Минимальная сумма пополнения: {amount}', ).format(amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)) ) return @@ -127,8 +127,8 @@ async def process_wata_payment_amount( if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: await message.answer( texts.t( - "WATA_AMOUNT_TOO_HIGH", - "Максимальная сумма пополнения: {amount}", + 'WATA_AMOUNT_TOO_HIGH', + 'Максимальная сумма пополнения: {amount}', ).format(amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)) ) return @@ -144,53 +144,53 @@ async def process_wata_payment_amount( language=db_user.language, ) except Exception as error: # pragma: no cover - handled by decorator logs - logger.exception("Ошибка создания WATA платежа: %s", error) + logger.exception('Ошибка создания WATA платежа: %s', error) result = None - if not result or not result.get("payment_url"): + if not result or not result.get('payment_url'): await message.answer( texts.t( - "WATA_PAYMENT_ERROR", - "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.", + 'WATA_PAYMENT_ERROR', + '❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.', ) ) await state.clear() return - payment_url = result["payment_url"] - payment_link_id = result["payment_link_id"] - local_payment_id = result["local_payment_id"] + payment_url = result['payment_url'] + payment_link_id = result['payment_link_id'] + local_payment_id = result['local_payment_id'] keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"), + text=texts.t('WATA_PAY_BUTTON', '💳 Оплатить через WATA'), url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_wata_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_wata_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], ] ) message_template = texts.t( - "WATA_PAYMENT_INSTRUCTIONS", + 'WATA_PAYMENT_INSTRUCTIONS', ( - "💳 Оплата через WATA\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID платежа: {payment_id}\n\n" - "📱 Инструкция:\n" + '💳 Оплата через WATA\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID платежа: {payment_id}\n\n' + '📱 Инструкция:\n' "1. Нажмите кнопку 'Оплатить через WATA'\n" - "2. Следуйте подсказкам платежной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '2. Следуйте подсказкам платежной системы\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) @@ -201,27 +201,27 @@ async def process_wata_payment_amount( ) state_data = await state.get_data() - prompt_message_id = state_data.get("wata_prompt_message_id") - prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('wata_prompt_message_id') + prompt_chat_id = state_data.get('wata_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой WATA: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - diagnostic logger.warning( - "Не удалось удалить сообщение с запросом суммы WATA: %s", + 'Не удалось удалить сообщение с запросом суммы WATA: %s', delete_error, ) invoice_message = await message.answer( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) try: @@ -229,10 +229,10 @@ async def process_wata_payment_amount( payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id) if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await db.execute( update(payment.__class__) @@ -241,7 +241,7 @@ async def process_wata_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - diagnostics - logger.warning("Не удалось сохранить сообщение WATA: %s", error) + logger.warning('Не удалось сохранить сообщение WATA: %s', error) await state.update_data( wata_invoice_message_id=invoice_message.message_id, @@ -251,7 +251,7 @@ async def process_wata_payment_amount( await state.clear() logger.info( - "Создан WATA платеж для пользователя %s: %s₽, ссылка: %s", + 'Создан WATA платеж для пользователя %s: %s₽, ссылка: %s', db_user.telegram_id, amount_kopeks / 100, payment_link_id, @@ -264,54 +264,54 @@ async def check_wata_payment_status( db: AsyncSession, ): try: - local_payment_id = int(callback.data.split("_")[-1]) + local_payment_id = int(callback.data.split('_')[-1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return payment_service = PaymentService(callback.bot) status_info = await payment_service.get_wata_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] - user_language = "ru" + user_language = 'ru' try: user = await fetch_user_by_id(db, payment.user_id) - if user and getattr(user, "language", None): + if user and getattr(user, 'language', None): user_language = user.language except Exception as error: - logger.debug("Не удалось получить пользователя для WATA статуса: %s", error) + logger.debug('Не удалось получить пользователя для WATA статуса: %s', error) texts = get_texts(user_language) - status_labels: Dict[str, Dict[str, str]] = { - "Opened": {"emoji": "⏳", "label": texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")}, - "Closed": {"emoji": "⌛", "label": texts.t("WATA_STATUS_CLOSED", "Обрабатывается")}, - "Paid": {"emoji": "✅", "label": texts.t("WATA_STATUS_PAID", "Оплачен")}, - "Declined": {"emoji": "❌", "label": texts.t("WATA_STATUS_DECLINED", "Отклонен")}, + status_labels: dict[str, dict[str, str]] = { + 'Opened': {'emoji': '⏳', 'label': texts.t('WATA_STATUS_OPENED', 'Ожидает оплаты')}, + 'Closed': {'emoji': '⌛', 'label': texts.t('WATA_STATUS_CLOSED', 'Обрабатывается')}, + 'Paid': {'emoji': '✅', 'label': texts.t('WATA_STATUS_PAID', 'Оплачен')}, + 'Declined': {'emoji': '❌', 'label': texts.t('WATA_STATUS_DECLINED', 'Отклонен')}, } - label_info = status_labels.get(payment.status, {"emoji": "❓", "label": texts.t("WATA_STATUS_UNKNOWN", "Неизвестно")}) + label_info = status_labels.get( + payment.status, {'emoji': '❓', 'label': texts.t('WATA_STATUS_UNKNOWN', 'Неизвестно')} + ) message_lines = [ - texts.t("WATA_STATUS_TITLE", "💳 Статус платежа WATA"), - "", - f"🆔 ID: {payment.payment_link_id}", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {label_info['emoji']} {label_info['label']}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + texts.t('WATA_STATUS_TITLE', '💳 Статус платежа WATA'), + '', + f'🆔 ID: {payment.payment_link_id}', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {label_info["emoji"]} {label_info["label"]}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"}', ] if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.") - elif payment.status in {"Opened", "Closed"}: - message_lines.append( - "\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже." - ) + message_lines.append('\n✅ Платеж успешно завершен! Средства уже на балансе.') + elif payment.status in {'Opened', 'Closed'}: + message_lines.append('\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже.') - await callback.message.answer("\n".join(message_lines), parse_mode="HTML") + await callback.message.answer('\n'.join(message_lines), parse_mode='HTML') await callback.answer() diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index bc71a9ad..2f6d1034 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -12,76 +12,70 @@ from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService -from app.utils.decorators import error_handler from app.states import BalanceStates +from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @error_handler -async def start_yookassa_payment( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, state: FSMContext): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_yookassa_enabled(): - await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True) + await callback.answer('❌ Оплата картой через YooKassa временно недоступна', show_alert=True) return - + min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100 max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100 - + # Формируем текст сообщения в зависимости от настройки if settings.is_quick_amount_buttons_enabled(): message_text = ( - f"💳 Оплата банковской картой\n\n" - f"Выберите сумму пополнения или введите вручную сумму " - f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:" + f'💳 Оплата банковской картой\n\n' + f'Выберите сумму пополнения или введите вручную сумму ' + f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:' ) else: message_text = ( - f"💳 Оплата банковской картой\n\n" - f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:" + f'💳 Оплата банковской картой\n\n' + f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:' ) - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="yookassa") + await state.update_data(payment_method='yookassa') await state.update_data( yookassa_prompt_message_id=callback.message.message_id, yookassa_prompt_chat_id=callback.message.chat.id, @@ -90,69 +84,62 @@ async def start_yookassa_payment( @error_handler -async def start_yookassa_sbp_payment( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: User, state: FSMContext): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await callback.message.edit_text( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED: - await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через СБП временно недоступна', show_alert=True) return - + min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100 max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100 - + # Формируем текст сообщения в зависимости от настройки if settings.is_quick_amount_buttons_enabled(): message_text = ( - f"🏦 Оплата через СБП\n\n" - f"Выберите сумму пополнения или введите вручную сумму " - f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:" + f'🏦 Оплата через СБП\n\n' + f'Выберите сумму пополнения или введите вручную сумму ' + f'от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:' ) else: message_text = ( - f"🏦 Оплата через СБП\n\n" - f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:" + f'🏦 Оплата через СБП\n\n' + f'Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:' ) - + # Создаем клавиатуру keyboard = get_back_keyboard(db_user.language) - + # Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки if settings.is_quick_amount_buttons_enabled(): from .main import get_quick_amount_buttons + quick_amount_buttons = await get_quick_amount_buttons(db_user.language, db_user) if quick_amount_buttons: # Вставляем кнопки быстрого выбора перед кнопкой "Назад" keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') await state.set_state(BalanceStates.waiting_for_amount) - await state.update_data(payment_method="yookassa_sbp") + await state.update_data(payment_method='yookassa_sbp') await state.update_data( yookassa_prompt_message_id=callback.message.message_id, yookassa_prompt_chat_id=callback.message.chat.id, @@ -162,69 +149,64 @@ async def start_yookassa_sbp_payment( @error_handler async def process_yookassa_payment_amount( - message: types.Message, - db_user: User, - db: AsyncSession, - amount_kopeks: int, - state: FSMContext + message: types.Message, db_user: User, db: AsyncSession, amount_kopeks: int, state: FSMContext ): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Оплата невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Оплата невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) if not settings.is_yookassa_enabled(): - await message.answer("❌ Оплата через YooKassa временно недоступна") + await message.answer('❌ Оплата через YooKassa временно недоступна') return - + if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽") + await message.answer(f'❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽') return - + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽".replace(',', ' ')) + await message.answer(f'❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽'.replace(',', ' ')) return - + try: payment_service = PaymentService(message.bot) - + payment_result = await payment_service.create_yookassa_payment( db=db, user_id=db_user.id, @@ -233,74 +215,78 @@ async def process_yookassa_payment_amount( receipt_email=None, receipt_phone=None, metadata={ - "user_telegram_id": str(db_user.telegram_id), - "user_username": db_user.username or "", - "purpose": "balance_topup" - } + 'user_telegram_id': str(db_user.telegram_id), + 'user_username': db_user.username or '', + 'purpose': 'balance_topup', + }, ) - + if not payment_result: - await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.") + await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() return - - confirmation_url = payment_result.get("confirmation_url") + + confirmation_url = payment_result.get('confirmation_url') if not confirmation_url: - await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.") + await message.answer('❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.') await state.clear() return - - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)], - [types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")] - ]) - + + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='💳 Оплатить картой', url=confirmation_url)], + [ + types.InlineKeyboardButton( + text='📊 Проверить статус', callback_data=f'check_yookassa_{payment_result["local_payment_id"]}' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')], + ] + ) + state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('yookassa_prompt_message_id') + prompt_chat_id = state_data.get('yookassa_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой YooKassa: %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa: %s", + 'Не удалось удалить сообщение с запросом суммы YooKassa: %s', delete_error, ) invoice_message = await message.answer( - f"💳 Оплата банковской картой\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" - f"📱 Инструкция:\n" + f'💳 Оплата банковской картой\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'🆔 ID платежа: {payment_result["yookassa_payment_id"][:8]}...\n\n' + f'📱 Инструкция:\n' f"1. Нажмите кнопку 'Оплатить картой'\n" - f"2. Введите данные вашей карты\n" - f"3. Подтвердите платеж\n" - f"4. Деньги поступят на баланс автоматически\n\n" - f"🔒 Оплата происходит через защищенную систему YooKassa\n" - f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", + f'2. Введите данные вашей карты\n' + f'3. Подтвердите платеж\n' + f'4. Деньги поступят на баланс автоматически\n\n' + f'🔒 Оплата происходит через защищенную систему YooKassa\n' + f'✅ Принимаем карты: Visa, MasterCard, МИР\n\n' + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}', reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) try: from app.services import payment_service as payment_module - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) + payment = await payment_module.get_yookassa_payment_by_local_id(db, payment_result['local_payment_id']) if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await db.execute( update(payment.__class__) @@ -309,7 +295,7 @@ async def process_yookassa_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa: %s", error) + logger.warning('Не удалось сохранить сообщение YooKassa: %s', error) await state.update_data( yookassa_invoice_message_id=invoice_message.message_id, @@ -317,80 +303,77 @@ async def process_yookassa_payment_amount( ) await state.clear() - logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: " - f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") - + logger.info( + f'Создан платеж YooKassa для пользователя {db_user.telegram_id}: ' + f'{amount_kopeks // 100}₽, ID: {payment_result["yookassa_payment_id"]}' + ) + except Exception as e: - logger.error(f"Ошибка создания YooKassa платежа: {e}") - await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.") + logger.error(f'Ошибка создания YooKassa платежа: {e}') + await message.answer('❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.') await state.clear() @error_handler async def process_yookassa_sbp_payment_amount( - message: types.Message, - db_user: User, - db: AsyncSession, - amount_kopeks: int, - state: FSMContext + message: types.Message, db_user: User, db: AsyncSession, amount_kopeks: int, state: FSMContext ): texts = get_texts(db_user.language) # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) await message.answer( - f"🚫 Пополнение ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), - parse_mode="HTML" + parse_mode='HTML', ) await state.clear() return # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Оплата невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Оплата невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED: - await message.answer("❌ Оплата через СБП временно недоступна") + await message.answer('❌ Оплата через СБП временно недоступна') return - + if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽") + await message.answer(f'❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽') return - + if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100 - await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽".replace(',', ' ')) + await message.answer(f'❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽'.replace(',', ' ')) return - + try: payment_service = PaymentService(message.bot) - + payment_result = await payment_service.create_yookassa_sbp_payment( db=db, user_id=db_user.id, @@ -399,136 +382,144 @@ async def process_yookassa_sbp_payment_amount( receipt_email=None, receipt_phone=None, metadata={ - "user_telegram_id": str(db_user.telegram_id), - "user_username": db_user.username or "", - "purpose": "balance_topup_sbp" - } + 'user_telegram_id': str(db_user.telegram_id), + 'user_username': db_user.username or '', + 'purpose': 'balance_topup_sbp', + }, ) - + if not payment_result: - await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.") + await message.answer('❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.') await state.clear() return - - confirmation_url = payment_result.get("confirmation_url") - qr_confirmation_data = payment_result.get("qr_confirmation_data") - + + confirmation_url = payment_result.get('confirmation_url') + qr_confirmation_data = payment_result.get('qr_confirmation_data') + if not confirmation_url and not qr_confirmation_data: - await message.answer("❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.") + await message.answer('❌ Ошибка получения данных для оплаты через СБП. Обратитесь в поддержку.') await state.clear() return - + # Подготовим QR-код для вставки в основное сообщение qr_photo = None if qr_confirmation_data: try: # Импортируем необходимые модули для генерации QR-кода - import base64 from io import BytesIO + import qrcode from aiogram.types import BufferedInputFile - + # Создаем QR-код из полученных данных qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(qr_confirmation_data) qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - + + img = qr.make_image(fill_color='black', back_color='white') + # Сохраняем изображение в байты img_bytes = BytesIO() img.save(img_bytes, format='PNG') img_bytes.seek(0) - - qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png") + + qr_photo = BufferedInputFile(img_bytes.getvalue(), filename='qrcode.png') except ImportError: - logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован") + logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f"Ошибка генерации QR-кода: {e}") - + logger.error(f'Ошибка генерации QR-кода: {e}') + # Если нет QR-данных из YooKassa, но есть URL, генерируем QR-код из URL if not qr_photo and confirmation_url: try: # Импортируем необходимые модули для генерации QR-кода - import base64 from io import BytesIO + import qrcode from aiogram.types import BufferedInputFile - + # Создаем QR-код из URL qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(confirmation_url) qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - + + img = qr.make_image(fill_color='black', back_color='white') + # Сохраняем изображение в байты img_bytes = BytesIO() img.save(img_bytes, format='PNG') img_bytes.seek(0) - - qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png") + + qr_photo = BufferedInputFile(img_bytes.getvalue(), filename='qrcode.png') except ImportError: - logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован") + logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f"Ошибка генерации QR-кода из URL: {e}") - + logger.error(f'Ошибка генерации QR-кода из URL: {e}') + # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: - keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) + keyboard_buttons.append([types.InlineKeyboardButton(text='🔗 Перейти к оплате', url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка - keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) + keyboard_buttons.append( + [types.InlineKeyboardButton(text='📱 Оплатить в приложении банка', callback_data='temp_disabled')] + ) # Добавляем общие кнопки - keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) - keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]) + keyboard_buttons.append( + [ + types.InlineKeyboardButton( + text='📊 Проверить статус', callback_data=f'check_yookassa_{payment_result["local_payment_id"]}' + ) + ] + ) + keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='balance_topup')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) state_data = await state.get_data() - prompt_message_id = state_data.get("yookassa_prompt_message_id") - prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id) + prompt_message_id = state_data.get('yookassa_prompt_message_id') + prompt_chat_id = state_data.get('yookassa_prompt_chat_id', message.chat.id) try: await message.delete() except Exception as delete_error: # pragma: no cover - зависит от прав бота - logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error) + logger.warning('Не удалось удалить сообщение с суммой YooKassa (СБП): %s', delete_error) if prompt_message_id: try: await message.bot.delete_message(prompt_chat_id, prompt_message_id) except Exception as delete_error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s", + 'Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s', delete_error, ) # Подготавливаем текст сообщения message_text = ( - f"🔗 Оплата через СБП\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n" + f'🔗 Оплата через СБП\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'🆔 ID платежа: {payment_result["yookassa_payment_id"][:8]}...\n\n' ) # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( - f"📱 Инструкция по оплате:\n" - f"1. Откройте приложение вашего банка\n" - f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n" - f"3. Введите ID платежа: {payment_result['yookassa_payment_id']}\n" - f"4. Подтвердите платеж в приложении банка\n" - f"5. Деньги поступят на баланс автоматически\n\n" + f'📱 Инструкция по оплате:\n' + f'1. Откройте приложение вашего банка\n' + f'2. Найдите функцию оплаты по реквизитам или перевод по СБП\n' + f'3. Введите ID платежа: {payment_result["yookassa_payment_id"]}\n' + f'4. Подтвердите платеж в приложении банка\n' + f'5. Деньги поступят на баланс автоматически\n\n' ) message_text += ( - f"🔒 Оплата происходит через защищенную систему YooKassa\n" - f"✅ Принимаем СБП от всех банков-участников\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" + f'🔒 Оплата происходит через защищенную систему YooKassa\n' + f'✅ Принимаем СБП от всех банков-участников\n\n' + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}' ) # Отправляем сообщение с инструкциями и клавиатурой @@ -536,30 +527,21 @@ async def process_yookassa_sbp_payment_amount( if qr_photo: # Используем метод отправки медиа-группы или фото с описанием invoice_message = await message.answer_photo( - photo=qr_photo, - caption=message_text, - reply_markup=keyboard, - parse_mode="HTML" + photo=qr_photo, caption=message_text, reply_markup=keyboard, parse_mode='HTML' ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - invoice_message = await message.answer( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + invoice_message = await message.answer(message_text, reply_markup=keyboard, parse_mode='HTML') try: from app.services import payment_service as payment_module - payment = await payment_module.get_yookassa_payment_by_local_id( - db, payment_result["local_payment_id"] - ) + payment = await payment_module.get_yookassa_payment_by_local_id(db, payment_result['local_payment_id']) if payment: - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["invoice_message"] = { - "chat_id": invoice_message.chat.id, - "message_id": invoice_message.message_id, + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['invoice_message'] = { + 'chat_id': invoice_message.chat.id, + 'message_id': invoice_message.message_id, } await db.execute( update(payment.__class__) @@ -568,7 +550,7 @@ async def process_yookassa_sbp_payment_amount( ) await db.commit() except Exception as error: # pragma: no cover - диагностический лог - logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error) + logger.warning('Не удалось сохранить сообщение YooKassa (СБП): %s', error) await state.update_data( yookassa_invoice_message_id=invoice_message.message_id, @@ -576,69 +558,66 @@ async def process_yookassa_sbp_payment_amount( ) await state.clear() - logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: " - f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}") - + logger.info( + f'Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: ' + f'{amount_kopeks // 100}₽, ID: {payment_result["yookassa_payment_id"]}' + ) + except Exception as e: - logger.error(f"Ошибка создания YooKassa СБП платежа: {e}") - await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.") + logger.error(f'Ошибка создания YooKassa СБП платежа: {e}') + await message.answer('❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.') await state.clear() - - - @error_handler -async def check_yookassa_payment_status( - callback: types.CallbackQuery, - db: AsyncSession -): +async def check_yookassa_payment_status(callback: types.CallbackQuery, db: AsyncSession): try: local_payment_id = int(callback.data.split('_')[-1]) - + from app.database.crud.yookassa import get_yookassa_payment_by_local_id + payment = await get_yookassa_payment_by_local_id(db, local_payment_id) - + if not payment: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - + status_emoji = { - "pending": "⏳", - "waiting_for_capture": "⌛", - "succeeded": "✅", - "canceled": "❌", - "failed": "❌" + 'pending': '⏳', + 'waiting_for_capture': '⌛', + 'succeeded': '✅', + 'canceled': '❌', + 'failed': '❌', } - + status_text = { - "pending": "Ожидает оплаты", - "waiting_for_capture": "Ожидает подтверждения", - "succeeded": "Оплачен", - "canceled": "Отменен", - "failed": "Ошибка" + 'pending': 'Ожидает оплаты', + 'waiting_for_capture': 'Ожидает подтверждения', + 'succeeded': 'Оплачен', + 'canceled': 'Отменен', + 'failed': 'Ошибка', } - - emoji = status_emoji.get(payment.status, "❓") - status = status_text.get(payment.status, "Неизвестно") - - message_text = (f"💳 Статус платежа:\n\n" - f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - f"📊 Статус: {emoji} {status}\n" - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n") - + + emoji = status_emoji.get(payment.status, '❓') + status = status_text.get(payment.status, 'Неизвестно') + + message_text = ( + f'💳 Статус платежа:\n\n' + f'🆔 ID: {payment.yookassa_payment_id[:8]}...\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'📊 Статус: {emoji} {status}\n' + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M")}\n' + ) + if payment.is_succeeded: - message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс." + message_text += '\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс.' elif payment.is_pending: message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше." elif payment.is_failed: - message_text += ( - f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}" - ) - + message_text += f'\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}' + await callback.answer(message_text, show_alert=True) - + except Exception as e: - logger.error(f"Ошибка проверки статуса платежа: {e}") - await callback.answer("❌ Ошибка проверки статуса", show_alert=True) \ No newline at end of file + logger.error(f'Ошибка проверки статуса платежа: {e}') + await callback.answer('❌ Ошибка проверки статуса', show_alert=True) diff --git a/app/handlers/common.py b/app/handlers/common.py index 4d9bcc70..17503bfc 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -1,12 +1,14 @@ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User -from app.localization.texts import get_texts, get_rules from app.keyboards.inline import get_back_keyboard +from app.localization.texts import get_rules, get_texts + logger = logging.getLogger(__name__) @@ -17,63 +19,45 @@ async def handle_delete_ban_notification( """Удаляет уведомление о бане при нажатии на кнопку""" try: await callback.message.delete() - await callback.answer("Уведомление удалено") + await callback.answer('Уведомление удалено') except Exception as e: - logger.warning(f"Не удалось удалить уведомление: {e}") - await callback.answer("Не удалось удалить", show_alert=False) + logger.warning(f'Не удалось удалить уведомление: {e}') + await callback.answer('Не удалось удалить', show_alert=False) -async def handle_unknown_callback( - callback: types.CallbackQuery, - db_user: User -): - - texts = get_texts(db_user.language if db_user else "ru") +async def handle_unknown_callback(callback: types.CallbackQuery, db_user: User): + texts = get_texts(db_user.language if db_user else 'ru') await callback.answer( texts.t( - "UNKNOWN_CALLBACK_ALERT", - "❓ Неизвестная команда. Попробуйте ещё раз.", + 'UNKNOWN_CALLBACK_ALERT', + '❓ Неизвестная команда. Попробуйте ещё раз.', ), show_alert=True, ) - logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}") + logger.warning(f'Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}') -async def handle_noop( - callback: types.CallbackQuery, - db_user: User -): +async def handle_noop(callback: types.CallbackQuery, db_user: User): try: await callback.answer() except Exception: pass -async def handle_current_page( - callback: types.CallbackQuery, - db_user: User -): +async def handle_current_page(callback: types.CallbackQuery, db_user: User): try: await callback.answer() except Exception: pass -async def handle_cancel( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): - +async def handle_cancel(callback: types.CallbackQuery, state: FSMContext, db_user: User): texts = get_texts(db_user.language) - + await state.clear() - await callback.message.edit_text( - texts.OPERATION_CANCELLED, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.OPERATION_CANCELLED, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() @@ -81,62 +65,37 @@ async def handle_unknown_message( message: types.Message, db_user: User | None = None, ): - - texts = get_texts(db_user.language if db_user else "ru") - + texts = get_texts(db_user.language if db_user else 'ru') + await message.answer( texts.t( - "UNKNOWN_COMMAND_MESSAGE", - "❓ Не понимаю эту команду. Используйте кнопки меню.", + 'UNKNOWN_COMMAND_MESSAGE', + '❓ Не понимаю эту команду. Используйте кнопки меню.', ), - reply_markup=get_back_keyboard(db_user.language if db_user else "ru"), + reply_markup=get_back_keyboard(db_user.language if db_user else 'ru'), ) -async def show_rules( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - - texts = get_texts(db_user.language) +async def show_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession): + get_texts(db_user.language) rules_text = await get_rules(db_user.language) - - await callback.message.edit_text( - rules_text, - reply_markup=get_back_keyboard(db_user.language) - ) + + await callback.message.edit_text(rules_text, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() def register_handlers(dp: Dispatcher): - # Удаление уведомлений о банах - dp.callback_query.register( - handle_delete_ban_notification, - F.data == "ban_notify:delete" - ) + dp.callback_query.register(handle_delete_ban_notification, F.data == 'ban_notify:delete') - dp.callback_query.register( - show_rules, - F.data == "menu_rules" - ) + dp.callback_query.register(show_rules, F.data == 'menu_rules') # No-op utility handlers used in many keyboards - dp.callback_query.register( - handle_noop, - F.data == "noop" - ) - dp.callback_query.register( - handle_current_page, - F.data == "current_page" - ) - - dp.callback_query.register( - handle_cancel, - F.data.in_(["cancel", "subscription_cancel"]) - ) + dp.callback_query.register(handle_noop, F.data == 'noop') + dp.callback_query.register(handle_current_page, F.data == 'current_page') + + dp.callback_query.register(handle_cancel, F.data.in_(['cancel', 'subscription_cancel'])) # Самый последний: ловим любые неизвестные текстовые сообщения # Исключаем специальные сервисные события (например, успешные платежи), @@ -146,6 +105,5 @@ def register_handlers(dp: Dispatcher): StateFilter(None), F.successful_payment.is_(None), F.text.is_not(None), - ~F.text.startswith("/"), + ~F.text.startswith('/'), ) - \ No newline at end of file diff --git a/app/handlers/contests.py b/app/handlers/contests.py index dd54de6d..752be504 100644 --- a/app/handlers/contests.py +++ b/app/handlers/contests.py @@ -2,7 +2,6 @@ import logging from datetime import datetime -from typing import Optional from aiogram import Dispatcher, F, types from aiogram.filters import Command @@ -12,17 +11,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.contest import get_active_rounds, get_attempt from app.database.crud.subscription import get_subscription_by_user_id from app.database.database import AsyncSessionLocal -from app.database.models import ContestRound, SubscriptionStatus +from app.database.models import SubscriptionStatus from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts from app.services.contests import ( ContestAttemptService, - GameType, get_game_strategy, ) from app.states import ContestStates from app.utils.decorators import auth_required, error_handler + logger = logging.getLogger(__name__) # Rate limiting storage @@ -34,7 +33,7 @@ _attempt_service = ContestAttemptService() def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool: """Check if user exceeds rate limit for contest actions.""" - key = f"{user_id}_{action}" + key = f'{user_id}_{action}' now = datetime.utcnow().timestamp() if key not in _rate_limits: @@ -50,13 +49,13 @@ def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: return True -def _validate_callback_data(data: str) -> Optional[list]: +def _validate_callback_data(data: str) -> list | None: """Validate and parse callback data safely.""" if not data or not isinstance(data, str): return None - parts = data.split("_") - if len(parts) < 2 or parts[0] != "contest": + parts = data.split('_') + if len(parts) < 2 or parts[0] != 'contest': return None for part in parts: @@ -80,7 +79,7 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str): """Reply that user is not eligible to play.""" texts = get_texts(language) await callback.answer( - texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), + texts.t('CONTEST_NOT_ELIGIBLE', 'Игры доступны только с активной или триальной подпиской.'), show_alert=True, ) @@ -106,34 +105,36 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe for rnd in active_rounds: if not rnd.template or not rnd.template.is_enabled: continue - tpl_slug = rnd.template.slug if rnd.template else "" + tpl_slug = rnd.template.slug if rnd.template else '' if tpl_slug not in unique_templates: unique_templates[tpl_slug] = rnd buttons = [] for tpl_slug, rnd in unique_templates.items(): title = rnd.template.name if rnd.template else tpl_slug - buttons.append([ - types.InlineKeyboardButton( - text=f"▶️ {title}", - callback_data=f"contest_play_{tpl_slug}_{rnd.id}", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=f'▶️ {title}', + callback_data=f'contest_play_{tpl_slug}_{rnd.id}', + ) + ] + ) if not buttons: - buttons.append([ - types.InlineKeyboardButton( - text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), - callback_data="noop", - ) - ]) + buttons.append( + [ + types.InlineKeyboardButton( + text=texts.t('CONTEST_EMPTY', 'Сейчас игр нет'), + callback_data='noop', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ]) + buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) await callback.message.edit_text( - texts.t("CONTEST_MENU_TITLE", "🎲 Игры/Конкурсы\nВыберите игру:"), + texts.t('CONTEST_MENU_TITLE', '🎲 Игры/Конкурсы\nВыберите игру:'), reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), ) await callback.answer() @@ -151,24 +152,24 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user return # Rate limit check - if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10): + if not _check_rate_limit(db_user.id, 'contest_play', limit=2, window_seconds=10): await callback.answer( - texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."), + texts.t('CONTEST_TOO_FAST', 'Слишком быстро! Подождите.'), show_alert=True, ) return # Validate callback data parts = _validate_callback_data(callback.data) - if not parts or len(parts) < 4 or parts[1] != "play": - await callback.answer("Некорректные данные", show_alert=True) + if not parts or len(parts) < 4 or parts[1] != 'play': + await callback.answer('Некорректные данные', show_alert=True) return round_id_str = parts[-1] try: round_id = int(round_id_str) except ValueError: - await callback.answer("Некорректные данные", show_alert=True) + await callback.answer('Некорректные данные', show_alert=True) return # Get round with template @@ -178,14 +179,14 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user if not round_obj: await callback.answer( - texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), + texts.t('CONTEST_ROUND_FINISHED', 'Раунд завершён или недоступен.'), show_alert=True, ) return if not round_obj.template or not round_obj.template.is_enabled: await callback.answer( - texts.t("CONTEST_DISABLED", "Игра отключена."), + texts.t('CONTEST_DISABLED', 'Игра отключена.'), show_alert=True, ) return @@ -194,7 +195,7 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user attempt = await get_attempt(db2, round_id, db_user.id) if attempt: await callback.answer( - texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), + texts.t('CONTEST_ALREADY_PLAYED', 'У вас уже была попытка в этом раунде.'), show_alert=True, ) return @@ -205,7 +206,7 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user if not strategy: await callback.answer( - texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), + texts.t('CONTEST_UNKNOWN', 'Тип конкурса не поддерживается.'), show_alert=True, ) return @@ -236,33 +237,33 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): texts = get_texts(db_user.language) # Rate limit check - if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3): + if not _check_rate_limit(db_user.id, 'contest_pick', limit=1, window_seconds=3): await callback.answer( - texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."), + texts.t('CONTEST_TOO_FAST', 'Слишком быстро! Подождите.'), show_alert=True, ) return # Validate callback data parts = _validate_callback_data(callback.data) - if not parts or len(parts) < 4 or parts[1] != "pick": - await callback.answer("Некорректные данные", show_alert=True) + if not parts or len(parts) < 4 or parts[1] != 'pick': + await callback.answer('Некорректные данные', show_alert=True) return round_id_str = parts[2] - pick = "_".join(parts[3:]) + pick = '_'.join(parts[3:]) try: round_id = int(round_id_str) except ValueError: - await callback.answer("Некорректные данные", show_alert=True) + await callback.answer('Некорректные данные', show_alert=True) return # Re-check subscription subscription = await get_subscription_by_user_id(db, db_user.id) if not _user_allowed(subscription): await callback.answer( - texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."), + texts.t('CONTEST_NOT_ELIGIBLE', 'Игра недоступна без активной подписки.'), show_alert=True, ) return @@ -273,7 +274,7 @@ async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): if not round_obj: await callback.answer( - texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), + texts.t('CONTEST_ROUND_FINISHED', 'Раунд завершён.'), show_alert=True, ) return @@ -297,7 +298,7 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, texts = get_texts(db_user.language) data = await state.get_data() - round_id = data.get("contest_round_id") + round_id = data.get('contest_round_id') if not round_id: await state.clear() return @@ -308,14 +309,14 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, if not round_obj: await message.answer( - texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), + texts.t('CONTEST_ROUND_FINISHED', 'Раунд завершён.'), reply_markup=get_back_keyboard(db_user.language), ) await state.clear() return # Process attempt using service - text_answer = (message.text or "").strip() + text_answer = (message.text or '').strip() result = await _attempt_service.process_text_attempt( db=db2, round_obj=round_obj, @@ -334,8 +335,8 @@ async def handle_text_answer(message: types.Message, state: FSMContext, db_user, def register_handlers(dp: Dispatcher): """Register contest handlers.""" - dp.callback_query.register(show_contests_menu, F.data == "contests_menu") - dp.callback_query.register(play_contest, F.data.startswith("contest_play_")) - dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_")) + dp.callback_query.register(show_contests_menu, F.data == 'contests_menu') + dp.callback_query.register(play_contest, F.data.startswith('contest_play_')) + dp.callback_query.register(handle_pick, F.data.startswith('contest_pick_')) dp.message.register(handle_text_answer, ContestStates.waiting_for_answer) - dp.message.register(lambda message: None, Command("contests")) # placeholder + dp.message.register(lambda message: None, Command('contests')) # placeholder diff --git a/app/handlers/menu.py b/app/handlers/menu.py index fc8c2bdb..d86c2924 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1,47 +1,47 @@ import html import logging +from datetime import datetime from decimal import Decimal -from typing import Dict, List -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession -from datetime import datetime from app.config import settings -from app.database.crud.user import get_user_by_telegram_id, update_user from app.database.crud.promo_group import ( get_auto_assign_promo_groups, has_auto_assign_promo_groups, ) from app.database.crud.transaction import get_user_total_spent_kopeks -from app.keyboards.inline import ( - get_main_menu_keyboard, - get_main_menu_keyboard_async, - get_language_selection_keyboard, - get_info_menu_keyboard, -) -from app.localization.texts import get_texts, get_rules -from app.database.models import PromoGroup, User +from app.database.crud.user import update_user from app.database.crud.user_message import get_random_active_message +from app.database.models import PromoGroup, User +from app.handlers.subscription.traffic import add_traffic, handle_add_traffic +from app.keyboards.inline import ( + get_info_menu_keyboard, + get_language_selection_keyboard, + get_main_menu_keyboard_async, +) +from app.localization.texts import get_rules, get_texts +from app.services.faq_service import FaqService +from app.services.main_menu_button_service import MainMenuButtonService +from app.services.privacy_policy_service import PrivacyPolicyService +from app.services.public_offer_service import PublicOfferService from app.services.subscription_checkout_service import ( has_subscription_checkout_draft, should_offer_checkout_resume, ) -from app.utils.photo_message import edit_or_answer_photo from app.services.support_settings_service import SupportSettingsService -from app.services.main_menu_button_service import MainMenuButtonService from app.services.user_cart_service import user_cart_service +from app.utils.photo_message import edit_or_answer_photo +from app.utils.pricing_utils import format_period_description from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, ) -from app.services.privacy_policy_service import PrivacyPolicyService -from app.services.public_offer_service import PublicOfferService -from app.services.faq_service import FaqService from app.utils.timezone import format_local_datetime -from app.utils.pricing_utils import format_period_description -from app.handlers.subscription.traffic import handle_add_traffic, add_traffic + logger = logging.getLogger(__name__) @@ -50,16 +50,16 @@ def _format_rubles(amount_kopeks: int) -> str: rubles = Decimal(amount_kopeks) / Decimal(100) if rubles == rubles.to_integral_value(): - formatted = f"{rubles:,.0f}" + formatted = f'{rubles:,.0f}' else: - formatted = f"{rubles:,.2f}" + formatted = f'{rubles:,.2f}' - return f"{formatted.replace(',', ' ')} ₽" + return f'{formatted.replace(",", " ")} ₽' -def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: - discounts: Dict[int, int] = {} - raw_discounts = getattr(group, "period_discounts", None) +def _collect_period_discounts(group: PromoGroup) -> dict[int, int]: + discounts: dict[int, int] = {} + raw_discounts = getattr(group, 'period_discounts', None) if isinstance(raw_discounts, dict): for key, value in raw_discounts.items(): @@ -99,23 +99,23 @@ def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]: def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]: lines: list[str] = [] - if getattr(group, "server_discount_percent", 0) > 0: + if getattr(group, 'server_discount_percent', 0) > 0: lines.append( - texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format( + texts.t('PROMO_GROUP_DISCOUNT_SERVERS', '🌍 Серверы: {percent}%').format( percent=group.server_discount_percent ) ) - if getattr(group, "traffic_discount_percent", 0) > 0: + if getattr(group, 'traffic_discount_percent', 0) > 0: lines.append( - texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format( + texts.t('PROMO_GROUP_DISCOUNT_TRAFFIC', '📊 Трафик: {percent}%').format( percent=group.traffic_discount_percent ) ) - if getattr(group, "device_discount_percent", 0) > 0: + if getattr(group, 'device_discount_percent', 0) > 0: lines.append( - texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format( + texts.t('PROMO_GROUP_DISCOUNT_DEVICES', '📱 Доп. устройства: {percent}%').format( percent=group.device_discount_percent ) ) @@ -125,16 +125,16 @@ def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list if period_discounts: lines.append( texts.t( - "PROMO_GROUP_PERIOD_DISCOUNTS_HEADER", - "⏳ Скидки за длительный период:", + 'PROMO_GROUP_PERIOD_DISCOUNTS_HEADER', + '⏳ Скидки за длительный период:', ) ) for period_days, percent in period_discounts.items(): lines.append( texts.t( - "PROMO_GROUP_PERIOD_DISCOUNT_ITEM", - "{period} — {percent}%", + 'PROMO_GROUP_PERIOD_DISCOUNT_ITEM', + '{period} — {percent}%', ).format( period=format_period_description(period_days, language), percent=percent, @@ -156,8 +156,8 @@ async def show_main_menu( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -183,13 +183,11 @@ async def show_main_menu( try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: - logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") + logger.error(f'Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}') has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) - is_moderator = (not is_admin) and SupportSettingsService.is_moderator( - db_user.telegram_id - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(db_user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -220,7 +218,7 @@ async def show_main_menu( callback=callback, caption=menu_text, keyboard=keyboard, - parse_mode="HTML", + parse_mode='HTML', force_text=settings.is_text_main_menu_mode(), ) if not skip_callback_answer: @@ -228,7 +226,7 @@ async def show_main_menu( async def handle_profile_unavailable(callback: types.CallbackQuery) -> None: - language = getattr(callback.from_user, "language_code", None) or settings.DEFAULT_LANGUAGE + language = getattr(callback.from_user, 'language_code', None) or settings.DEFAULT_LANGUAGE try: texts = get_texts(language) except Exception: @@ -236,25 +234,21 @@ async def handle_profile_unavailable(callback: types.CallbackQuery) -> None: await callback.answer( texts.t( - "MENU_PROFILE_UNAVAILABLE", - "❗️ Личный кабинет пока недоступен. Попробуйте позже.", + 'MENU_PROFILE_UNAVAILABLE', + '❗️ Личный кабинет пока недоступен. Попробуйте позже.', ), show_alert=True, ) -async def show_service_rules( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_service_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -269,10 +263,10 @@ async def show_service_rules( rules_text = await get_rules(db_user.language) await callback.message.edit_text( - f"{texts.t('RULES_HEADER', '📋 Правила сервиса')}\n\n{rules_text}", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]) + f'{texts.t("RULES_HEADER", "📋 Правила сервиса")}\n\n{rules_text}', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]] + ), ) await callback.answer() @@ -287,8 +281,8 @@ async def show_info_menu( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -296,9 +290,9 @@ async def show_info_menu( texts = get_texts(db_user.language) - header = texts.t("MENU_INFO_HEADER", "ℹ️ Инфо") - prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:") - caption = f"{header}\n\n{prompt}" if prompt else header + header = texts.t('MENU_INFO_HEADER', 'ℹ️ Инфо') + prompt = texts.t('MENU_INFO_PROMPT', 'Выберите раздел:') + caption = f'{header}\n\n{prompt}' if prompt else header privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language) public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language) @@ -315,7 +309,7 @@ async def show_info_menu( show_faq=faq_enabled, show_promo_groups=promo_groups_available, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -330,8 +324,8 @@ async def show_promo_groups_info( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -342,21 +336,21 @@ async def show_promo_groups_info( promo_groups = await get_auto_assign_promo_groups(db) keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_info')]] ) if not promo_groups: empty_text = texts.t( - "PROMO_GROUPS_INFO_EMPTY", - "Промогруппы с автовыдачей ещё не настроены.", + 'PROMO_GROUPS_INFO_EMPTY', + 'Промогруппы с автовыдачей ещё не настроены.', ) - header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 Промогруппы") - message = f"{header}\n\n{empty_text}" if empty_text else header + header = texts.t('PROMO_GROUPS_INFO_HEADER', '🎯 Промогруппы') + message = f'{header}\n\n{empty_text}' if empty_text else header await callback.message.edit_text( message, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -369,7 +363,7 @@ async def show_promo_groups_info( key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id), ) - achieved_groups: List[PromoGroup] = [ + achieved_groups: list[PromoGroup] = [ group for group in sorted_groups if (group.auto_assign_total_spent_kopeks or 0) > 0 @@ -385,35 +379,31 @@ async def show_promo_groups_info( current_group = achieved_groups[-1] next_group = next( - ( - group - for group in sorted_groups - if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks - ), + (group for group in sorted_groups if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks), None, ) - header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 Промогруппы") - lines: List[str] = [header, ""] + header = texts.t('PROMO_GROUPS_INFO_HEADER', '🎯 Промогруппы') + lines: list[str] = [header, ''] spent_line = texts.t( - "PROMO_GROUPS_INFO_TOTAL_SPENT", - "💰 Потрачено в боте: {amount}", + 'PROMO_GROUPS_INFO_TOTAL_SPENT', + '💰 Потрачено в боте: {amount}', ).format(amount=total_spent_text) lines.append(spent_line) if current_group: lines.append( texts.t( - "PROMO_GROUPS_INFO_CURRENT_LEVEL", - "🏆 Текущий уровень: {name}", + 'PROMO_GROUPS_INFO_CURRENT_LEVEL', + '🏆 Текущий уровень: {name}', ).format(name=html.escape(current_group.name)), ) else: lines.append( texts.t( - "PROMO_GROUPS_INFO_NO_LEVEL", - "🏆 Текущий уровень: пока не получен", + 'PROMO_GROUPS_INFO_NO_LEVEL', + '🏆 Текущий уровень: пока не получен', ) ) @@ -421,8 +411,8 @@ async def show_promo_groups_info( remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks lines.append( texts.t( - "PROMO_GROUPS_INFO_NEXT_LEVEL", - "📈 До уровня «{name}»: осталось {amount}", + 'PROMO_GROUPS_INFO_NEXT_LEVEL', + '📈 До уровня «{name}»: осталось {amount}', ).format( name=html.escape(next_group.name), amount=_format_rubles(max(remaining_kopeks, 0)), @@ -431,20 +421,20 @@ async def show_promo_groups_info( else: lines.append( texts.t( - "PROMO_GROUPS_INFO_MAX_LEVEL", - "🏆 Вы уже получили максимальный уровень скидок!", + 'PROMO_GROUPS_INFO_MAX_LEVEL', + '🏆 Вы уже получили максимальный уровень скидок!', ) ) - lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")]) + lines.extend(['', texts.t('PROMO_GROUPS_INFO_LEVELS_HEADER', '📋 Уровни с автовыдачей:')]) for group in sorted_groups: threshold = group.auto_assign_total_spent_kopeks or 0 - status_icon = "✅" if total_spent_kopeks >= threshold else "🔒" + status_icon = '✅' if total_spent_kopeks >= threshold else '🔒' lines.append( texts.t( - "PROMO_GROUPS_INFO_LEVEL_LINE", - "{status} {name} — от {amount}", + 'PROMO_GROUPS_INFO_LEVEL_LINE', + '{status} {name} — от {amount}', ).format( status=status_icon, name=html.escape(group.name), @@ -455,19 +445,19 @@ async def show_promo_groups_info( discount_lines = _build_group_discount_lines(group, texts, db_user.language) for discount_line in discount_lines: if discount_line: - lines.append(f" {discount_line}") + lines.append(f' {discount_line}') - lines.append("") + lines.append('') while lines and not lines[-1]: lines.pop() - message_text = "\n".join(lines) + message_text = '\n'.join(lines) await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -482,8 +472,8 @@ async def show_faq_pages( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -494,32 +484,32 @@ async def show_faq_pages( pages = await FaqService.get_pages(db, db_user.language) if not pages: await callback.answer( - texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."), + texts.t('FAQ_NOT_AVAILABLE', 'FAQ временно недоступен.'), show_alert=True, ) return - header = texts.t("FAQ_HEADER", "❓ FAQ") - prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" ) - caption = f"{header}\n\n{prompt}" if prompt else header + header = texts.t('FAQ_HEADER', '❓ FAQ') + prompt = texts.t('FAQ_PAGES_PROMPT', 'Выберите вопрос:') + caption = f'{header}\n\n{prompt}' if prompt else header buttons: list[list[types.InlineKeyboardButton]] = [] for index, page in enumerate(pages, start=1): - raw_title = (page.title or "").strip() + raw_title = (page.title or '').strip() if not raw_title: - raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + raw_title = texts.t('FAQ_PAGE_UNTITLED', 'Без названия') if len(raw_title) > 60: - raw_title = f"{raw_title[:57]}..." - buttons.append([ - types.InlineKeyboardButton( - text=f"{index}. {raw_title}", - callback_data=f"menu_faq_page:{page.id}:1", - ) - ]) + raw_title = f'{raw_title[:57]}...' + buttons.append( + [ + types.InlineKeyboardButton( + text=f'{index}. {raw_title}', + callback_data=f'menu_faq_page:{page.id}:1', + ) + ] + ) - buttons.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") - ]) + buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_info')]) await callback.message.edit_text( caption, @@ -539,8 +529,8 @@ async def show_faq_page( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -548,8 +538,8 @@ async def show_faq_page( texts = get_texts(db_user.language) - raw_data = callback.data or "" - parts = raw_data.split(":") + raw_data = callback.data or '' + parts = raw_data.split(':') page_id = None requested_page = 1 @@ -574,7 +564,7 @@ async def show_faq_page( if not page or not page.is_active: await callback.answer( - texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."), + texts.t('FAQ_PAGE_NOT_AVAILABLE', 'Эта страница FAQ недоступна.'), show_alert=True, ) return @@ -583,7 +573,7 @@ async def show_faq_page( if not content_pages: await callback.answer( - texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."), + texts.t('FAQ_PAGE_EMPTY', 'Текст для этой страницы ещё не добавлен.'), show_alert=True, ) return @@ -591,33 +581,33 @@ async def show_faq_page( total_pages = len(content_pages) current_page = max(1, min(requested_page, total_pages)) - header = texts.t("FAQ_HEADER", "❓ FAQ") - title_template = texts.t("FAQ_PAGE_TITLE", "{title}") - page_title = (page.title or "").strip() + header = texts.t('FAQ_HEADER', '❓ FAQ') + title_template = texts.t('FAQ_PAGE_TITLE', '{title}') + page_title = (page.title or '').strip() if not page_title: - page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия") + page_title = texts.t('FAQ_PAGE_UNTITLED', 'Без названия') title_block = title_template.format(title=html.escape(page_title)) body = content_pages[current_page - 1] footer_template = texts.t( - "FAQ_PAGE_FOOTER", - "Страница {current} из {total}", + 'FAQ_PAGE_FOOTER', + 'Страница {current} из {total}', ) - footer = "" + footer = '' if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: - footer = f"{current_page}/{total_pages}" + footer = f'{current_page}/{total_pages}' parts_to_join = [header, title_block] if body: parts_to_join.append(body) if footer: - parts_to_join.append(f"{footer}") + parts_to_join.append(f'{footer}') - message_text = "\n\n".join(segment for segment in parts_to_join if segment) + message_text = '\n\n'.join(segment for segment in parts_to_join if segment) keyboard_rows: list[list[types.InlineKeyboardButton]] = [] @@ -626,37 +616,37 @@ async def show_faq_page( if current_page > 1: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"menu_faq_page:{page.id}:{current_page - 1}", + text=texts.t('PAGINATION_PREV', '⬅️'), + callback_data=f'menu_faq_page:{page.id}:{current_page - 1}', ) ) nav_row.append( types.InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="noop", + text=f'{current_page}/{total_pages}', + callback_data='noop', ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"menu_faq_page:{page.id}:{current_page + 1}", + text=texts.t('PAGINATION_NEXT', '➡️'), + callback_data=f'menu_faq_page:{page.id}:{current_page + 1}', ) ) keyboard_rows.append(nav_row) - keyboard_rows.append([ - types.InlineKeyboardButton( - text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"), - callback_data="menu_faq", - ) - ]) - keyboard_rows.append([ - types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info") - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=texts.t('FAQ_BACK_TO_LIST', '⬅️ К списку FAQ'), + callback_data='menu_faq', + ) + ] + ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_info')]) await callback.message.edit_text( message_text, @@ -665,6 +655,7 @@ async def show_faq_page( ) await callback.answer() + async def show_privacy_policy( callback: types.CallbackQuery, db_user: User, @@ -675,8 +666,8 @@ async def show_privacy_policy( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -685,22 +676,21 @@ async def show_privacy_policy( texts = get_texts(db_user.language) raw_page = 1 - if callback.data and ":" in callback.data: + if callback.data and ':' in callback.data: try: - raw_page = int(callback.data.split(":", 1)[1]) + raw_page = int(callback.data.split(':', 1)[1]) except ValueError: raw_page = 1 - if raw_page < 1: - raw_page = 1 + raw_page = max(raw_page, 1) policy = await PrivacyPolicyService.get_active_policy(db, db_user.language) if not policy: await callback.answer( texts.t( - "PRIVACY_POLICY_NOT_AVAILABLE", - "Политика конфиденциальности временно недоступна.", + 'PRIVACY_POLICY_NOT_AVAILABLE', + 'Политика конфиденциальности временно недоступна.', ), show_alert=True, ) @@ -711,38 +701,38 @@ async def show_privacy_policy( if not pages: await callback.answer( texts.t( - "PRIVACY_POLICY_EMPTY_ALERT", - "Политика конфиденциальности ещё не заполнена.", + 'PRIVACY_POLICY_EMPTY_ALERT', + 'Политика конфиденциальности ещё не заполнена.', ), show_alert=True, ) return total_pages = len(pages) - current_page = raw_page if raw_page <= total_pages else total_pages + current_page = min(raw_page, total_pages) header = texts.t( - "PRIVACY_POLICY_HEADER", - "🛡️ Политика конфиденциальности", + 'PRIVACY_POLICY_HEADER', + '🛡️ Политика конфиденциальности', ) body = pages[current_page - 1] footer_template = texts.t( - "PRIVACY_POLICY_PAGE_INFO", - "Страница {current} из {total}", + 'PRIVACY_POLICY_PAGE_INFO', + 'Страница {current} из {total}', ) - footer = "" + footer = '' if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: - footer = f"{current_page}/{total_pages}" + footer = f'{current_page}/{total_pages}' message_text = header if body: - message_text += f"\n\n{body}" + message_text += f'\n\n{body}' if footer: - message_text += f"\n\n{footer}" + message_text += f'\n\n{footer}' keyboard_rows: list[list[types.InlineKeyboardButton]] = [] @@ -751,31 +741,29 @@ async def show_privacy_policy( if current_page > 1: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"menu_privacy_policy:{current_page - 1}", + text=texts.t('PAGINATION_PREV', '⬅️'), + callback_data=f'menu_privacy_policy:{current_page - 1}', ) ) nav_row.append( types.InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="noop", + text=f'{current_page}/{total_pages}', + callback_data='noop', ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"menu_privacy_policy:{current_page + 1}", + text=texts.t('PAGINATION_NEXT', '➡️'), + callback_data=f'menu_privacy_policy:{current_page + 1}', ) ) keyboard_rows.append(nav_row) - keyboard_rows.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_info')]) await callback.message.edit_text( message_text, @@ -795,8 +783,8 @@ async def show_public_offer( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -805,22 +793,21 @@ async def show_public_offer( texts = get_texts(db_user.language) raw_page = 1 - if callback.data and ":" in callback.data: + if callback.data and ':' in callback.data: try: - raw_page = int(callback.data.split(":", 1)[1]) + raw_page = int(callback.data.split(':', 1)[1]) except ValueError: raw_page = 1 - if raw_page < 1: - raw_page = 1 + raw_page = max(raw_page, 1) offer = await PublicOfferService.get_active_offer(db, db_user.language) if not offer: await callback.answer( texts.t( - "PUBLIC_OFFER_NOT_AVAILABLE", - "Публичная оферта временно недоступна.", + 'PUBLIC_OFFER_NOT_AVAILABLE', + 'Публичная оферта временно недоступна.', ), show_alert=True, ) @@ -831,38 +818,38 @@ async def show_public_offer( if not pages: await callback.answer( texts.t( - "PUBLIC_OFFER_EMPTY_ALERT", - "Публичная оферта ещё не заполнена.", + 'PUBLIC_OFFER_EMPTY_ALERT', + 'Публичная оферта ещё не заполнена.', ), show_alert=True, ) return total_pages = len(pages) - current_page = raw_page if raw_page <= total_pages else total_pages + current_page = min(raw_page, total_pages) header = texts.t( - "PUBLIC_OFFER_HEADER", - "📄 Публичная оферта", + 'PUBLIC_OFFER_HEADER', + '📄 Публичная оферта', ) body = pages[current_page - 1] footer_template = texts.t( - "PUBLIC_OFFER_PAGE_INFO", - "Страница {current} из {total}", + 'PUBLIC_OFFER_PAGE_INFO', + 'Страница {current} из {total}', ) - footer = "" + footer = '' if total_pages > 1 and footer_template: try: footer = footer_template.format(current=current_page, total=total_pages) except Exception: - footer = f"{current_page}/{total_pages}" + footer = f'{current_page}/{total_pages}' message_text = header if body: - message_text += f"\n\n{body}" + message_text += f'\n\n{body}' if footer: - message_text += f"\n\n{footer}" + message_text += f'\n\n{footer}' keyboard_rows: list[list[types.InlineKeyboardButton]] = [] @@ -871,31 +858,29 @@ async def show_public_offer( if current_page > 1: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"menu_public_offer:{current_page - 1}", + text=texts.t('PAGINATION_PREV', '⬅️'), + callback_data=f'menu_public_offer:{current_page - 1}', ) ) nav_row.append( types.InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="noop", + text=f'{current_page}/{total_pages}', + callback_data='noop', ) ) if current_page < total_pages: nav_row.append( types.InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"menu_public_offer:{current_page + 1}", + text=texts.t('PAGINATION_NEXT', '➡️'), + callback_data=f'menu_public_offer:{current_page + 1}', ) ) keyboard_rows.append(nav_row) - keyboard_rows.append( - [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_info')]) await callback.message.edit_text( message_text, @@ -915,8 +900,8 @@ async def show_language_menu( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -927,8 +912,8 @@ async def show_language_menu( if not settings.is_language_selection_enabled(): await callback.answer( texts.t( - "LANGUAGE_SELECTION_DISABLED", - "⚙️ Выбор языка временно недоступен.", + 'LANGUAGE_SELECTION_DISABLED', + '⚙️ Выбор языка временно недоступен.', ), show_alert=True, ) @@ -936,13 +921,13 @@ async def show_language_menu( await edit_or_answer_photo( callback=callback, - caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"), + 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", + parse_mode='HTML', ) await callback.answer() @@ -957,8 +942,8 @@ async def process_language_change( texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -969,14 +954,14 @@ async def process_language_change( if not settings.is_language_selection_enabled(): await callback.answer( texts.t( - "LANGUAGE_SELECTION_DISABLED", - "⚙️ Выбор языка временно недоступен.", + 'LANGUAGE_SELECTION_DISABLED', + '⚙️ Выбор языка временно недоступен.', ), show_alert=True, ) return - selected_raw = (callback.data or "").split(":", 1)[-1] + selected_raw = (callback.data or '').split(':', 1)[-1] normalized_selected = selected_raw.strip().lower() available_map = { @@ -986,7 +971,7 @@ async def process_language_change( } if normalized_selected not in available_map: - await callback.answer("❌ Unsupported language", show_alert=True) + await callback.answer('❌ Unsupported language', show_alert=True) return resolved_language = available_map[normalized_selected].lower() @@ -998,7 +983,7 @@ async def process_language_change( db, skip_callback_answer=True, ) - await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен.")) + await callback.answer(texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.')) return updated_user = await update_user(db, db_user, language=resolved_language) @@ -1010,22 +995,17 @@ async def process_language_change( db, skip_callback_answer=True, ) - await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен.")) + await callback.answer(texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.')) -async def handle_back_to_menu( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): if db_user is None: # Пользователь не найден, используем язык по умолчанию texts = get_texts(settings.DEFAULT_LANGUAGE_CODE) await callback.answer( texts.t( - "USER_NOT_FOUND_ERROR", - "Ошибка: пользователь не найден.", + 'USER_NOT_FOUND_ERROR', + 'Ошибка: пользователь не найден.', ), show_alert=True, ) @@ -1050,13 +1030,11 @@ async def handle_back_to_menu( try: has_saved_cart = await user_cart_service.has_user_cart(db_user.id) except Exception as e: - logger.error(f"Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}") + logger.error(f'Ошибка проверки сохраненной корзины для пользователя {db_user.id}: {e}') has_saved_cart = False is_admin = settings.is_admin(db_user.telegram_id) - is_moderator = (not is_admin) and SupportSettingsService.is_moderator( - db_user.telegram_id - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(db_user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -1087,105 +1065,104 @@ async def handle_back_to_menu( callback=callback, caption=menu_text, keyboard=keyboard, - parse_mode="HTML", + parse_mode='HTML', force_text=settings.is_text_main_menu_mode(), ) await callback.answer() + def _get_subscription_status(user: User, texts, is_daily_tariff: bool = False) -> str: - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if not subscription: - return texts.t("SUB_STATUS_NONE", "❌ Отсутствует") + return texts.t('SUB_STATUS_NONE', '❌ Отсутствует') current_time = datetime.utcnow() - actual_status = (subscription.actual_status or "").lower() - end_date = getattr(subscription, "end_date", None) - end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None + actual_status = (subscription.actual_status or '').lower() + end_date = getattr(subscription, 'end_date', None) + end_date_text = format_local_datetime(end_date, '%d.%m.%Y') if end_date else None days_left = 0 if subscription.end_date > current_time: days_left = (subscription.end_date - current_time).days - if actual_status == "pending": - return texts.t("SUBSCRIPTION_NONE", "❌ Нет активной подписки") + if actual_status == 'pending': + return texts.t('SUBSCRIPTION_NONE', '❌ Нет активной подписки') - if actual_status == "disabled": - return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена") + if actual_status == 'disabled': + return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена') - if actual_status == "expired": + if actual_status == 'expired': return texts.t( - "SUB_STATUS_EXPIRED", - "🔴 Истекла\n📅 {end_date}", - ).format(end_date=end_date_text or "—") + 'SUB_STATUS_EXPIRED', + '🔴 Истекла\n📅 {end_date}', + ).format(end_date=end_date_text or '—') - is_trial_subscription = getattr(subscription, "is_trial", False) + is_trial_subscription = getattr(subscription, 'is_trial', False) - is_trial_like_status = actual_status == "trial" or ( - is_trial_subscription and actual_status in {"active", "trial"} - ) + is_trial_like_status = actual_status == 'trial' or (is_trial_subscription and actual_status in {'active', 'trial'}) if is_trial_like_status: if days_left > 1 and end_date_text: return texts.t( - "SUB_STATUS_TRIAL_ACTIVE", - "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", + 'SUB_STATUS_TRIAL_ACTIVE', + '🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)', ).format( end_date=end_date_text, days=days_left, ) if days_left == 1: return texts.t( - "SUB_STATUS_TRIAL_TOMORROW", - "🎁 Тестовая подписка\n⚠️ истекает завтра!", + 'SUB_STATUS_TRIAL_TOMORROW', + '🎁 Тестовая подписка\n⚠️ истекает завтра!', ) return texts.t( - "SUB_STATUS_TRIAL_TODAY", - "🎁 Тестовая подписка\n⚠️ истекает сегодня!", + 'SUB_STATUS_TRIAL_TODAY', + '🎁 Тестовая подписка\n⚠️ истекает сегодня!', ) - if actual_status == "active": + if actual_status == 'active': # Для суточных тарифов не показываем предупреждение об истечении if is_daily_tariff: - return texts.t("SUB_STATUS_DAILY_ACTIVE", "💎 Активна") + return texts.t('SUB_STATUS_DAILY_ACTIVE', '💎 Активна') if days_left > 7 and end_date_text: return texts.t( - "SUB_STATUS_ACTIVE_LONG", - "💎 Активна\n📅 до {end_date} ({days} дн.)", + 'SUB_STATUS_ACTIVE_LONG', + '💎 Активна\n📅 до {end_date} ({days} дн.)', ).format( end_date=end_date_text, days=days_left, ) if days_left > 1: return texts.t( - "SUB_STATUS_ACTIVE_FEW_DAYS", - "💎 Активна\n⚠️ истекает через {days} дн.", + 'SUB_STATUS_ACTIVE_FEW_DAYS', + '💎 Активна\n⚠️ истекает через {days} дн.', ).format(days=days_left) if days_left == 1: return texts.t( - "SUB_STATUS_ACTIVE_TOMORROW", - "💎 Активна\n⚠️ истекает завтра!", + 'SUB_STATUS_ACTIVE_TOMORROW', + '💎 Активна\n⚠️ истекает завтра!', ) return texts.t( - "SUB_STATUS_ACTIVE_TODAY", - "💎 Активна\n⚠️ истекает сегодня!", + 'SUB_STATUS_ACTIVE_TODAY', + '💎 Активна\n⚠️ истекает сегодня!', ) - return texts.t("SUB_STATUS_UNKNOWN", "❓ Неизвестно") + return texts.t('SUB_STATUS_UNKNOWN', '❓ Неизвестно') def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str: if not random_message: return base_text - prompt = action_prompt or "" + prompt = action_prompt or '' if prompt and prompt in base_text: parts = base_text.split(prompt, 1) if len(parts) == 2: - return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}" - return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1) + return f'{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}' + return base_text.replace(prompt, f'\n{random_message}\n\n{prompt}', 1) - return f"{base_text}\n\n{random_message}" + return f'{base_text}\n\n{random_message}' async def get_main_menu_text(user, texts, db: AsyncSession): @@ -1194,35 +1171,32 @@ async def get_main_menu_text(user, texts, db: AsyncSession): # Загружаем информацию о тарифе если включен режим тарифов tariff = None is_daily_tariff = False - tariff_info_block = "" + tariff_info_block = '' - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if settings.is_tariffs_mode() and subscription and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: is_daily_tariff = getattr(tariff, 'is_daily', False) # Формируем краткий блок информации о тарифе для главного меню - tariff_info_block = f"\n📦 Тариф: {tariff.name}" + tariff_info_block = f'\n📦 Тариф: {tariff.name}' except Exception as e: - logger.debug(f"Не удалось загрузить тариф для главного меню: {e}") + logger.debug(f'Не удалось загрузить тариф для главного меню: {e}') base_text = texts.MAIN_MENU.format( - user_name=user.full_name, - subscription_status=_get_subscription_status(user, texts, is_daily_tariff) + user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff) ) # Добавляем информацию о тарифе перед "Выберите действие" if tariff_info_block: - action_prompt_text = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + action_prompt_text = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:') if action_prompt_text in base_text: - base_text = base_text.replace( - action_prompt_text, - f"{tariff_info_block}\n\n{action_prompt_text}" - ) + base_text = base_text.replace(action_prompt_text, f'{tariff_info_block}\n\n{action_prompt_text}') - action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + action_prompt = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:') info_sections: list[str] = [] @@ -1232,8 +1206,8 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(promo_hint.strip()) except Exception as hint_error: logger.debug( - "Не удалось построить подсказку промо-предложения для пользователя %s: %s", - getattr(user, "id", None), + 'Не удалось построить подсказку промо-предложения для пользователя %s: %s', + getattr(user, 'id', None), hint_error, ) @@ -1243,13 +1217,13 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(test_access_hint.strip()) except Exception as test_error: logger.debug( - "Не удалось построить подсказку тестового доступа для пользователя %s: %s", - getattr(user, "id", None), + 'Не удалось построить подсказку тестового доступа для пользователя %s: %s', + getattr(user, 'id', None), test_error, ) if info_sections: - extra_block = "\n\n".join(section for section in info_sections if section) + extra_block = '\n\n'.join(section for section in info_sections if section) if extra_block: base_text = _insert_random_message(base_text, extra_block, action_prompt) @@ -1259,16 +1233,12 @@ async def get_main_menu_text(user, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f"Ошибка получения случайного сообщения: {e}") + logger.error(f'Ошибка получения случайного сообщения: {e}') return base_text -async def handle_activate_button( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_activate_button(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """ Умная кнопка активации — система сама решает что делать: - Если подписка активна — ничего не делать @@ -1278,20 +1248,20 @@ async def handle_activate_button( """ texts = get_texts(db_user.language) - from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription - from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads + from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids + from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance - from app.database.models import TransactionType, PaymentMethod - from app.services.subscription_service import SubscriptionService + from app.database.models import PaymentMethod, TransactionType from app.services.subscription_renewal_service import SubscriptionRenewalService + from app.services.subscription_service import SubscriptionService subscription = await get_subscription_by_user_id(db, db_user.id) # Если подписка активна — ничего не делаем - if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): + if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow(): await callback.answer( - texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"), + texts.t('SUBSCRIPTION_ALREADY_ACTIVE', '✅ Подписка уже активна!'), show_alert=True, ) return @@ -1311,10 +1281,7 @@ async def handle_activate_button( # Если серверы не выбраны — берём бесплатные по умолчанию if not connected_squads: available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id) - connected_squads = [ - s.squad_uuid for s in available_servers - if s.is_available and s.price_kopeks == 0 - ] + connected_squads = [s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0] # Если бесплатных нет — берём первый доступный if not connected_squads and available_servers: connected_squads = [available_servers[0].squad_uuid] @@ -1332,12 +1299,7 @@ async def handle_activate_button( for period in available_periods: price, _ = await subscription_service.calculate_subscription_price_with_months( - period, - traffic_limit_gb, - server_ids, - device_limit, - db, - user=db_user + period, traffic_limit_gb, server_ids, device_limit, db, user=db_user ) if price <= balance: best_period = period @@ -1352,10 +1314,7 @@ async def handle_activate_button( ) missing = min_price - balance await callback.answer( - texts.t( - "INSUFFICIENT_FUNDS_DETAILED", - f"❌ Недостаточно средств. Не хватает {missing // 100} ₽" - ), + texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'), show_alert=True, ) return @@ -1364,22 +1323,19 @@ async def handle_activate_button( if subscription: # Продление существующей подписки renewal_service = SubscriptionRenewalService() - pricing = await renewal_service.calculate_pricing( - db, db_user, subscription, best_period - ) + pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period) - result = await renewal_service.finalize( - db, db_user, subscription, + await renewal_service.finalize( + db, + db_user, + subscription, pricing, - description=f"Автоматическое продление на {best_period} дней", + description=f'Автоматическое продление на {best_period} дней', payment_method=PaymentMethod.BALANCE, ) await callback.answer( - texts.t( - "ACTIVATION_SUCCESS", - f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!" - ), + texts.t('ACTIVATION_SUCCESS', f'✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!'), show_alert=True, ) else: @@ -1391,14 +1347,11 @@ async def handle_activate_button( traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads, - update_server_counters=True + update_server_counters=True, ) # Списать баланс правильно - await subtract_user_balance( - db, db_user, best_price, - f"Активация подписки на {best_period} дней" - ) + await subtract_user_balance(db, db_user, best_price, f'Активация подписки на {best_period} дней') # Создать пользователя в RemnaWave await subscription_service.create_remnawave_user(db, new_subscription) @@ -1409,107 +1362,83 @@ async def handle_activate_button( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=best_price, - description=f"Активация подписки на {best_period} дней", + description=f'Активация подписки на {best_period} дней', payment_method=PaymentMethod.BALANCE, ) await callback.answer( texts.t( - "ACTIVATION_SUCCESS", - f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!" + 'ACTIVATION_SUCCESS', f'✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!' ), show_alert=True, ) except Exception as e: - user_id_display = db_user.telegram_id or db_user.email or f"#{db_user.id}" - logger.error(f"Ошибка автоматической активации для {user_id_display}: {e}") + user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' + logger.error(f'Ошибка автоматической активации для {user_id_display}: {e}') await db.rollback() await callback.answer( - texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."), + texts.t('ACTIVATION_ERROR', '❌ Ошибка активации. Попробуйте позже.'), show_alert=True, ) def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - handle_back_to_menu, - F.data == "back_to_menu" - ) + dp.callback_query.register(handle_back_to_menu, F.data == 'back_to_menu') dp.callback_query.register( handle_profile_unavailable, - F.data == "menu_profile_unavailable", + F.data == 'menu_profile_unavailable', ) - dp.callback_query.register( - show_service_rules, - F.data == "menu_rules" - ) + dp.callback_query.register(show_service_rules, F.data == 'menu_rules') dp.callback_query.register( show_info_menu, - F.data == "menu_info", + F.data == 'menu_info', ) dp.callback_query.register( show_promo_groups_info, - F.data == "menu_info_promo_groups", + F.data == 'menu_info_promo_groups', ) dp.callback_query.register( show_faq_pages, - F.data == "menu_faq", + F.data == 'menu_faq', ) dp.callback_query.register( show_faq_page, - F.data.startswith("menu_faq_page:"), + F.data.startswith('menu_faq_page:'), ) dp.callback_query.register( show_privacy_policy, - F.data == "menu_privacy_policy", + F.data == 'menu_privacy_policy', ) dp.callback_query.register( show_privacy_policy, - F.data.startswith("menu_privacy_policy:"), + F.data.startswith('menu_privacy_policy:'), ) dp.callback_query.register( show_public_offer, - F.data == "menu_public_offer", + F.data == 'menu_public_offer', ) dp.callback_query.register( show_public_offer, - F.data.startswith("menu_public_offer:"), + F.data.startswith('menu_public_offer:'), ) - dp.callback_query.register( - show_language_menu, - F.data == "menu_language" - ) + 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) - ) + dp.callback_query.register(process_language_change, F.data.startswith('language_select:'), StateFilter(None)) - dp.callback_query.register( - handle_add_traffic, - F.data == "buy_traffic" - ) + dp.callback_query.register(handle_add_traffic, F.data == 'buy_traffic') - dp.callback_query.register( - add_traffic, - F.data.startswith("add_traffic_") - ) + dp.callback_query.register(add_traffic, F.data.startswith('add_traffic_')) - dp.callback_query.register( - handle_activate_button, - F.data == "activate_button" - ) + dp.callback_query.register(handle_activate_button, F.data == 'activate_button') diff --git a/app/handlers/polls.py b/app/handlers/polls.py index de51d892..dfb35e44 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -15,6 +15,7 @@ from app.database.models import PollQuestion, User from app.localization.texts import get_texts from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll + logger = logging.getLogger(__name__) @@ -23,7 +24,7 @@ async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = await asyncio.sleep(delay) await bot.delete_message(chat_id, message_id) except Exception as error: # pragma: no cover - cleanup best effort - logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error) + logger.debug('Не удалось удалить сообщение опроса %s: %s', message_id, error) async def _render_question_text( @@ -34,12 +35,12 @@ async def _render_question_text( language: str, ) -> str: texts = get_texts(language) - header = texts.t("POLL_QUESTION_HEADER", "Вопрос {current}/{total}").format( + header = texts.t('POLL_QUESTION_HEADER', 'Вопрос {current}/{total}').format( current=current_index, total=total, ) - lines = [f"🗳️ {poll_title}", "", header, "", question.text] - return "\n".join(lines) + lines = [f'🗳️ {poll_title}', '', header, '', question.text] + return '\n'.join(lines) async def _update_poll_message( @@ -47,7 +48,7 @@ async def _update_poll_message( text: str, *, reply_markup: types.InlineKeyboardMarkup | None = None, - parse_mode: str | None = "HTML", + parse_mode: str | None = 'HTML', ) -> bool: try: await message.edit_text( @@ -58,21 +59,21 @@ async def _update_poll_message( return True except TelegramBadRequest as error: error_text = str(error).lower() - if "message is not modified" in error_text: + if 'message is not modified' in error_text: logger.debug( - "Опросное сообщение уже актуально, пропускаем обновление: %s", + 'Опросное сообщение уже актуально, пропускаем обновление: %s', error, ) return True logger.warning( - "Не удалось обновить сообщение опроса %s: %s", + 'Не удалось обновить сообщение опроса %s: %s', message.message_id, error, ) except Exception as error: # pragma: no cover - defensive logging logger.exception( - "Непредвиденная ошибка при обновлении сообщения опроса %s: %s", + 'Непредвиденная ошибка при обновлении сообщения опроса %s: %s', message.message_id, error, ) @@ -87,7 +88,7 @@ def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.I [ types.InlineKeyboardButton( text=option.text, - callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}", + callback_data=f'poll_answer:{response_id}:{question.id}:{option.id}', ) ] ) @@ -100,24 +101,24 @@ async def handle_poll_start( db: AsyncSession, ): try: - response_id = int(callback.data.split(":")[1]) + response_id = int(callback.data.split(':')[1]) except (IndexError, ValueError): - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return response = await get_poll_response_by_id(db, response_id) if not response or response.user_id != db_user.id: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return texts = get_texts(db_user.language) if response.completed_at: - await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + await callback.answer(texts.t('POLL_ALREADY_COMPLETED', 'Вы уже прошли этот опрос.'), show_alert=True) return if not response.poll or not response.poll.questions: - await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True) + await callback.answer(texts.t('POLL_EMPTY', 'Опрос пока недоступен.'), show_alert=True) return if not response.started_at: @@ -126,7 +127,7 @@ async def handle_poll_start( index, question = await get_next_question(response) if not question: - await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Не удалось загрузить вопросы.'), show_alert=True) return question_text = await _render_question_text( @@ -142,7 +143,7 @@ async def handle_poll_start( question_text, reply_markup=_build_options_keyboard(response.id, question), ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Не удалось показать вопрос.'), show_alert=True) return await callback.answer() @@ -153,37 +154,37 @@ async def handle_poll_answer( db: AsyncSession, ): try: - _, response_id, question_id, option_id = callback.data.split(":", 3) + _, response_id, question_id, option_id = callback.data.split(':', 3) response_id = int(response_id) question_id = int(question_id) option_id = int(option_id) except (ValueError, IndexError): - await callback.answer("❌ Некорректные данные", show_alert=True) + await callback.answer('❌ Некорректные данные', show_alert=True) return response = await get_poll_response_by_id(db, response_id) texts = get_texts(db_user.language) if not response or response.user_id != db_user.id: - await callback.answer("❌ Опрос не найден", show_alert=True) + await callback.answer('❌ Опрос не найден', show_alert=True) return if not response.poll: - await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Опрос недоступен.'), show_alert=True) return if response.completed_at: - await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True) + await callback.answer(texts.t('POLL_ALREADY_COMPLETED', 'Вы уже прошли этот опрос.'), show_alert=True) return question = next((q for q in response.poll.questions if q.id == question_id), None) if not question: - await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Вопрос не найден.'), show_alert=True) return option = await get_question_option(question, option_id) if not option: - await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Вариант ответа не найден.'), show_alert=True) return await record_poll_answer( @@ -194,16 +195,16 @@ async def handle_poll_answer( ) try: - await db.refresh(response, attribute_names=["answers"]) + await db.refresh(response, attribute_names=['answers']) except Exception as error: # pragma: no cover - defensive cache busting logger.debug( - "Не удалось обновить локальные ответы опроса %s: %s", + 'Не удалось обновить локальные ответы опроса %s: %s', response.id, error, ) response = await get_poll_response_by_id(db, response.id) if not response: - await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Опрос недоступен.'), show_alert=True) return index, next_question = await get_next_question(response) @@ -220,7 +221,7 @@ async def handle_poll_answer( question_text, reply_markup=_build_options_keyboard(response.id, next_question), ): - await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True) + await callback.answer(texts.t('POLL_ERROR', 'Не удалось показать вопрос.'), show_alert=True) return await callback.answer() return @@ -230,27 +231,25 @@ async def handle_poll_answer( reward_amount = await reward_user_for_poll(db, response) - thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")] + thanks_lines = [texts.t('POLL_COMPLETED', '🙏 Спасибо за участие в опросе!')] if reward_amount: thanks_lines.append( texts.t( - "POLL_REWARD_GRANTED", - "Награда {amount} зачислена на ваш баланс.", + 'POLL_REWARD_GRANTED', + 'Награда {amount} зачислена на ваш баланс.', ).format(amount=settings.format_price(reward_amount)) ) if not await _update_poll_message( callback.message, - "\n\n".join(thanks_lines), + '\n\n'.join(thanks_lines), ): - await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")) + await callback.answer(texts.t('POLL_COMPLETED', '🙏 Спасибо за участие в опросе!')) return - asyncio.create_task( - _delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id) - ) + asyncio.create_task(_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)) await callback.answer() def register_handlers(dp: Dispatcher): - dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:")) - dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:")) + dp.callback_query.register(handle_poll_start, F.data.startswith('poll_start:')) + dp.callback_query.register(handle_poll_answer, F.data.startswith('poll_answer:')) diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index afcfd686..1f97de06 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -1,51 +1,38 @@ import logging -from aiogram import Dispatcher, types, F, Bot + +from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext from aiogram.types import InaccessibleMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.states import PromoCodeStates from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.admin_notification_service import AdminNotificationService from app.services.blacklist_service import blacklist_service from app.services.promocode_service import PromoCodeService -from app.services.admin_notification_service import AdminNotificationService +from app.states import PromoCodeStates from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) @error_handler -async def show_promocode_menu( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, state: FSMContext): texts = get_texts(db_user.language) # Если сообщение недоступно, отправляем новое if isinstance(callback.message, InaccessibleMessage): - await callback.message.answer( - texts.PROMOCODE_ENTER, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) else: - await callback.message.edit_text( - texts.PROMOCODE_ENTER, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language)) await state.set_state(PromoCodeStates.waiting_for_code) await callback.answer() -async def activate_promocode_for_registration( - db: AsyncSession, - user_id: int, - code: str, - bot: Bot = None -) -> dict: +async def activate_promocode_for_registration(db: AsyncSession, user_id: int, code: str, bot: Bot = None) -> dict: """ Активирует промокод для пользователя во время регистрации. Возвращает результат активации без отправки сообщений. @@ -53,27 +40,28 @@ async def activate_promocode_for_registration( promocode_service = PromoCodeService() result = await promocode_service.activate_promocode(db, user_id, code) - if result["success"]: - logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации") + if result['success']: + logger.info(f'✅ Пользователь {user_id} активировал промокод {code} при регистрации') # Отправляем уведомление админу, если бот доступен if bot: try: from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) if user: notification_service = AdminNotificationService(bot) await notification_service.send_promocode_activation_notification( db, user, - result.get("promocode", {"code": code}), - result["description"], - result.get("balance_before_kopeks"), - result.get("balance_after_kopeks"), + result.get('promocode', {'code': code}), + result['description'], + result.get('balance_before_kopeks'), + result.get('balance_after_kopeks'), ) except Exception as notify_error: logger.error( - "Ошибка отправки админ уведомления об активации промокода %s: %s", + 'Ошибка отправки админ уведомления об активации промокода %s: %s', code, notify_error, ) @@ -82,28 +70,22 @@ async def activate_promocode_for_registration( @error_handler -async def process_promocode( - message: types.Message, - db_user: User, - state: FSMContext, - db: AsyncSession -): +async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession): # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Активация промокода невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Активация промокода невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) @@ -113,54 +95,43 @@ async def process_promocode( if not code: await message.answer( texts.t( - "PROMOCODE_EMPTY_INPUT", - "❌ Введите корректный промокод", + 'PROMOCODE_EMPTY_INPUT', + '❌ Введите корректный промокод', ), - reply_markup=get_back_keyboard(db_user.language) + reply_markup=get_back_keyboard(db_user.language), ) return result = await activate_promocode_for_registration(db, db_user.id, code, message.bot) - if result["success"]: + if result['success']: await message.answer( - texts.PROMOCODE_SUCCESS.format(description=result["description"]), - reply_markup=get_back_keyboard(db_user.language) + texts.PROMOCODE_SUCCESS.format(description=result['description']), + reply_markup=get_back_keyboard(db_user.language), ) else: error_messages = { - "not_found": texts.PROMOCODE_INVALID, - "expired": texts.PROMOCODE_EXPIRED, - "used": texts.PROMOCODE_USED, - "already_used_by_user": texts.PROMOCODE_USED, - "not_first_purchase": texts.t( - "PROMOCODE_NOT_FIRST_PURCHASE", - "❌ Этот промокод доступен только для первой покупки" + 'not_found': texts.PROMOCODE_INVALID, + 'expired': texts.PROMOCODE_EXPIRED, + 'used': texts.PROMOCODE_USED, + 'already_used_by_user': texts.PROMOCODE_USED, + 'not_first_purchase': texts.t( + 'PROMOCODE_NOT_FIRST_PURCHASE', '❌ Этот промокод доступен только для первой покупки' ), - "active_discount_exists": texts.t( - "PROMOCODE_ACTIVE_DISCOUNT_EXISTS", - "❌ У вас уже есть активная скидка. Используйте её перед активацией новой." + 'active_discount_exists': texts.t( + 'PROMOCODE_ACTIVE_DISCOUNT_EXISTS', + '❌ У вас уже есть активная скидка. Используйте её перед активацией новой.', ), - "server_error": texts.ERROR + 'server_error': texts.ERROR, } - error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID) - await message.answer( - error_text, - reply_markup=get_back_keyboard(db_user.language) - ) + error_text = error_messages.get(result['error'], texts.PROMOCODE_INVALID) + await message.answer(error_text, reply_markup=get_back_keyboard(db_user.language)) await state.clear() def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_promocode_menu, - F.data == "menu_promocode" - ) - - dp.message.register( - process_promocode, - PromoCodeStates.waiting_for_code - ) + dp.callback_query.register(show_promocode_menu, F.data == 'menu_promocode') + + dp.message.register(process_promocode, PromoCodeStates.waiting_for_code) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index 3ec9ae17..58d3e6b7 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -13,8 +13,8 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_referral_keyboard from app.localization.texts import get_texts -from app.services.referral_withdrawal_service import referral_withdrawal_service from app.services.admin_notification_service import AdminNotificationService +from app.services.referral_withdrawal_service import referral_withdrawal_service from app.states import ReferralWithdrawalStates from app.utils.photo_message import edit_or_answer_photo from app.utils.user_utils import ( @@ -24,172 +24,181 @@ from app.utils.user_utils import ( get_user_referral_summary, ) + logger = logging.getLogger(__name__) -async def show_referral_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, включена ли реферальная программа if not settings.is_referral_program_enabled(): texts = get_texts(db_user.language) - await callback.answer( - texts.t("REFERRAL_PROGRAM_DISABLED", "Реферальная программа отключена"), - show_alert=True - ) + await callback.answer(texts.t('REFERRAL_PROGRAM_DISABLED', 'Реферальная программа отключена'), show_alert=True) return texts = get_texts(db_user.language) summary = await get_user_referral_summary(db, db_user.id) - + bot_username = (await callback.bot.get_me()).username - referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" - + referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}' + referral_text = ( - texts.t("REFERRAL_PROGRAM_TITLE", "👥 Реферальная программа") - + "\n\n" - + texts.t("REFERRAL_STATS_HEADER", "📊 Ваша статистика:") - + "\n" + texts.t('REFERRAL_PROGRAM_TITLE', '👥 Реферальная программа') + + '\n\n' + + texts.t('REFERRAL_STATS_HEADER', '📊 Ваша статистика:') + + '\n' + texts.t( - "REFERRAL_STATS_INVITED", - "• Приглашено пользователей: {count}", + 'REFERRAL_STATS_INVITED', + '• Приглашено пользователей: {count}', ).format(count=summary['invited_count']) - + "\n" + + '\n' + texts.t( - "REFERRAL_STATS_FIRST_TOPUPS", - "• Сделали первое пополнение: {count}", + 'REFERRAL_STATS_FIRST_TOPUPS', + '• Сделали первое пополнение: {count}', ).format(count=summary['paid_referrals_count']) - + "\n" + + '\n' + texts.t( - "REFERRAL_STATS_ACTIVE", - "• Активных рефералов: {count}", + 'REFERRAL_STATS_ACTIVE', + '• Активных рефералов: {count}', ).format(count=summary['active_referrals_count']) - + "\n" + + '\n' + texts.t( - "REFERRAL_STATS_CONVERSION", - "• Конверсия: {rate}%", + 'REFERRAL_STATS_CONVERSION', + '• Конверсия: {rate}%', ).format(rate=summary['conversion_rate']) - + "\n" + + '\n' + texts.t( - "REFERRAL_STATS_TOTAL_EARNED", - "• Заработано всего: {amount}", + 'REFERRAL_STATS_TOTAL_EARNED', + '• Заработано всего: {amount}', ).format(amount=texts.format_price(summary['total_earned_kopeks'])) - + "\n" + + '\n' + texts.t( - "REFERRAL_STATS_MONTH_EARNED", - "• За последний месяц: {amount}", + 'REFERRAL_STATS_MONTH_EARNED', + '• За последний месяц: {amount}', ).format(amount=texts.format_price(summary['month_earned_kopeks'])) - + "\n\n" - + texts.t("REFERRAL_REWARDS_HEADER", "🎁 Как работают награды:") - + "\n" + + '\n\n' + + texts.t('REFERRAL_REWARDS_HEADER', '🎁 Как работают награды:') + + '\n' + texts.t( - "REFERRAL_REWARD_NEW_USER", - "• Новый пользователь получает: {bonus} при первом пополнении от {minimum}", + 'REFERRAL_REWARD_NEW_USER', + '• Новый пользователь получает: {bonus} при первом пополнении от {minimum}', ).format( bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS), minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS), ) - + "\n" + + '\n' + texts.t( - "REFERRAL_REWARD_INVITER", - "• Вы получаете при первом пополнении реферала: {bonus}", + 'REFERRAL_REWARD_INVITER', + '• Вы получаете при первом пополнении реферала: {bonus}', ).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)) - + "\n" + + '\n' + texts.t( - "REFERRAL_REWARD_COMMISSION", - "• Комиссия с каждого пополнения реферала: {percent}%", + 'REFERRAL_REWARD_COMMISSION', + '• Комиссия с каждого пополнения реферала: {percent}%', ).format(percent=get_effective_referral_commission_percent(db_user)) - + "\n\n" - + texts.t("REFERRAL_LINK_TITLE", "🔗 Ваша реферальная ссылка:") - + f"\n{referral_link}\n\n" - + texts.t("REFERRAL_CODE_TITLE", "🆔 Ваш код: {code}").format(code=db_user.referral_code) - + "\n\n" + + '\n\n' + + texts.t('REFERRAL_LINK_TITLE', '🔗 Ваша реферальная ссылка:') + + f'\n{referral_link}\n\n' + + texts.t('REFERRAL_CODE_TITLE', '🆔 Ваш код: {code}').format(code=db_user.referral_code) + + '\n\n' ) if summary['recent_earnings']: - meaningful_earnings = [ - earning for earning in summary['recent_earnings'][:5] - if earning['amount_kopeks'] > 0 - ] + meaningful_earnings = [earning for earning in summary['recent_earnings'][:5] if earning['amount_kopeks'] > 0] if meaningful_earnings: - referral_text += texts.t( - "REFERRAL_RECENT_EARNINGS_HEADER", - "💰 Последние начисления:", - ) + "\n" + referral_text += ( + texts.t( + 'REFERRAL_RECENT_EARNINGS_HEADER', + '💰 Последние начисления:', + ) + + '\n' + ) for earning in meaningful_earnings[:3]: reason_text = { - "referral_first_topup": texts.t( - "REFERRAL_EARNING_REASON_FIRST_TOPUP", - "🎉 Первое пополнение", + 'referral_first_topup': texts.t( + 'REFERRAL_EARNING_REASON_FIRST_TOPUP', + '🎉 Первое пополнение', ), - "referral_commission_topup": texts.t( - "REFERRAL_EARNING_REASON_COMMISSION_TOPUP", - "💰 Комиссия с пополнения", + 'referral_commission_topup': texts.t( + 'REFERRAL_EARNING_REASON_COMMISSION_TOPUP', + '💰 Комиссия с пополнения', ), - "referral_commission": texts.t( - "REFERRAL_EARNING_REASON_COMMISSION_PURCHASE", - "💰 Комиссия с покупки", + 'referral_commission': texts.t( + 'REFERRAL_EARNING_REASON_COMMISSION_PURCHASE', + '💰 Комиссия с покупки', ), }.get(earning['reason'], earning['reason']) - referral_text += texts.t( - "REFERRAL_RECENT_EARNINGS_ITEM", - "• {reason}: {amount} от {referral_name}", - ).format( - reason=reason_text, - amount=texts.format_price(earning['amount_kopeks']), - referral_name=earning['referral_name'], - ) + "\n" - referral_text += "\n" + referral_text += ( + texts.t( + 'REFERRAL_RECENT_EARNINGS_ITEM', + '• {reason}: {amount} от {referral_name}', + ).format( + reason=reason_text, + amount=texts.format_price(earning['amount_kopeks']), + referral_name=earning['referral_name'], + ) + + '\n' + ) + referral_text += '\n' if summary['earnings_by_type']: - referral_text += texts.t( - "REFERRAL_EARNINGS_BY_TYPE_HEADER", - "📈 Доходы по типам:", - ) + "\n" + referral_text += ( + texts.t( + 'REFERRAL_EARNINGS_BY_TYPE_HEADER', + '📈 Доходы по типам:', + ) + + '\n' + ) if 'referral_first_topup' in summary['earnings_by_type']: data = summary['earnings_by_type']['referral_first_topup'] if data['total_amount_kopeks'] > 0: - referral_text += texts.t( - "REFERRAL_EARNINGS_FIRST_TOPUPS", - "• Бонусы за первые пополнения: {count} ({amount})", - ).format( - count=data['count'], - amount=texts.format_price(data['total_amount_kopeks']), - ) + "\n" + referral_text += ( + texts.t( + 'REFERRAL_EARNINGS_FIRST_TOPUPS', + '• Бонусы за первые пополнения: {count} ({amount})', + ).format( + count=data['count'], + amount=texts.format_price(data['total_amount_kopeks']), + ) + + '\n' + ) if 'referral_commission_topup' in summary['earnings_by_type']: data = summary['earnings_by_type']['referral_commission_topup'] if data['total_amount_kopeks'] > 0: - referral_text += texts.t( - "REFERRAL_EARNINGS_TOPUPS", - "• Комиссии с пополнений: {count} ({amount})", - ).format( - count=data['count'], - amount=texts.format_price(data['total_amount_kopeks']), - ) + "\n" + referral_text += ( + texts.t( + 'REFERRAL_EARNINGS_TOPUPS', + '• Комиссии с пополнений: {count} ({amount})', + ).format( + count=data['count'], + amount=texts.format_price(data['total_amount_kopeks']), + ) + + '\n' + ) if 'referral_commission' in summary['earnings_by_type']: data = summary['earnings_by_type']['referral_commission'] if data['total_amount_kopeks'] > 0: - referral_text += texts.t( - "REFERRAL_EARNINGS_PURCHASES", - "• Комиссии с покупок: {count} ({amount})", - ).format( - count=data['count'], - amount=texts.format_price(data['total_amount_kopeks']), - ) + "\n" + referral_text += ( + texts.t( + 'REFERRAL_EARNINGS_PURCHASES', + '• Комиссии с покупок: {count} ({amount})', + ).format( + count=data['count'], + amount=texts.format_price(data['total_amount_kopeks']), + ) + + '\n' + ) - referral_text += "\n" + referral_text += '\n' referral_text += texts.t( - "REFERRAL_INVITE_FOOTER", - "📢 Приглашайте друзей и зарабатывайте!", + 'REFERRAL_INVITE_FOOTER', + '📢 Приглашайте друзей и зарабатывайте!', ) await edit_or_answer_photo( @@ -209,19 +218,19 @@ async def show_referral_qr( texts = get_texts(db_user.language) bot_username = (await callback.bot.get_me()).username - referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" + referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}' - qr_dir = Path("data") / "referral_qr" + qr_dir = Path('data') / 'referral_qr' qr_dir.mkdir(parents=True, exist_ok=True) - file_path = qr_dir / f"{db_user.id}.png" + file_path = qr_dir / f'{db_user.id}.png' if not file_path.exists(): img = qrcode.make(referral_link) img.save(file_path) photo = FSInputFile(file_path) keyboard = types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] ) try: @@ -229,8 +238,8 @@ async def show_referral_qr( types.InputMediaPhoto( media=photo, caption=texts.t( - "REFERRAL_LINK_CAPTION", - "🔗 Ваша реферальная ссылка:\n{link}", + 'REFERRAL_LINK_CAPTION', + '🔗 Ваша реферальная ссылка:\n{link}', ).format(link=referral_link), ), reply_markup=keyboard, @@ -240,19 +249,14 @@ async def show_referral_qr( await callback.message.answer_photo( photo, caption=texts.t( - "REFERRAL_LINK_CAPTION", - "🔗 Ваша реферальная ссылка:\n{link}", + 'REFERRAL_LINK_CAPTION', + '🔗 Ваша реферальная ссылка:\n{link}', ).format(link=referral_link), reply_markup=keyboard, ) -async def show_detailed_referral_list( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - page: int = 1 -): +async def show_detailed_referral_list(callback: types.CallbackQuery, db_user: User, db: AsyncSession, page: int = 1): texts = get_texts(db_user.language) referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10) @@ -261,82 +265,102 @@ async def show_detailed_referral_list( await edit_or_answer_photo( callback, texts.t( - "REFERRAL_LIST_EMPTY", - "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!", + 'REFERRAL_LIST_EMPTY', + '📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!', ), types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]] + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] ), parse_mode=None, ) await callback.answer() return - text = texts.t( - "REFERRAL_LIST_HEADER", - "👥 Ваши рефералы (стр. {current}/{total})", - ).format( - current=referrals_data['current_page'], - total=referrals_data['total_pages'], - ) + "\n\n" - + text = ( + texts.t( + 'REFERRAL_LIST_HEADER', + '👥 Ваши рефералы (стр. {current}/{total})', + ).format( + current=referrals_data['current_page'], + total=referrals_data['total_pages'], + ) + + '\n\n' + ) + for i, referral in enumerate(referrals_data['referrals'], 1): - status_emoji = "🟢" if referral['status'] == 'active' else "🔴" - - topup_emoji = "💰" if referral['has_made_first_topup'] else "⏳" - - text += texts.t( - "REFERRAL_LIST_ITEM_HEADER", - "{index}. {status} {name}", - ).format(index=i, status=status_emoji, name=referral['full_name']) + "\n" - text += texts.t( - "REFERRAL_LIST_ITEM_TOPUPS", - " {emoji} Пополнений: {count}", - ).format(emoji=topup_emoji, count=referral['topups_count']) + "\n" - text += texts.t( - "REFERRAL_LIST_ITEM_EARNED", - " 💎 Заработано с него: {amount}", - ).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n" - text += texts.t( - "REFERRAL_LIST_ITEM_REGISTERED", - " 📅 Регистрация: {days} дн. назад", - ).format(days=referral['days_since_registration']) + "\n" + status_emoji = '🟢' if referral['status'] == 'active' else '🔴' + + topup_emoji = '💰' if referral['has_made_first_topup'] else '⏳' + + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_HEADER', + '{index}. {status} {name}', + ).format(index=i, status=status_emoji, name=referral['full_name']) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_TOPUPS', + ' {emoji} Пополнений: {count}', + ).format(emoji=topup_emoji, count=referral['topups_count']) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_EARNED', + ' 💎 Заработано с него: {amount}', + ).format(amount=texts.format_price(referral['total_earned_kopeks'])) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_REGISTERED', + ' 📅 Регистрация: {days} дн. назад', + ).format(days=referral['days_since_registration']) + + '\n' + ) if referral['days_since_activity'] is not None: - text += texts.t( - "REFERRAL_LIST_ITEM_ACTIVITY", - " 🕐 Активность: {days} дн. назад", - ).format(days=referral['days_since_activity']) + "\n" + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_ACTIVITY', + ' 🕐 Активность: {days} дн. назад', + ).format(days=referral['days_since_activity']) + + '\n' + ) else: - text += texts.t( - "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO", - " 🕐 Активность: давно", - ) + "\n" - - text += "\n" - + text += ( + texts.t( + 'REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO', + ' 🕐 Активность: давно', + ) + + '\n' + ) + + text += '\n' + keyboard = [] nav_buttons = [] - + if referrals_data['has_prev']: - nav_buttons.append(types.InlineKeyboardButton( - text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"), - callback_data=f"referral_list_page_{page - 1}" - )) + nav_buttons.append( + types.InlineKeyboardButton( + text=texts.t('REFERRAL_LIST_PREV_PAGE', '⬅️ Назад'), callback_data=f'referral_list_page_{page - 1}' + ) + ) if referrals_data['has_next']: - nav_buttons.append(types.InlineKeyboardButton( - text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"), - callback_data=f"referral_list_page_{page + 1}" - )) - + nav_buttons.append( + types.InlineKeyboardButton( + text=texts.t('REFERRAL_LIST_NEXT_PAGE', 'Вперед ➡️'), callback_data=f'referral_list_page_{page + 1}' + ) + ) + if nav_buttons: keyboard.append(nav_buttons) - - keyboard.append([types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_referrals" - )]) + + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]) await edit_or_answer_photo( callback, @@ -346,142 +370,148 @@ async def show_detailed_referral_list( await callback.answer() -async def show_referral_analytics( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_referral_analytics(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) analytics = await get_referral_analytics(db, db_user.id) - text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 Аналитика рефералов") + "\n\n" + text = texts.t('REFERRAL_ANALYTICS_TITLE', '📊 Аналитика рефералов') + '\n\n' - text += texts.t( - "REFERRAL_ANALYTICS_EARNINGS_HEADER", - "💰 Доходы по периодам:", - ) + "\n" - text += texts.t( - "REFERRAL_ANALYTICS_EARNINGS_TODAY", - "• Сегодня: {amount}", - ).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n" - text += texts.t( - "REFERRAL_ANALYTICS_EARNINGS_WEEK", - "• За неделю: {amount}", - ).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n" - text += texts.t( - "REFERRAL_ANALYTICS_EARNINGS_MONTH", - "• За месяц: {amount}", - ).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n" - text += texts.t( - "REFERRAL_ANALYTICS_EARNINGS_QUARTER", - "• За квартал: {amount}", - ).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n" + text += ( + texts.t( + 'REFERRAL_ANALYTICS_EARNINGS_HEADER', + '💰 Доходы по периодам:', + ) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_ANALYTICS_EARNINGS_TODAY', + '• Сегодня: {amount}', + ).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_ANALYTICS_EARNINGS_WEEK', + '• За неделю: {amount}', + ).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_ANALYTICS_EARNINGS_MONTH', + '• За месяц: {amount}', + ).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + + '\n' + ) + text += ( + texts.t( + 'REFERRAL_ANALYTICS_EARNINGS_QUARTER', + '• За квартал: {amount}', + ).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + + '\n\n' + ) if analytics['top_referrals']: - text += texts.t( - "REFERRAL_ANALYTICS_TOP_TITLE", - "🏆 Топ-{count} рефералов:", - ).format(count=len(analytics['top_referrals'])) + "\n" + text += ( + texts.t( + 'REFERRAL_ANALYTICS_TOP_TITLE', + '🏆 Топ-{count} рефералов:', + ).format(count=len(analytics['top_referrals'])) + + '\n' + ) for i, ref in enumerate(analytics['top_referrals'], 1): - text += texts.t( - "REFERRAL_ANALYTICS_TOP_ITEM", - "{index}. {name}: {amount} ({count} начислений)", - ).format( - index=i, - name=ref['referral_name'], - amount=texts.format_price(ref['total_earned_kopeks']), - count=ref['earnings_count'], - ) + "\n" - text += "\n" + text += ( + texts.t( + 'REFERRAL_ANALYTICS_TOP_ITEM', + '{index}. {name}: {amount} ({count} начислений)', + ).format( + index=i, + name=ref['referral_name'], + amount=texts.format_price(ref['total_earned_kopeks']), + count=ref['earnings_count'], + ) + + '\n' + ) + text += '\n' text += texts.t( - "REFERRAL_ANALYTICS_FOOTER", - "📈 Продолжайте развивать свою реферальную сеть!", + 'REFERRAL_ANALYTICS_FOOTER', + '📈 Продолжайте развивать свою реферальную сеть!', ) await edit_or_answer_photo( callback, text, - types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")] - ]), + types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] + ), ) await callback.answer() -async def create_invite_message( - callback: types.CallbackQuery, - db_user: User -): +async def create_invite_message(callback: types.CallbackQuery, db_user: User): texts = get_texts(db_user.language) bot_username = (await callback.bot.get_me()).username - referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}" + referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}' invite_text = ( - texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!") - + "\n\n" + texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + + '\n\n' + texts.t( - "REFERRAL_INVITE_BONUS", - "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", + 'REFERRAL_INVITE_BONUS', + '💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!', ).format( minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS), bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS), ) - + "\n\n" - + texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение") - + "\n" - + texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру") - + "\n" - + texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита") - + "\n\n" - + texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:") - + f"\n{referral_link}" + + '\n\n' + + texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение') + + '\n' + + texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру') + + '\n' + + texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита') + + '\n\n' + + texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:') + + f'\n{referral_link}' ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"), - switch_inline_query=invite_text - )], - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_referrals" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=invite_text + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')], + ] + ) await edit_or_answer_photo( callback, ( - texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 Приглашение создано!") - + "\n\n" + texts.t('REFERRAL_INVITE_CREATED_TITLE', '📝 Приглашение создано!') + + '\n\n' + texts.t( - "REFERRAL_INVITE_CREATED_INSTRUCTION", - "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + 'REFERRAL_INVITE_CREATED_INSTRUCTION', + 'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:', ) - + "\n\n" - f"{invite_text}" + + '\n\n' + f'{invite_text}' ), keyboard, ) await callback.answer() -async def show_withdrawal_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def show_withdrawal_info(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): """Показывает информацию о выводе реферального баланса.""" texts = get_texts(db_user.language) if not settings.is_referral_withdrawal_enabled(): - await callback.answer( - texts.t("REFERRAL_WITHDRAWAL_DISABLED", "Функция вывода отключена"), - show_alert=True - ) + await callback.answer(texts.t('REFERRAL_WITHDRAWAL_DISABLED', 'Функция вывода отключена'), show_alert=True) return # Получаем детальную статистику баланса @@ -492,54 +522,45 @@ async def show_withdrawal_info( # Проверяем возможность вывода can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, db_user.id) - text = texts.t("REFERRAL_WITHDRAWAL_TITLE", "💸 Вывод реферального баланса") + "\n\n" + text = texts.t('REFERRAL_WITHDRAWAL_TITLE', '💸 Вывод реферального баланса') + '\n\n' # Показываем детальную статистику text += referral_withdrawal_service.format_balance_stats_for_user(stats, texts) - text += "\n" + text += '\n' - text += texts.t( - "REFERRAL_WITHDRAWAL_MIN_AMOUNT", - "📊 Минимальная сумма: {amount}" - ).format(amount=texts.format_price(min_amount)) + "\n" - text += texts.t( - "REFERRAL_WITHDRAWAL_COOLDOWN", - "⏱ Частота вывода: раз в {days} дней" - ).format(days=cooldown_days) + "\n\n" + text += ( + texts.t('REFERRAL_WITHDRAWAL_MIN_AMOUNT', '📊 Минимальная сумма: {amount}').format( + amount=texts.format_price(min_amount) + ) + + '\n' + ) + text += ( + texts.t('REFERRAL_WITHDRAWAL_COOLDOWN', '⏱ Частота вывода: раз в {days} дней').format(days=cooldown_days) + + '\n\n' + ) keyboard = [] if can_request: - text += texts.t( - "REFERRAL_WITHDRAWAL_READY", - "✅ Вы можете запросить вывод средств" - ) + "\n" - keyboard.append([types.InlineKeyboardButton( - text=texts.t("REFERRAL_WITHDRAWAL_REQUEST_BUTTON", "📝 Оформить заявку"), - callback_data="referral_withdrawal_start" - )]) + text += texts.t('REFERRAL_WITHDRAWAL_READY', '✅ Вы можете запросить вывод средств') + '\n' + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t('REFERRAL_WITHDRAWAL_REQUEST_BUTTON', '📝 Оформить заявку'), + callback_data='referral_withdrawal_start', + ) + ] + ) else: - text += f"❌ {reason}\n" + text += f'❌ {reason}\n' - keyboard.append([types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_referrals" - )]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]) - await edit_or_answer_photo( - callback, - text, - types.InlineKeyboardMarkup(inline_keyboard=keyboard) - ) + await edit_or_answer_photo(callback, text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)) await callback.answer() -async def start_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def start_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): """Начинает процесс оформления заявки на вывод.""" texts = get_texts(db_user.language) @@ -556,62 +577,59 @@ async def start_withdrawal_request( await state.set_state(ReferralWithdrawalStates.waiting_for_amount) text = texts.t( - "REFERRAL_WITHDRAWAL_ENTER_AMOUNT", - "💸 Введите сумму для вывода в рублях\n\nДоступно: {amount}" + 'REFERRAL_WITHDRAWAL_ENTER_AMOUNT', '💸 Введите сумму для вывода в рублях\n\nДоступно: {amount}' ).format(amount=texts.format_price(available)) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("REFERRAL_WITHDRAWAL_ALL", f"Вывести всё ({available / 100:.0f}₽)"), - callback_data=f"referral_withdrawal_amount_{available}" - )], - [types.InlineKeyboardButton( - text=texts.t("CANCEL", "❌ Отмена"), - callback_data="referral_withdrawal_cancel" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('REFERRAL_WITHDRAWAL_ALL', f'Вывести всё ({available / 100:.0f}₽)'), + callback_data=f'referral_withdrawal_amount_{available}', + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('CANCEL', '❌ Отмена'), callback_data='referral_withdrawal_cancel' + ) + ], + ] + ) await edit_or_answer_photo(callback, text, keyboard) await callback.answer() -async def process_withdrawal_amount( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_withdrawal_amount(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): """Обрабатывает ввод суммы для вывода.""" texts = get_texts(db_user.language) data = await state.get_data() - available = data.get("available_balance", 0) + available = data.get('available_balance', 0) try: # Парсим сумму (в рублях) - amount_text = message.text.strip().replace(",", ".").replace("₽", "").replace(" ", "") + amount_text = message.text.strip().replace(',', '.').replace('₽', '').replace(' ', '') amount_rubles = float(amount_text) amount_kopeks = int(amount_rubles * 100) if amount_kopeks <= 0: - await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите положительную сумму")) + await message.answer(texts.t('REFERRAL_WITHDRAWAL_INVALID_AMOUNT', '❌ Введите положительную сумму')) return min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS if amount_kopeks < min_amount: await message.answer( - texts.t( - "REFERRAL_WITHDRAWAL_MIN_ERROR", - "❌ Минимальная сумма: {amount}" - ).format(amount=texts.format_price(min_amount)) + texts.t('REFERRAL_WITHDRAWAL_MIN_ERROR', '❌ Минимальная сумма: {amount}').format( + amount=texts.format_price(min_amount) + ) ) return if amount_kopeks > available: await message.answer( - texts.t( - "REFERRAL_WITHDRAWAL_INSUFFICIENT", - "❌ Недостаточно средств. Доступно: {amount}" - ).format(amount=texts.format_price(available)) + texts.t('REFERRAL_WITHDRAWAL_INSUFFICIENT', '❌ Недостаточно средств. Доступно: {amount}').format( + amount=texts.format_price(available) + ) ) return @@ -620,120 +638,113 @@ async def process_withdrawal_amount( await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details) text = texts.t( - "REFERRAL_WITHDRAWAL_ENTER_DETAILS", - "💳 Введите реквизиты для перевода:\n\n" - "Например:\n" - "• СБП: +7 999 123-45-67 (Сбербанк)" + 'REFERRAL_WITHDRAWAL_ENTER_DETAILS', + '💳 Введите реквизиты для перевода:\n\nНапример:\n• СБП: +7 999 123-45-67 (Сбербанк)', ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("CANCEL", "❌ Отмена"), - callback_data="referral_withdrawal_cancel" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CANCEL', '❌ Отмена'), callback_data='referral_withdrawal_cancel' + ) + ] + ] + ) await message.answer(text, reply_markup=keyboard) except ValueError: - await message.answer(texts.t("REFERRAL_WITHDRAWAL_INVALID_AMOUNT", "❌ Введите корректную сумму")) + await message.answer(texts.t('REFERRAL_WITHDRAWAL_INVALID_AMOUNT', '❌ Введите корректную сумму')) async def process_withdrawal_amount_callback( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): """Обрабатывает выбор суммы для вывода через кнопку.""" texts = get_texts(db_user.language) # Получаем сумму из callback_data - amount_kopeks = int(callback.data.split("_")[-1]) + amount_kopeks = int(callback.data.split('_')[-1]) # Сохраняем сумму и переходим к вводу реквизитов await state.update_data(withdrawal_amount=amount_kopeks) await state.set_state(ReferralWithdrawalStates.waiting_for_payment_details) text = texts.t( - "REFERRAL_WITHDRAWAL_ENTER_DETAILS", - "💳 Введите реквизиты для перевода:\n\n" - "Например:\n" - "• СБП: +7 999 123-45-67 (Сбербанк)" + 'REFERRAL_WITHDRAWAL_ENTER_DETAILS', + '💳 Введите реквизиты для перевода:\n\nНапример:\n• СБП: +7 999 123-45-67 (Сбербанк)', ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("CANCEL", "❌ Отмена"), - callback_data="referral_withdrawal_cancel" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CANCEL', '❌ Отмена'), callback_data='referral_withdrawal_cancel' + ) + ] + ] + ) await edit_or_answer_photo(callback, text, keyboard) await callback.answer() -async def process_payment_details( - message: types.Message, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def process_payment_details(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext): """Обрабатывает ввод реквизитов и показывает подтверждение.""" texts = get_texts(db_user.language) data = await state.get_data() - amount_kopeks = data.get("withdrawal_amount", 0) + amount_kopeks = data.get('withdrawal_amount', 0) payment_details = message.text.strip() if len(payment_details) < 10: - await message.answer( - texts.t("REFERRAL_WITHDRAWAL_DETAILS_TOO_SHORT", "❌ Реквизиты слишком короткие") - ) + await message.answer(texts.t('REFERRAL_WITHDRAWAL_DETAILS_TOO_SHORT', '❌ Реквизиты слишком короткие')) return # Сохраняем реквизиты await state.update_data(payment_details=payment_details) await state.set_state(ReferralWithdrawalStates.confirming) - text = texts.t("REFERRAL_WITHDRAWAL_CONFIRM_TITLE", "📋 Подтверждение заявки") + "\n\n" - text += texts.t( - "REFERRAL_WITHDRAWAL_CONFIRM_AMOUNT", - "💰 Сумма: {amount}" - ).format(amount=texts.format_price(amount_kopeks)) + "\n\n" - text += texts.t( - "REFERRAL_WITHDRAWAL_CONFIRM_DETAILS", - "💳 Реквизиты:\n{details}" - ).format(details=payment_details) + "\n\n" - text += texts.t( - "REFERRAL_WITHDRAWAL_CONFIRM_WARNING", - "⚠️ После отправки заявка будет рассмотрена администрацией" + text = texts.t('REFERRAL_WITHDRAWAL_CONFIRM_TITLE', '📋 Подтверждение заявки') + '\n\n' + text += ( + texts.t('REFERRAL_WITHDRAWAL_CONFIRM_AMOUNT', '💰 Сумма: {amount}').format( + amount=texts.format_price(amount_kopeks) + ) + + '\n\n' ) + text += ( + texts.t('REFERRAL_WITHDRAWAL_CONFIRM_DETAILS', '💳 Реквизиты:\n{details}').format( + details=payment_details + ) + + '\n\n' + ) + text += texts.t('REFERRAL_WITHDRAWAL_CONFIRM_WARNING', '⚠️ После отправки заявка будет рассмотрена администрацией') - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("REFERRAL_WITHDRAWAL_CONFIRM_BUTTON", "✅ Подтвердить"), - callback_data="referral_withdrawal_confirm" - )], - [types.InlineKeyboardButton( - text=texts.t("CANCEL", "❌ Отмена"), - callback_data="referral_withdrawal_cancel" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('REFERRAL_WITHDRAWAL_CONFIRM_BUTTON', '✅ Подтвердить'), + callback_data='referral_withdrawal_confirm', + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('CANCEL', '❌ Отмена'), callback_data='referral_withdrawal_cancel' + ) + ], + ] + ) await message.answer(text, reply_markup=keyboard) -async def confirm_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): +async def confirm_withdrawal_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): """Подтверждает и создаёт заявку на вывод.""" texts = get_texts(db_user.language) data = await state.get_data() - amount_kopeks = data.get("withdrawal_amount", 0) - payment_details = data.get("payment_details", "") + amount_kopeks = data.get('withdrawal_amount', 0) + payment_details = data.get('payment_details', '') await state.clear() @@ -743,13 +754,13 @@ async def confirm_withdrawal_request( ) if error: - await callback.answer(f"❌ {error}", show_alert=True) + await callback.answer(f'❌ {error}', show_alert=True) return # Отправляем уведомление админам analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} - user_id_display = db_user.telegram_id or db_user.email or f"#{db_user.id}" + user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' admin_text = f""" 🔔 Новая заявка на вывод #{request.id} @@ -766,31 +777,25 @@ async def confirm_withdrawal_request( # Формируем клавиатуру - кнопка профиля только для Telegram-пользователей keyboard_rows = [ [ - types.InlineKeyboardButton( - text="✅ Одобрить", - callback_data=f"admin_withdrawal_approve_{request.id}" - ), - types.InlineKeyboardButton( - text="❌ Отклонить", - callback_data=f"admin_withdrawal_reject_{request.id}" - ) + types.InlineKeyboardButton(text='✅ Одобрить', callback_data=f'admin_withdrawal_approve_{request.id}'), + types.InlineKeyboardButton(text='❌ Отклонить', callback_data=f'admin_withdrawal_reject_{request.id}'), ] ] if db_user.telegram_id: - keyboard_rows.append([types.InlineKeyboardButton( - text="👤 Профиль пользователя", - callback_data=f"admin_user_{db_user.telegram_id}" - )]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='👤 Профиль пользователя', callback_data=f'admin_user_{db_user.telegram_id}' + ) + ] + ) admin_keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) try: notification_service = AdminNotificationService(callback.bot) - await notification_service.send_to_admins( - admin_text, - keyboard=admin_keyboard - ) + await notification_service.send_to_admins(admin_text, keyboard=admin_keyboard) except Exception as e: - logger.error(f"Ошибка отправки уведомления админам о заявке на вывод: {e}") + logger.error(f'Ошибка отправки уведомления админам о заявке на вывод: {e}') # Уведомление в топик, если настроено topic_id = settings.REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID @@ -801,125 +806,71 @@ async def confirm_withdrawal_request( message_thread_id=topic_id, text=admin_text, reply_markup=admin_keyboard, - parse_mode="HTML" + parse_mode='HTML', ) except Exception as e: - logger.error(f"Ошибка отправки уведомления в топик о заявке на вывод: {e}") + logger.error(f'Ошибка отправки уведомления в топик о заявке на вывод: {e}') # Отвечаем пользователю text = texts.t( - "REFERRAL_WITHDRAWAL_SUCCESS", - "✅ Заявка #{id} создана!\n\n" - "Сумма: {amount}\n\n" - "Ваша заявка будет рассмотрена администрацией. " - "Мы уведомим вас о результате." + 'REFERRAL_WITHDRAWAL_SUCCESS', + '✅ Заявка #{id} создана!\n\n' + 'Сумма: {amount}\n\n' + 'Ваша заявка будет рассмотрена администрацией. ' + 'Мы уведомим вас о результате.', ).format(id=request.id, amount=texts.format_price(amount_kopeks)) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_referrals" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] + ) await edit_or_answer_photo(callback, text, keyboard) await callback.answer() -async def cancel_withdrawal_request( - callback: types.CallbackQuery, - db_user: User, - state: FSMContext -): +async def cancel_withdrawal_request(callback: types.CallbackQuery, db_user: User, state: FSMContext): """Отменяет процесс создания заявки на вывод.""" await state.clear() texts = get_texts(db_user.language) - await callback.answer(texts.t("CANCELLED", "Отменено")) + await callback.answer(texts.t('CANCELLED', 'Отменено')) # Возвращаем в меню партнёрки - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_referrals" - )] - ]) - await edit_or_answer_photo( - callback, - texts.t("REFERRAL_WITHDRAWAL_CANCELLED", "❌ Заявка отменена"), - keyboard + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')]] ) + await edit_or_answer_photo(callback, texts.t('REFERRAL_WITHDRAWAL_CANCELLED', '❌ Заявка отменена'), keyboard) def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_referral_info, F.data == 'menu_referrals') - dp.callback_query.register( - show_referral_info, - F.data == "menu_referrals" - ) - - dp.callback_query.register( - create_invite_message, - F.data == "referral_create_invite" - ) + dp.callback_query.register(create_invite_message, F.data == 'referral_create_invite') + + dp.callback_query.register(show_referral_qr, F.data == 'referral_show_qr') + + dp.callback_query.register(show_detailed_referral_list, F.data == 'referral_list') + + dp.callback_query.register(show_referral_analytics, F.data == 'referral_analytics') - dp.callback_query.register( - show_referral_qr, - F.data == "referral_show_qr" - ) - - dp.callback_query.register( - show_detailed_referral_list, - F.data == "referral_list" - ) - - dp.callback_query.register( - show_referral_analytics, - F.data == "referral_analytics" - ) - async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[-1]) await show_detailed_referral_list(callback, db_user, db, page) - dp.callback_query.register( - handle_referral_list_page, - F.data.startswith("referral_list_page_") - ) + dp.callback_query.register(handle_referral_list_page, F.data.startswith('referral_list_page_')) # Хендлеры вывода реферального баланса - dp.callback_query.register( - show_withdrawal_info, - F.data == "referral_withdrawal" - ) + dp.callback_query.register(show_withdrawal_info, F.data == 'referral_withdrawal') - dp.callback_query.register( - start_withdrawal_request, - F.data == "referral_withdrawal_start" - ) + dp.callback_query.register(start_withdrawal_request, F.data == 'referral_withdrawal_start') - dp.callback_query.register( - process_withdrawal_amount_callback, - F.data.startswith("referral_withdrawal_amount_") - ) + dp.callback_query.register(process_withdrawal_amount_callback, F.data.startswith('referral_withdrawal_amount_')) - dp.callback_query.register( - confirm_withdrawal_request, - F.data == "referral_withdrawal_confirm" - ) + dp.callback_query.register(confirm_withdrawal_request, F.data == 'referral_withdrawal_confirm') - dp.callback_query.register( - cancel_withdrawal_request, - F.data == "referral_withdrawal_cancel" - ) + dp.callback_query.register(cancel_withdrawal_request, F.data == 'referral_withdrawal_cancel') # Обработка текстового ввода суммы - dp.message.register( - process_withdrawal_amount, - ReferralWithdrawalStates.waiting_for_amount - ) + dp.message.register(process_withdrawal_amount, ReferralWithdrawalStates.waiting_for_amount) # Обработка текстового ввода реквизитов - dp.message.register( - process_payment_details, - ReferralWithdrawalStates.waiting_for_payment_details - ) + dp.message.register(process_payment_details, ReferralWithdrawalStates.waiting_for_payment_details) diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index c223dc5a..9f527a23 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -1,6 +1,5 @@ import logging from datetime import datetime -from typing import List, Tuple from aiogram import Dispatcher, F, types @@ -14,6 +13,7 @@ from app.services.server_status_service import ( ServerStatusService, ) + logger = logging.getLogger(__name__) _status_service = ServerStatusService() @@ -25,7 +25,7 @@ async def show_server_status(callback: types.CallbackQuery, db_user: User) -> No async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None: try: - _, page_str = callback.data.split(":", 1) + _, page_str = callback.data.split(':', 1) page = int(page_str) except (ValueError, AttributeError, IndexError): page = 1 @@ -40,23 +40,23 @@ async def _render_server_status( ) -> None: texts = get_texts(db_user.language) - if settings.get_server_status_mode() != "xray": - await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True) + if settings.get_server_status_mode() != 'xray': + await callback.answer(texts.t('SERVER_STATUS_NOT_CONFIGURED', 'Функция недоступна.'), show_alert=True) return try: servers = await _status_service.get_servers() except ServerStatusError as error: - logger.warning("Server status error: %s", error) + logger.warning('Server status error: %s', error) await callback.answer( - texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"), + texts.t('SERVER_STATUS_ERROR_SHORT', 'Не удалось получить данные'), show_alert=True, ) return except Exception as error: # pragma: no cover - defensive logging - logger.error("Unexpected server status error: %s", error) + logger.error('Unexpected server status error: %s', error) await callback.answer( - texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"), + texts.t('SERVER_STATUS_ERROR_SHORT', 'Не удалось получить данные'), show_alert=True, ) return @@ -73,10 +73,10 @@ async def _render_server_status( def _build_status_message( - servers: List[ServerStatusEntry], + servers: list[ServerStatusEntry], texts, page: int, -) -> Tuple[str, int, int]: +) -> tuple[str, int, int]: total_servers = len(servers) online_servers = [server for server in servers if server.is_online] offline_servers = [server for server in servers if not server.is_online] @@ -89,72 +89,72 @@ def _build_status_message( current_online, current_offline = pages[current_index] if pages else ([], []) - lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 Статус серверов")] + lines: list[str] = [texts.t('SERVER_STATUS_TITLE', '📊 Статус серверов')] if total_servers == 0: - lines.append("") - lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах.")) - message = "\n".join(lines).strip() + lines.append('') + lines.append(texts.t('SERVER_STATUS_NO_SERVERS', 'Нет данных о серверах.')) + message = '\n'.join(lines).strip() return message, 1, 1 summary = texts.t( - "SERVER_STATUS_SUMMARY", - "Всего серверов: {total} (в сети: {online}, вне сети: {offline})", + 'SERVER_STATUS_SUMMARY', + 'Всего серверов: {total} (в сети: {online}, вне сети: {offline})', ).format( total=total_servers, online=len(online_servers), offline=len(offline_servers), ) - updated_at = datetime.now().strftime("%H:%M:%S") + updated_at = datetime.now().strftime('%H:%M:%S') lines.extend( [ - "", + '', summary, - texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at), - "", + texts.t('SERVER_STATUS_UPDATED_AT', '⏱ Обновлено: {time}').format(time=updated_at), + '', ] ) if current_online: - lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ Доступны")) + lines.append(texts.t('SERVER_STATUS_AVAILABLE', '✅ Доступны')) lines.extend(_format_server_lines(current_online, texts, online=True)) - lines.append("") + lines.append('') if current_offline: - lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ Недоступны")) + lines.append(texts.t('SERVER_STATUS_UNAVAILABLE', '❌ Недоступны')) lines.extend(_format_server_lines(current_offline, texts, online=False)) - lines.append("") + lines.append('') if total_pages > 1: lines.append( - texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format( + texts.t('SERVER_STATUS_PAGINATION', 'Страница {current} из {total}').format( current=current_index + 1, total=total_pages, ) ) - message = "\n".join(line for line in lines if line is not None) + message = '\n'.join(line for line in lines if line is not None) message = message.strip() return message, total_pages, current_index + 1 def _split_into_pages( - online: List[ServerStatusEntry], - offline: List[ServerStatusEntry], + online: list[ServerStatusEntry], + offline: list[ServerStatusEntry], items_per_page: int, -) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]: +) -> list[tuple[list[ServerStatusEntry], list[ServerStatusEntry]]]: if not online and not offline: return [([], [])] - pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = [] + pages: list[tuple[list[ServerStatusEntry], list[ServerStatusEntry]]] = [] online_index = 0 offline_index = 0 while online_index < len(online) or offline_index < len(offline): - current_online: List[ServerStatusEntry] = [] - current_offline: List[ServerStatusEntry] = [] + current_online: list[ServerStatusEntry] = [] + current_offline: list[ServerStatusEntry] = [] remaining = max(1, items_per_page) while remaining > 0 and online_index < len(online): @@ -173,28 +173,26 @@ def _split_into_pages( def _format_server_lines( - servers: List[ServerStatusEntry], + servers: list[ServerStatusEntry], texts, *, online: bool, -) -> List[str]: - lines: List[str] = [] +) -> list[str]: + lines: list[str] = [] for server in servers: latency_text: str if online: if server.latency_ms and server.latency_ms > 0: - latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format( - latency=server.latency_ms - ) + latency_text = texts.t('SERVER_STATUS_LATENCY', '{latency} мс').format(latency=server.latency_ms) else: - latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных") + latency_text = texts.t('SERVER_STATUS_LATENCY_UNKNOWN', 'нет данных') else: - latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа") + latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа') name = server.display_name or server.name - flag_prefix = f"{server.flag} " if server.flag else "" - server_line = f"{flag_prefix}{name} — {latency_text}" - lines.append(f"
{server_line}
") + flag_prefix = f'{server.flag} ' if server.flag else '' + server_line = f'{flag_prefix}{name} — {latency_text}' + lines.append(f'
{server_line}
') return lines @@ -202,11 +200,10 @@ def _format_server_lines( def register_handlers(dp: Dispatcher) -> None: dp.callback_query.register( show_server_status, - F.data == "menu_server_status", + F.data == 'menu_server_status', ) dp.callback_query.register( change_server_status_page, - F.data.startswith("server_status_page:"), + F.data.startswith('server_status_page:'), ) - diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index a59145e9..63a95cb1 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -1,26 +1,29 @@ """Обработчики для простой покупки подписки.""" + import html import logging from datetime import datetime -from typing import Any, Dict, Optional, Tuple -from aiogram import types, F +from typing import Any + +from aiogram import F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import User -from app.keyboards.inline import get_back_keyboard, get_happ_download_button_row +from app.keyboards.inline import get_happ_download_button_row from app.localization.texts import get_texts from app.services.payment_service import PaymentService from app.services.subscription_purchase_service import SubscriptionPurchaseService -from app.utils.decorators import error_handler from app.states import SubscriptionStates +from app.utils.decorators import error_handler +from app.utils.pricing_utils import compute_simple_subscription_price from app.utils.subscription_utils import ( get_display_subscription_link, resolve_simple_subscription_device_limit, ) -from app.utils.pricing_utils import compute_simple_subscription_price + logger = logging.getLogger(__name__) @@ -34,30 +37,31 @@ async def start_simple_subscription_purchase( ): """Начинает процесс простой покупки подписки.""" texts = get_texts(db_user.language) - + if not settings.SIMPLE_SUBSCRIPTION_ENABLED: - await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True) + await callback.answer('❌ Простая покупка подписки временно недоступна', show_alert=True) return # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription')]) await callback.message.edit_text( - f"🚫 Покупка подписки ограничена\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Покупка подписки ограничена\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return # Проверяем, есть ли у пользователя подписка from app.database.crud.subscription import get_subscription_by_user_id + current_subscription = await get_subscription_by_user_id(db, db_user.id) device_limit = resolve_simple_subscription_device_limit() @@ -73,12 +77,12 @@ async def start_simple_subscription_purchase( # Подготовим параметры простой подписки subscription_params = { - "period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, - "device_limit": device_limit, - "traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, - "squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID + 'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, + 'device_limit': device_limit, + 'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, + 'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID, } - + # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) @@ -98,27 +102,27 @@ async def start_simple_subscription_purchase( resolved_squad_uuid=resolved_squad_uuid, ) - period_days = subscription_params["period_days"] - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + period_days = subscription_params['period_days'] + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s", + 'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s', db_user.id, period_days, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), price_kopeks, - ",".join(price_breakdown.get("resolved_squad_uuids", [])) - if price_breakdown.get("resolved_squad_uuids") - else "none", + ','.join(price_breakdown.get('resolved_squad_uuids', [])) + if price_breakdown.get('resolved_squad_uuids') + else 'none', ) can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s", + 'SIMPLE_SUBSCRIPTION_DEBUG_START_BALANCE | user=%s | balance=%s | min_required=%s | can_pay=%s', db_user.id, user_balance_kopeks, price_kopeks, @@ -127,21 +131,25 @@ async def start_simple_subscription_purchase( # Проверяем, является ли у пользователя текущая подписка активной платной подпиской has_active_paid_subscription = False - trial_notice = "" + trial_notice = '' if current_subscription: - if not getattr(current_subscription, "is_trial", False) and current_subscription.is_active: + if not getattr(current_subscription, 'is_trial', False) and current_subscription.is_active: # Это платная активная подписка - требуем подтверждение has_active_paid_subscription = True - elif getattr(current_subscription, "is_trial", False): + elif getattr(current_subscription, 'is_trial', False): # Это тестовая подписка try: days_left = max(0, (current_subscription.end_date - datetime.utcnow()).days) except Exception: days_left = 0 - key = "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE" if current_subscription.is_active else "SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL" + key = ( + 'SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE' + if current_subscription.is_active + else 'SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL' + ) trial_notice = texts.t( key, - "ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.", + 'ℹ️ У вас уже есть триальная подписка. Она истекает через {days} дн.', ).format(days=days_left) server_label = _get_simple_subscription_server_label( @@ -152,86 +160,80 @@ async def start_simple_subscription_purchase( show_devices = settings.is_devices_selection_enabled() message_lines = [ - "⚡ Простая покупка подписки", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '⚡ Простая покупка подписки', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if show_devices: - message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + message_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - traffic_limit_gb = subscription_params["traffic_limit_gb"] - traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ" + traffic_limit_gb = subscription_params['traffic_limit_gb'] + traffic_label = 'Безлимит' if traffic_limit_gb == 0 else f'{traffic_limit_gb} ГБ' - message_lines.extend([ - f"📊 Трафик: {traffic_label}", - f"🌍 Сервер: {server_label}", - "", - f"💰 Стоимость: {settings.format_price(price_kopeks)}", - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", - "", - ]) + message_lines.extend( + [ + f'📊 Трафик: {traffic_label}', + f'🌍 Сервер: {server_label}', + '', + f'💰 Стоимость: {settings.format_price(price_kopeks)}', + f'💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}', + '', + ] + ) # Если у пользователя уже есть активная платная подписка, требуем подтверждение if has_active_paid_subscription: # У пользователя уже есть активная платная подписка message_lines.append( - "⚠️ У вас уже есть активная платная подписка. " - "Покупка простой подписки изменит параметры вашей текущей подписки. " - "Требуется подтверждение." + '⚠️ У вас уже есть активная платная подписка. ' + 'Покупка простой подписки изменит параметры вашей текущей подписки. ' + 'Требуется подтверждение.' ) - message_text = "\n".join(message_lines) + message_text = '\n'.join(message_lines) # Клавиатура с подтверждением keyboard_rows = [ - [types.InlineKeyboardButton( - text="✅ Подтвердить покупку", - callback_data="simple_subscription_confirm_purchase" - )], - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_purchase" - )] + [ + types.InlineKeyboardButton( + text='✅ Подтвердить покупку', callback_data='simple_subscription_confirm_purchase' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) else: # У пользователя нет активной платной подписки (или есть только пробная) # Показываем стандартный выбор метода оплаты if can_pay_from_balance: - message_lines.append( - "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты." - ) + message_lines.append('Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.') else: - message_lines.append( - "Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:" - ) - - message_text = "\n".join(message_lines) - + message_lines.append('Баланс пока недостаточный для мгновенной оплаты. Выберите подходящий способ оплаты:') + + message_text = '\n'.join(message_lines) + if trial_notice: - message_text = f"{trial_notice}\n\n{message_text}" + message_text = f'{trial_notice}\n\n{message_text}' methods_keyboard = _get_simple_subscription_payment_keyboard(db_user.language) keyboard_rows = [] if can_pay_from_balance: - keyboard_rows.append([ - types.InlineKeyboardButton( - text="✅ Оплатить с баланса", - callback_data="simple_subscription_pay_with_balance", - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='✅ Оплатить с баланса', + callback_data='simple_subscription_pay_with_balance', + ) + ] + ) keyboard_rows.extend(methods_keyboard.inline_keyboard) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) - + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') + # Устанавливаем соответствующее состояние if has_active_paid_subscription: await state.set_state(SubscriptionStates.waiting_for_simple_subscription_confirmation) @@ -244,9 +246,9 @@ async def _calculate_simple_subscription_price( db: AsyncSession, params: dict, *, - user: Optional[User] = None, - resolved_squad_uuid: Optional[str] = None, -) -> Tuple[int, Dict[str, Any]]: + user: User | None = None, + resolved_squad_uuid: str | None = None, +) -> tuple[int, dict[str, Any]]: """Рассчитывает цену простой подписки.""" resolved_uuids = [resolved_squad_uuid] if resolved_squad_uuid else None @@ -262,105 +264,88 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb """Создает клавиатуру с методами оплаты для простой подписки.""" texts = get_texts(language) keyboard = [] - + # Добавляем доступные методы оплаты if settings.TELEGRAM_STARS_ENABLED: - keyboard.append([types.InlineKeyboardButton( - text="⭐ Telegram Stars", - callback_data="simple_subscription_stars" - )]) - + keyboard.append( + [types.InlineKeyboardButton(text='⭐ Telegram Stars', callback_data='simple_subscription_stars')] + ) + if settings.is_yookassa_enabled(): yookassa_methods = [] if settings.YOOKASSA_SBP_ENABLED: - yookassa_methods.append(types.InlineKeyboardButton( - text="🏦 YooKassa (СБП)", - callback_data="simple_subscription_yookassa_sbp" - )) - yookassa_methods.append(types.InlineKeyboardButton( - text="💳 YooKassa (Карта)", - callback_data="simple_subscription_yookassa" - )) + yookassa_methods.append( + types.InlineKeyboardButton(text='🏦 YooKassa (СБП)', callback_data='simple_subscription_yookassa_sbp') + ) + yookassa_methods.append( + types.InlineKeyboardButton(text='💳 YooKassa (Карта)', callback_data='simple_subscription_yookassa') + ) if yookassa_methods: keyboard.append(yookassa_methods) - + if settings.is_cryptobot_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="🪙 CryptoBot", - callback_data="simple_subscription_cryptobot" - )]) + keyboard.append( + [types.InlineKeyboardButton(text='🪙 CryptoBot', callback_data='simple_subscription_cryptobot')] + ) if settings.is_heleket_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="🪙 Heleket", - callback_data="simple_subscription_heleket" - )]) - + keyboard.append([types.InlineKeyboardButton(text='🪙 Heleket', callback_data='simple_subscription_heleket')]) + if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() - keyboard.append([types.InlineKeyboardButton( - text=f"💳 {mulenpay_name}", - callback_data="simple_subscription_mulenpay" - )]) - + keyboard.append( + [types.InlineKeyboardButton(text=f'💳 {mulenpay_name}', callback_data='simple_subscription_mulenpay')] + ) + if settings.is_pal24_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="💳 PayPalych", - callback_data="simple_subscription_pal24" - )]) - + keyboard.append([types.InlineKeyboardButton(text='💳 PayPalych', callback_data='simple_subscription_pal24')]) + if settings.is_wata_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="💳 WATA", - callback_data="simple_subscription_wata" - )]) - + keyboard.append([types.InlineKeyboardButton(text='💳 WATA', callback_data='simple_subscription_wata')]) + # Кнопка назад - keyboard.append([types.InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_purchase" - )]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')]) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) def _get_simple_subscription_server_label( texts, - subscription_params: Dict[str, Any], - resolved_squad_uuid: Optional[str] = None, + subscription_params: dict[str, Any], + resolved_squad_uuid: str | None = None, ) -> str: """Возвращает локализованное описание выбранного сервера.""" - if subscription_params.get("squad_uuid"): - return texts.t("SIMPLE_SUBSCRIPTION_SERVER_SELECTED", "Выбранный") + if subscription_params.get('squad_uuid'): + return texts.t('SIMPLE_SUBSCRIPTION_SERVER_SELECTED', 'Выбранный') if resolved_squad_uuid: return texts.t( - "SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED", - "Назначен автоматически", + 'SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED', + 'Назначен автоматически', ) - return texts.t("SIMPLE_SUBSCRIPTION_SERVER_ANY", "Любой доступный") + return texts.t('SIMPLE_SUBSCRIPTION_SERVER_ANY', 'Любой доступный') async def _ensure_simple_subscription_squad_uuid( db: AsyncSession, state: FSMContext, - subscription_params: Dict[str, Any], + subscription_params: dict[str, Any], *, - user_id: Optional[int] = None, - state_data: Optional[Dict[str, Any]] = None, -) -> Optional[str]: + user_id: int | None = None, + state_data: dict[str, Any] | None = None, +) -> str | None: """Определяет UUID сквада для простой подписки.""" - explicit_uuid = subscription_params.get("squad_uuid") + explicit_uuid = subscription_params.get('squad_uuid') if explicit_uuid: return explicit_uuid if state_data is None: state_data = await state.get_data() - resolved_uuid = state_data.get("resolved_squad_uuid") + resolved_uuid = state_data.get('resolved_squad_uuid') if resolved_uuid: return resolved_uuid @@ -370,7 +355,7 @@ async def _ensure_simple_subscription_squad_uuid( resolved_uuid = await get_random_active_squad_uuid(db) except Exception as error: # pragma: no cover - defensive logging logger.error( - "SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ERROR | user=%s | error=%s", + 'SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ERROR | user=%s | error=%s', user_id, error, ) @@ -379,7 +364,7 @@ async def _ensure_simple_subscription_squad_uuid( if resolved_uuid: await state.update_data(resolved_squad_uuid=resolved_uuid) logger.info( - "SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ASSIGNED | user=%s | squad=%s", + 'SIMPLE_SUBSCRIPTION_RANDOM_SQUAD_ASSIGNED | user=%s | squad=%s', user_id, resolved_uuid, ) @@ -396,21 +381,24 @@ async def handle_simple_subscription_pay_with_balance( ): """Обрабатывает оплату простой подписки с баланса.""" texts = get_texts(db_user.language) - + data = await state.get_data() - subscription_params = data.get("subscription_params", {}) - + subscription_params = data.get('subscription_params', {}) + if not subscription_params: - await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) + await callback.answer('❌ Данные подписки устарели. Пожалуйста, начните сначала.', show_alert=True) return # Проверяем, имеет ли пользователь активную платную подписку from app.database.crud.subscription import get_subscription_by_user_id + current_subscription = await get_subscription_by_user_id(db, db_user.id) - - if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active: + + if current_subscription and not getattr(current_subscription, 'is_trial', False) and current_subscription.is_active: # У пользователя есть активная платная подписка - требуем подтверждение - await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку.", show_alert=True) + await callback.answer( + '⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку.', show_alert=True + ) return resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( @@ -430,111 +418,116 @@ async def handle_simple_subscription_pay_with_balance( ) total_required = price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", + 'SIMPLE_SUBSCRIPTION_DEBUG_PAY_BALANCE | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s', db_user.id, - subscription_params["period_days"], - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + subscription_params['period_days'], + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), total_required, - getattr(db_user, "balance_kopeks", 0), + getattr(db_user, 'balance_kopeks', 0), ) # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) if user_balance_kopeks < total_required: - await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True) + await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True) return - + try: # Списываем средства с баланса пользователя from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( db, db_user, price_kopeks, - f"Оплата подписки на {subscription_params['period_days']} дней", + f'Оплата подписки на {subscription_params["period_days"]} дней', consume_promo_offer=False, ) - + if not success: - await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True) + await callback.answer('❌ Ошибка списания средств с баланса', show_alert=True) return - + # Проверяем, есть ли у пользователя уже подписка - from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription - + from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id + existing_subscription = await get_subscription_by_user_id(db, db_user.id) - + if existing_subscription: # Если подписка уже существует (платная или тестовая), продлеваем её # Сохраняем информацию о текущей подписке, особенно является ли она пробной - was_trial = getattr(existing_subscription, "is_trial", False) - + was_trial = getattr(existing_subscription, 'is_trial', False) + subscription = await extend_subscription( - db=db, - subscription=existing_subscription, - days=subscription_params["period_days"] + db=db, subscription=existing_subscription, days=subscription_params['period_days'] ) # Обновляем параметры подписки - subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"] - subscription.device_limit = subscription_params["device_limit"] - + subscription.traffic_limit_gb = subscription_params['traffic_limit_gb'] + subscription.device_limit = subscription_params['device_limit'] + # Если текущая подписка была пробной, и мы обновляем её # нужно изменить статус подписки if was_trial: from app.database.models import SubscriptionStatus + # Переводим подписку из пробной в активную платную subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = False - + # Устанавливаем новый выбранный сквад if resolved_squad_uuid: subscription.connected_squads = [resolved_squad_uuid] - + await db.commit() await db.refresh(subscription) else: # Если подписки нет, создаём новую from app.database.crud.subscription import create_paid_subscription + subscription = await create_paid_subscription( db=db, user_id=db_user.id, - duration_days=subscription_params["period_days"], - traffic_limit_gb=subscription_params["traffic_limit_gb"], - device_limit=subscription_params["device_limit"], + duration_days=subscription_params['period_days'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], + device_limit=subscription_params['device_limit'], connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else [], update_server_counters=True, ) - + if not subscription: # Возвращаем средства на баланс в случае ошибки from app.services.payment_service import add_user_balance + await add_user_balance( db, db_user.id, price_kopeks, - f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней", + f'Возврат средств за неудавшуюся подписку на {subscription_params["period_days"]} дней', ) - await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True) + await callback.answer('❌ Ошибка создания подписки. Средства возвращены на баланс.', show_alert=True) return - + # Обновляем баланс пользователя await db.refresh(db_user) # Обновляем или создаём ссылку подписки в RemnaWave try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() remnawave_user = await subscription_service.create_remnawave_user(db, subscription) if remnawave_user: await db.refresh(subscription) except Exception as sync_error: - logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True) - + logger.error( + f'Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}', exc_info=True + ) + # Отправляем уведомление об успешной покупке server_label = _get_simple_subscription_server_label( texts, @@ -544,40 +537,42 @@ async def handle_simple_subscription_pay_with_balance( show_devices = settings.is_devices_selection_enabled() success_lines = [ - "✅ Подписка успешно активирована!", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '✅ Подписка успешно активирована!', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if show_devices: - success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + success_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - success_traffic_gb = subscription_params["traffic_limit_gb"] - success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ" + success_traffic_gb = subscription_params['traffic_limit_gb'] + success_traffic_label = 'Безлимит' if success_traffic_gb == 0 else f'{success_traffic_gb} ГБ' - success_lines.extend([ - f"📊 Трафик: {success_traffic_label}", - f"🌍 Сервер: {server_label}", - "", - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}", - f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}", - "", - "🔗 Для подключения перейдите в раздел 'Подключиться'", - ]) + success_lines.extend( + [ + f'📊 Трафик: {success_traffic_label}', + f'🌍 Сервер: {server_label}', + '', + f'💰 Списано с баланса: {settings.format_price(price_kopeks)}', + f'💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}', + '', + "🔗 Для подключения перейдите в раздел 'Подключиться'", + ] + ) + + success_message = '\n'.join(success_lines) - success_message = "\n".join(success_lines) - connect_mode = settings.CONNECT_BUTTON_MODE subscription_link = get_display_subscription_link(subscription) - connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться") + connect_button_text = texts.t('CONNECT_BUTTON', '🔗 Подключиться') def _fallback_connect_button() -> types.InlineKeyboardButton: return types.InlineKeyboardButton( text=connect_button_text, - callback_data="subscription_connect", + callback_data='subscription_connect', ) - if connect_mode == "miniapp_subscription": + if connect_mode == 'miniapp_subscription': if subscription_link: connect_row = [ types.InlineKeyboardButton( @@ -587,7 +582,7 @@ async def handle_simple_subscription_pay_with_balance( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "miniapp_custom": + elif connect_mode == 'miniapp_custom': custom_url = settings.MINIAPP_CUSTOM_URL if custom_url: connect_row = [ @@ -598,7 +593,7 @@ async def handle_simple_subscription_pay_with_balance( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "link": + elif connect_mode == 'link': if subscription_link: connect_row = [ types.InlineKeyboardButton( @@ -608,12 +603,12 @@ async def handle_simple_subscription_pay_with_balance( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "happ_cryptolink": + elif connect_mode == 'happ_cryptolink': if subscription_link: connect_row = [ types.InlineKeyboardButton( text=connect_button_text, - callback_data="open_subscription_link", + callback_data='open_subscription_link', ) ] else: @@ -627,48 +622,43 @@ async def handle_simple_subscription_pay_with_balance( if happ_row: keyboard_rows.append(happ_row) - keyboard_rows.append( - [types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - await callback.message.edit_text( - success_message, - reply_markup=keyboard, - parse_mode="HTML" - ) - + await callback.message.edit_text(success_message, reply_markup=keyboard, parse_mode='HTML') + # Отправляем уведомление админам try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_purchase_notification( db, db_user, subscription, None, # transaction - subscription_params["period_days"], + subscription_params['period_days'], False, # was_trial_conversion amount_kopeks=price_kopeks, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админам о покупке: {e}") - + logger.error(f'Ошибка отправки уведомления админам о покупке: {e}') + await state.clear() await callback.answer() - logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}₽") + logger.info(f'Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks / 100}₽') except Exception as error: logger.error( - "Ошибка оплаты простой подписки с баланса для пользователя %s: %s", + 'Ошибка оплаты простой подписки с баланса для пользователя %s: %s', db_user.id, error, exc_info=True, ) await callback.answer( - "❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.", + '❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.', show_alert=True, ) await state.clear() @@ -683,7 +673,7 @@ async def handle_simple_subscription_pay_with_balance_disabled( ): """Показывает уведомление, если баланса недостаточно для прямой оплаты.""" await callback.answer( - "❌ Недостаточно средств на балансе. Пополните баланс или выберите другой способ оплаты.", + '❌ Недостаточно средств на балансе. Пополните баланс или выберите другой способ оплаты.', show_alert=True, ) @@ -697,12 +687,12 @@ async def handle_simple_subscription_other_payment_methods( ): """Обрабатывает выбор других способов оплаты.""" texts = get_texts(db_user.language) - + data = await state.get_data() - subscription_params = data.get("subscription_params", {}) + subscription_params = data.get('subscription_params', {}) if not subscription_params: - await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) + await callback.answer('❌ Данные подписки устарели. Пожалуйста, начните сначала.', show_alert=True) return resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( @@ -721,17 +711,17 @@ async def handle_simple_subscription_other_payment_methods( resolved_squad_uuid=resolved_squad_uuid, ) - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) can_pay_from_balance = user_balance_kopeks >= price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s", + 'SIMPLE_SUBSCRIPTION_DEBUG_METHODS | user=%s | balance=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | can_pay=%s', db_user.id, user_balance_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), price_kopeks, can_pay_from_balance, ) @@ -745,52 +735,51 @@ async def handle_simple_subscription_other_payment_methods( show_devices = settings.is_devices_selection_enabled() message_lines = [ - "💳 Оплата подписки", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '💳 Оплата подписки', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if show_devices: - message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + message_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - payment_traffic_gb = subscription_params["traffic_limit_gb"] - payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ" + payment_traffic_gb = subscription_params['traffic_limit_gb'] + payment_traffic_label = 'Безлимит' if payment_traffic_gb == 0 else f'{payment_traffic_gb} ГБ' - message_lines.extend([ - f"📊 Трафик: {payment_traffic_label}", - f"🌍 Сервер: {server_label}", - "", - f"💰 Стоимость: {settings.format_price(price_kopeks)}", - "", - ( - "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:" - if can_pay_from_balance - else "Выберите подходящий способ оплаты:" - ), - ]) + message_lines.extend( + [ + f'📊 Трафик: {payment_traffic_label}', + f'🌍 Сервер: {server_label}', + '', + f'💰 Стоимость: {settings.format_price(price_kopeks)}', + '', + ( + 'Вы можете оплатить подписку с баланса или выбрать другой способ оплаты:' + if can_pay_from_balance + else 'Выберите подходящий способ оплаты:' + ), + ] + ) + + message_text = '\n'.join(message_lines) - message_text = "\n".join(message_lines) - base_keyboard = _get_simple_subscription_payment_keyboard(db_user.language) keyboard_rows = [] - + if can_pay_from_balance: - keyboard_rows.append([ - types.InlineKeyboardButton( - text="✅ Оплатить с баланса", - callback_data="simple_subscription_pay_with_balance" - ) - ]) - + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text='✅ Оплатить с баланса', callback_data='simple_subscription_pay_with_balance' + ) + ] + ) + keyboard_rows.extend(base_keyboard.inline_keyboard) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) - + + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') + await callback.answer() @@ -803,24 +792,28 @@ async def handle_simple_subscription_payment_method( ): """Обрабатывает выбор метода оплаты для простой подписки.""" texts = get_texts(db_user.language) - + data = await state.get_data() - subscription_params = data.get("subscription_params", {}) - + subscription_params = data.get('subscription_params', {}) + if not subscription_params: - await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) + await callback.answer('❌ Данные подписки устарели. Пожалуйста, начните сначала.', show_alert=True) return - + # Проверяем, имеет ли пользователь активную платную подписку from app.database.crud.subscription import get_subscription_by_user_id + current_subscription = await get_subscription_by_user_id(db, db_user.id) - - if current_subscription and not getattr(current_subscription, "is_trial", False) and current_subscription.is_active: + + if current_subscription and not getattr(current_subscription, 'is_trial', False) and current_subscription.is_active: # У пользователя есть активная платная подписка - показываем сообщение - await callback.answer("⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку через главное меню.", show_alert=True) + await callback.answer( + '⚠️ У вас уже есть активная платная подписка. Пожалуйста, подтвердите покупку через главное меню.', + show_alert=True, + ) return - - payment_method = callback.data.replace("simple_subscription_", "") + + payment_method = callback.data.replace('simple_subscription_', '') try: payment_service = PaymentService(callback.bot) @@ -842,241 +835,243 @@ async def handle_simple_subscription_payment_method( resolved_squad_uuid=resolved_squad_uuid, ) - if payment_method == "stars": + if payment_method == 'stars': # Оплата через Telegram Stars order = await purchase_service.create_subscription_order( db=db, user_id=db_user.id, - period_days=subscription_params["period_days"], - device_limit=subscription_params["device_limit"], - traffic_limit_gb=subscription_params["traffic_limit_gb"], + period_days=subscription_params['period_days'], + device_limit=subscription_params['device_limit'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], squad_uuid=resolved_squad_uuid, - payment_method="telegram_stars", + payment_method='telegram_stars', total_price_kopeks=price_kopeks, ) if not order: - await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True) + await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True) return stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks)) - stars_traffic_gb = subscription_params["traffic_limit_gb"] - stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ" + stars_traffic_gb = subscription_params['traffic_limit_gb'] + stars_traffic_label = 'Безлимит' if stars_traffic_gb == 0 else f'{stars_traffic_gb} ГБ' await callback.bot.send_invoice( chat_id=callback.from_user.id, - title=f"Подписка на {subscription_params['period_days']} дней", + title=f'Подписка на {subscription_params["period_days"]} дней', description=( - f"Простая покупка подписки\n" - f"Период: {subscription_params['period_days']} дней\n" - f"Устройства: {subscription_params['device_limit']}\n" - f"Трафик: {stars_traffic_label}" + f'Простая покупка подписки\n' + f'Период: {subscription_params["period_days"]} дней\n' + f'Устройства: {subscription_params["device_limit"]}\n' + f'Трафик: {stars_traffic_label}' ), - payload=( - f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}" - ), - provider_token="", # Пустой токен для Telegram Stars - currency="XTR", # Telegram Stars - prices=[types.LabeledPrice(label="Подписка", amount=stars_count)] + payload=(f'simple_sub_{db_user.id}_{order.id}_{subscription_params["period_days"]}'), + provider_token='', # Пустой токен для Telegram Stars + currency='XTR', # Telegram Stars + prices=[types.LabeledPrice(label='Подписка', amount=stars_count)], ) - + await state.clear() await callback.answer() - - elif payment_method in ["yookassa", "yookassa_sbp"]: + + elif payment_method in ['yookassa', 'yookassa_sbp']: # Оплата через YooKassa if not settings.is_yookassa_enabled(): - await callback.answer("❌ Оплата через YooKassa временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через YooKassa временно недоступна', show_alert=True) return - - if payment_method == "yookassa_sbp" and not settings.YOOKASSA_SBP_ENABLED: - await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True) + + if payment_method == 'yookassa_sbp' and not settings.YOOKASSA_SBP_ENABLED: + await callback.answer('❌ Оплата через СБП временно недоступна', show_alert=True) return - + # Создаем заказ на подписку order = await purchase_service.create_subscription_order( db=db, user_id=db_user.id, - period_days=subscription_params["period_days"], - device_limit=subscription_params["device_limit"], - traffic_limit_gb=subscription_params["traffic_limit_gb"], + period_days=subscription_params['period_days'], + device_limit=subscription_params['device_limit'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], squad_uuid=resolved_squad_uuid, - payment_method="yookassa_sbp" if payment_method == "yookassa_sbp" else "yookassa", - total_price_kopeks=price_kopeks + payment_method='yookassa_sbp' if payment_method == 'yookassa_sbp' else 'yookassa', + total_price_kopeks=price_kopeks, ) - + if not order: - await callback.answer("❌ Ошибка создания заказа", show_alert=True) + await callback.answer('❌ Ошибка создания заказа', show_alert=True) return - + # Создаем платеж через YooKassa - if payment_method == "yookassa_sbp": + if payment_method == 'yookassa_sbp': payment_result = await payment_service.create_yookassa_sbp_payment( db=db, user_id=db_user.id, amount_kopeks=price_kopeks, - description=f"Оплата подписки на {subscription_params['period_days']} дней", + description=f'Оплата подписки на {subscription_params["period_days"]} дней', receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None, receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None, metadata={ - "user_telegram_id": str(db_user.telegram_id), - "user_username": db_user.username or "", - "order_id": str(order.id), - "subscription_period": str(subscription_params["period_days"]), - "payment_purpose": "simple_subscription_purchase" - } + 'user_telegram_id': str(db_user.telegram_id), + 'user_username': db_user.username or '', + 'order_id': str(order.id), + 'subscription_period': str(subscription_params['period_days']), + 'payment_purpose': 'simple_subscription_purchase', + }, ) else: payment_result = await payment_service.create_yookassa_payment( db=db, user_id=db_user.id, amount_kopeks=price_kopeks, - description=f"Оплата подписки на {subscription_params['period_days']} дней", + description=f'Оплата подписки на {subscription_params["period_days"]} дней', receipt_email=db_user.email if hasattr(db_user, 'email') and db_user.email else None, receipt_phone=db_user.phone if hasattr(db_user, 'phone') and db_user.phone else None, metadata={ - "user_telegram_id": str(db_user.telegram_id), - "user_username": db_user.username or "", - "order_id": str(order.id), - "subscription_period": str(subscription_params["period_days"]), - "payment_purpose": "simple_subscription_purchase" - } + 'user_telegram_id': str(db_user.telegram_id), + 'user_username': db_user.username or '', + 'order_id': str(order.id), + 'subscription_period': str(subscription_params['period_days']), + 'payment_purpose': 'simple_subscription_purchase', + }, ) - + if not payment_result: - await callback.answer("❌ Ошибка создания платежа", show_alert=True) + await callback.answer('❌ Ошибка создания платежа', show_alert=True) return - + # Отправляем QR-код и/или ссылку для оплаты - confirmation_url = payment_result.get("confirmation_url") - qr_confirmation_data = payment_result.get("qr_confirmation_data") - + confirmation_url = payment_result.get('confirmation_url') + qr_confirmation_data = payment_result.get('qr_confirmation_data') + if not confirmation_url and not qr_confirmation_data: - await callback.answer("❌ Ошибка получения данных для оплаты", show_alert=True) + await callback.answer('❌ Ошибка получения данных для оплаты', show_alert=True) return - + # Подготовим QR-код для вставки в основное сообщение qr_photo = None if qr_confirmation_data or confirmation_url: try: # Импортируем необходимые модули для генерации QR-кода - import base64 from io import BytesIO + import qrcode from aiogram.types import BufferedInputFile - + # Используем qr_confirmation_data если доступно, иначе confirmation_url qr_data = qr_confirmation_data if qr_confirmation_data else confirmation_url - + # Создаем QR-код из полученных данных qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(qr_data) qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - + + img = qr.make_image(fill_color='black', back_color='white') + # Сохраняем изображение в байты img_bytes = BytesIO() img.save(img_bytes, format='PNG') img_bytes.seek(0) - - qr_photo = BufferedInputFile(img_bytes.getvalue(), filename="qrcode.png") + + qr_photo = BufferedInputFile(img_bytes.getvalue(), filename='qrcode.png') except ImportError: - logger.warning("qrcode библиотека не установлена, QR-код не будет сгенерирован") + logger.warning('qrcode библиотека не установлена, QR-код не будет сгенерирован') except Exception as e: - logger.error(f"Ошибка генерации QR-кода: {e}") - + logger.error(f'Ошибка генерации QR-кода: {e}') + # Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса keyboard_buttons = [] - + # Добавляем кнопку оплаты, если доступна ссылка if confirmation_url: - keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)]) + keyboard_buttons.append([types.InlineKeyboardButton(text='🔗 Перейти к оплате', url=confirmation_url)]) else: # Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка - keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")]) - + keyboard_buttons.append( + [types.InlineKeyboardButton(text='📱 Оплатить в приложении банка', callback_data='temp_disabled')] + ) + # Добавляем общие кнопки - keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")]) - keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")]) - + keyboard_buttons.append( + [ + types.InlineKeyboardButton( + text='📊 Проверить статус', callback_data=f'check_yookassa_{payment_result["local_payment_id"]}' + ) + ] + ) + keyboard_buttons.append( + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')] + ) + keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) - + # Подготавливаем текст сообщения show_devices = settings.is_devices_selection_enabled() message_lines = [ - "💳 Оплата подписки через YooKassa", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '💳 Оплата подписки через YooKassa', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if show_devices: - message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + message_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - yookassa_traffic_gb = subscription_params["traffic_limit_gb"] - yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ" + yookassa_traffic_gb = subscription_params['traffic_limit_gb'] + yookassa_traffic_label = 'Безлимит' if yookassa_traffic_gb == 0 else f'{yookassa_traffic_gb} ГБ' - message_lines.extend([ - f"📊 Трафик: {yookassa_traffic_label}", - f"💰 Сумма: {settings.format_price(price_kopeks)}", - f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...", - "", - ]) + message_lines.extend( + [ + f'📊 Трафик: {yookassa_traffic_label}', + f'💰 Сумма: {settings.format_price(price_kopeks)}', + f'🆔 ID платежа: {payment_result["yookassa_payment_id"][:8]}...', + '', + ] + ) + + message_text = '\n'.join(message_lines) - message_text = "\n".join(message_lines) - # Добавляем инструкции в зависимости от доступных способов оплаты if not confirmation_url: message_text += ( - f"📱 Инструкция по оплате:\n" - f"1. Откройте приложение вашего банка\n" - f"2. Найдите функцию оплаты по реквизитам или перевод по СБП\n" - f"3. Введите ID платежа: {payment_result['yookassa_payment_id']}\n" - f"4. Подтвердите платеж в приложении банка\n" - f"5. Деньги поступят на баланс автоматически\n\n" + f'📱 Инструкция по оплате:\n' + f'1. Откройте приложение вашего банка\n' + f'2. Найдите функцию оплаты по реквизитам или перевод по СБП\n' + f'3. Введите ID платежа: {payment_result["yookassa_payment_id"]}\n' + f'4. Подтвердите платеж в приложении банка\n' + f'5. Деньги поступят на баланс автоматически\n\n' ) - + message_text += ( - f"🔒 Оплата происходит через защищенную систему YooKassa\n" - f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" + f'🔒 Оплата происходит через защищенную систему YooKassa\n' + f'✅ Принимаем карты: Visa, MasterCard, МИР\n\n' + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}' ) - + # Отправляем сообщение с инструкциями и клавиатурой # Если есть QR-код, отправляем его как медиа-сообщение if qr_photo: # Используем метод отправки фото с описанием await callback.message.edit_media( - media=types.InputMediaPhoto( - media=qr_photo, - caption=message_text, - parse_mode="HTML" - ), - reply_markup=keyboard + media=types.InputMediaPhoto(media=qr_photo, caption=message_text, parse_mode='HTML'), + reply_markup=keyboard, ) else: # Если QR-код недоступен, отправляем обычное текстовое сообщение - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) - + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') + await state.clear() await callback.answer() - - elif payment_method == "cryptobot": + + elif payment_method == 'cryptobot': # Оплата через CryptoBot if not settings.is_cryptobot_enabled(): - await callback.answer("❌ Оплата через CryptoBot временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через CryptoBot временно недоступна', show_alert=True) return amount_rubles = price_kopeks / 100 if amount_rubles < 100 or amount_rubles > 100000: await callback.answer( - "❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через CryptoBot", + '❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через CryptoBot', show_alert=True, ) return @@ -1086,19 +1081,19 @@ async def handle_simple_subscription_payment_method( usd_rate = await currency_converter.get_usd_to_rub_rate() except Exception as rate_error: - logger.warning("Не удалось получить курс USD: %s", rate_error) + logger.warning('Не удалось получить курс USD: %s', rate_error) usd_rate = 95.0 amount_usd = round(amount_rubles / usd_rate, 2) if amount_usd < 1: await callback.answer( - "❌ Минимальная сумма для оплаты через CryptoBot — примерно 1 USD", + '❌ Минимальная сумма для оплаты через CryptoBot — примерно 1 USD', show_alert=True, ) return if amount_usd > 1000: await callback.answer( - "❌ Максимальная сумма для оплаты через CryptoBot — 1000 USD", + '❌ Максимальная сумма для оплаты через CryptoBot — 1000 USD', show_alert=True, ) return @@ -1110,28 +1105,28 @@ async def handle_simple_subscription_payment_method( amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, description=settings.get_subscription_payment_description( - subscription_params["period_days"], + subscription_params['period_days'], price_kopeks, ), - payload=f"simple_subscription_{db_user.id}_{price_kopeks}", + payload=f'simple_subscription_{db_user.id}_{price_kopeks}', ) if not crypto_result: await callback.answer( - "❌ Ошибка создания платежа через CryptoBot. Попробуйте позже или обратитесь в поддержку.", + '❌ Ошибка создания платежа через CryptoBot. Попробуйте позже или обратитесь в поддержку.', show_alert=True, ) return payment_url = ( - crypto_result.get("mini_app_invoice_url") - or crypto_result.get("bot_invoice_url") - or crypto_result.get("web_app_invoice_url") + crypto_result.get('mini_app_invoice_url') + or crypto_result.get('bot_invoice_url') + or crypto_result.get('web_app_invoice_url') ) if not payment_url: await callback.answer( - "❌ Не удалось получить ссылку для оплаты. Обратитесь в поддержку.", + '❌ Не удалось получить ссылку для оплаты. Обратитесь в поддержку.', show_alert=True, ) return @@ -1140,54 +1135,54 @@ async def handle_simple_subscription_payment_method( inline_keyboard=[ [ types.InlineKeyboardButton( - text="🪙 Оплатить через CryptoBot", + text='🪙 Оплатить через CryptoBot', url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_cryptobot_{crypto_result['local_payment_id']}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_cryptobot_{crypto_result["local_payment_id"]}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) message_text = ( - "🪙 Оплата через CryptoBot\n\n" - f"💰 Сумма к оплате: {amount_rubles:.0f} ₽\n" - f"💵 В долларах: {amount_usd:.2f} USD\n" - f"🪙 Актив: {crypto_result['asset']}\n" - f"💱 Курс: 1 USD ≈ {usd_rate:.2f} ₽\n" - f"🆔 ID платежа: {crypto_result['invoice_id'][:8]}...\n\n" - "📱 Инструкция:\n" + '🪙 Оплата через CryptoBot\n\n' + f'💰 Сумма к оплате: {amount_rubles:.0f} ₽\n' + f'💵 В долларах: {amount_usd:.2f} USD\n' + f'🪙 Актив: {crypto_result["asset"]}\n' + f'💱 Курс: 1 USD ≈ {usd_rate:.2f} ₽\n' + f'🆔 ID платежа: {crypto_result["invoice_id"][:8]}...\n\n' + '📱 Инструкция:\n' "1. Нажмите кнопку 'Оплатить через CryptoBot'\n" - "2. Выберите актив и следуйте подсказкам\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}" + '2. Выберите актив и следуйте подсказкам\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}' ) await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() return - elif payment_method == "heleket": + elif payment_method == 'heleket': if not settings.is_heleket_enabled(): - await callback.answer("❌ Оплата через Heleket временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через Heleket временно недоступна', show_alert=True) return amount_rubles = price_kopeks / 100 if amount_rubles < 100 or amount_rubles > 100000: await callback.answer( - "❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через Heleket", + '❌ Сумма должна быть от 100 до 100 000 ₽ для оплаты через Heleket', show_alert=True, ) return @@ -1197,7 +1192,7 @@ async def handle_simple_subscription_payment_method( user_id=db_user.id, amount_kopeks=price_kopeks, description=settings.get_subscription_payment_description( - subscription_params["period_days"], + subscription_params['period_days'], price_kopeks, ), language=db_user.language, @@ -1205,23 +1200,23 @@ async def handle_simple_subscription_payment_method( if not heleket_result: await callback.answer( - "❌ Ошибка создания платежа Heleket. Попробуйте позже или обратитесь в поддержку.", + '❌ Ошибка создания платежа Heleket. Попробуйте позже или обратитесь в поддержку.', show_alert=True, ) return - payment_url = heleket_result.get("payment_url") + payment_url = heleket_result.get('payment_url') if not payment_url: await callback.answer( - "❌ Не удалось получить ссылку для оплаты Heleket. Обратитесь в поддержку.", + '❌ Не удалось получить ссылку для оплаты Heleket. Обратитесь в поддержку.', show_alert=True, ) return - local_payment_id = heleket_result.get("local_payment_id") - payer_amount = heleket_result.get("payer_amount") - payer_currency = heleket_result.get("payer_currency") - discount_percent = heleket_result.get("discount_percent") + local_payment_id = heleket_result.get('local_payment_id') + payer_amount = heleket_result.get('payer_amount') + payer_currency = heleket_result.get('payer_currency') + discount_percent = heleket_result.get('discount_percent') markup_percent = None if discount_percent is not None: @@ -1234,81 +1229,76 @@ async def handle_simple_subscription_payment_method( inline_keyboard=[ [ types.InlineKeyboardButton( - text="🪙 Оплатить через Heleket", + text='🪙 Оплатить через Heleket', url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_heleket_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_heleket_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) message_lines = [ - "🪙 Оплата через Heleket", - "", - f"💰 Сумма: {settings.format_price(price_kopeks)}", + '🪙 Оплата через Heleket', + '', + f'💰 Сумма: {settings.format_price(price_kopeks)}', ] if payer_amount and payer_currency: - message_lines.append(f"🪙 К оплате: {payer_amount} {payer_currency}") + message_lines.append(f'🪙 К оплате: {payer_amount} {payer_currency}') try: payer_amount_float = float(payer_amount) if payer_amount_float > 0: rub_per_currency = amount_rubles / payer_amount_float - message_lines.append( - f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽" - ) + message_lines.append(f'💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽') except (TypeError, ValueError, ZeroDivisionError): pass if markup_percent: - sign = "+" if markup_percent > 0 else "" - message_lines.append(f"📈 Наценка: {sign}{markup_percent}%") + sign = '+' if markup_percent > 0 else '' + message_lines.append(f'📈 Наценка: {sign}{markup_percent}%') message_lines.extend( [ - "", - "📱 Инструкция:", + '', + '📱 Инструкция:', "1. Нажмите кнопку 'Оплатить через Heleket'", - "2. Следуйте подсказкам на странице оплаты", - "3. Подтвердите перевод", - "4. Средства зачислятся автоматически", - "", - f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}", + '2. Следуйте подсказкам на странице оплаты', + '3. Подтвердите перевод', + '4. Средства зачислятся автоматически', + '', + f'❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}', ] ) await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() return - elif payment_method == "mulenpay": + elif payment_method == 'mulenpay': # Оплата через MulenPay mulenpay_name = settings.get_mulenpay_display_name() if not settings.is_mulenpay_enabled(): await callback.answer( - f"❌ Оплата через {mulenpay_name} временно недоступна", + f'❌ Оплата через {mulenpay_name} временно недоступна', show_alert=True, ) return if price_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS or price_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: await callback.answer( - "❌ Сумма для Mulen Pay должна быть в пределах от {min_amount} до {max_amount}".format( - min_amount=settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS), - max_amount=settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS), - ), + f'❌ Сумма для Mulen Pay должна быть в пределах от {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)} до {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}', show_alert=True, ) return @@ -1319,56 +1309,56 @@ async def handle_simple_subscription_payment_method( user_id=db_user.id, amount_kopeks=price_kopeks, description=settings.get_subscription_payment_description( - subscription_params["period_days"], + subscription_params['period_days'], price_kopeks, ), language=db_user.language, ) - if not mulen_result or not mulen_result.get("payment_url"): + if not mulen_result or not mulen_result.get('payment_url'): await callback.answer( texts.t( - "MULENPAY_PAYMENT_ERROR", - "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.", + 'MULENPAY_PAYMENT_ERROR', + '❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.', ), show_alert=True, ) return - payment_url = mulen_result["payment_url"] - local_payment_id = mulen_result.get("local_payment_id") - payment_id_display = mulen_result.get("mulen_payment_id") or local_payment_id + payment_url = mulen_result['payment_url'] + local_payment_id = mulen_result.get('local_payment_id') + payment_id_display = mulen_result.get('mulen_payment_id') or local_payment_id keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("MULENPAY_PAY_BUTTON", "💳 Оплатить через Mulen Pay"), + text=texts.t('MULENPAY_PAY_BUTTON', '💳 Оплатить через Mulen Pay'), url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_mulenpay_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_mulenpay_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) message_template = texts.t( - "MULENPAY_PAYMENT_INSTRUCTIONS", + 'MULENPAY_PAYMENT_INSTRUCTIONS', ( - "💳 Оплата через {mulenpay_name_html}\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID платежа: {payment_id}\n\n" - "📱 Инструкция:\n" + '💳 Оплата через {mulenpay_name_html}\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID платежа: {payment_id}\n\n' + '📱 Инструкция:\n' "1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n" - "2. Следуйте подсказкам платежной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '2. Следуйте подсказкам платежной системы\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) @@ -1381,17 +1371,17 @@ async def handle_simple_subscription_payment_method( support=settings.get_support_contact_display_html(), ), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() return - - elif payment_method == "pal24": + + elif payment_method == 'pal24': # Оплата через PayPalych if not settings.is_pal24_enabled(): - await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через PayPalych временно недоступна', show_alert=True) return payment_service = PaymentService(callback.bot) @@ -1400,7 +1390,7 @@ async def handle_simple_subscription_payment_method( user_id=db_user.id, amount_kopeks=price_kopeks, description=settings.get_subscription_payment_description( - subscription_params["period_days"], + subscription_params['period_days'], price_kopeks, ), language=db_user.language, @@ -1409,22 +1399,22 @@ async def handle_simple_subscription_payment_method( if not pal24_result: await callback.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ), show_alert=True, ) return - sbp_url = pal24_result.get("sbp_url") or pal24_result.get("transfer_url") - card_url = pal24_result.get("card_url") - fallback_url = pal24_result.get("link_page_url") or pal24_result.get("link_url") + sbp_url = pal24_result.get('sbp_url') or pal24_result.get('transfer_url') + card_url = pal24_result.get('card_url') + fallback_url = pal24_result.get('link_page_url') or pal24_result.get('link_url') if not (sbp_url or card_url or fallback_url): await callback.answer( texts.t( - "PAL24_PAYMENT_ERROR", - "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.", + 'PAL24_PAYMENT_ERROR', + '❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.', ), show_alert=True, ) @@ -1433,16 +1423,16 @@ async def handle_simple_subscription_payment_method( if not sbp_url: sbp_url = fallback_url - bill_id = pal24_result.get("bill_id") - local_payment_id = pal24_result.get("local_payment_id") + bill_id = pal24_result.get('bill_id') + local_payment_id = pal24_result.get('local_payment_id') pay_buttons: list[list[types.InlineKeyboardButton]] = [] steps: list[str] = [] step_counter = 1 default_sbp_text = texts.t( - "PAL24_SBP_PAY_BUTTON", - "🏦 Оплатить через PayPalych (СБП)", + 'PAL24_SBP_PAY_BUTTON', + '🏦 Оплатить через PayPalych (СБП)', ) sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) @@ -1457,15 +1447,15 @@ async def handle_simple_subscription_payment_method( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(sbp_button_text)) ) step_counter += 1 default_card_text = texts.t( - "PAL24_CARD_PAY_BUTTON", - "💳 Оплатить банковской картой (PayPalych)", + 'PAL24_CARD_PAY_BUTTON', + '💳 Оплатить банковской картой (PayPalych)', ) card_button_text = settings.get_pal24_card_button_text(default_card_text) @@ -1480,8 +1470,8 @@ async def handle_simple_subscription_payment_method( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(card_button_text)) ) step_counter += 1 @@ -1497,51 +1487,51 @@ async def handle_simple_subscription_payment_method( ) steps.append( texts.t( - "PAL24_INSTRUCTION_BUTTON", - "{step}. Нажмите кнопку «{button}»", + 'PAL24_INSTRUCTION_BUTTON', + '{step}. Нажмите кнопку «{button}»', ).format(step=step_counter, button=html.escape(sbp_button_text)) ) step_counter += 1 follow_template = texts.t( - "PAL24_INSTRUCTION_FOLLOW", - "{step}. Следуйте подсказкам платёжной системы", + 'PAL24_INSTRUCTION_FOLLOW', + '{step}. Следуйте подсказкам платёжной системы', ) steps.append(follow_template.format(step=step_counter)) step_counter += 1 confirm_template = texts.t( - "PAL24_INSTRUCTION_CONFIRM", - "{step}. Подтвердите перевод", + 'PAL24_INSTRUCTION_CONFIRM', + '{step}. Подтвердите перевод', ) steps.append(confirm_template.format(step=step_counter)) step_counter += 1 success_template = texts.t( - "PAL24_INSTRUCTION_COMPLETE", - "{step}. Средства зачислятся автоматически", + 'PAL24_INSTRUCTION_COMPLETE', + '{step}. Средства зачислятся автоматически', ) steps.append(success_template.format(step=step_counter)) message_template = texts.t( - "PAL24_PAYMENT_INSTRUCTIONS", + 'PAL24_PAYMENT_INSTRUCTIONS', ( - "🏦 Оплата через PayPalych\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID счета: {bill_id}\n\n" - "📱 Инструкция:\n{steps}\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '🏦 Оплата через PayPalych\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID счета: {bill_id}\n\n' + '📱 Инструкция:\n{steps}\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) keyboard_rows = pay_buttons + [ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_pal24_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_pal24_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) @@ -1549,31 +1539,28 @@ async def handle_simple_subscription_payment_method( message_text = message_template.format( amount=settings.format_price(price_kopeks), bill_id=bill_id, - steps="\n".join(steps), + steps='\n'.join(steps), support=settings.get_support_contact_display_html(), ) await callback.message.edit_text( message_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() return - elif payment_method == "wata": + elif payment_method == 'wata': # Оплата через WATA if not settings.is_wata_enabled(): - await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True) + await callback.answer('❌ Оплата через WATA временно недоступна', show_alert=True) return if price_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS or price_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: await callback.answer( - "❌ Сумма для WATA должна быть между {min_amount} и {max_amount}.".format( - min_amount=settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS), - max_amount=settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS), - ), + f'❌ Сумма для WATA должна быть между {settings.format_price(settings.WATA_MIN_AMOUNT_KOPEKS)} и {settings.format_price(settings.WATA_MAX_AMOUNT_KOPEKS)}.', show_alert=True, ) return @@ -1585,59 +1572,59 @@ async def handle_simple_subscription_payment_method( user_id=db_user.id, amount_kopeks=price_kopeks, description=settings.get_subscription_payment_description( - subscription_params["period_days"], + subscription_params['period_days'], price_kopeks, ), language=db_user.language, ) except Exception as error: - logger.error("Ошибка создания WATA платежа: %s", error) + logger.error('Ошибка создания WATA платежа: %s', error) wata_result = None - if not wata_result or not wata_result.get("payment_url"): + if not wata_result or not wata_result.get('payment_url'): await callback.answer( texts.t( - "WATA_PAYMENT_ERROR", - "❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.", + 'WATA_PAYMENT_ERROR', + '❌ Ошибка создания платежа WATA. Попробуйте позже или обратитесь в поддержку.', ), show_alert=True, ) return - payment_url = wata_result["payment_url"] - payment_link_id = wata_result.get("payment_link_id") - local_payment_id = wata_result.get("local_payment_id") + payment_url = wata_result['payment_url'] + payment_link_id = wata_result.get('payment_link_id') + local_payment_id = wata_result.get('local_payment_id') keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("WATA_PAY_BUTTON", "💳 Оплатить через WATA"), + text=texts.t('WATA_PAY_BUTTON', '💳 Оплатить через WATA'), url=payment_url, ) ], [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_wata_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_wata_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) message_template = texts.t( - "WATA_PAYMENT_INSTRUCTIONS", + 'WATA_PAYMENT_INSTRUCTIONS', ( - "💳 Оплата через WATA\n\n" - "💰 Сумма: {amount}\n" - "🆔 ID платежа: {payment_id}\n\n" - "📱 Инструкция:\n" + '💳 Оплата через WATA\n\n' + '💰 Сумма: {amount}\n' + '🆔 ID платежа: {payment_id}\n\n' + '📱 Инструкция:\n' "1. Нажмите кнопку 'Оплатить через WATA'\n" - "2. Следуйте подсказкам платежной системы\n" - "3. Подтвердите перевод\n" - "4. Средства зачислятся автоматически\n\n" - "❓ Если возникнут проблемы, обратитесь в {support}" + '2. Следуйте подсказкам платежной системы\n' + '3. Подтвердите перевод\n' + '4. Средства зачислятся автоматически\n\n' + '❓ Если возникнут проблемы, обратитесь в {support}' ), ) @@ -1648,19 +1635,21 @@ async def handle_simple_subscription_payment_method( support=settings.get_support_contact_display_html(), ), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() return - + else: - await callback.answer("❌ Неизвестный способ оплаты", show_alert=True) - + await callback.answer('❌ Неизвестный способ оплаты', show_alert=True) + except Exception as e: - logger.error(f"Ошибка обработки метода оплаты простой подписки: {e}") - await callback.answer("❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.", show_alert=True) + logger.error(f'Ошибка обработки метода оплаты простой подписки: {e}') + await callback.answer( + '❌ Ошибка обработки запроса. Попробуйте позже или обратитесь в поддержку.', show_alert=True + ) await state.clear() @@ -1675,77 +1664,68 @@ async def check_simple_pal24_payment_status( status_info = await payment_service.get_pal24_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] status_labels = { - "NEW": ("⏳", "Ожидает оплаты"), - "PROCESS": ("⌛", "Обрабатывается"), - "SUCCESS": ("✅", "Оплачен"), - "FAIL": ("❌", "Отменен"), - "UNDERPAID": ("⚠️", "Недоплата"), - "OVERPAID": ("⚠️", "Переплата"), + 'NEW': ('⏳', 'Ожидает оплаты'), + 'PROCESS': ('⌛', 'Обрабатывается'), + 'SUCCESS': ('✅', 'Оплачен'), + 'FAIL': ('❌', 'Отменен'), + 'UNDERPAID': ('⚠️', 'Недоплата'), + 'OVERPAID': ('⚠️', 'Переплата'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) metadata = payment.metadata_json or {} - links_meta = metadata.get("links") if isinstance(metadata, dict) else {} + links_meta = metadata.get('links') if isinstance(metadata, dict) else {} if not isinstance(links_meta, dict): links_meta = {} - links_info = status_info.get("links") or {} - sbp_link = ( - links_info.get("sbp") - or links_meta.get("sbp") - or status_info.get("sbp_url") - or payment.link_url - ) - card_link = ( - links_info.get("card") - or links_meta.get("card") - or status_info.get("card_url") - ) + links_info = status_info.get('links') or {} + sbp_link = links_info.get('sbp') or links_meta.get('sbp') or status_info.get('sbp_url') or payment.link_url + card_link = links_info.get('card') or links_meta.get('card') or status_info.get('card_url') if not card_link and payment.link_page_url and payment.link_page_url != sbp_link: card_link = payment.link_page_url - db_user = getattr(callback, "db_user", None) + db_user = getattr(callback, 'db_user', None) texts = get_texts(db_user.language if db_user else settings.DEFAULT_LANGUAGE) message_lines = [ - "🏦 Статус платежа PayPalych:", - "", - f"🆔 ID счета: {payment.bill_id}", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}", + '🏦 Статус платежа PayPalych:', + '', + f'🆔 ID счета: {payment.bill_id}', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M")}', ] if payment.is_paid: - message_lines += ["", "✅ Платеж успешно завершен! Средства уже зачислены."] - elif payment.status in {"NEW", "PROCESS"}: + message_lines += ['', '✅ Платеж успешно завершен! Средства уже зачислены.'] + elif payment.status in {'NEW', 'PROCESS'}: message_lines += [ - "", - "⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.", + '', + '⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.', ] if sbp_link: - message_lines += ["", f"🏦 СБП: {sbp_link}"] + message_lines += ['', f'🏦 СБП: {sbp_link}'] if card_link and card_link != sbp_link: - message_lines.append(f"💳 Карта: {card_link}") - elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}: + message_lines.append(f'💳 Карта: {card_link}') + elif payment.status in {'FAIL', 'UNDERPAID', 'OVERPAID'}: message_lines += [ - "", - f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}", + '', + f'❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}', ] pay_rows: list[list[types.InlineKeyboardButton]] = [] - if not payment.is_paid and payment.status in {"NEW", "PROCESS"}: + if not payment.is_paid and payment.status in {'NEW', 'PROCESS'}: default_sbp_text = texts.t( - "PAL24_SBP_PAY_BUTTON", - "🏦 Оплатить через PayPalych (СБП)", + 'PAL24_SBP_PAY_BUTTON', + '🏦 Оплатить через PayPalych (СБП)', ) sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text) @@ -1760,8 +1740,8 @@ async def check_simple_pal24_payment_status( ) default_card_text = texts.t( - "PAL24_CARD_PAY_BUTTON", - "💳 Оплатить банковской картой (PayPalych)", + 'PAL24_CARD_PAY_BUTTON', + '💳 Оплатить банковской картой (PayPalych)', ) card_button_text = settings.get_pal24_card_button_text(default_card_text) @@ -1781,30 +1761,30 @@ async def check_simple_pal24_payment_status( + [ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_pal24_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_pal24_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) await callback.answer() try: await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, disable_web_page_preview=True, ) except TelegramBadRequest as error: - if "message is not modified" in str(error).lower(): - await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился")) + if 'message is not modified' in str(error).lower(): + await callback.answer(texts.t('CHECK_STATUS_NO_CHANGES', 'Статус не изменился')) else: raise except Exception as error: - logger.error(f"Ошибка проверки статуса PayPalych для простой подписки: {error}") - await callback.answer("❌ Ошибка проверки статуса", show_alert=True) + logger.error(f'Ошибка проверки статуса PayPalych для простой подписки: {error}') + await callback.answer('❌ Ошибка проверки статуса', show_alert=True) @error_handler @@ -1815,72 +1795,72 @@ async def check_simple_mulenpay_payment_status( try: local_payment_id = int(callback.data.rsplit('_', 1)[-1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return payment_service = PaymentService(callback.bot) status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] user_language = settings.DEFAULT_LANGUAGE try: from app.services.payment_service import get_user_by_id as fetch_user_by_id user = await fetch_user_by_id(db, payment.user_id) - if user and getattr(user, "language", None): + if user and getattr(user, 'language', None): user_language = user.language except Exception as error: - logger.debug("Не удалось получить пользователя для MulenPay статуса: %s", error) + logger.debug('Не удалось получить пользователя для MulenPay статуса: %s', error) texts = get_texts(user_language) status_labels = { - "created": ("⏳", "Ожидает оплаты"), - "processing": ("⌛", "Обрабатывается"), - "success": ("✅", "Оплачен"), - "canceled": ("❌", "Отменен"), - "error": ("⚠️", "Ошибка"), - "hold": ("🔒", "Холд"), - "unknown": ("❓", "Неизвестно"), + 'created': ('⏳', 'Ожидает оплаты'), + 'processing': ('⌛', 'Обрабатывается'), + 'success': ('✅', 'Оплачен'), + 'canceled': ('❌', 'Отменен'), + 'error': ('⚠️', 'Ошибка'), + 'hold': ('🔒', 'Холд'), + 'unknown': ('❓', 'Неизвестно'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) message_lines = [ - "💳 Статус платежа Mulen Pay:", - "", - f"🆔 ID: {payment.mulen_payment_id or payment.id}", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + '💳 Статус платежа Mulen Pay:', + '', + f'🆔 ID: {payment.mulen_payment_id or payment.id}', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"}', ] if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.") - elif payment.status in {"created", "processing"}: - message_lines.append("\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.") + message_lines.append('\n✅ Платеж успешно завершен! Средства уже зачислены.') + elif payment.status in {'created', 'processing'}: + message_lines.append('\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.') keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_mulenpay_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_mulenpay_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) await callback.answer() await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -1892,65 +1872,65 @@ async def check_simple_cryptobot_payment_status( try: local_payment_id = int(callback.data.rsplit('_', 1)[-1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return from app.database.crud.cryptobot import get_cryptobot_payment_by_id payment = await get_cryptobot_payment_by_id(db, local_payment_id) if not payment: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return status_labels = { - "active": ("⏳", "Ожидает оплаты"), - "paid": ("✅", "Оплачен"), - "expired": ("❌", "Истек"), + 'active': ('⏳', 'Ожидает оплаты'), + 'paid': ('✅', 'Оплачен'), + 'expired': ('❌', 'Истек'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) language = settings.DEFAULT_LANGUAGE try: from app.services.payment_service import get_user_by_id as fetch_user_by_id user = await fetch_user_by_id(db, payment.user_id) - if user and getattr(user, "language", None): + if user and getattr(user, 'language', None): language = user.language except Exception as error: - logger.debug("Не удалось получить пользователя для CryptoBot статуса: %s", error) + logger.debug('Не удалось получить пользователя для CryptoBot статуса: %s', error) texts = get_texts(language) message_lines = [ - "🪙 Статус платежа CryptoBot", - "", - f"🆔 ID: {payment.invoice_id}", - f"💰 Сумма: {payment.amount} {payment.asset}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + '🪙 Статус платежа CryptoBot', + '', + f'🆔 ID: {payment.invoice_id}', + f'💰 Сумма: {payment.amount} {payment.asset}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"}', ] - if payment.status == "paid": - message_lines.append("\n✅ Платеж подтвержден. Средства уже зачислены.") - elif payment.status == "active": - message_lines.append("\n⏳ Платеж еще ожидает подтверждения. Оплатите счет и проверьте статус позже.") + if payment.status == 'paid': + message_lines.append('\n✅ Платеж подтвержден. Средства уже зачислены.') + elif payment.status == 'active': + message_lines.append('\n⏳ Платеж еще ожидает подтверждения. Оплатите счет и проверьте статус позже.') keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_cryptobot_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_cryptobot_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) await callback.answer() await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -1962,83 +1942,81 @@ async def check_simple_heleket_payment_status( try: local_payment_id = int(callback.data.rsplit('_', 1)[-1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return from app.database.crud.heleket import get_heleket_payment_by_id payment = await get_heleket_payment_by_id(db, local_payment_id) if not payment: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return status_labels = { - "check": ("⏳", "Ожидает оплаты"), - "paid": ("✅", "Оплачен"), - "paid_over": ("✅", "Оплачен (переплата)"), - "wrong_amount": ("⚠️", "Неверная сумма"), - "cancel": ("❌", "Отменен"), - "fail": ("❌", "Ошибка"), - "process": ("⌛", "Обрабатывается"), - "confirm_check": ("⌛", "Ожидает подтверждения"), + 'check': ('⏳', 'Ожидает оплаты'), + 'paid': ('✅', 'Оплачен'), + 'paid_over': ('✅', 'Оплачен (переплата)'), + 'wrong_amount': ('⚠️', 'Неверная сумма'), + 'cancel': ('❌', 'Отменен'), + 'fail': ('❌', 'Ошибка'), + 'process': ('⌛', 'Обрабатывается'), + 'confirm_check': ('⌛', 'Ожидает подтверждения'), } - emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно")) + emoji, status_text = status_labels.get(payment.status, ('❓', 'Неизвестно')) language = settings.DEFAULT_LANGUAGE try: from app.services.payment_service import get_user_by_id as fetch_user_by_id user = await fetch_user_by_id(db, payment.user_id) - if user and getattr(user, "language", None): + if user and getattr(user, 'language', None): language = user.language except Exception as error: - logger.debug("Не удалось получить пользователя для Heleket статуса: %s", error) + logger.debug('Не удалось получить пользователя для Heleket статуса: %s', error) texts = get_texts(language) message_lines = [ - "🪙 Статус платежа Heleket:", - "", - f"🆔 UUID: {payment.uuid[:8]}...", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + '🪙 Статус платежа Heleket:', + '', + f'🆔 UUID: {payment.uuid[:8]}...', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"}', ] if payment.payer_amount and payment.payer_currency: - message_lines.append( - f"🪙 Оплата: {payment.payer_amount} {payment.payer_currency}" - ) + message_lines.append(f'🪙 Оплата: {payment.payer_amount} {payment.payer_currency}') if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.") - elif payment.status in {"check", "process", "confirm_check"}: - message_lines.append("\n⏳ Платеж еще обрабатывается. Завершите оплату и проверьте статус позже.") + message_lines.append('\n✅ Платеж успешно завершен! Средства уже зачислены.') + elif payment.status in {'check', 'process', 'confirm_check'}: + message_lines.append('\n⏳ Платеж еще обрабатывается. Завершите оплату и проверьте статус позже.') if payment.payment_url: - message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}") - elif payment.status in {"fail", "cancel", "wrong_amount"}: + message_lines.append(f'\n🔗 Ссылка на оплату: {payment.payment_url}') + elif payment.status in {'fail', 'cancel', 'wrong_amount'}: message_lines.append( - f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}" + f'\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}' ) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_heleket_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_heleket_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) await callback.answer() await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -2050,58 +2028,58 @@ async def check_simple_wata_payment_status( try: local_payment_id = int(callback.data.rsplit('_', 1)[-1]) except (ValueError, IndexError): - await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True) + await callback.answer('❌ Некорректный идентификатор платежа', show_alert=True) return payment_service = PaymentService(callback.bot) status_info = await payment_service.get_wata_payment_status(db, local_payment_id) if not status_info: - await callback.answer("❌ Платеж не найден", show_alert=True) + await callback.answer('❌ Платеж не найден', show_alert=True) return - payment = status_info["payment"] + payment = status_info['payment'] texts = get_texts(settings.DEFAULT_LANGUAGE) status_labels = { - "Opened": ("⏳", texts.t("WATA_STATUS_OPENED", "Ожидает оплаты")), - "Closed": ("⌛", texts.t("WATA_STATUS_CLOSED", "Обрабатывается")), - "Paid": ("✅", texts.t("WATA_STATUS_PAID", "Оплачен")), - "Declined": ("❌", texts.t("WATA_STATUS_DECLINED", "Отклонен")), + 'Opened': ('⏳', texts.t('WATA_STATUS_OPENED', 'Ожидает оплаты')), + 'Closed': ('⌛', texts.t('WATA_STATUS_CLOSED', 'Обрабатывается')), + 'Paid': ('✅', texts.t('WATA_STATUS_PAID', 'Оплачен')), + 'Declined': ('❌', texts.t('WATA_STATUS_DECLINED', 'Отклонен')), } - emoji, status_text = status_labels.get(payment.status, ("❓", texts.t("WATA_STATUS_UNKNOWN", "Неизвестно"))) + emoji, status_text = status_labels.get(payment.status, ('❓', texts.t('WATA_STATUS_UNKNOWN', 'Неизвестно'))) message_lines = [ - texts.t("WATA_STATUS_TITLE", "💳 Статус платежа WATA"), - "", - f"🆔 ID: {payment.payment_link_id}", - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}", - f"📊 Статус: {emoji} {status_text}", - f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M') if payment.created_at else '—'}", + texts.t('WATA_STATUS_TITLE', '💳 Статус платежа WATA'), + '', + f'🆔 ID: {payment.payment_link_id}', + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}', + f'📊 Статус: {emoji} {status_text}', + f'📅 Создан: {payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"}', ] if payment.is_paid: - message_lines.append("\n✅ Платеж успешно завершен! Средства уже зачислены.") - elif payment.status in {"Opened", "Closed"}: - message_lines.append("\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.") + message_lines.append('\n✅ Платеж успешно завершен! Средства уже зачислены.') + elif payment.status in {'Opened', 'Closed'}: + message_lines.append('\n⏳ Платеж еще не завершен. Завершите оплату и проверьте статус позже.') keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_simple_wata_{local_payment_id}", + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_simple_wata_{local_payment_id}', ) ], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], ] ) await callback.answer() await callback.message.edit_text( - "\n".join(message_lines), + '\n'.join(message_lines), reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) @@ -2114,12 +2092,12 @@ async def confirm_simple_subscription_purchase( ): """Обрабатывает подтверждение простой покупки подписки при наличии активной платной подписки.""" texts = get_texts(db_user.language) - + data = await state.get_data() - subscription_params = data.get("subscription_params", {}) - + subscription_params = data.get('subscription_params', {}) + if not subscription_params: - await callback.answer("❌ Данные подписки устарели. Пожалуйста, начните сначала.", show_alert=True) + await callback.answer('❌ Данные подписки устарели. Пожалуйста, начните сначала.', show_alert=True) return resolved_squad_uuid = await _ensure_simple_subscription_squad_uuid( @@ -2139,111 +2117,116 @@ async def confirm_simple_subscription_purchase( ) total_required = price_kopeks logger.warning( - "SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s", + 'SIMPLE_SUBSCRIPTION_DEBUG_CONFIRM | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total_required=%s | balance=%s', db_user.id, - subscription_params["period_days"], - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + subscription_params['period_days'], + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), total_required, - getattr(db_user, "balance_kopeks", 0), + getattr(db_user, 'balance_kopeks', 0), ) # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) if user_balance_kopeks < total_required: - await callback.answer("❌ Недостаточно средств на балансе для оплаты подписки", show_alert=True) + await callback.answer('❌ Недостаточно средств на балансе для оплаты подписки', show_alert=True) return - + try: # Списываем средства с баланса пользователя from app.database.crud.user import subtract_user_balance + success = await subtract_user_balance( db, db_user, price_kopeks, - f"Оплата подписки на {subscription_params['period_days']} дней", + f'Оплата подписки на {subscription_params["period_days"]} дней', consume_promo_offer=False, ) - + if not success: - await callback.answer("❌ Ошибка списания средств с баланса", show_alert=True) + await callback.answer('❌ Ошибка списания средств с баланса', show_alert=True) return - + # Проверяем, есть ли у пользователя уже подписка - from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription - + from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id + existing_subscription = await get_subscription_by_user_id(db, db_user.id) - + if existing_subscription: # Если подписка уже существует, продлеваем её # Сохраняем информацию о текущей подписке, особенно является ли она пробной - was_trial = getattr(existing_subscription, "is_trial", False) - + was_trial = getattr(existing_subscription, 'is_trial', False) + subscription = await extend_subscription( - db=db, - subscription=existing_subscription, - days=subscription_params["period_days"] + db=db, subscription=existing_subscription, days=subscription_params['period_days'] ) # Обновляем параметры подписки - subscription.traffic_limit_gb = subscription_params["traffic_limit_gb"] - subscription.device_limit = subscription_params["device_limit"] - + subscription.traffic_limit_gb = subscription_params['traffic_limit_gb'] + subscription.device_limit = subscription_params['device_limit'] + # Если текущая подписка была пробной, и мы обновляем её # нужно изменить статус подписки if was_trial: from app.database.models import SubscriptionStatus + # Переводим подписку из пробной в активную платную subscription.status = SubscriptionStatus.ACTIVE.value subscription.is_trial = False - + # Устанавливаем новый выбранный сквад if resolved_squad_uuid: subscription.connected_squads = [resolved_squad_uuid] - + await db.commit() await db.refresh(subscription) else: # Если подписки нет, создаём новую from app.database.crud.subscription import create_paid_subscription + subscription = await create_paid_subscription( db=db, user_id=db_user.id, - duration_days=subscription_params["period_days"], - traffic_limit_gb=subscription_params["traffic_limit_gb"], - device_limit=subscription_params["device_limit"], + duration_days=subscription_params['period_days'], + traffic_limit_gb=subscription_params['traffic_limit_gb'], + device_limit=subscription_params['device_limit'], connected_squads=[resolved_squad_uuid] if resolved_squad_uuid else [], update_server_counters=True, ) - + if not subscription: # Возвращаем средства на баланс в случае ошибки from app.services.payment_service import add_user_balance + await add_user_balance( db, db_user.id, price_kopeks, - f"Возврат средств за неудавшуюся подписку на {subscription_params['period_days']} дней", + f'Возврат средств за неудавшуюся подписку на {subscription_params["period_days"]} дней', ) - await callback.answer("❌ Ошибка создания подписки. Средства возвращены на баланс.", show_alert=True) + await callback.answer('❌ Ошибка создания подписки. Средства возвращены на баланс.', show_alert=True) return - + # Обновляем баланс пользователя await db.refresh(db_user) # Обновляем или создаём ссылку подписки в RemnaWave try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() remnawave_user = await subscription_service.create_remnawave_user(db, subscription) if remnawave_user: await db.refresh(subscription) except Exception as sync_error: - logger.error(f"Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}", exc_info=True) - + logger.error( + f'Ошибка синхронизации подписки с RemnaWave для пользователя {db_user.id}: {sync_error}', exc_info=True + ) + # Отправляем уведомление об успешной покупке server_label = _get_simple_subscription_server_label( texts, @@ -2253,40 +2236,42 @@ async def confirm_simple_subscription_purchase( show_devices = settings.is_devices_selection_enabled() success_lines = [ - "✅ Подписка успешно активирована!", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '✅ Подписка успешно активирована!', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if show_devices: - success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + success_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - success_traffic_gb = subscription_params["traffic_limit_gb"] - success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ" + success_traffic_gb = subscription_params['traffic_limit_gb'] + success_traffic_label = 'Безлимит' if success_traffic_gb == 0 else f'{success_traffic_gb} ГБ' - success_lines.extend([ - f"📊 Трафик: {success_traffic_label}", - f"🌍 Сервер: {server_label}", - "", - f"💰 Списано с баланса: {settings.format_price(price_kopeks)}", - f"💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}", - "", - "🔗 Для подключения перейдите в раздел 'Подключиться'", - ]) + success_lines.extend( + [ + f'📊 Трафик: {success_traffic_label}', + f'🌍 Сервер: {server_label}', + '', + f'💰 Списано с баланса: {settings.format_price(price_kopeks)}', + f'💳 Ваш баланс: {settings.format_price(db_user.balance_kopeks)}', + '', + "🔗 Для подключения перейдите в раздел 'Подключиться'", + ] + ) + + success_message = '\n'.join(success_lines) - success_message = "\n".join(success_lines) - connect_mode = settings.CONNECT_BUTTON_MODE subscription_link = get_display_subscription_link(subscription) - connect_button_text = texts.t("CONNECT_BUTTON", "🔗 Подключиться") + connect_button_text = texts.t('CONNECT_BUTTON', '🔗 Подключиться') def _fallback_connect_button() -> types.InlineKeyboardButton: return types.InlineKeyboardButton( text=connect_button_text, - callback_data="subscription_connect", + callback_data='subscription_connect', ) - if connect_mode == "miniapp_subscription": + if connect_mode == 'miniapp_subscription': if subscription_link: connect_row = [ types.InlineKeyboardButton( @@ -2296,7 +2281,7 @@ async def confirm_simple_subscription_purchase( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "miniapp_custom": + elif connect_mode == 'miniapp_custom': custom_url = settings.MINIAPP_CUSTOM_URL if custom_url: connect_row = [ @@ -2307,7 +2292,7 @@ async def confirm_simple_subscription_purchase( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "link": + elif connect_mode == 'link': if subscription_link: connect_row = [ types.InlineKeyboardButton( @@ -2317,12 +2302,12 @@ async def confirm_simple_subscription_purchase( ] else: connect_row = [_fallback_connect_button()] - elif connect_mode == "happ_cryptolink": + elif connect_mode == 'happ_cryptolink': if subscription_link: connect_row = [ types.InlineKeyboardButton( text=connect_button_text, - callback_data="open_subscription_link", + callback_data='open_subscription_link', ) ] else: @@ -2336,111 +2321,77 @@ async def confirm_simple_subscription_purchase( if happ_row: keyboard_rows.append(happ_row) - keyboard_rows.append( - [types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ) + keyboard_rows.append([types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')]) keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - await callback.message.edit_text( - success_message, - reply_markup=keyboard, - parse_mode="HTML" - ) - + await callback.message.edit_text(success_message, reply_markup=keyboard, parse_mode='HTML') + # Отправляем уведомление админам try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_purchase_notification( db, db_user, subscription, None, # transaction - subscription_params["period_days"], + subscription_params['period_days'], False, # was_trial_conversion amount_kopeks=price_kopeks, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админам о покупке: {e}") - + logger.error(f'Ошибка отправки уведомления админам о покупке: {e}') + await state.clear() await callback.answer() - logger.info(f"Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks/100}₽") + logger.info(f'Пользователь {db_user.telegram_id} успешно купил подписку с баланса на {price_kopeks / 100}₽') except Exception as error: logger.error( - "Ошибка подтверждения простой подписки с баланса для пользователя %s: %s", + 'Ошибка подтверждения простой подписки с баланса для пользователя %s: %s', db_user.id, error, exc_info=True, ) await callback.answer( - "❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.", + '❌ Ошибка оплаты подписки. Попробуйте позже или обратитесь в поддержку.', show_alert=True, ) await state.clear() + def register_simple_subscription_handlers(dp): """Регистрирует обработчики простой покупки подписки.""" - + + dp.callback_query.register(start_simple_subscription_purchase, F.data == 'simple_subscription_purchase') + + dp.callback_query.register(confirm_simple_subscription_purchase, F.data == 'simple_subscription_confirm_purchase') + dp.callback_query.register( - start_simple_subscription_purchase, - F.data == "simple_subscription_purchase" - ) - - dp.callback_query.register( - confirm_simple_subscription_purchase, - F.data == "simple_subscription_confirm_purchase" - ) - - dp.callback_query.register( - handle_simple_subscription_pay_with_balance, - F.data == "simple_subscription_pay_with_balance" - ) - - dp.callback_query.register( - handle_simple_subscription_pay_with_balance_disabled, - F.data == "simple_subscription_pay_with_balance_disabled" - ) - - dp.callback_query.register( - handle_simple_subscription_other_payment_methods, - F.data == "simple_subscription_other_payment_methods" - ) - - dp.callback_query.register( - handle_simple_subscription_payment_method, - F.data.startswith("simple_subscription_") + handle_simple_subscription_pay_with_balance, F.data == 'simple_subscription_pay_with_balance' ) dp.callback_query.register( - check_simple_pal24_payment_status, - F.data.startswith("check_simple_pal24_") + handle_simple_subscription_pay_with_balance_disabled, F.data == 'simple_subscription_pay_with_balance_disabled' ) dp.callback_query.register( - check_simple_mulenpay_payment_status, - F.data.startswith("check_simple_mulenpay_") + handle_simple_subscription_other_payment_methods, F.data == 'simple_subscription_other_payment_methods' ) - dp.callback_query.register( - check_simple_cryptobot_payment_status, - F.data.startswith("check_simple_cryptobot_") - ) + dp.callback_query.register(handle_simple_subscription_payment_method, F.data.startswith('simple_subscription_')) - dp.callback_query.register( - check_simple_heleket_payment_status, - F.data.startswith("check_simple_heleket_") - ) + dp.callback_query.register(check_simple_pal24_payment_status, F.data.startswith('check_simple_pal24_')) - dp.callback_query.register( - check_simple_wata_payment_status, - F.data.startswith("check_simple_wata_") - ) + dp.callback_query.register(check_simple_mulenpay_payment_status, F.data.startswith('check_simple_mulenpay_')) - dp.callback_query.register( - check_simple_pal24_payment_status, - F.data.startswith("check_simple_pal24_") - ) + dp.callback_query.register(check_simple_cryptobot_payment_status, F.data.startswith('check_simple_cryptobot_')) + + dp.callback_query.register(check_simple_heleket_payment_status, F.data.startswith('check_simple_heleket_')) + + dp.callback_query.register(check_simple_wata_payment_status, F.data.startswith('check_simple_wata_')) + + dp.callback_query.register(check_simple_pal24_payment_status, F.data.startswith('check_simple_pal24_')) diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 5e239ab5..a55d4efe 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,15 +1,17 @@ import logging -from decimal import Decimal, ROUND_HALF_UP -from aiogram import Dispatcher, types, F +from decimal import ROUND_HALF_UP, Decimal + +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.services.payment_service import PaymentService -from app.external.telegram_stars import TelegramStarsService from app.database.crud.user import get_user_by_telegram_id +from app.external.telegram_stars import TelegramStarsService from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts +from app.services.payment_service import PaymentService + logger = logging.getLogger(__name__) @@ -23,15 +25,15 @@ async def _handle_wheel_spin_payment( texts, ): """Обработка Stars платежа для колеса удачи.""" - from app.services.wheel_service import wheel_service from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes + from app.services.wheel_service import wheel_service try: config = await get_or_create_wheel_config(db) if not config.is_enabled: await message.answer( - "❌ Колесо удачи временно недоступно. Звезды будут возвращены.", + '❌ Колесо удачи временно недоступно. Звезды будут возвращены.', ) return False @@ -41,7 +43,7 @@ async def _handle_wheel_spin_payment( if not prizes: await message.answer( - "❌ Призы не настроены. Обратитесь в поддержку.", + '❌ Призы не настроены. Обратитесь в поддержку.', ) return False @@ -62,16 +64,14 @@ async def _handle_wheel_spin_payment( promocode_id = None if generated_promocode: - result = await db.execute( - f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'" - ) + result = await db.execute(f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'") row = result.fetchone() if row: promocode_id = row[0] logger.info( - f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, " - f"prize={selected_prize.display_name}" + f'🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, ' + f'prize={selected_prize.display_name}' ) spin = await create_wheel_spin( @@ -89,7 +89,7 @@ async def _handle_wheel_spin_payment( is_applied=True, ) - logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}") + logger.info(f'🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}') # Ensure all changes are committed (subscription days, traffic GB, etc.) await db.commit() @@ -97,25 +97,24 @@ async def _handle_wheel_spin_payment( # Отправляем результат prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode) - emoji = selected_prize.emoji or "🎁" + emoji = selected_prize.emoji or '🎁' await message.answer( - f"🎰 Колесо удачи!\n\n" - f"{emoji} {selected_prize.display_name}\n\n" - f"{prize_message}\n\n" - f"⭐ Потрачено: {stars_amount} Stars", - parse_mode="HTML", + f'🎰 Колесо удачи!\n\n' + f'{emoji} {selected_prize.display_name}\n\n' + f'{prize_message}\n\n' + f'⭐ Потрачено: {stars_amount} Stars', + parse_mode='HTML', ) logger.info( - f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, " - f"stars={stars_amount}" + f'🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, stars={stars_amount}' ) return True except Exception as e: - logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True) + logger.error(f'Ошибка обработки wheel spin payment: {e}', exc_info=True) await message.answer( - "❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.", + '❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.', ) return False @@ -130,27 +129,27 @@ async def _handle_trial_payment( ): """Обработка Stars платежа для платного триала.""" from app.database.crud.subscription import activate_pending_trial_subscription - from app.services.subscription_service import SubscriptionService - from app.services.admin_notification_service import AdminNotificationService from app.database.crud.transaction import create_transaction - from app.database.models import TransactionType, PaymentMethod + from app.database.models import PaymentMethod, TransactionType + from app.services.admin_notification_service import AdminNotificationService + from app.services.subscription_service import SubscriptionService try: # Парсим payload: trial_{subscription_id} - parts = payload.split("_") + parts = payload.split('_') if len(parts) < 2: - logger.error(f"Невалидный trial payload: {payload}") + logger.error(f'Невалидный trial payload: {payload}') await message.answer( - "❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.", + '❌ Ошибка: неверный формат платежа. Обратитесь в поддержку.', ) return False try: subscription_id = int(parts[1]) except ValueError: - logger.error(f"Невалидный subscription_id в trial payload: {payload}") + logger.error(f'Невалидный subscription_id в trial payload: {payload}') await message.answer( - "❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.", + '❌ Ошибка: неверный ID подписки. Обратитесь в поддержку.', ) return False @@ -159,14 +158,14 @@ async def _handle_trial_payment( amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) # Создаём транзакцию - transaction = await create_transaction( + await create_transaction( db=db, user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=amount_kopeks, - description=f"Оплата пробной подписки через Telegram Stars ({stars_amount} ⭐)", + description=f'Оплата пробной подписки через Telegram Stars ({stars_amount} ⭐)', payment_method=PaymentMethod.TELEGRAM_STARS, - external_id=f"trial_stars_{subscription_id}", + external_id=f'trial_stars_{subscription_id}', is_completed=True, ) @@ -178,18 +177,19 @@ async def _handle_trial_payment( ) if not subscription: - logger.error(f"Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}") + logger.error(f'Не удалось активировать триальную подписку {subscription_id} для пользователя {user.id}') # Возвращаем деньги на баланс from app.database.crud.user import add_user_balance + await add_user_balance( db, user, amount_kopeks, - "Возврат за неудачную активацию триала", + 'Возврат за неудачную активацию триала', transaction_type=TransactionType.REFUND, ) await message.answer( - "❌ Не удалось активировать пробную подписку. Средства возвращены на баланс.", + '❌ Не удалось активировать пробную подписку. Средства возвращены на баланс.', ) return False @@ -198,7 +198,7 @@ async def _handle_trial_payment( try: await subscription_service.create_remnawave_user(db, subscription) except Exception as rw_error: - logger.error(f"Ошибка создания пользователя RemnaWave для триала: {rw_error}") + logger.error(f'Ошибка создания пользователя RemnaWave для триала: {rw_error}') # Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен await db.commit() @@ -211,31 +211,31 @@ async def _handle_trial_payment( user=user, subscription=subscription, paid_amount=amount_kopeks, - payment_method="Telegram Stars", + payment_method='Telegram Stars', ) except Exception as admin_error: - logger.warning(f"Ошибка отправки уведомления админам о триале: {admin_error}") + logger.warning(f'Ошибка отправки уведомления админам о триале: {admin_error}') # Отправляем сообщение пользователю await message.answer( - f"🎉 Пробная подписка активирована!\n\n" - f"⭐ Потрачено: {stars_amount} Stars\n" - f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n" - f"📱 Устройств: {subscription.device_limit}\n\n" - f"Используйте меню для подключения к VPN.", - parse_mode="HTML", + f'🎉 Пробная подписка активирована!\n\n' + f'⭐ Потрачено: {stars_amount} Stars\n' + f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n' + f'📱 Устройств: {subscription.device_limit}\n\n' + f'Используйте меню для подключения к VPN.', + parse_mode='HTML', ) logger.info( - f"✅ Платный триал активирован через Stars: user={user.id}, " - f"subscription={subscription.id}, stars={stars_amount}" + f'✅ Платный триал активирован через Stars: user={user.id}, ' + f'subscription={subscription.id}, stars={stars_amount}' ) return True except Exception as e: - logger.error(f"Ошибка обработки trial payment: {e}", exc_info=True) + logger.error(f'Ошибка обработки trial payment: {e}', exc_info=True) await message.answer( - "❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.", + '❌ Произошла ошибка при активации пробной подписки. Обратитесь в поддержку.', ) return False @@ -245,18 +245,18 @@ 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}" + f'📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}' ) - allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_", "trial_") + allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_') if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes): - logger.warning(f"Невалидный payload: {query.invoice_payload}") + logger.warning(f'Невалидный payload: {query.invoice_payload}') await query.answer( ok=False, error_message=texts.t( - "STARS_PRECHECK_INVALID_PAYLOAD", - "Ошибка валидации платежа. Попробуйте еще раз.", + 'STARS_PRECHECK_INVALID_PAYLOAD', + 'Ошибка валидации платежа. Попробуйте еще раз.', ), ) return @@ -267,47 +267,42 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery): async with AsyncSessionLocal() as db: user = await get_user_by_telegram_id(db, query.from_user.id) if not user: - logger.warning(f"Пользователь {query.from_user.id} не найден в БД") + logger.warning(f'Пользователь {query.from_user.id} не найден в БД') await query.answer( ok=False, error_message=texts.t( - "STARS_PRECHECK_USER_NOT_FOUND", - "Пользователь не найден. Обратитесь в поддержку.", + 'STARS_PRECHECK_USER_NOT_FOUND', + 'Пользователь не найден. Обратитесь в поддержку.', ), ) return texts = get_texts(user.language or DEFAULT_LANGUAGE) except Exception as db_error: - logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}") + logger.error(f'Ошибка подключения к БД в pre_checkout_query: {db_error}') await query.answer( ok=False, error_message=texts.t( - "STARS_PRECHECK_TECHNICAL_ERROR", - "Техническая ошибка. Попробуйте позже.", + 'STARS_PRECHECK_TECHNICAL_ERROR', + 'Техническая ошибка. Попробуйте позже.', ), ) return await query.answer(ok=True) - logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}") + logger.info(f'✅ Pre-checkout одобрен для пользователя {query.from_user.id}') except Exception as e: - logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True) + logger.error(f'Ошибка в pre_checkout_query: {e}', exc_info=True) await query.answer( ok=False, error_message=texts.t( - "STARS_PRECHECK_TECHNICAL_ERROR", - "Техническая ошибка. Попробуйте позже.", + 'STARS_PRECHECK_TECHNICAL_ERROR', + 'Техническая ошибка. Попробуйте позже.', ), ) -async def handle_successful_payment( - message: types.Message, - db: AsyncSession, - state: FSMContext, - **kwargs -): +async def handle_successful_payment(message: types.Message, db: AsyncSession, state: FSMContext, **kwargs): texts = get_texts(DEFAULT_LANGUAGE) try: @@ -315,27 +310,27 @@ async def handle_successful_payment( user_id = message.from_user.id logger.info( - f"💳 Успешный Stars платеж от {user_id}: " - f"{payment.total_amount} XTR, " - f"payload: {payment.invoice_payload}, " - f"charge_id: {payment.telegram_payment_charge_id}" + f'💳 Успешный Stars платеж от {user_id}: ' + f'{payment.total_amount} XTR, ' + f'payload: {payment.invoice_payload}, ' + f'charge_id: {payment.telegram_payment_charge_id}' ) user = await get_user_by_telegram_id(db, user_id) texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE) if not user: - logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа") + logger.error(f'Пользователь {user_id} не найден при обработке Stars платежа') await message.answer( texts.t( - "STARS_PAYMENT_USER_NOT_FOUND", - "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.", + 'STARS_PAYMENT_USER_NOT_FOUND', + '❌ Ошибка: пользователь не найден. Обратитесь в поддержку.', ) ) return # Обработка оплаты спина колеса удачи - if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"): + if payment.invoice_payload and payment.invoice_payload.startswith('wheel_spin_'): await _handle_wheel_spin_payment( message=message, db=db, @@ -347,7 +342,7 @@ async def handle_successful_payment( return # Обработка оплаты платного триала - if payment.invoice_payload and payment.invoice_payload.startswith("trial_"): + if payment.invoice_payload and payment.invoice_payload.startswith('trial_'): await _handle_trial_payment( message=message, db=db, @@ -361,21 +356,21 @@ async def handle_successful_payment( payment_service = PaymentService(message.bot) state_data = await state.get_data() - prompt_message_id = state_data.get("stars_prompt_message_id") - prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id) - invoice_message_id = state_data.get("stars_invoice_message_id") - invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id) + prompt_message_id = state_data.get('stars_prompt_message_id') + prompt_chat_id = state_data.get('stars_prompt_chat_id', message.chat.id) + invoice_message_id = state_data.get('stars_invoice_message_id') + invoice_chat_id = state_data.get('stars_invoice_chat_id', message.chat.id) for chat_id, message_id, label in [ - (prompt_chat_id, prompt_message_id, "запрос суммы"), - (invoice_chat_id, invoice_message_id, "инвойс Stars"), + (prompt_chat_id, prompt_message_id, 'запрос суммы'), + (invoice_chat_id, invoice_message_id, 'инвойс Stars'), ]: if message_id: try: await message.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - зависит от прав бота logger.warning( - "Не удалось удалить сообщение %s после оплаты Stars: %s", + 'Не удалось удалить сообщение %s после оплаты Stars: %s', label, delete_error, ) @@ -385,7 +380,7 @@ async def handle_successful_payment( user_id=user.id, stars_amount=payment.total_amount, payload=payment.invoice_payload, - telegram_payment_charge_id=payment.telegram_payment_charge_id + telegram_payment_charge_id=payment.telegram_payment_charge_id, ) await state.update_data( @@ -398,7 +393,7 @@ async def handle_successful_payment( if success: rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount) amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) - amount_text = settings.format_price(amount_kopeks).replace(" ₽", "") + amount_text = settings.format_price(amount_kopeks).replace(' ₽', '') keyboard = await payment_service.build_topup_success_keyboard(user) @@ -406,62 +401,54 @@ async def handle_successful_payment( await message.answer( texts.t( - "STARS_PAYMENT_SUCCESS", - "🎉 Платеж успешно обработан!\n\n" - "⭐ Потрачено звезд: {stars_spent}\n" - "💰 Зачислено на баланс: {amount} ₽\n" - "🆔 ID транзакции: {transaction_id}...\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - "🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - "подписка будет приобретена автоматически после пополнения баланса.\n\n" - "Спасибо за пополнение! 🚀", + 'STARS_PAYMENT_SUCCESS', + '🎉 Платеж успешно обработан!\n\n' + '⭐ Потрачено звезд: {stars_spent}\n' + '💰 Зачислено на баланс: {amount} ₽\n' + '🆔 ID транзакции: {transaction_id}...\n\n' + '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + 'Обязательно активируйте подписку отдельно!\n\n' + '🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + 'подписка будет приобретена автоматически после пополнения баланса.\n\n' + 'Спасибо за пополнение! 🚀', ).format( stars_spent=payment.total_amount, amount=amount_text, transaction_id=transaction_id_short, ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) logger.info( - "✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s", + '✅ Stars платеж успешно обработан: пользователь %s, %s звезд → %s', user.id, payment.total_amount, settings.format_price(amount_kopeks), ) else: - logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}") + logger.error(f'Ошибка обработки Stars платежа для пользователя {user.id}') await message.answer( texts.t( - "STARS_PAYMENT_ENROLLMENT_ERROR", - "❌ Произошла ошибка при зачислении средств. " - "Обратитесь в поддержку, платеж будет проверен вручную.", + 'STARS_PAYMENT_ENROLLMENT_ERROR', + '❌ Произошла ошибка при зачислении средств. ' + 'Обратитесь в поддержку, платеж будет проверен вручную.', ) ) except Exception as e: - logger.error(f"Ошибка в successful_payment: {e}", exc_info=True) + logger.error(f'Ошибка в successful_payment: {e}', exc_info=True) await message.answer( texts.t( - "STARS_PAYMENT_PROCESSING_ERROR", - "❌ Техническая ошибка при обработке платежа. " - "Обратитесь в поддержку для решения проблемы.", + 'STARS_PAYMENT_PROCESSING_ERROR', + '❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.', ) ) def register_stars_handlers(dp: Dispatcher): + dp.pre_checkout_query.register(handle_pre_checkout_query, F.currency == 'XTR') - dp.pre_checkout_query.register( - handle_pre_checkout_query, - F.currency == "XTR" - ) + dp.message.register(handle_successful_payment, F.successful_payment) - dp.message.register( - handle_successful_payment, - F.successful_payment - ) - - logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей") + logger.info('🌟 Зарегистрированы обработчики Telegram Stars платежей') diff --git a/app/handlers/start.py b/app/handlers/start.py index 0deaf458..7bdf8a54 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,7 +1,7 @@ import logging from datetime import datetime -from typing import Optional -from aiogram import Dispatcher, types, F, Bot + +from aiogram import Bot, Dispatcher, F, types from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramForbiddenError from aiogram.filters import Command, StateFilter @@ -9,51 +9,50 @@ from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.states import RegistrationStates +from app.database.crud.campaign import ( + get_campaign_by_id, + get_campaign_by_start_parameter, +) +from app.database.crud.subscription import decrement_subscription_server_counts from app.database.crud.user import ( - get_user_by_telegram_id, create_user, get_user_by_referral_code, + get_user_by_telegram_id, ) -from app.database.crud.campaign import ( - get_campaign_by_start_parameter, - get_campaign_by_id, -) +from app.database.crud.user_message import get_random_active_message from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus from app.keyboards.inline import ( - get_rules_keyboard, - get_privacy_policy_keyboard, - get_main_menu_keyboard, + get_language_selection_keyboard, get_main_menu_keyboard_async, get_post_registration_keyboard, - get_language_selection_keyboard, + get_privacy_policy_keyboard, + get_rules_keyboard, ) from app.localization.loader import DEFAULT_LANGUAGE -from app.localization.texts import get_texts, get_rules, get_privacy_policy -from app.services.referral_service import process_referral_registration -from app.services.campaign_service import AdvertisingCampaignService +from app.localization.texts import get_privacy_policy, get_rules, get_texts +from app.middlewares.channel_checker import ( + delete_pending_payload_from_redis, + get_pending_payload_from_redis, +) from app.services.admin_notification_service import AdminNotificationService -from app.services.subscription_service import SubscriptionService -from app.services.support_settings_service import SupportSettingsService +from app.services.blacklist_service import blacklist_service +from app.services.campaign_service import AdvertisingCampaignService from app.services.main_menu_button_service import MainMenuButtonService -from app.services.privacy_policy_service import PrivacyPolicyService from app.services.pinned_message_service import ( deliver_pinned_message_to_user, get_active_pinned_message, ) -from app.utils.user_utils import generate_unique_referral_code +from app.services.privacy_policy_service import PrivacyPolicyService +from app.services.referral_service import process_referral_registration +from app.services.subscription_service import SubscriptionService +from app.services.support_settings_service import SupportSettingsService +from app.states import RegistrationStates from app.utils.promo_offer import ( build_promo_offer_hint, build_test_access_hint, ) from app.utils.timezone import format_local_datetime -from app.database.crud.user_message import get_random_active_message -from app.middlewares.channel_checker import ( - get_pending_payload_from_redis, - delete_pending_payload_from_redis, -) -from app.database.crud.subscription import decrement_subscription_server_counts -from app.services.blacklist_service import blacklist_service +from app.utils.user_utils import generate_unique_referral_code logger = logging.getLogger(__name__) @@ -63,9 +62,9 @@ def _calculate_subscription_flags(subscription): if not subscription: return False, False - actual_status = getattr(subscription, "actual_status", None) - has_active_subscription = actual_status in {"active", "trial"} - subscription_is_active = bool(getattr(subscription, "is_active", False)) + actual_status = getattr(subscription, 'actual_status', None) + has_active_subscription = actual_status in {'active', 'trial'} + subscription_is_active = bool(getattr(subscription, 'is_active', False)) return has_active_subscription, subscription_is_active @@ -74,14 +73,14 @@ async def _send_pinned_message( bot: Bot, db: AsyncSession, user, - pinned_message: Optional[PinnedMessage] = None, + pinned_message: PinnedMessage | None = None, ) -> None: try: await deliver_pinned_message_to_user(bot, db, user, pinned_message) - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error( - "Не удалось отправить закрепленное сообщение пользователю %s: %s", - getattr(user, "telegram_id", "unknown"), + 'Не удалось отправить закрепленное сообщение пользователю %s: %s', + getattr(user, 'telegram_id', 'unknown'), error, ) @@ -92,7 +91,7 @@ async def _apply_campaign_bonus_if_needed( state_data: dict, texts, ): - campaign_id = state_data.get("campaign_id") if state_data else None + campaign_id = state_data.get('campaign_id') if state_data else None if not campaign_id: return None @@ -105,14 +104,14 @@ async def _apply_campaign_bonus_if_needed( if not result.success: return None - if result.bonus_type == "balance": + if result.bonus_type == 'balance': amount_text = texts.format_price(result.balance_kopeks) return texts.CAMPAIGN_BONUS_BALANCE.format( amount=amount_text, name=campaign.name, ) - if result.bonus_type == "subscription": + if result.bonus_type == 'subscription': traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( name=campaign.name, @@ -121,19 +120,17 @@ async def _apply_campaign_bonus_if_needed( devices=result.subscription_device_limit, ) - if result.bonus_type == "none": + if result.bonus_type == 'none': # Ссылка без награды - не показываем сообщение return None - if result.bonus_type == "tariff": + if result.bonus_type == 'tariff': traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) return texts.t( - "CAMPAIGN_BONUS_TARIFF", - "🎁 Вам выдан тариф '{tariff_name}' на {days} дней!\n" - "📊 Трафик: {traffic}\n" - "📱 Устройств: {devices}", + 'CAMPAIGN_BONUS_TARIFF', + "🎁 Вам выдан тариф '{tariff_name}' на {days} дней!\n📊 Трафик: {traffic}\n📱 Устройств: {devices}", ).format( - tariff_name=result.tariff_name or "Подарочный", + tariff_name=result.tariff_name or 'Подарочный', days=result.tariff_duration_days, traffic=traffic_text, devices=result.subscription_device_limit, @@ -142,11 +139,7 @@ async def _apply_campaign_bonus_if_needed( return None -async def handle_potential_referral_code( - message: types.Message, - state: FSMContext, - db: AsyncSession -): +async def handle_potential_referral_code(message: types.Message, state: FSMContext, db: AsyncSession): current_state = await state.get_state() logger.info(f"🔍 REFERRAL/PROMO CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}") @@ -154,7 +147,7 @@ async def handle_potential_referral_code( RegistrationStates.waiting_for_rules_accept.state, RegistrationStates.waiting_for_privacy_policy_accept.state, RegistrationStates.waiting_for_referral_code.state, - None + None, ]: return False @@ -163,11 +156,7 @@ async def handle_potential_referral_code( return False data = await state.get_data() or {} - language = ( - data.get("language") - or (getattr(user, "language", None) if user else None) - or DEFAULT_LANGUAGE - ) + language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE texts = get_texts(language) potential_code = message.text.strip() @@ -181,20 +170,17 @@ async def handle_potential_referral_code( data['referrer_id'] = referrer.id await state.set_data(data) - await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!")) - logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}") + await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!')) + logger.info(f'✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}') if current_state != RegistrationStates.waiting_for_referral_code.state: language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) rules_text = await get_rules(language) - await message.answer( - rules_text, - reply_markup=get_rules_keyboard(language) - ) + await message.answer(rules_text, reply_markup=get_rules_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_rules_accept) - logger.info("📋 Правила отправлены после ввода реферального кода") + logger.info('📋 Правила отправлены после ввода реферального кода') else: await complete_registration(message, state, db) @@ -205,51 +191,50 @@ async def handle_potential_referral_code( promocode_check = await check_promocode_validity(db, potential_code) - if promocode_check["valid"]: + if promocode_check['valid']: # Промокод валиден - сохраняем его в state для активации после создания пользователя data['promocode'] = potential_code await state.set_data(data) await message.answer( texts.t( - "PROMOCODE_ACCEPTED_WILL_ACTIVATE", - "✅ Промокод принят! Он будет активирован после завершения регистрации." + 'PROMOCODE_ACCEPTED_WILL_ACTIVATE', + '✅ Промокод принят! Он будет активирован после завершения регистрации.', ) ) - logger.info(f"✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}") + logger.info(f'✅ Промокод {potential_code} сохранен для активации для пользователя {message.from_user.id}') if current_state != RegistrationStates.waiting_for_referral_code.state: language = data.get('language', DEFAULT_LANGUAGE) texts = get_texts(language) rules_text = await get_rules(language) - await message.answer( - rules_text, - reply_markup=get_rules_keyboard(language) - ) + await message.answer(rules_text, reply_markup=get_rules_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_rules_accept) - logger.info("📋 Правила отправлены после принятия промокода") + logger.info('📋 Правила отправлены после принятия промокода') else: await complete_registration(message, state, db) return True # Ни реферальный код, ни промокод не найдены - await message.answer(texts.t( - "REFERRAL_OR_PROMO_CODE_INVALID_HELP", - "❌ Неверный реферальный код или промокод.\n\n" - "💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n" - "⏭️ Для продолжения регистрации без кода используйте команду /start", - )) + await message.answer( + texts.t( + 'REFERRAL_OR_PROMO_CODE_INVALID_HELP', + '❌ Неверный реферальный код или промокод.\n\n' + '💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n' + '⏭️ Для продолжения регистрации без кода используйте команду /start', + ) + ) return True def _get_language_prompt_text() -> str: - return "🌐 Выберите язык / Choose your language:" + return '🌐 Выберите язык / Choose your language:' async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None: - logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}") + logger.info(f'🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}') await state.set_state(RegistrationStates.waiting_for_language) await message.answer( @@ -271,7 +256,7 @@ async def _continue_registration_after_language( target_message = callback.message if callback else message if not target_message: - logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации") + logger.warning('⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации') return async def _complete_registration_wrapper(): @@ -281,14 +266,14 @@ async def _continue_registration_after_language( await complete_registration(message, state, db) if settings.SKIP_RULES_ACCEPT: - logger.info("⚙️ LANGUAGE: 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}") + logger.info(f'✅ LANGUAGE: Реферер найден: {referrer.id}') if settings.SKIP_REFERRAL_CODE or data.get('referral_code'): await _complete_registration_wrapper() @@ -296,39 +281,38 @@ async def _continue_registration_after_language( try: await target_message.answer( texts.t( - "REFERRAL_CODE_QUESTION", + 'REFERRAL_CODE_QUESTION', "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", ), - reply_markup=get_referral_code_keyboard(language) + reply_markup=get_referral_code_keyboard(language), ) await state.set_state(RegistrationStates.waiting_for_referral_code) - logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода") + logger.info('🔍 LANGUAGE: Ожидание ввода реферального кода') except Exception as error: - logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}") + logger.error(f'Ошибка при показе вопроса о реферальном коде после выбора языка: {error}') await _complete_registration_wrapper() return rules_text = await get_rules(language) try: - await target_message.answer( - rules_text, - reply_markup=get_rules_keyboard(language) - ) + await target_message.answer(rules_text, reply_markup=get_rules_keyboard(language)) except TelegramForbiddenError: - logger.warning(f"⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил") + logger.warning( + f'⚠️ Пользователь {callback.from_user.id if callback else message.from_user.id} заблокировал бота, пропускаем отправку правил' + ) return await state.set_state(RegistrationStates.waiting_for_rules_accept) - logger.info("📋 LANGUAGE: Правила отправлены после выбора языка") + logger.info('📋 LANGUAGE: Правила отправлены после выбора языка') async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None): - logger.info(f"🚀 START: Обработка /start от {message.from_user.id}") + logger.info(f'🚀 START: Обработка /start от {message.from_user.id}') data = await state.get_data() or {} - had_pending_payload = "pending_start_payload" in data - pending_start_payload = data.pop("pending_start_payload", None) - had_campaign_notification_flag = "campaign_notification_sent" in data - campaign_notification_sent = data.pop("campaign_notification_sent", False) + had_pending_payload = 'pending_start_payload' in data + pending_start_payload = data.pop('pending_start_payload', None) + had_campaign_notification_flag = 'campaign_notification_sent' in data + campaign_notification_sent = data.pop('campaign_notification_sent', False) state_needs_update = had_pending_payload or had_campaign_notification_flag # Если в FSM state нет payload, пробуем получить из Redis (резервный механизм) @@ -370,14 +354,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if campaign: logger.info( - "📣 Найдена рекламная кампания %s (start=%s)", + '📣 Найдена рекламная кампания %s (start=%s)', campaign.id, campaign.start_parameter, ) await state.update_data(campaign_id=campaign.id) else: referral_code = start_parameter - logger.info(f"🔎 Найден реферальный код: {referral_code}") + logger.info(f'🔎 Найден реферальный код: {referral_code}') if referral_code: await state.update_data(referral_code=referral_code) @@ -395,13 +379,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, ) except Exception as notify_error: logger.error( - "Ошибка отправки админ уведомления о переходе по кампании %s: %s", + 'Ошибка отправки админ уведомления о переходе по кампании %s: %s', campaign.id, notify_error, ) if user and user.status != UserStatus.DELETED.value: - logger.info(f"✅ Активный пользователь найден: {user.telegram_id}") + logger.info(f'✅ Активный пользователь найден: {user.telegram_id}') profile_updated = False @@ -429,7 +413,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, user.updated_at = datetime.utcnow() await db.commit() await db.refresh(user) - logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен") + logger.info(f'💾 Профиль пользователя {user.telegram_id} обновлен') else: await db.commit() @@ -438,8 +422,8 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if referral_code and not user.referred_by_id: await message.answer( texts.t( - "ALREADY_REGISTERED_REFERRAL", - "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + 'ALREADY_REGISTERED_REFERRAL', + 'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.', ) ) @@ -447,18 +431,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, try: await message.answer( texts.t( - "CAMPAIGN_EXISTING_USERL", - "ℹ️ Эта рекламная ссылка доступна только новым пользователям.", + 'CAMPAIGN_EXISTING_USERL', + 'ℹ️ Эта рекламная ссылка доступна только новым пользователям.', ) ) except Exception as e: - logger.error( - f"Ошибка отправки уведомления о рекламной кампании: {e}" - ) + logger.error(f'Ошибка отправки уведомления о рекламной кампании: {e}') - has_active_subscription, subscription_is_active = _calculate_subscription_flags( - user.subscription - ) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) pinned_message = await get_active_pinned_message(db) @@ -468,9 +448,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) - is_moderator = (not is_admin) and SupportSettingsService.is_moderator( - user.telegram_id - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -494,11 +472,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, is_moderator=is_moderator, custom_buttons=custom_buttons, ) - await message.answer( - menu_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') if pinned_message and not pinned_message.send_before_menu: await _send_pinned_message(message.bot, db, user, pinned_message) @@ -506,43 +480,30 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, return if user and user.status == UserStatus.DELETED.value: - logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию") + logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию') try: - from app.services.user_service import UserService - from app.database.models import ( - Subscription, Transaction, PromoCodeUse, - ReferralEarning, SubscriptionServer - ) from sqlalchemy import delete + from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction + if user.subscription: await decrement_subscription_server_counts(db, user.subscription) await db.execute( - delete(SubscriptionServer).where( - SubscriptionServer.subscription_id == user.subscription.id - ) + delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) - logger.info(f"🗑️ Удалены записи SubscriptionServer") + logger.info('🗑️ Удалены записи SubscriptionServer') if user.subscription: await db.delete(user.subscription) - logger.info(f"🗑️ Удалена подписка пользователя") + logger.info('🗑️ Удалена подписка пользователя') - await db.execute( - delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id) - ) + await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)) - await db.execute( - delete(ReferralEarning).where(ReferralEarning.user_id == user.id) - ) - await db.execute( - delete(ReferralEarning).where(ReferralEarning.referral_id == user.id) - ) + await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id)) + await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)) - await db.execute( - delete(Transaction).where(Transaction.user_id == user.id) - ) + await db.execute(delete(Transaction).where(Transaction.user_id == user.id)) user.status = UserStatus.ACTIVE.value user.balance_kopeks = 0 @@ -557,17 +518,18 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, user.last_activity = datetime.utcnow() from app.utils.user_utils import generate_unique_referral_code + user.referral_code = await generate_unique_referral_code(db, user.telegram_id) await db.commit() - logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению") + logger.info(f'✅ Пользователь {user.telegram_id} подготовлен к восстановлению') except Exception as e: - logger.error(f"❌ Ошибка подготовки к восстановлению: {e}") + logger.error(f'❌ Ошибка подготовки к восстановлению: {e}') await db.rollback() else: - logger.info(f"🆕 Новый пользователь, начинаем регистрацию") + logger.info('🆕 Новый пользователь, начинаем регистрацию') data = await state.get_data() or {} if not data.get('language'): @@ -580,7 +542,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if isinstance(settings.DEFAULT_LANGUAGE, str) else DEFAULT_LANGUAGE ) - normalized_default = default_language.split("-")[0].lower() + normalized_default = default_language.split('-')[0].lower() data['language'] = normalized_default await state.set_data(data) logger.info( @@ -601,9 +563,7 @@ async def process_language_selection( state: FSMContext, db: AsyncSession, ): - logger.info( - f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})" - ) + logger.info(f'🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})') if not settings.is_language_selection_enabled(): data = await state.get_data() or {} @@ -612,7 +572,7 @@ async def process_language_selection( if isinstance(settings.DEFAULT_LANGUAGE, str) else DEFAULT_LANGUAGE ) - normalized_default = default_language.split("-")[0].lower() + normalized_default = default_language.split('-')[0].lower() data['language'] = normalized_default await state.set_data(data) @@ -621,15 +581,15 @@ async def process_language_selection( try: await callback.message.edit_text( texts.t( - "LANGUAGE_SELECTION_DISABLED", - "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", + 'LANGUAGE_SELECTION_DISABLED', + '⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.', ) ) except Exception: await callback.message.answer( texts.t( - "LANGUAGE_SELECTION_DISABLED", - "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", + 'LANGUAGE_SELECTION_DISABLED', + '⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.', ) ) @@ -643,7 +603,7 @@ async def process_language_selection( ) return - selected_raw = (callback.data or "").split(":", 1)[-1] + selected_raw = (callback.data or '').split(':', 1)[-1] normalized_selected = selected_raw.strip().lower() available_map = { @@ -656,7 +616,7 @@ async def process_language_selection( logger.warning( f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}" ) - await callback.answer("❌ Unsupported language", show_alert=True) + await callback.answer('❌ Unsupported language', show_alert=True) return resolved_language = available_map[normalized_selected].lower() @@ -669,13 +629,12 @@ async def process_language_selection( try: await callback.message.edit_text( - texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."), + texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'), ) except Exception as error: - logger.warning( - f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}") + logger.warning(f'⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}') await callback.message.answer( - texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."), + texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'), ) await callback.answer() @@ -701,39 +660,35 @@ async def _show_privacy_policy_after_rules( policy = await PrivacyPolicyService.get_policy(db, language, fallback=True) if not policy or not policy.is_enabled: - logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ") + logger.info('⚠️ Политика конфиденциальности не включена, пропускаем её показ') return False if not policy.content or not policy.content.strip(): privacy_policy_text = get_privacy_policy(language) if not privacy_policy_text or not privacy_policy_text.strip(): - logger.info("⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ") + logger.info('⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ') return False - logger.info(f"🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}") + logger.info(f'🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}') else: privacy_policy_text = policy.content - logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}") + logger.info(f'🔒 Используется политика конфиденциальности из БД для языка {language}') try: - await callback.message.edit_text( - privacy_policy_text, - reply_markup=get_privacy_policy_keyboard(language) - ) + await callback.message.edit_text(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept) - logger.info(f"🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}") + logger.info(f'🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}') return True except Exception as e: - logger.error(f"Ошибка при показе политики конфиденциальности: {e}", exc_info=True) + logger.error(f'Ошибка при показе политики конфиденциальности: {e}', exc_info=True) try: - await callback.message.answer( - privacy_policy_text, - reply_markup=get_privacy_policy_keyboard(language) - ) + await callback.message.answer(privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language)) await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept) - logger.info(f"🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}") + logger.info( + f'🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}' + ) return True except Exception as e2: - logger.error(f"Критическая ошибка при отправке политики конфиденциальности: {e2}", exc_info=True) + logger.error(f'Критическая ошибка при отправке политики конфиденциальности: {e2}', exc_info=True) return False @@ -750,49 +705,44 @@ async def _continue_registration_after_rules( texts = get_texts(language) if data.get('referral_code'): - logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") + logger.info(f'🎫 Найден реферальный код из deep link: {data["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}") + logger.info(f'✅ Реферер найден: {referrer.id}') + await complete_registration_from_callback(callback, state, db) + elif settings.SKIP_REFERRAL_CODE: + logger.info('⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода') await complete_registration_from_callback(callback, state, db) else: - if settings.SKIP_REFERRAL_CODE: - logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода") + try: + await callback.message.edit_text( + texts.t( + 'REFERRAL_CODE_QUESTION', + "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", + ), + reply_markup=get_referral_code_keyboard(language), + ) + await state.set_state(RegistrationStates.waiting_for_referral_code) + logger.info('🔍 Ожидание ввода реферального кода') + except Exception as e: + logger.error(f'Ошибка при показе вопроса о реферальном коде: {e}') await complete_registration_from_callback(callback, state, db) - else: - try: - await callback.message.edit_text( - texts.t( - "REFERRAL_CODE_QUESTION", - "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", - ), - reply_markup=get_referral_code_keyboard(language) - ) - await state.set_state(RegistrationStates.waiting_for_referral_code) - logger.info(f"🔍 Ожидание ввода реферального кода") - except Exception as e: - logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}") - await complete_registration_from_callback(callback, state, db) -async def process_rules_accept( - callback: types.CallbackQuery, - state: FSMContext, - db: AsyncSession -): +async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): """ Обрабатывает принятие или отклонение правил пользователем. """ - logger.info(f"📋 RULES: Начало обработки правил") - logger.info(f"📊 Callback data: {callback.data}") - logger.info(f"👤 User: {callback.from_user.id}") + logger.info('📋 RULES: Начало обработки правил') + logger.info(f'📊 Callback data: {callback.data}') + logger.info(f'👤 User: {callback.from_user.id}') current_state = await state.get_state() - logger.info(f"📊 Текущее состояние: {current_state}") + logger.info(f'📊 Текущее состояние: {current_state}') language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -805,48 +755,38 @@ async def process_rules_accept( texts = get_texts(language) if callback.data == 'rules_accept': - logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}") + logger.info(f'✅ Правила приняты пользователем {callback.from_user.id}') # Пытаемся показать политику конфиденциальности - policy_shown = await _show_privacy_policy_after_rules( - callback, state, db, language - ) + policy_shown = await _show_privacy_policy_after_rules(callback, state, db, language) # Если политика не была показана, продолжаем регистрацию if not policy_shown: - await _continue_registration_after_rules( - callback, state, db, language - ) + await _continue_registration_after_rules(callback, state, db, language) else: - logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}") + logger.info(f'❌ Правила отклонены пользователем {callback.from_user.id}') rules_required_text = texts.t( - "RULES_REQUIRED", - "Для использования бота необходимо принять правила сервиса.", + 'RULES_REQUIRED', + 'Для использования бота необходимо принять правила сервиса.', ) try: - await callback.message.edit_text( - rules_required_text, - reply_markup=get_rules_keyboard(language) - ) + await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language)) except Exception as e: - logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}") + logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}') try: - await callback.message.edit_text( - rules_required_text, - reply_markup=get_rules_keyboard(language) - ) + await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language)) except: pass - logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}") + logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}') except Exception as e: - logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True) + logger.error(f'❌ Ошибка обработки правил: {e}', exc_info=True) await callback.answer( - texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."), + texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'), show_alert=True, ) @@ -856,28 +796,23 @@ async def process_rules_accept( texts = get_texts(language) await callback.message.answer( texts.t( - "ERROR_RULES_RETRY", - "Произошла ошибка. Попробуйте принять правила еще раз:", + 'ERROR_RULES_RETRY', + 'Произошла ошибка. Попробуйте принять правила еще раз:', ), - reply_markup=get_rules_keyboard(language) + reply_markup=get_rules_keyboard(language), ) await state.set_state(RegistrationStates.waiting_for_rules_accept) except: pass -async def process_privacy_policy_accept( - callback: types.CallbackQuery, - state: FSMContext, - db: AsyncSession -): - - logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности") - logger.info(f"📊 Callback data: {callback.data}") - logger.info(f"👤 User: {callback.from_user.id}") +async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): + logger.info('🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности') + logger.info(f'📊 Callback data: {callback.data}') + logger.info(f'👤 User: {callback.from_user.id}') current_state = await state.get_state() - logger.info(f"📊 Текущее состояние: {current_state}") + logger.info(f'📊 Текущее состояние: {current_state}') language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -890,85 +825,82 @@ async def process_privacy_policy_accept( texts = get_texts(language) if callback.data == 'privacy_policy_accept': - logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}") + logger.info(f'✅ Политика конфиденциальности принята пользователем {callback.from_user.id}') try: await callback.message.delete() - logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено") + logger.info('🗑️ Сообщение с политикой конфиденциальности удалено') except Exception as e: - logger.warning(f"⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}") + logger.warning(f'⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}') try: await callback.message.edit_text( texts.t( - "PRIVACY_POLICY_ACCEPTED_PROCESSING", - "✅ Политика конфиденциальности принята! Продолжаем регистрацию...", + 'PRIVACY_POLICY_ACCEPTED_PROCESSING', + '✅ Политика конфиденциальности принята! Продолжаем регистрацию...', ), - reply_markup=None + reply_markup=None, ) except Exception: pass if data.get('referral_code'): - logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}") + logger.info(f'🎫 Найден реферальный код из deep link: {data["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}") + logger.info(f'✅ Реферер найден: {referrer.id}') + await complete_registration_from_callback(callback, state, db) + elif settings.SKIP_REFERRAL_CODE: + logger.info('⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода') await complete_registration_from_callback(callback, state, db) else: - if settings.SKIP_REFERRAL_CODE: - logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода") - await complete_registration_from_callback(callback, state, db) - else: - try: - await state.set_data(data) - await state.set_state(RegistrationStates.waiting_for_referral_code) + try: + await state.set_data(data) + await state.set_state(RegistrationStates.waiting_for_referral_code) - await callback.bot.send_message( - chat_id=callback.from_user.id, - text=texts.t( - "REFERRAL_CODE_QUESTION", - "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", - ), - reply_markup=get_referral_code_keyboard(language) - ) - logger.info(f"🔍 Ожидание ввода реферального кода") - except Exception as e: - logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}") - await complete_registration_from_callback(callback, state, db) + await callback.bot.send_message( + chat_id=callback.from_user.id, + text=texts.t( + 'REFERRAL_CODE_QUESTION', + "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", + ), + reply_markup=get_referral_code_keyboard(language), + ) + logger.info('🔍 Ожидание ввода реферального кода') + except Exception as e: + logger.error(f'Ошибка при показе вопроса о реферальном коде: {e}') + await complete_registration_from_callback(callback, state, db) else: - logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}") + logger.info(f'❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}') privacy_policy_required_text = texts.t( - "PRIVACY_POLICY_REQUIRED", - "Для использования бота необходимо принять политику конфиденциальности.", + 'PRIVACY_POLICY_REQUIRED', + 'Для использования бота необходимо принять политику конфиденциальности.', ) try: await callback.message.edit_text( - privacy_policy_required_text, - reply_markup=get_privacy_policy_keyboard(language) + privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language) ) except Exception as e: - logger.error(f"Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}") + logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}') try: await callback.message.edit_text( - privacy_policy_required_text, - reply_markup=get_privacy_policy_keyboard(language) + privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language) ) except: pass - logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}") + logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}') except Exception as e: - logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True) + logger.error(f'❌ Ошибка обработки политики конфиденциальности: {e}', exc_info=True) await callback.answer( - texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."), + texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'), show_alert=True, ) @@ -978,23 +910,18 @@ async def process_privacy_policy_accept( texts = get_texts(language) await callback.message.answer( texts.t( - "ERROR_PRIVACY_POLICY_RETRY", - "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:", + 'ERROR_PRIVACY_POLICY_RETRY', + 'Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:', ), - reply_markup=get_privacy_policy_keyboard(language) + reply_markup=get_privacy_policy_keyboard(language), ) await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept) except: pass -async def process_referral_code_input( - message: types.Message, - state: FSMContext, - db: AsyncSession -): - - logger.info(f"🎫 REFERRAL/PROMO: Обработка кода: {message.text}") +async def process_referral_code_input(message: types.Message, state: FSMContext, db: AsyncSession): + logger.info(f'🎫 REFERRAL/PROMO: Обработка кода: {message.text}') data = await state.get_data() or {} language = data.get('language', DEFAULT_LANGUAGE) @@ -1007,8 +934,8 @@ async def process_referral_code_input( if referrer: data['referrer_id'] = referrer.id await state.set_data(data) - await message.answer(texts.t("REFERRAL_CODE_ACCEPTED", "✅ Реферальный код принят!")) - logger.info(f"✅ Реферальный код применен: {code}") + await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!')) + logger.info(f'✅ Реферальный код применен: {code}') await complete_registration(message, state, db) return @@ -1017,38 +944,28 @@ async def process_referral_code_input( promocode_check = await check_promocode_validity(db, code) - if promocode_check["valid"]: + if promocode_check['valid']: # Промокод валиден - сохраняем его в state для активации после создания пользователя data['promocode'] = code await state.set_data(data) await message.answer( texts.t( - "PROMOCODE_ACCEPTED_WILL_ACTIVATE", - "✅ Промокод принят! Он будет активирован после завершения регистрации." + 'PROMOCODE_ACCEPTED_WILL_ACTIVATE', + '✅ Промокод принят! Он будет активирован после завершения регистрации.', ) ) - logger.info(f"✅ Промокод сохранен для активации: {code}") + logger.info(f'✅ Промокод сохранен для активации: {code}') await complete_registration(message, state, db) return # Ни реферальный код, ни промокод не найдены - await message.answer( - texts.t( - "REFERRAL_OR_PROMO_CODE_INVALID", - "❌ Неверный реферальный код или промокод" - ) - ) - logger.info(f"❌ Неверный код (ни реферальный, ни промокод): {code}") + await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод')) + logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}') return -async def process_referral_code_skip( - callback: types.CallbackQuery, - state: FSMContext, - db: AsyncSession -): - - logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}") +async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): + logger.info(f'⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}') await callback.answer() data = await state.get_data() or {} @@ -1057,13 +974,12 @@ async def process_referral_code_skip( try: await callback.message.delete() - logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено") + logger.info('🗑️ Сообщение с вопросом о реферальном коде удалено') except Exception as e: - logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}") + logger.warning(f'⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}') try: await callback.message.edit_text( - texts.t("REGISTRATION_COMPLETING", "✅ Завершаем регистрацию..."), - reply_markup=None + texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None ) except: pass @@ -1071,64 +987,51 @@ async def process_referral_code_skip( await complete_registration_from_callback(callback, state, db) - -async def complete_registration_from_callback( - callback: types.CallbackQuery, - state: FSMContext, - db: AsyncSession -): - logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}") +async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession): + logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}') # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - callback.from_user.id, - callback.from_user.username + callback.from_user.id, callback.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}') try: await callback.message.answer( - f"🚫 Регистрация невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Регистрация невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') await state.clear() return - from sqlalchemy.orm import selectinload - existing_user = await get_user_by_telegram_id(db, callback.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: - logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.") + logger.warning(f'⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.') texts = get_texts(existing_user.language) data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await callback.message.answer( texts.t( - "ALREADY_REGISTERED_REFERRAL", - "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + 'ALREADY_REGISTERED_REFERRAL', + 'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.', ) ) await db.refresh(existing_user, ['subscription']) - has_active_subscription, subscription_is_active = _calculate_subscription_flags( - existing_user.subscription - ) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(existing_user.subscription) menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(existing_user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(existing_user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -1153,18 +1056,14 @@ async def complete_registration_from_callback( is_moderator=is_moderator, custom_buttons=custom_buttons, ) - await callback.message.answer( - menu_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(callback.bot, db, existing_user) except Exception as e: - logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}") + logger.error(f'Ошибка при показе главного меню существующему пользователю: {e}') await callback.message.answer( texts.t( - "WELCOME_FALLBACK", - "Добро пожаловать, {user_name}!", + 'WELCOME_FALLBACK', + 'Добро пожаловать, {user_name}!', ).format(user_name=existing_user.full_name) ) @@ -1176,12 +1075,8 @@ async def complete_registration_from_callback( texts = get_texts(language) campaign_id = data.get('campaign_id') - is_new_user_registration = ( - existing_user is None - or ( - existing_user - and existing_user.status == UserStatus.DELETED.value - ) + is_new_user_registration = existing_user is None or ( + existing_user and existing_user.status == UserStatus.DELETED.value ) referrer_id = data.get('referrer_id') @@ -1191,7 +1086,7 @@ async def complete_registration_from_callback( referrer_id = referrer.id if existing_user and existing_user.status == UserStatus.DELETED.value: - logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}") + logger.info(f'🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}') existing_user.username = callback.from_user.username existing_user.first_name = callback.from_user.first_name @@ -1203,6 +1098,7 @@ async def complete_registration_from_callback( existing_user.has_had_paid_subscription = False from datetime import datetime + existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() @@ -1210,10 +1106,10 @@ async def complete_registration_from_callback( await db.refresh(existing_user, ['subscription']) user = existing_user - logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен") + logger.info(f'✅ Пользователь {callback.from_user.id} восстановлен') elif not existing_user: - logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}") + logger.info(f'🆕 Создаем нового пользователя {callback.from_user.id}') referral_code = await generate_unique_referral_code(db, callback.from_user.id) @@ -1225,17 +1121,18 @@ async def complete_registration_from_callback( last_name=callback.from_user.last_name, language=language, referred_by_id=referrer_id, - referral_code=referral_code + referral_code=referral_code, ) await db.refresh(user, ['subscription']) else: - logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}") + logger.info(f'🔄 Обновляем существующего пользователя {callback.from_user.id}') existing_user.status = UserStatus.ACTIVE.value existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id from datetime import datetime + existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() @@ -1246,9 +1143,9 @@ async def complete_registration_from_callback( if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, callback.bot) - logger.info(f"✅ Реферальная регистрация обработана для {user.id}") + logger.info(f'✅ Реферальная регистрация обработана для {user.id}') except Exception as e: - logger.error(f"Ошибка при обработке реферальной регистрации: {e}") + logger.error(f'Ошибка при обработке реферальной регистрации: {e}') campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) @@ -1256,16 +1153,16 @@ async def complete_registration_from_callback( await db.refresh(user) except Exception as refresh_error: logger.error( - "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + 'Ошибка обновления данных пользователя %s после бонуса кампании: %s', user.telegram_id, refresh_error, ) try: - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) except Exception as refresh_subscription_error: logger.error( - "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + 'Ошибка обновления подписки пользователя %s после бонуса кампании: %s', user.telegram_id, refresh_subscription_error, ) @@ -1276,16 +1173,17 @@ async def complete_registration_from_callback( try: await callback.message.answer(campaign_message) except Exception as e: - logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + logger.error(f'Ошибка отправки сообщения о бонусе кампании: {e}') from app.database.crud.welcome_text import get_welcome_text_for_user + offer_text = await get_welcome_text_for_user(db, callback.from_user) skip_welcome_offer = bool(campaign_id) and is_new_user_registration if skip_welcome_offer: logger.info( - "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + 'ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s', user.telegram_id, campaign_id, ) @@ -1296,24 +1194,23 @@ async def complete_registration_from_callback( offer_text, reply_markup=get_post_registration_keyboard(user.language), ) - logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}") + logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}') await _send_pinned_message(callback.bot, db, user) except Exception as e: - logger.error(f"Ошибка при отправке приветственного сообщения: {e}") + logger.error(f'Ошибка при отправке приветственного сообщения: {e}') else: - logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") + logger.info( + f'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}' + ) has_active_subscription, subscription_is_active = _calculate_subscription_flags( - getattr(user, "subscription", None) + getattr(user, 'subscription', None) ) menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -1338,48 +1235,39 @@ async def complete_registration_from_callback( is_moderator=is_moderator, custom_buttons=custom_buttons, ) - await callback.message.answer( - menu_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(callback.bot, db, user) - logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") + logger.info(f'✅ Главное меню показано пользователю {user.telegram_id}') except Exception as e: - logger.error(f"Ошибка при показе главного меню: {e}") + logger.error(f'Ошибка при показе главного меню: {e}') await callback.message.answer( texts.t( - "WELCOME_FALLBACK", - "Добро пожаловать, {user_name}!", + 'WELCOME_FALLBACK', + 'Добро пожаловать, {user_name}!', ).format(user_name=user.full_name) ) - logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}") + logger.info(f'✅ Регистрация завершена для пользователя: {user.telegram_id}') -async def complete_registration( - message: types.Message, - state: FSMContext, - db: AsyncSession -): - logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}") +async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession): + logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}') # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - message.from_user.id, - message.from_user.username + message.from_user.id, message.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}') try: await message.answer( - f"🚫 Регистрация невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку." + f'🚫 Регистрация невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.' ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') await state.clear() return @@ -1387,31 +1275,26 @@ async def complete_registration( existing_user = await get_user_by_telegram_id(db, message.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: - logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.") + logger.warning(f'⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.') texts = get_texts(existing_user.language) data = await state.get_data() or {} if data.get('referral_code') and not existing_user.referred_by_id: await message.answer( texts.t( - "ALREADY_REGISTERED_REFERRAL", - "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.", + 'ALREADY_REGISTERED_REFERRAL', + 'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.', ) ) await db.refresh(existing_user, ['subscription']) - has_active_subscription, subscription_is_active = _calculate_subscription_flags( - existing_user.subscription - ) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(existing_user.subscription) menu_text = await get_main_menu_text(existing_user, texts, db) is_admin = settings.is_admin(existing_user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(existing_user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(existing_user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -1436,18 +1319,14 @@ async def complete_registration( is_moderator=is_moderator, custom_buttons=custom_buttons, ) - await message.answer( - menu_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') await _send_pinned_message(message.bot, db, existing_user) except Exception as e: - logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}") + logger.error(f'Ошибка при показе главного меню существующему пользователю: {e}') await message.answer( texts.t( - "WELCOME_FALLBACK", - "Добро пожаловать, {user_name}!", + 'WELCOME_FALLBACK', + 'Добро пожаловать, {user_name}!', ).format(user_name=existing_user.full_name) ) @@ -1459,12 +1338,8 @@ async def complete_registration( texts = get_texts(language) campaign_id = data.get('campaign_id') - is_new_user_registration = ( - existing_user is None - or ( - existing_user - and existing_user.status == UserStatus.DELETED.value - ) + is_new_user_registration = existing_user is None or ( + existing_user and existing_user.status == UserStatus.DELETED.value ) referrer_id = data.get('referrer_id') @@ -1474,7 +1349,7 @@ async def complete_registration( referrer_id = referrer.id if existing_user and existing_user.status == UserStatus.DELETED.value: - logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}") + logger.info(f'🔄 Восстанавливаем удаленного пользователя {message.from_user.id}') existing_user.username = message.from_user.username existing_user.first_name = message.from_user.first_name @@ -1486,6 +1361,7 @@ async def complete_registration( existing_user.has_had_paid_subscription = False from datetime import datetime + existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() @@ -1493,10 +1369,10 @@ async def complete_registration( await db.refresh(existing_user, ['subscription']) user = existing_user - logger.info(f"✅ Пользователь {message.from_user.id} восстановлен") + logger.info(f'✅ Пользователь {message.from_user.id} восстановлен') elif not existing_user: - logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}") + logger.info(f'🆕 Создаем нового пользователя {message.from_user.id}') referral_code = await generate_unique_referral_code(db, message.from_user.id) @@ -1508,17 +1384,18 @@ async def complete_registration( last_name=message.from_user.last_name, language=language, referred_by_id=referrer_id, - referral_code=referral_code + referral_code=referral_code, ) await db.refresh(user, ['subscription']) else: - logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}") + logger.info(f'🔄 Обновляем существующего пользователя {message.from_user.id}') existing_user.status = UserStatus.ACTIVE.value existing_user.language = language if referrer_id and not existing_user.referred_by_id: existing_user.referred_by_id = referrer_id from datetime import datetime + existing_user.updated_at = datetime.utcnow() existing_user.last_activity = datetime.utcnow() @@ -1529,9 +1406,9 @@ async def complete_registration( if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, message.bot) - logger.info(f"✅ Реферальная регистрация обработана для {user.id}") + logger.info(f'✅ Реферальная регистрация обработана для {user.id}') except Exception as e: - logger.error(f"Ошибка при обработке реферальной регистрации: {e}") + logger.error(f'Ошибка при обработке реферальной регистрации: {e}') # Активируем промокод если был сохранен в state promocode_to_activate = data.get('promocode') @@ -1543,18 +1420,19 @@ async def complete_registration( db, user.id, promocode_to_activate, message.bot ) - if promocode_result["success"]: + if promocode_result['success']: await message.answer( - texts.t( - "PROMOCODE_ACTIVATED_AT_REGISTRATION", - "✅ Промокод активирован!\n\n{description}" - ).format(description=promocode_result["description"]) + texts.t('PROMOCODE_ACTIVATED_AT_REGISTRATION', '✅ Промокод активирован!\n\n{description}').format( + description=promocode_result['description'] + ) ) - logger.info(f"✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}") + logger.info(f'✅ Промокод {promocode_to_activate} активирован для пользователя {user.id}') else: - logger.warning(f"⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get('error')}") + logger.warning( + f'⚠️ Не удалось активировать промокод {promocode_to_activate}: {promocode_result.get("error")}' + ) except Exception as e: - logger.error(f"❌ Ошибка при активации промокода {promocode_to_activate}: {e}") + logger.error(f'❌ Ошибка при активации промокода {promocode_to_activate}: {e}') campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts) @@ -1562,16 +1440,16 @@ async def complete_registration( await db.refresh(user) except Exception as refresh_error: logger.error( - "Ошибка обновления данных пользователя %s после бонуса кампании: %s", + 'Ошибка обновления данных пользователя %s после бонуса кампании: %s', user.telegram_id, refresh_error, ) try: - await db.refresh(user, ["subscription"]) + await db.refresh(user, ['subscription']) except Exception as refresh_subscription_error: logger.error( - "Ошибка обновления подписки пользователя %s после бонуса кампании: %s", + 'Ошибка обновления подписки пользователя %s после бонуса кампании: %s', user.telegram_id, refresh_subscription_error, ) @@ -1582,16 +1460,17 @@ async def complete_registration( try: await message.answer(campaign_message) except Exception as e: - logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}") + logger.error(f'Ошибка отправки сообщения о бонусе кампании: {e}') from app.database.crud.welcome_text import get_welcome_text_for_user + offer_text = await get_welcome_text_for_user(db, message.from_user) skip_welcome_offer = bool(campaign_id) and is_new_user_registration if skip_welcome_offer: logger.info( - "ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s", + 'ℹ️ Пропускаем приветственное предложение для нового пользователя %s из рекламной кампании %s', user.telegram_id, campaign_id, ) @@ -1602,24 +1481,23 @@ async def complete_registration( offer_text, reply_markup=get_post_registration_keyboard(user.language), ) - logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}") + logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}') await _send_pinned_message(message.bot, db, user) except Exception as e: - logger.error(f"Ошибка при отправке приветственного сообщения: {e}") + logger.error(f'Ошибка при отправке приветственного сообщения: {e}') else: - logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}") + logger.info( + f'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}' + ) has_active_subscription, subscription_is_active = _calculate_subscription_flags( - getattr(user, "subscription", None) + getattr(user, 'subscription', None) ) menu_text = await get_main_menu_text(user, texts, db) is_admin = settings.is_admin(user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id) custom_buttons = [] if not settings.is_text_main_menu_mode(): @@ -1644,136 +1522,131 @@ async def complete_registration( is_moderator=is_moderator, custom_buttons=custom_buttons, ) - await message.answer( - menu_text, - reply_markup=keyboard, - parse_mode="HTML" - ) - logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}") + await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML') + logger.info(f'✅ Главное меню показано пользователю {user.telegram_id}') await _send_pinned_message(message.bot, db, user) except Exception as e: - logger.error(f"Ошибка при показе главного меню: {e}") + logger.error(f'Ошибка при показе главного меню: {e}') await message.answer( texts.t( - "WELCOME_FALLBACK", - "Добро пожаловать, {user_name}!", + 'WELCOME_FALLBACK', + 'Добро пожаловать, {user_name}!', ).format(user_name=user.full_name) ) - logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}") + logger.info(f'✅ Регистрация завершена для пользователя: {user.telegram_id}') def _get_subscription_status(user, texts): - if not user or not hasattr(user, "subscription") or not user.subscription: - return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки") + if not user or not hasattr(user, 'subscription') or not user.subscription: + return texts.t('SUBSCRIPTION_NONE', 'Нет активной подписки') subscription = user.subscription - actual_status = getattr(subscription, "actual_status", None) + actual_status = getattr(subscription, 'actual_status', None) from datetime import datetime - end_date = getattr(subscription, "end_date", None) - end_date_display = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None + end_date = getattr(subscription, 'end_date', None) + end_date_display = format_local_datetime(end_date, '%d.%m.%Y') if end_date else None current_time = datetime.utcnow() - if actual_status == "disabled": - return texts.t("SUB_STATUS_DISABLED", "⚫ Отключена") + if actual_status == 'disabled': + return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена') - if actual_status == "pending": - return texts.t("SUB_STATUS_PENDING", "⏳ Ожидает активации") + if actual_status == 'pending': + return texts.t('SUB_STATUS_PENDING', '⏳ Ожидает активации') - if actual_status == "expired" or (end_date and end_date <= current_time): + if actual_status == 'expired' or (end_date and end_date <= current_time): if end_date_display: return texts.t( - "SUB_STATUS_EXPIRED", - "🔴 Истекла\n📅 {end_date}", + 'SUB_STATUS_EXPIRED', + '🔴 Истекла\n📅 {end_date}', ).format(end_date=end_date_display) - return texts.t("SUBSCRIPTION_STATUS_EXPIRED", "🔴 Истекла") + return texts.t('SUBSCRIPTION_STATUS_EXPIRED', '🔴 Истекла') if not end_date: - return texts.t("SUBSCRIPTION_ACTIVE", "✅ Активна") + return texts.t('SUBSCRIPTION_ACTIVE', '✅ Активна') days_left = (end_date - current_time).days - is_trial = actual_status == "trial" or getattr(subscription, "is_trial", False) + is_trial = actual_status == 'trial' or getattr(subscription, 'is_trial', False) - if actual_status not in {"active", "trial", None} and not is_trial: - return texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "❓ Статус неизвестен") + if actual_status not in {'active', 'trial', None} and not is_trial: + return texts.t('SUBSCRIPTION_STATUS_UNKNOWN', '❓ Статус неизвестен') if is_trial: if days_left > 1 and end_date_display: return texts.t( - "SUB_STATUS_TRIAL_ACTIVE", - "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)", + 'SUB_STATUS_TRIAL_ACTIVE', + '🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)', ).format(end_date=end_date_display, days=days_left) if days_left == 1: return texts.t( - "SUB_STATUS_TRIAL_TOMORROW", - "🎁 Тестовая подписка\n⚠️ истекает завтра!", + 'SUB_STATUS_TRIAL_TOMORROW', + '🎁 Тестовая подписка\n⚠️ истекает завтра!', ) return texts.t( - "SUB_STATUS_TRIAL_TODAY", - "🎁 Тестовая подписка\n⚠️ истекает сегодня!", + 'SUB_STATUS_TRIAL_TODAY', + '🎁 Тестовая подписка\n⚠️ истекает сегодня!', ) if days_left > 7 and end_date_display: return texts.t( - "SUB_STATUS_ACTIVE_LONG", - "💎 Активна\n📅 до {end_date} ({days} дн.)", + 'SUB_STATUS_ACTIVE_LONG', + '💎 Активна\n📅 до {end_date} ({days} дн.)', ).format(end_date=end_date_display, days=days_left) if days_left > 1: return texts.t( - "SUB_STATUS_ACTIVE_FEW_DAYS", - "💎 Активна\n⚠️ истекает через {days} дн.", + 'SUB_STATUS_ACTIVE_FEW_DAYS', + '💎 Активна\n⚠️ истекает через {days} дн.', ).format(days=days_left) if days_left == 1: return texts.t( - "SUB_STATUS_ACTIVE_TOMORROW", - "💎 Активна\n⚠️ истекает завтра!", + 'SUB_STATUS_ACTIVE_TOMORROW', + '💎 Активна\n⚠️ истекает завтра!', ) return texts.t( - "SUB_STATUS_ACTIVE_TODAY", - "💎 Активна\n⚠️ истекает сегодня!", + 'SUB_STATUS_ACTIVE_TODAY', + '💎 Активна\n⚠️ истекает сегодня!', ) def _get_subscription_status_simple(texts): - return texts.t("SUBSCRIPTION_NONE", "Нет активной подписки") + return texts.t('SUBSCRIPTION_NONE', 'Нет активной подписки') def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str: if not random_message: return base_text - prompt = action_prompt or "" + prompt = action_prompt or '' if prompt and prompt in base_text: parts = base_text.split(prompt, 1) if len(parts) == 2: - return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}" - return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1) + return f'{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}' + return base_text.replace(prompt, f'\n{random_message}\n\n{prompt}', 1) - return f"{base_text}\n\n{random_message}" + return f'{base_text}\n\n{random_message}' def get_referral_code_keyboard(language: str): - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("REFERRAL_CODE_SKIP", "⭐️ Пропустить"), - callback_data="referral_skip" - )] - ]) - -async def get_main_menu_text(user, texts, db: AsyncSession): - - import html - base_text = texts.MAIN_MENU.format( - user_name=html.escape(user.full_name or ""), - subscription_status=_get_subscription_status(user, texts) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('REFERRAL_CODE_SKIP', '⭐️ Пропустить'), callback_data='referral_skip')] + ] ) - action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + +async def get_main_menu_text(user, texts, db: AsyncSession): + import html + + base_text = texts.MAIN_MENU.format( + user_name=html.escape(user.full_name or ''), subscription_status=_get_subscription_status(user, texts) + ) + + action_prompt = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:') info_sections: list[str] = [] @@ -1783,8 +1656,8 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(promo_hint.strip()) except Exception as hint_error: logger.debug( - "Не удалось построить подсказку промо-предложения для пользователя %s: %s", - getattr(user, "id", None), + 'Не удалось построить подсказку промо-предложения для пользователя %s: %s', + getattr(user, 'id', None), hint_error, ) @@ -1794,13 +1667,13 @@ async def get_main_menu_text(user, texts, db: AsyncSession): info_sections.append(test_access_hint.strip()) except Exception as test_error: logger.debug( - "Не удалось построить подсказку тестового доступа для пользователя %s: %s", - getattr(user, "id", None), + 'Не удалось построить подсказку тестового доступа для пользователя %s: %s', + getattr(user, 'id', None), test_error, ) if info_sections: - extra_block = "\n\n".join(section for section in info_sections if section) + extra_block = '\n\n'.join(section for section in info_sections if section) if extra_block: base_text = _insert_random_message(base_text, extra_block, action_prompt) @@ -1810,19 +1683,19 @@ async def get_main_menu_text(user, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f"Ошибка получения случайного сообщения: {e}") + logger.error(f'Ошибка получения случайного сообщения: {e}') return base_text -async def get_main_menu_text_simple(user_name, 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=html.escape(user_name or ""), - subscription_status=_get_subscription_status_simple(texts) + user_name=html.escape(user_name or ''), subscription_status=_get_subscription_status_simple(texts) ) - action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:") + action_prompt = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:') try: random_message = await get_random_active_message(db) @@ -1830,17 +1703,13 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession): return _insert_random_message(base_text, random_message, action_prompt) except Exception as e: - logger.error(f"Ошибка получения случайного сообщения: {e}") + logger.error(f'Ошибка получения случайного сообщения: {e}') return base_text async def required_sub_channel_check( - query: types.CallbackQuery, - bot: Bot, - state: FSMContext, - db: AsyncSession, - db_user=None + query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None ): language = DEFAULT_LANGUAGE texts = get_texts(language) @@ -1848,7 +1717,7 @@ async def required_sub_channel_check( try: state_data = await state.get_data() or {} - pending_start_payload = state_data.pop("pending_start_payload", None) + pending_start_payload = state_data.pop('pending_start_payload', None) # Если в FSM state нет payload, пробуем получить из Redis (резервный механизм) if not pending_start_payload: @@ -1880,15 +1749,15 @@ async def required_sub_channel_check( ) if campaign: - state_data["campaign_id"] = campaign.id + state_data['campaign_id'] = campaign.id logger.info( - "📣 CHANNEL CHECK: Кампания %s восстановлена из payload", + '📣 CHANNEL CHECK: Кампания %s восстановлена из payload', campaign.id, ) else: - state_data["referral_code"] = pending_start_payload + state_data['referral_code'] = pending_start_payload logger.info( - "🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код", + '🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код', ) if state_updated: @@ -1898,36 +1767,34 @@ async def required_sub_channel_check( if not user: user = await get_user_by_telegram_id(db, query.from_user.id) - if user and getattr(user, "language", None): + if user and getattr(user, 'language', None): language = user.language - elif state_data.get("language"): - language = state_data["language"] + elif state_data.get('language'): + language = state_data['language'] texts = get_texts(language) - chat_member = await bot.get_chat_member( - chat_id=settings.CHANNEL_SUB_ID, - user_id=query.from_user.id - ) + chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id) - if chat_member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR]: + if chat_member.status not in [ + ChatMemberStatus.MEMBER, + ChatMemberStatus.ADMINISTRATOR, + ChatMemberStatus.CREATOR, + ]: return await query.answer( - texts.t("CHANNEL_SUBSCRIBE_REQUIRED_ALERT", "❌ Вы не подписались на канал!"), + texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'), show_alert=True, ) if user and user.subscription: subscription = user.subscription - if ( - subscription.is_trial - and subscription.status == SubscriptionStatus.DISABLED.value - ): + 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 восстановлена после подтверждения подписки на канал", + '✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал', user.telegram_id, ) @@ -1939,36 +1806,32 @@ async def required_sub_channel_check( await subscription_service.create_remnawave_user(db, subscription) except Exception as api_error: logger.error( - "❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s", + '❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s', user.telegram_id if user else query.from_user.id, api_error, ) await query.answer( - texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"), + texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'), show_alert=True, ) try: await query.message.delete() except Exception as e: - logger.warning(f"Не удалось удалить сообщение: {e}") + logger.warning(f'Не удалось удалить сообщение: {e}') if user and user.status != UserStatus.DELETED.value: - has_active_subscription, subscription_is_active = _calculate_subscription_flags( - user.subscription - ) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) menu_text = await get_main_menu_text(user, texts, db) - from app.utils.message_patch import LOGO_PATH from aiogram.types import FSInputFile + from app.utils.message_patch import LOGO_PATH + is_admin = settings.is_admin(user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id) custom_buttons = await MainMenuButtonService.get_buttons_for_user( db, @@ -1997,14 +1860,14 @@ async def required_sub_channel_check( photo=FSInputFile(LOGO_PATH), caption=menu_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await bot.send_message( chat_id=query.from_user.id, text=menu_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await _send_pinned_message(bot, db, user) else: @@ -2024,7 +1887,7 @@ async def required_sub_channel_check( referrer = await get_user_by_referral_code(db, ref_code_from_link) if referrer: referrer_id = referrer.id - logger.info(f"✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}") + logger.info(f'✅ CHANNEL CHECK: Реферер найден из ссылки: {referrer.id}') referral_code = await generate_unique_referral_code(db, query.from_user.id) @@ -2044,25 +1907,21 @@ async def required_sub_channel_check( if referrer_id: try: await process_referral_registration(db, user.id, referrer_id, bot) - logger.info(f"✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}") + logger.info(f'✅ CHANNEL CHECK: Реферальная регистрация обработана для {user.id}') except Exception as e: - logger.error(f"Ошибка при обработке реферальной регистрации: {e}") + logger.error(f'Ошибка при обработке реферальной регистрации: {e}') # Показываем главное меню после создания пользователя - has_active_subscription, subscription_is_active = _calculate_subscription_flags( - user.subscription - ) + has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription) menu_text = await get_main_menu_text(user, texts, db) - from app.utils.message_patch import LOGO_PATH from aiogram.types import FSInputFile + from app.utils.message_patch import LOGO_PATH + is_admin = settings.is_admin(user.telegram_id) - is_moderator = ( - (not is_admin) - and SupportSettingsService.is_moderator(user.telegram_id) - ) + is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id) custom_buttons = await MainMenuButtonService.get_buttons_for_user( db, @@ -2091,30 +1950,31 @@ async def required_sub_channel_check( photo=FSInputFile(LOGO_PATH), caption=menu_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await bot.send_message( chat_id=query.from_user.id, text=menu_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await _send_pinned_message(bot, db, user) else: await bot.send_message( chat_id=query.from_user.id, text=texts.t( - "REFERRAL_CODE_QUESTION", + 'REFERRAL_CODE_QUESTION', "У вас есть реферальный код? Введите его или нажмите 'Пропустить'", ), reply_markup=get_referral_code_keyboard(language), ) await state.set_state(RegistrationStates.waiting_for_referral_code) else: - from app.utils.message_patch import LOGO_PATH from aiogram.types import FSInputFile + from app.utils.message_patch import LOGO_PATH + rules_text = await get_rules(language) if settings.ENABLE_LOGO_MODE: @@ -2133,67 +1993,52 @@ async def required_sub_channel_check( await state.set_state(RegistrationStates.waiting_for_rules_accept) except Exception as e: - logger.error(f"Ошибка в required_sub_channel_check: {e}") - await query.answer(f"{texts.ERROR}!", show_alert=True) + logger.error(f'Ошибка в required_sub_channel_check: {e}') + await query.answer(f'{texts.ERROR}!', show_alert=True) + def register_handlers(dp: Dispatcher): + logger.info('🔧 === НАЧАЛО регистрации обработчиков start.py ===') - logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===") - - dp.message.register( - cmd_start, - Command("start") - ) - logger.info("✅ Зарегистрирован cmd_start") + dp.message.register(cmd_start, Command('start')) + logger.info('✅ Зарегистрирован cmd_start') dp.callback_query.register( process_rules_accept, - F.data.in_(["rules_accept", "rules_decline"]), - StateFilter(RegistrationStates.waiting_for_rules_accept) + F.data.in_(['rules_accept', 'rules_decline']), + StateFilter(RegistrationStates.waiting_for_rules_accept), ) - logger.info("✅ Зарегистрирован process_rules_accept") + logger.info('✅ Зарегистрирован process_rules_accept') dp.callback_query.register( process_privacy_policy_accept, - F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]), - StateFilter(RegistrationStates.waiting_for_privacy_policy_accept) + F.data.in_(['privacy_policy_accept', 'privacy_policy_decline']), + StateFilter(RegistrationStates.waiting_for_privacy_policy_accept), ) - logger.info("✅ Зарегистрирован process_privacy_policy_accept") + logger.info('✅ Зарегистрирован process_privacy_policy_accept') dp.callback_query.register( process_language_selection, - F.data.startswith("language_select:"), - StateFilter(RegistrationStates.waiting_for_language) + F.data.startswith('language_select:'), + StateFilter(RegistrationStates.waiting_for_language), ) - logger.info("✅ Зарегистрирован process_language_selection") + logger.info('✅ Зарегистрирован process_language_selection') dp.callback_query.register( - process_referral_code_skip, - F.data == "referral_skip", - StateFilter(RegistrationStates.waiting_for_referral_code) + process_referral_code_skip, F.data == 'referral_skip', StateFilter(RegistrationStates.waiting_for_referral_code) ) - logger.info("✅ Зарегистрирован process_referral_code_skip") + logger.info('✅ Зарегистрирован process_referral_code_skip') - dp.message.register( - process_referral_code_input, - StateFilter(RegistrationStates.waiting_for_referral_code) - ) - logger.info("✅ Зарегистрирован process_referral_code_input") + dp.message.register(process_referral_code_input, StateFilter(RegistrationStates.waiting_for_referral_code)) + logger.info('✅ Зарегистрирован process_referral_code_input') dp.message.register( handle_potential_referral_code, - StateFilter( - RegistrationStates.waiting_for_rules_accept, - RegistrationStates.waiting_for_referral_code - ) + StateFilter(RegistrationStates.waiting_for_rules_accept, RegistrationStates.waiting_for_referral_code), ) - logger.info("✅ Зарегистрирован handle_potential_referral_code") + logger.info('✅ Зарегистрирован handle_potential_referral_code') - dp.callback_query.register( - required_sub_channel_check, - F.data.in_(["sub_channel_check"]) - ) - logger.info("✅ Зарегистрирован required_sub_channel_check") - - logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===") + dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check'])) + logger.info('✅ Зарегистрирован required_sub_channel_check') + logger.info('🔧 === КОНЕЦ регистрации обработчиков start.py ===') diff --git a/app/handlers/subscription/__init__.py b/app/handlers/subscription/__init__.py index 60a83a6b..9585cb97 100644 --- a/app/handlers/subscription/__init__.py +++ b/app/handlers/subscription/__init__.py @@ -8,7 +8,6 @@ from .autopay import ( show_autopay_days, toggle_autopay, ) - from .common import ( build_redirect_link, create_deep_link, @@ -25,7 +24,6 @@ from .common import ( update_traffic_prices, validate_traffic_price, ) - from .countries import ( apply_countries_changes, confirm_add_countries_to_subscription, @@ -36,7 +34,6 @@ from .countries import ( handle_manage_country, select_country, ) - from .devices import ( confirm_add_devices, confirm_change_devices, @@ -57,35 +54,36 @@ from .devices import ( show_device_connection_help, show_devices_page, ) - from .happ import ( handle_happ_download_back, handle_happ_download_close, handle_happ_download_platform_choice, handle_happ_download_request, ) - from .links import ( handle_connect_subscription, handle_open_subscription_link, ) - +from .modem import ( + handle_modem_confirm, + handle_modem_disable, + handle_modem_enable, + handle_modem_menu, + register_modem_handlers, +) from .notifications import ( send_extension_notification, send_purchase_notification, send_trial_notification, ) - from .pricing import ( get_subscription_cost, get_subscription_info_text, ) - from .promo import ( claim_discount_offer, handle_promo_offer_close, ) - from .purchase import ( activate_trial, clear_saved_cart, @@ -105,7 +103,6 @@ from .purchase import ( show_trial_offer, start_subscription_purchase, ) - from .traffic import ( add_traffic, confirm_reset_traffic, @@ -120,13 +117,6 @@ from .traffic import ( select_traffic, ) -from .modem import ( - handle_modem_menu, - handle_modem_enable, - handle_modem_confirm, - handle_modem_disable, - register_modem_handlers, -) __all__ = [ 'activate_trial', @@ -182,6 +172,10 @@ __all__ = [ 'handle_happ_download_platform_choice', 'handle_happ_download_request', 'handle_manage_country', + 'handle_modem_confirm', + 'handle_modem_disable', + 'handle_modem_enable', + 'handle_modem_menu', 'handle_no_traffic_packages', 'handle_open_subscription_link', 'handle_promo_offer_close', @@ -193,14 +187,10 @@ __all__ = [ 'handle_subscription_config_back', 'handle_subscription_settings', 'handle_switch_traffic', - 'handle_modem_menu', - 'handle_modem_enable', - 'handle_modem_confirm', - 'handle_modem_disable', - 'register_modem_handlers', 'load_app_config', 'refresh_traffic_config', 'register_handlers', + 'register_modem_handlers', 'resume_subscription_checkout', 'return_to_saved_cart', 'save_cart_and_redirect_to_topup', diff --git a/app/handlers/subscription/autopay.py b/app/handlers/subscription/autopay.py index 71059e4d..2343324b 100644 --- a/app/handlers/subscription/autopay.py +++ b/app/handlers/subscription/autopay.py @@ -1,83 +1,24 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F +from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) + +from app.config import settings +from app.database.crud.subscription import update_subscription_autopay +from app.database.models import User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_autopay_days_keyboard, + get_autopay_keyboard, + get_countries_keyboard, + get_devices_keyboard, + get_subscription_period_keyboard, + get_traffic_packages_keyboard, ) from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, ) -from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service from app.states import SubscriptionStates -from app.utils.pagination import paginate_list -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, - apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, -) from .countries import ( _build_countries_selection_text, @@ -87,104 +28,81 @@ from .countries import ( ) from .pricing import _build_subscription_period_prompt -async def handle_autopay_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription: await callback.answer( - texts.t("SUBSCRIPTION_ACTIVE_REQUIRED", "⚠️ У вас нет активной подписки!"), + texts.t('SUBSCRIPTION_ACTIVE_REQUIRED', '⚠️ У вас нет активной подписки!'), show_alert=True, ) return status = ( - texts.t("AUTOPAY_STATUS_ENABLED", "включен") + texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if subscription.autopay_enabled - else texts.t("AUTOPAY_STATUS_DISABLED", "выключен") + else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен') ) days = subscription.autopay_days_before text = texts.t( - "AUTOPAY_MENU_TEXT", + 'AUTOPAY_MENU_TEXT', ( - "💳 Автоплатеж\n\n" - "📊 Статус: {status}\n" - "⏰ Списание за: {days} дн. до окончания\n\n" - "Выберите действие:" + '💳 Автоплатеж\n\n' + '📊 Статус: {status}\n' + '⏰ Списание за: {days} дн. до окончания\n\n' + 'Выберите действие:' ), ).format(status=status, days=days) await callback.message.edit_text( text, reply_markup=get_autopay_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() -async def toggle_autopay( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: AsyncSession): subscription = db_user.subscription - enable = callback.data == "autopay_enable" + enable = callback.data == 'autopay_enable' await update_subscription_autopay(db, subscription, enable) texts = get_texts(db_user.language) - status = ( - texts.t("AUTOPAY_STATUS_ENABLED", "включен") - if enable - else texts.t("AUTOPAY_STATUS_DISABLED", "выключен") - ) - await callback.answer( - texts.t("AUTOPAY_TOGGLE_SUCCESS", "✅ Автоплатеж {status}!").format(status=status) - ) + status = texts.t('AUTOPAY_STATUS_ENABLED', 'включен') if enable else texts.t('AUTOPAY_STATUS_DISABLED', 'выключен') + await callback.answer(texts.t('AUTOPAY_TOGGLE_SUCCESS', '✅ Автоплатеж {status}!').format(status=status)) await handle_autopay_menu(callback, db_user, db) -async def show_autopay_days( - callback: types.CallbackQuery, - db_user: User -): + +async def show_autopay_days(callback: types.CallbackQuery, db_user: User): texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "AUTOPAY_SELECT_DAYS_PROMPT", - "⏰ Выберите за сколько дней до окончания списывать средства:", + 'AUTOPAY_SELECT_DAYS_PROMPT', + '⏰ Выберите за сколько дней до окончания списывать средства:', ), - reply_markup=get_autopay_days_keyboard(db_user.language) + reply_markup=get_autopay_days_keyboard(db_user.language), ) await callback.answer() -async def set_autopay_days( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def set_autopay_days(callback: types.CallbackQuery, db_user: User, db: AsyncSession): days = int(callback.data.split('_')[2]) subscription = db_user.subscription - await update_subscription_autopay( - db, subscription, subscription.autopay_enabled, days - ) + await update_subscription_autopay(db, subscription, subscription.autopay_enabled, days) texts = get_texts(db_user.language) - await callback.answer( - texts.t("AUTOPAY_DAYS_SET", "✅ Установлено {days} дней!").format(days=days) - ) + await callback.answer(texts.t('AUTOPAY_DAYS_SET', '✅ Установлено {days} дней!').format(days=days)) await handle_autopay_menu(callback, db_user, db) + async def handle_subscription_config_back( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession ): current_state = await state.get_state() texts = get_texts(db_user.language) @@ -193,22 +111,21 @@ async def handle_subscription_config_back( await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), reply_markup=get_subscription_period_keyboard(db_user.language, db_user), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_period) elif current_state == SubscriptionStates.selecting_countries.state: if settings.is_traffic_selectable(): await callback.message.edit_text( - texts.SELECT_TRAFFIC, - reply_markup=get_traffic_packages_keyboard(db_user.language) + texts.SELECT_TRAFFIC, reply_markup=get_traffic_packages_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_traffic) else: await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), reply_markup=get_subscription_period_keyboard(db_user.language, db_user), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_period) @@ -221,8 +138,7 @@ async def handle_subscription_config_back( selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) + texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) else: @@ -230,18 +146,15 @@ async def handle_subscription_config_back( else: from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) await state.clear() await callback.answer() -async def handle_subscription_cancel( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - texts = get_texts(db_user.language) + +async def handle_subscription_cancel(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): + get_texts(db_user.language) await state.clear() await clear_subscription_checkout_draft(db_user.id) @@ -250,15 +163,18 @@ async def handle_subscription_cancel( await user_cart_service.delete_user_cart(db_user.id) from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) - await callback.answer("❌ Покупка отменена") + await callback.answer('❌ Покупка отменена') + + async def _show_previous_configuration_step( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - texts, - db: AsyncSession, + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + texts, + db: AsyncSession, ): if await _should_show_countries_management(db_user): countries = await _get_available_countries(db_user.promo_group_id) @@ -276,15 +192,14 @@ async def _show_previous_configuration_step( await callback.message.edit_text( selection_text, reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_countries) return if settings.is_traffic_selectable(): await callback.message.edit_text( - texts.SELECT_TRAFFIC, - reply_markup=get_traffic_packages_keyboard(db_user.language) + texts.SELECT_TRAFFIC, reply_markup=get_traffic_packages_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_traffic) return @@ -292,7 +207,6 @@ async def _show_previous_configuration_step( await callback.message.edit_text( await _build_subscription_period_prompt(db_user, texts, db), reply_markup=get_subscription_period_keyboard(db_user.language, db_user), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_period) - diff --git a/app/handlers/subscription/common.py b/app/handlers/subscription/common.py index 6759f758..ff9ac24e 100644 --- a/app/handlers/subscription/common.py +++ b/app/handlers/subscription/common.py @@ -1,92 +1,35 @@ import base64 import json import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional +from datetime import datetime +from typing import Any from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) -from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart -) + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +from app.config import get_traffic_prices, settings +from app.database.models import Subscription, User from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, + get_remaining_months, ) from app.utils.promo_offer import ( - build_promo_offer_hint, get_user_active_promo_discount_percent, ) + logger = logging.getLogger(__name__) TRAFFIC_PRICES = get_traffic_prices() + class _SafeFormatDict(dict): def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback - return "{" + key + "}" + return '{' + key + '}' -def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str: + +def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str: if not isinstance(template, str): return template @@ -99,10 +42,11 @@ def _format_text_with_placeholders(template: str, values: Dict[str, Any]) -> str logger.warning("Failed to format template '%s' with values %s", template, values) return template + def _get_addon_discount_percent_for_user( - user: Optional[User], - category: str, - period_days_hint: Optional[int] = None, + user: User | None, + category: str, + period_days_hint: int | None = None, ) -> int: if user is None: return 0 @@ -111,7 +55,7 @@ def _get_addon_discount_percent_for_user( if promo_group is None: return 0 - if not getattr(promo_group, "apply_discounts_to_addons", True): + if not getattr(promo_group, 'apply_discounts_to_addons', True): return 0 try: @@ -119,34 +63,38 @@ def _get_addon_discount_percent_for_user( except AttributeError: return 0 + def _apply_addon_discount( - user: Optional[User], - category: str, - amount: int, - period_days_hint: Optional[int] = None, -) -> Dict[str, int]: + user: User | None, + category: str, + amount: int, + period_days_hint: int | None = None, +) -> dict[str, int]: percent = _get_addon_discount_percent_for_user(user, category, period_days_hint) discounted_amount, discount_value = apply_percentage_discount(amount, percent) return { - "discounted": discounted_amount, - "discount": discount_value, - "percent": percent, + 'discounted': discounted_amount, + 'discount': discount_value, + 'percent': percent, } -def _get_promo_offer_discount_percent(user: Optional[User]) -> int: + +def _get_promo_offer_discount_percent(user: User | None) -> int: return get_user_active_promo_discount_percent(user) -def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]: + +def _apply_promo_offer_discount(user: User | None, amount: int) -> dict[str, int]: percent = _get_promo_offer_discount_percent(user) if amount <= 0 or percent <= 0: - return {"discounted": amount, "discount": 0, "percent": 0} + return {'discounted': amount, 'discount': 0, 'percent': 0} discounted, discount_value = apply_percentage_discount(amount, percent) - return {"discounted": discounted, "discount": discount_value, "percent": percent} + return {'discounted': discounted, 'discount': discount_value, 'percent': percent} -def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]: + +def _get_period_hint_from_subscription(subscription: Subscription | None) -> int | None: if not subscription: return None @@ -156,26 +104,30 @@ def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> return months_remaining * 30 + def _apply_discount_to_monthly_component( - amount_per_month: int, - percent: int, - months: int, -) -> Dict[str, int]: + amount_per_month: int, + percent: int, + months: int, +) -> dict[str, int]: discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent) return { - "original_per_month": amount_per_month, - "discounted_per_month": discounted_per_month, - "discount_percent": max(0, min(100, percent)), - "discount_per_month": discount_per_month, - "total": discounted_per_month * months, - "discount_total": discount_per_month * months, + 'original_per_month': amount_per_month, + 'discounted_per_month': discounted_per_month, + 'discount_percent': max(0, min(100, percent)), + 'discount_per_month': discount_per_month, + 'total': discounted_per_month * months, + 'discount_total': discount_per_month * months, } + def update_traffic_prices(): from app.config import refresh_traffic_prices + refresh_traffic_prices() - logger.info("🔄 TRAFFIC_PRICES обновлены из конфигурации") + logger.info('🔄 TRAFFIC_PRICES обновлены из конфигурации') + def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: if is_fixed_mode is None: @@ -183,14 +135,12 @@ def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str: if traffic_gb == 0: if is_fixed_mode: - return "Безлимитный" - else: - return "Безлимитный" - else: - if is_fixed_mode: - return f"{traffic_gb} ГБ" - else: - return f"{traffic_gb} ГБ" + return 'Безлимитный' + return 'Безлимитный' + if is_fixed_mode: + return f'{traffic_gb} ГБ' + return f'{traffic_gb} ГБ' + def validate_traffic_price(gb: int) -> bool: from app.config import settings @@ -201,34 +151,37 @@ def validate_traffic_price(gb: int) -> bool: return price > 0 -def load_app_config() -> Dict[str, Any]: + +def load_app_config() -> dict[str, Any]: try: from app.config import settings + config_path = settings.get_app_config_path() - with open(config_path, 'r', encoding='utf-8') as f: + with open(config_path, encoding='utf-8') as f: data = json.load(f) if isinstance(data, dict): return data - logger.error("Некорректный формат app-config.json: ожидается объект") + logger.error('Некорректный формат app-config.json: ожидается объект') except Exception as e: - logger.error(f"Ошибка загрузки конфига приложений: {e}") + logger.error(f'Ошибка загрузки конфига приложений: {e}') return {} -def get_localized_value(values: Any, language: str, default_language: str = "en") -> str: - if not isinstance(values, dict): - return "" - candidates: List[str] = [] - normalized_language = (language or "").strip().lower() +def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str: + if not isinstance(values, dict): + return '' + + candidates: list[str] = [] + normalized_language = (language or '').strip().lower() if normalized_language: candidates.append(normalized_language) - if "-" in normalized_language: - candidates.append(normalized_language.split("-")[0]) + if '-' in normalized_language: + candidates.append(normalized_language.split('-')[0]) - default_language = (default_language or "").strip().lower() + default_language = (default_language or '').strip().lower() if default_language and default_language not in candidates: candidates.append(default_language) @@ -243,42 +196,45 @@ def get_localized_value(values: Any, language: str, default_language: str = "en" if isinstance(value, str) and value.strip(): return value - return "" + return '' -def get_step_description(app: Dict[str, Any], step_key: str, language: str) -> str: + +def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str: if not isinstance(app, dict): - return "" + return '' step = app.get(step_key) if not isinstance(step, dict): - return "" + return '' - description = step.get("description") + description = step.get('description') return get_localized_value(description, language) + def format_additional_section(additional: Any, texts, language: str) -> str: if not isinstance(additional, dict): - return "" + return '' - title = get_localized_value(additional.get("title"), language) - description = get_localized_value(additional.get("description"), language) + title = get_localized_value(additional.get('title'), language) + description = get_localized_value(additional.get('description'), language) - parts: List[str] = [] + parts: list[str] = [] if title: parts.append( texts.t( - "SUBSCRIPTION_ADDITIONAL_STEP_TITLE", - "{title}:", + 'SUBSCRIPTION_ADDITIONAL_STEP_TITLE', + '{title}:', ).format(title=title) ) if description: parts.append(description) - return "\n".join(parts) + return '\n'.join(parts) -def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> Optional[str]: + +def build_redirect_link(target_link: str | None, template: str | None) -> str | None: if not target_link or not template: return None @@ -288,15 +244,15 @@ def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> if not normalized_target or not normalized_template: return None - encoded_target = quote(normalized_target, safe="") + encoded_target = quote(normalized_target, safe='') result = normalized_template replaced = False replacements = [ - ("{subscription_link}", encoded_target), - ("{link}", encoded_target), - ("{subscription_link_raw}", normalized_target), - ("{link_raw}", normalized_target), + ('{subscription_link}', encoded_target), + ('{link}', encoded_target), + ('{subscription_link_raw}', normalized_target), + ('{link_raw}', normalized_target), ] for placeholder, replacement in replacements: @@ -305,13 +261,14 @@ def build_redirect_link(target_link: Optional[str], template: Optional[str]) -> replaced = True if not replaced: - result = f"{result}{encoded_target}" + result = f'{result}{encoded_target}' return result -def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str, Any]]: + +def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]: config = load_app_config() - platforms = config.get("platforms", {}) if isinstance(config, dict) else {} + platforms = config.get('platforms', {}) if isinstance(config, dict) else {} if not isinstance(platforms, dict): return [] @@ -330,7 +287,8 @@ def get_apps_for_device(device_type: str, language: str = "ru") -> List[Dict[str apps = platforms.get(config_key, []) return apps if isinstance(apps, list) else [] -def get_device_name(device_type: str, language: str = "ru") -> str: + +def get_device_name(device_type: str, language: str = 'ru') -> str: names = { 'ios': 'iPhone/iPad', 'android': 'Android', @@ -343,54 +301,52 @@ def get_device_name(device_type: str, language: str = "ru") -> str: return names.get(device_type, device_type) -def create_deep_link(app: Dict[str, Any], subscription_url: str) -> Optional[str]: + +def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None: if not subscription_url: return None if not isinstance(app, dict): return subscription_url - scheme = str(app.get("urlScheme", "")).strip() + scheme = str(app.get('urlScheme', '')).strip() payload = subscription_url - if app.get("isNeedBase64Encoding"): + if app.get('isNeedBase64Encoding'): try: - payload = base64.b64encode(subscription_url.encode("utf-8")).decode("utf-8") + payload = base64.b64encode(subscription_url.encode('utf-8')).decode('utf-8') except Exception as exc: logger.warning( - "Не удалось закодировать ссылку подписки в base64 для приложения %s: %s", - app.get("id"), + 'Не удалось закодировать ссылку подписки в base64 для приложения %s: %s', + app.get('id'), exc, ) payload = subscription_url - scheme_link = f"{scheme}{payload}" if scheme else None + scheme_link = f'{scheme}{payload}' if scheme else None template = settings.get_happ_cryptolink_redirect_template() redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None return redirect_link or scheme_link or subscription_url -def get_reset_devices_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Да, сбросить все устройства", - callback_data="confirm_reset_devices" - ) - ], - [ - InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription") + +def get_reset_devices_confirm_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✅ Да, сбросить все устройства', callback_data='confirm_reset_devices')], + [InlineKeyboardButton(text='❌ Отмена', callback_data='menu_subscription')], ] - ]) + ) + def get_traffic_switch_keyboard( - current_traffic_gb: int, - language: str = "ru", - subscription_end_date: datetime = None, - discount_percent: int = 0, - base_traffic_gb: int = None, + current_traffic_gb: int, + language: str = 'ru', + subscription_end_date: datetime = None, + discount_percent: int = 0, + base_traffic_gb: int = None, ) -> InlineKeyboardMarkup: from app.config import settings @@ -400,11 +356,11 @@ def get_traffic_switch_keyboard( base_traffic_gb = current_traffic_gb months_multiplier = 1 - period_text = "" + period_text = '' if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = f" (за {months_multiplier} мес)" + period_text = f' (за {months_multiplier} мес)' packages = settings.get_traffic_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] @@ -431,65 +387,53 @@ def get_traffic_switch_keyboard( # Сравниваем с базовым трафиком (без докупленного) if gb == base_traffic_gb: - emoji = "✅" - action_text = " (текущий)" - price_text = "" + emoji = '✅' + action_text = ' (текущий)' + price_text = '' elif total_price_diff > 0: - emoji = "⬆️" - action_text = "" - price_text = f" (+{total_price_diff // 100}₽{period_text})" + emoji = '⬆️' + action_text = '' + price_text = f' (+{total_price_diff // 100}₽{period_text})' if discount_percent > 0: - discount_total = ( - (price_per_month - current_price_per_month) * months_multiplier - - total_price_diff - ) + discount_total = (price_per_month - current_price_per_month) * months_multiplier - total_price_diff if discount_total > 0: - price_text += f" (скидка {discount_percent}%: -{discount_total // 100}₽)" + price_text += f' (скидка {discount_percent}%: -{discount_total // 100}₽)' elif total_price_diff < 0: - emoji = "⬇️" - action_text = "" - price_text = " (без возврата)" + emoji = '⬇️' + action_text = '' + price_text = ' (без возврата)' else: - emoji = "🔄" - action_text = "" - price_text = " (бесплатно)" + emoji = '🔄' + action_text = '' + price_text = ' (бесплатно)' if gb == 0: - traffic_text = "Безлимит" + traffic_text = 'Безлимит' else: - traffic_text = f"{gb} ГБ" + traffic_text = f'{gb} ГБ' - button_text = f"{emoji} {traffic_text}{action_text}{price_text}" + button_text = f'{emoji} {traffic_text}{action_text}{price_text}' - buttons.append([ - InlineKeyboardButton(text=button_text, callback_data=f"switch_traffic_{gb}") - ]) + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')]) - buttons.append([ - InlineKeyboardButton( - text="⬅️ Назад" if language == "ru" else "⬅️ Back", - callback_data="subscription_settings" - ) - ]) + buttons.append( + [InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')] + ) return InlineKeyboardMarkup(inline_keyboard=buttons) + def get_confirm_switch_traffic_keyboard( - new_traffic_gb: int, - price_difference: int, - language: str = "ru" + new_traffic_gb: int, price_difference: int, language: str = 'ru' ) -> InlineKeyboardMarkup: - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить переключение", - callback_data=f"confirm_switch_traffic_{new_traffic_gb}_{price_difference}" - ) - ], - [ - InlineKeyboardButton( - text="❌ Отмена", - callback_data="subscription_settings" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Подтвердить переключение', + callback_data=f'confirm_switch_traffic_{new_traffic_gb}_{price_difference}', + ) + ], + [InlineKeyboardButton(text='❌ Отмена', callback_data='subscription_settings')], ] - ]) + ) diff --git a/app/handlers/subscription/countries.py b/app/handlers/subscription/countries.py index 258b4e76..35e59aca 100644 --- a/app/handlers/subscription/countries.py +++ b/app/handlers/subscription/countries.py @@ -1,98 +1,44 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F +from datetime import datetime + +from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) + +from app.config import PERIOD_PRICES, settings from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) +from app.database.models import TransactionType, User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, + get_back_keyboard, + get_countries_keyboard, + get_devices_keyboard, + get_insufficient_balance_keyboard, get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, save_subscription_checkout_draft, should_offer_checkout_resume, ) from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service from app.states import SubscriptionStates -from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, + calculate_prorated_price, + get_remaining_months, ) from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger from .summary import present_subscription_summary -async def handle_add_countries( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): + +async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): if not await _should_show_countries_management(db_user): texts = get_texts(db_user.language) await callback.answer( texts.t( - "COUNTRY_MANAGEMENT_UNAVAILABLE", - "ℹ️ Управление серверами недоступно - доступен только один сервер", + 'COUNTRY_MANAGEMENT_UNAVAILABLE', + 'ℹ️ Управление серверами недоступно - доступен только один сервер', ), show_alert=True, ) @@ -103,7 +49,7 @@ async def handle_add_countries( if not subscription or subscription.is_trial: await callback.answer( - texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"), + texts.t('PAID_FEATURE_ONLY', '⚠ Эта функция доступна только для платных подписок'), show_alert=True, ) return @@ -114,7 +60,7 @@ async def handle_add_countries( period_hint_days = _get_period_hint_from_subscription(subscription) servers_discount_percent = _get_addon_discount_percent_for_user( db_user, - "servers", + 'servers', period_hint_days, ) @@ -124,23 +70,23 @@ async def handle_add_countries( current_countries_names.append(country['name']) current_list = ( - "\n".join(f"• {name}" for name in current_countries_names) + '\n'.join(f'• {name}' for name in current_countries_names) if current_countries_names - else texts.t("COUNTRY_MANAGEMENT_NONE", "Нет подключенных стран") + else texts.t('COUNTRY_MANAGEMENT_NONE', 'Нет подключенных стран') ) text = texts.t( - "COUNTRY_MANAGEMENT_PROMPT", + 'COUNTRY_MANAGEMENT_PROMPT', ( - "🌍 Управление странами подписки\n\n" - "📋 Текущие страны ({current_count}):\n" - "{current_list}\n\n" - "💡 Инструкция:\n" - "✅ - страна подключена\n" - "➕ - будет добавлена (платно)\n" - "➖ - будет отключена (бесплатно)\n" - "⚪ - не выбрана\n\n" - "⚠️ Важно: Повторное подключение отключенных стран будет платным!" + '🌍 Управление странами подписки\n\n' + '📋 Текущие страны ({current_count}):\n' + '{current_list}\n\n' + '💡 Инструкция:\n' + '✅ - страна подключена\n' + '➕ - будет добавлена (платно)\n' + '➖ - будет отключена (бесплатно)\n' + '⚪ - не выбрана\n\n' + '⚠️ Важно: Повторное подключение отключенных стран будет платным!' ), ).format( current_count=len(current_countries), @@ -159,16 +105,17 @@ async def handle_add_countries( subscription.end_date, servers_discount_percent, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + async def get_countries_price_by_uuids_fallback( - country_uuids: List[str], - db: AsyncSession, - promo_group_id: Optional[int] = None, -) -> Tuple[int, List[int]]: + country_uuids: list[str], + db: AsyncSession, + promo_group_id: int | None = None, +) -> tuple[int, list[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid @@ -199,17 +146,13 @@ async def get_countries_price_by_uuids_fallback( return total_price, prices_list except Exception as e: - logger.error(f"Ошибка fallback функции: {e}") + logger.error(f'Ошибка fallback функции: {e}') default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices -async def handle_manage_country( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - logger.info(f"🔍 Управление страной: {callback.data}") + +async def handle_manage_country(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + logger.info(f'🔍 Управление страной: {callback.data}') country_uuid = callback.data.split('_')[2] @@ -217,7 +160,7 @@ async def handle_manage_country( if not subscription or subscription.is_trial: texts = get_texts(db_user.language) await callback.answer( - texts.t("PAID_FEATURE_ONLY_SHORT", "⚠ Только для платных подписок"), + texts.t('PAID_FEATURE_ONLY_SHORT', '⚠ Только для платных подписок'), show_alert=True, ) return @@ -232,8 +175,8 @@ async def handle_manage_country( texts = get_texts(db_user.language) await callback.answer( texts.t( - "COUNTRY_NOT_AVAILABLE_PROMOGROUP", - "❌ Сервер недоступен для вашей промогруппы", + 'COUNTRY_NOT_AVAILABLE_PROMOGROUP', + '❌ Сервер недоступен для вашей промогруппы', ), show_alert=True, ) @@ -241,19 +184,19 @@ async def handle_manage_country( if country_uuid in current_selected: current_selected.remove(country_uuid) - action = "removed" + action = 'removed' else: current_selected.append(country_uuid) - action = "added" + action = 'added' - logger.info(f"🔍 Страна {country_uuid} {action}") + logger.info(f'🔍 Страна {country_uuid} {action}') await state.update_data(countries=current_selected) period_hint_days = _get_period_hint_from_subscription(subscription) servers_discount_percent = _get_addon_discount_percent_for_user( db_user, - "servers", + 'servers', period_hint_days, ) @@ -268,30 +211,22 @@ async def handle_manage_country( servers_discount_percent, ) ) - logger.info(f"✅ Клавиатура обновлена") + logger.info('✅ Клавиатура обновлена') except Exception as e: - logger.error(f"⚠ Ошибка обновления клавиатуры: {e}") + logger.error(f'⚠ Ошибка обновления клавиатуры: {e}') await callback.answer() -async def apply_countries_changes( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext -): - logger.info(f"🔧 Применение изменений стран") + +async def apply_countries_changes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): + logger.info('🔧 Применение изменений стран') data = await state.get_data() texts = get_texts(db_user.language) await save_subscription_checkout_draft(db_user.id, dict(data)) - resume_callback = ( - "subscription_resume_checkout" - if should_offer_checkout_resume(db_user, True) - else None - ) + resume_callback = 'subscription_resume_checkout' if should_offer_checkout_resume(db_user, True) else None subscription = db_user.subscription selected_countries = data.get('countries', []) @@ -311,19 +246,19 @@ async def apply_countries_changes( if not added and not removed: await callback.answer( - texts.t("COUNTRY_CHANGES_NOT_FOUND", "⚠️ Изменения не обнаружены"), + texts.t('COUNTRY_CHANGES_NOT_FOUND', '⚠️ Изменения не обнаружены'), show_alert=True, ) return - logger.info(f"🔧 Добавлено: {added}, Удалено: {removed}") + logger.info(f'🔧 Добавлено: {added}, Удалено: {removed}') months_to_pay = get_remaining_months(subscription.end_date) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None servers_discount_percent = _get_addon_discount_percent_for_user( db_user, - "servers", + 'servers', period_hint_days, ) @@ -331,7 +266,7 @@ async def apply_countries_changes( added_names = [] removed_names = [] - added_server_components: List[Dict[str, int]] = [] + added_server_components: list[dict[str, int]] = [] for country in countries: if not country.get('is_available', True): @@ -347,9 +282,9 @@ async def apply_countries_changes( added_names.append(country['name']) added_server_components.append( { - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "original_per_month": server_price_per_month, + 'discounted_per_month': discounted_per_month, + 'discount_per_month': discount_per_month, + 'original_per_month': server_price_per_month, } ) if country['uuid'] in removed: @@ -357,19 +292,13 @@ async def apply_countries_changes( total_cost, charged_months = calculate_prorated_price(cost_per_month, subscription.end_date) - added_server_prices = [ - component["discounted_per_month"] * charged_months - for component in added_server_components - ] + added_server_prices = [component['discounted_per_month'] * charged_months for component in added_server_components] - total_discount = sum( - component["discount_per_month"] * charged_months - for component in added_server_components - ) + total_discount = sum(component['discount_per_month'] * charged_months for component in added_server_components) if added_names: logger.info( - "Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + 'Стоимость новых серверов: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)', cost_per_month / 100, charged_months, total_cost / 100, @@ -378,15 +307,15 @@ async def apply_countries_changes( if total_cost > 0 and db_user.balance_kopeks < total_cost: missing_kopeks = total_cost - db_user.balance_kopeks - required_text = f"{texts.format_price(total_cost)} (за {charged_months} мес)" + required_text = f'{texts.format_price(total_cost)} (за {charged_months} мес)' message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, @@ -401,7 +330,7 @@ async def apply_countries_changes( resume_callback=resume_callback, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -410,22 +339,21 @@ async def apply_countries_changes( if len(selected_countries) == 0: await callback.answer( texts.t( - "COUNTRIES_MINIMUM_REQUIRED", - "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." + 'COUNTRIES_MINIMUM_REQUIRED', + '❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.', ), - show_alert=True + show_alert=True, ) return try: if added and total_cost > 0: success = await subtract_user_balance( - db, db_user, total_cost, - f"Добавление стран: {', '.join(added_names)} на {charged_months} мес" + db, db_user, total_cost, f'Добавление стран: {", ".join(added_names)} на {charged_months} мес' ) if not success: await callback.answer( - texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"), + texts.t('PAYMENT_CHARGE_ERROR', '⚠️ Ошибка списания средств'), show_alert=True, ) return @@ -435,11 +363,11 @@ async def apply_countries_changes( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_cost, - description=f"Добавление стран к подписке: {', '.join(added_names)} на {charged_months} мес" + description=f'Добавление стран к подписке: {", ".join(added_names)} на {charged_months} мес', ) if added: - from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.server_squad import add_user_to_servers, get_server_ids_by_uuids from app.database.crud.subscription import add_subscription_servers added_server_ids = await get_server_ids_by_uuids(db, added) @@ -449,7 +377,8 @@ async def apply_countries_changes( await add_user_to_servers(db, added_server_ids) logger.info( - f"📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices))}") + f'📊 Добавлены серверы с ценами за {charged_months} мес: {list(zip(added_server_ids, added_server_prices, strict=False))}' + ) subscription.connected_squads = selected_countries subscription.updated_at = datetime.utcnow() @@ -462,83 +391,80 @@ async def apply_countries_changes( try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( - db, db_user, subscription, "servers", current_countries, selected_countries, total_cost + db, db_user, subscription, 'servers', current_countries, selected_countries, total_cost ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об изменении серверов: {e}") + logger.error(f'Ошибка отправки уведомления об изменении серверов: {e}') success_text = texts.t( - "COUNTRY_CHANGES_SUCCESS_HEADER", - "✅ Страны успешно обновлены!\n\n", + 'COUNTRY_CHANGES_SUCCESS_HEADER', + '✅ Страны успешно обновлены!\n\n', ) if added_names: success_text += texts.t( - "COUNTRY_CHANGES_ADDED_HEADER", - "➕ Добавлены страны:\n", + 'COUNTRY_CHANGES_ADDED_HEADER', + '➕ Добавлены страны:\n', ) - success_text += "\n".join(f"• {name}" for name in added_names) + success_text += '\n'.join(f'• {name}' for name in added_names) if total_cost > 0: - success_text += "\n" + texts.t( - "COUNTRY_CHANGES_CHARGED", - "💰 Списано: {amount} (за {months} мес)", + success_text += '\n' + texts.t( + 'COUNTRY_CHANGES_CHARGED', + '💰 Списано: {amount} (за {months} мес)', ).format( amount=texts.format_price(total_cost), months=charged_months, ) if total_discount > 0: success_text += texts.t( - "COUNTRY_CHANGES_DISCOUNT_INFO", - " (скидка {percent}%: -{amount})", + 'COUNTRY_CHANGES_DISCOUNT_INFO', + ' (скидка {percent}%: -{amount})', ).format( percent=servers_discount_percent, amount=texts.format_price(total_discount), ) - success_text += "\n" + success_text += '\n' if removed_names: - success_text += "\n" + texts.t( - "COUNTRY_CHANGES_REMOVED_HEADER", - "➖ Отключены страны:\n", + success_text += '\n' + texts.t( + 'COUNTRY_CHANGES_REMOVED_HEADER', + '➖ Отключены страны:\n', + ) + success_text += '\n'.join(f'• {name}' for name in removed_names) + success_text += ( + '\n' + + texts.t( + 'COUNTRY_CHANGES_REMOVED_WARNING', + 'ℹ️ Повторное подключение будет платным', + ) + + '\n' ) - success_text += "\n".join(f"• {name}" for name in removed_names) - success_text += "\n" + texts.t( - "COUNTRY_CHANGES_REMOVED_WARNING", - "ℹ️ Повторное подключение будет платным", - ) + "\n" - success_text += "\n" + texts.t( - "COUNTRY_CHANGES_ACTIVE_COUNT", - "🌐 Активных стран: {count}", + success_text += '\n' + texts.t( + 'COUNTRY_CHANGES_ACTIVE_COUNT', + '🌐 Активных стран: {count}', ).format(count=len(selected_countries)) await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML" + success_text, reply_markup=get_back_keyboard(db_user.language), parse_mode='HTML' ) await state.clear() logger.info( - f"✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽") + f'✅ Пользователь {db_user.telegram_id} обновил страны. Добавлено: {len(added)}, удалено: {len(removed)}, заплатил: {total_cost / 100}₽' + ) except Exception as e: - logger.error(f"⚠️ Ошибка применения изменений: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'⚠️ Ошибка применения изменений: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() -async def select_country( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): + +async def select_country(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): country_uuid = callback.data.split('_')[1] data = await state.get_data() @@ -552,13 +478,13 @@ async def select_country( allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: - await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True) return period_base_price = PERIOD_PRICES.get(data['period_days'], 0) discounted_base_price, _ = apply_percentage_discount( period_base_price, - db_user.get_promo_discount("period", data['period_days']), + db_user.get_promo_discount('period', data['period_days']), ) base_price = discounted_base_price + settings.get_traffic_price(data['traffic_gb']) @@ -571,7 +497,7 @@ async def select_country( promo_group_id=db_user.promo_group_id, ) except AttributeError: - logger.warning("Используем fallback функцию для расчета цен стран") + logger.warning('Используем fallback функцию для расчета цен стран') countries_price, _ = await get_countries_price_by_uuids_fallback( selected_countries, db, @@ -587,16 +513,13 @@ async def select_country( ) await callback.answer() -async def countries_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): + +async def countries_continue(callback: types.CallbackQuery, state: FSMContext, db_user: User): data = await state.get_data() texts = get_texts(db_user.language) if not data.get('countries'): - await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True) + await callback.answer('⚠️ Выберите хотя бы одну страну!', show_alert=True) return if not settings.is_devices_selection_enabled(): @@ -607,32 +530,30 @@ async def countries_continue( selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) + texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) await callback.answer() -async def _get_available_countries(promo_group_id: Optional[int] = None): - from app.utils.cache import cache, cache_key - from app.database.database import AsyncSessionLocal - from app.database.crud.server_squad import get_available_server_squads - cache_key_value = cache_key("available_countries", promo_group_id or "all") +async def _get_available_countries(promo_group_id: int | None = None): + from app.database.crud.server_squad import get_available_server_squads + from app.database.database import AsyncSessionLocal + from app.utils.cache import cache, cache_key + + cache_key_value = cache_key('available_countries', promo_group_id or 'all') cached_countries = await cache.get(cache_key_value) if cached_countries: return cached_countries try: async with AsyncSessionLocal() as db: - available_servers = await get_available_server_squads( - db, promo_group_id=promo_group_id - ) + available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id) if promo_group_id is not None and not available_servers: logger.info( - "Промогруппа %s не имеет доступных серверов, возврат пустого списка", + 'Промогруппа %s не имеет доступных серверов, возврат пустого списка', promo_group_id, ) await cache.set(cache_key_value, [], 60) @@ -640,71 +561,80 @@ async def _get_available_countries(promo_group_id: Optional[int] = None): countries = [] for server in available_servers: - countries.append({ - "uuid": server.squad_uuid, - "name": server.display_name, - "price_kopeks": server.price_kopeks, - "country_code": server.country_code, - "is_available": server.is_available and not server.is_full, - "description": server.description or "" - }) + countries.append( + { + 'uuid': server.squad_uuid, + 'name': server.display_name, + 'price_kopeks': server.price_kopeks, + 'country_code': server.country_code, + 'is_available': server.is_available and not server.is_full, + 'description': server.description or '', + } + ) if not countries: - logger.info("🔄 Серверов в БД нет, получаем из RemnaWave...") + logger.info('🔄 Серверов в БД нет, получаем из RemnaWave...') from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() squads = await service.get_all_squads() for squad in squads: - squad_name = squad["name"] + squad_name = squad['name'] - if not any(flag in squad_name for flag in - ["🇳🇱", "🇩🇪", "🇺🇸", "🇫🇷", "🇬🇧", "🇮🇹", "🇪🇸", "🇨🇦", "🇯🇵", "🇸🇬", "🇦🇺"]): + if not any( + flag in squad_name for flag in ['🇳🇱', '🇩🇪', '🇺🇸', '🇫🇷', '🇬🇧', '🇮🇹', '🇪🇸', '🇨🇦', '🇯🇵', '🇸🇬', '🇦🇺'] + ): name_lower = squad_name.lower() - if "netherlands" in name_lower or "нидерланды" in name_lower or "nl" in name_lower: - squad_name = f"🇳🇱 {squad_name}" - elif "germany" in name_lower or "германия" in name_lower or "de" in name_lower: - squad_name = f"🇩🇪 {squad_name}" - elif "usa" in name_lower or "сша" in name_lower or "america" in name_lower or "us" in name_lower: - squad_name = f"🇺🇸 {squad_name}" + if 'netherlands' in name_lower or 'нидерланды' in name_lower or 'nl' in name_lower: + squad_name = f'🇳🇱 {squad_name}' + elif 'germany' in name_lower or 'германия' in name_lower or 'de' in name_lower: + squad_name = f'🇩🇪 {squad_name}' + elif 'usa' in name_lower or 'сша' in name_lower or 'america' in name_lower or 'us' in name_lower: + squad_name = f'🇺🇸 {squad_name}' else: - squad_name = f"🌐 {squad_name}" + squad_name = f'🌐 {squad_name}' - countries.append({ - "uuid": squad["uuid"], - "name": squad_name, - "price_kopeks": 0, - "is_available": True, - "description": "" - }) + countries.append( + { + 'uuid': squad['uuid'], + 'name': squad_name, + 'price_kopeks': 0, + 'is_available': True, + 'description': '', + } + ) await cache.set(cache_key_value, countries, 300) return countries except Exception as e: - logger.error(f"Ошибка получения списка стран: {e}") + logger.error(f'Ошибка получения списка стран: {e}') fallback_countries = [ - {"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""}, + { + 'uuid': 'default-free', + 'name': '🆓 Бесплатный сервер', + 'price_kopeks': 0, + 'is_available': True, + 'description': '', + }, ] await cache.set(cache_key_value, fallback_countries, 60) return fallback_countries + async def _get_countries_info(squad_uuids): countries = await _get_available_countries() return [c for c in countries if c['uuid'] in squad_uuids] -def _get_preselected_free_countries(countries: List[dict]) -> List[str]: +def _get_preselected_free_countries(countries: list[dict]) -> list[str]: """Получить UUID бесплатных серверов для автоматического предвыбора.""" - return [ - c['uuid'] for c in countries - if c.get('is_available', True) and c.get('price_kopeks', 0) == 0 - ] + return [c['uuid'] for c in countries if c.get('is_available', True) and c.get('price_kopeks', 0) == 0] -def _build_countries_selection_text(countries: List[dict], base_text: str) -> str: +def _build_countries_selection_text(countries: list[dict], base_text: str) -> str: """ Формирует текст выбора серверов с описаниями. @@ -717,51 +647,48 @@ def _build_countries_selection_text(countries: List[dict], base_text: str) -> st desc = country.get('description', '').strip() if desc: name = country.get('name', '') - descriptions.append(f"{name}\n{desc}") + descriptions.append(f'{name}\n{desc}') if not descriptions: return base_text - return f"{base_text}\n\n" + "\n\n".join(descriptions) + return f'{base_text}\n\n' + '\n\n'.join(descriptions) async def handle_add_country_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): - logger.info(f"🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}") - logger.info(f"🔍 Callback data: {callback.data}") + logger.info(f'🔍 handle_add_country_to_subscription вызван для {db_user.telegram_id}') + logger.info(f'🔍 Callback data: {callback.data}') current_state = await state.get_state() - logger.info(f"🔍 Текущее состояние: {current_state}") + logger.info(f'🔍 Текущее состояние: {current_state}') country_uuid = callback.data.split('_')[1] data = await state.get_data() - logger.info(f"🔍 Данные состояния: {data}") + logger.info(f'🔍 Данные состояния: {data}') selected_countries = data.get('countries', []) countries = await _get_available_countries(db_user.promo_group_id) allowed_country_ids = {country['uuid'] for country in countries} if country_uuid not in allowed_country_ids and country_uuid not in selected_countries: - await callback.answer("❌ Сервер недоступен для вашей промогруппы", show_alert=True) + await callback.answer('❌ Сервер недоступен для вашей промогруппы', show_alert=True) return if country_uuid in selected_countries: selected_countries.remove(country_uuid) - logger.info(f"🔍 Удалена страна: {country_uuid}") + logger.info(f'🔍 Удалена страна: {country_uuid}') else: selected_countries.append(country_uuid) - logger.info(f"🔍 Добавлена страна: {country_uuid}") + logger.info(f'🔍 Добавлена страна: {country_uuid}') total_price = 0 subscription = db_user.subscription period_hint_days = _get_period_hint_from_subscription(subscription) servers_discount_percent = _get_addon_discount_percent_for_user( db_user, - "servers", + 'servers', period_hint_days, ) @@ -769,10 +696,7 @@ async def handle_add_country_to_subscription( if not country.get('is_available', True): continue - if ( - country['uuid'] in selected_countries - and country['uuid'] not in subscription.connected_squads - ): + if country['uuid'] in selected_countries and country['uuid'] not in subscription.connected_squads: server_price = country['price_kopeks'] if servers_discount_percent > 0 and server_price > 0: discounted_price, _ = apply_percentage_discount( @@ -787,11 +711,12 @@ async def handle_add_country_to_subscription( data['total_price'] = total_price await state.set_data(data) - logger.info(f"🔍 Новые выбранные страны: {selected_countries}") - logger.info(f"🔍 Общая стоимость: {total_price}") + logger.info(f'🔍 Новые выбранные страны: {selected_countries}') + logger.info(f'🔍 Общая стоимость: {total_price}') try: from app.keyboards.inline import get_manage_countries_keyboard + await callback.message.edit_reply_markup( reply_markup=get_manage_countries_keyboard( countries, @@ -802,35 +727,34 @@ async def handle_add_country_to_subscription( servers_discount_percent, ) ) - logger.info(f"✅ Клавиатура обновлена") + logger.info('✅ Клавиатура обновлена') except Exception as e: - logger.error(f"❌ Ошибка обновления клавиатуры: {e}") + logger.error(f'❌ Ошибка обновления клавиатуры: {e}') await callback.answer() -async def _should_show_countries_management(user: Optional[User] = None) -> bool: + +async def _should_show_countries_management(user: User | None = None) -> bool: try: promo_group_id = user.promo_group_id if user else None - promo_group = getattr(user, "promo_group", None) if user else None - if promo_group and getattr(promo_group, "server_squads", None): + promo_group = getattr(user, 'promo_group', None) if user else None + if promo_group and getattr(promo_group, 'server_squads', None): allowed_servers = [ - server - for server in promo_group.server_squads - if server.is_available and not server.is_full + server for server in promo_group.server_squads if server.is_available and not server.is_full ] if allowed_servers: if len(allowed_servers) > 1: logger.debug( - "Промогруппа %s имеет %s доступных серверов, показываем управление странами", + 'Промогруппа %s имеет %s доступных серверов, показываем управление странами', promo_group.id, len(allowed_servers), ) return True logger.debug( - "Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран", + 'Промогруппа %s имеет всего %s доступный сервер, пропускаем шаг выбора стран', promo_group.id, len(allowed_servers), ) @@ -840,14 +764,12 @@ async def _should_show_countries_management(user: Optional[User] = None) -> bool available_countries = [c for c in countries if c.get('is_available', True)] return len(available_countries) > 1 except Exception as e: - logger.error(f"Ошибка проверки доступных серверов: {e}") + logger.error(f'Ошибка проверки доступных серверов: {e}') return True + async def confirm_add_countries_to_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, - state: FSMContext + callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext ): data = await state.get_data() texts = get_texts(db_user.language) @@ -869,7 +791,7 @@ async def confirm_add_countries_to_subscription( removed_countries = [c for c in current_countries if c not in selected_countries] if not new_countries and not removed_countries: - await callback.answer("⚠️ Изменения не обнаружены", show_alert=True) + await callback.answer('⚠️ Изменения не обнаружены', show_alert=True) return total_price = 0 @@ -879,7 +801,7 @@ async def confirm_add_countries_to_subscription( period_hint_days = _get_period_hint_from_subscription(subscription) servers_discount_percent = _get_addon_discount_percent_for_user( db_user, - "servers", + 'servers', period_hint_days, ) total_discount_value = 0 @@ -913,13 +835,13 @@ async def confirm_add_countries_to_subscription( if new_countries and db_user.balance_kopeks < total_price: missing_kopeks = total_price - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(total_price), @@ -933,7 +855,7 @@ async def confirm_add_countries_to_subscription( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await state.clear() await callback.answer() @@ -944,21 +866,20 @@ async def confirm_add_countries_to_subscription( if len(selected_countries) == 0: await callback.answer( texts.t( - "COUNTRIES_MINIMUM_REQUIRED", - "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." + 'COUNTRIES_MINIMUM_REQUIRED', + '❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.', ), - show_alert=True + show_alert=True, ) return if new_countries and total_price > 0: success = await subtract_user_balance( - db, db_user, total_price, - f"Добавление стран к подписке: {', '.join(new_countries_names)}" + db, db_user, total_price, f'Добавление стран к подписке: {", ".join(new_countries_names)}' ) if not success: - await callback.answer("❌ Ошибка списания средств", show_alert=True) + await callback.answer('❌ Ошибка списания средств', show_alert=True) return await create_transaction( @@ -966,7 +887,7 @@ async def confirm_add_countries_to_subscription( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_price, - description=f"Добавление стран к подписке: {', '.join(new_countries_names)}" + description=f'Добавление стран к подписке: {", ".join(new_countries_names)}', ) subscription.connected_squads = selected_countries @@ -979,39 +900,33 @@ async def confirm_add_countries_to_subscription( await db.refresh(db_user) await db.refresh(subscription) - success_text = "✅ Страны успешно обновлены!\n\n" + success_text = '✅ Страны успешно обновлены!\n\n' if new_countries_names: - success_text += f"➕ Добавлены страны:\n{chr(10).join(f'• {name}' for name in new_countries_names)}\n" + success_text += f'➕ Добавлены страны:\n{chr(10).join(f"• {name}" for name in new_countries_names)}\n' if total_price > 0: - success_text += f"💰 Списано: {texts.format_price(total_price)}" + success_text += f'💰 Списано: {texts.format_price(total_price)}' if total_discount_value > 0: success_text += ( - f" (скидка {servers_discount_percent}%:" - f" -{texts.format_price(total_discount_value)})" + f' (скидка {servers_discount_percent}%: -{texts.format_price(total_discount_value)})' ) - success_text += "\n" + success_text += '\n' if removed_countries_names: - success_text += f"\n➖ Отключены страны:\n{chr(10).join(f'• {name}' for name in removed_countries_names)}\n" - success_text += "ℹ️ Повторное подключение будет платным\n" + success_text += f'\n➖ Отключены страны:\n{chr(10).join(f"• {name}" for name in removed_countries_names)}\n' + success_text += 'ℹ️ Повторное подключение будет платным\n' - success_text += f"\n🌍 Активных стран: {len(selected_countries)}" + success_text += f'\n🌍 Активных стран: {len(selected_countries)}' - await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f"✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}") + f'✅ Пользователь {db_user.telegram_id} обновил страны подписки. Добавлено: {len(new_countries)}, убрано: {len(removed_countries)}' + ) except Exception as e: - logger.error(f"Ошибка обновления стран подписки: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка обновления стран подписки: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await state.clear() await callback.answer() diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py index 6da8c50d..dc16b7c0 100644 --- a/app/handlers/subscription/devices.py +++ b/app/handlers/subscription/devices.py @@ -1,92 +1,56 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from datetime import datetime + +from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) + +from app.config import settings +from app.database.crud.subscription import add_subscription_devices from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) +from app.database.models import TransactionType, User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_app_selection_keyboard, + get_back_keyboard, + get_change_devices_keyboard, + get_confirm_change_devices_keyboard, + get_connection_guide_keyboard, + get_device_management_help_keyboard, + get_devices_management_keyboard, + get_insufficient_balance_keyboard, + get_specific_app_keyboard, ) from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, apply_percentage_discount, + calculate_prorated_price, + get_remaining_months, ) from app.utils.subscription_utils import ( get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, ) -from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, format_additional_section, get_apps_for_device, get_device_name, get_step_description, logger +from .common import ( + _get_addon_discount_percent_for_user, + _get_period_hint_from_subscription, + format_additional_section, + get_apps_for_device, + get_device_name, + get_step_description, + logger, +) from .countries import _get_available_countries + async def get_current_devices_detailed(db_user: User) -> dict: try: if not db_user.remnawave_uuid: - return {"count": 0, "devices": []} + return {'count': 0, 'devices': []} from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -97,24 +61,21 @@ async def get_current_devices_detailed(db_user: User) -> dict: total_devices = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) - return { - "count": total_devices, - "devices": devices_list[:5] - } - else: - return {"count": 0, "devices": []} + return {'count': total_devices, 'devices': devices_list[:5]} + return {'count': 0, 'devices': []} except Exception as e: - logger.error(f"Ошибка получения детальной информации об устройствах: {e}") - return {"count": 0, "devices": []} + logger.error(f'Ошибка получения детальной информации об устройствах: {e}') + return {'count': 0, 'devices': []} -async def get_servers_display_names(squad_uuids: List[str]) -> str: + +async def get_servers_display_names(squad_uuids: list[str]) -> str: if not squad_uuids: - return "Нет серверов" + return 'Нет серверов' try: - from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import get_server_squad_by_uuid + from app.database.database import AsyncSessionLocal server_names = [] @@ -123,9 +84,9 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: server = await get_server_squad_by_uuid(db, uuid) if server: server_names.append(server.display_name) - logger.debug(f"Найден сервер в БД: {uuid} -> {server.display_name}") + logger.debug(f'Найден сервер в БД: {uuid} -> {server.display_name}') else: - logger.warning(f"Сервер с UUID {uuid} не найден в БД") + logger.warning(f'Сервер с UUID {uuid} не найден в БД') if not server_names: countries = await _get_available_countries() @@ -133,33 +94,34 @@ async def get_servers_display_names(squad_uuids: List[str]) -> str: for country in countries: if country['uuid'] == uuid: server_names.append(country['name']) - logger.debug(f"Найден сервер в кэше: {uuid} -> {country['name']}") + logger.debug(f'Найден сервер в кэше: {uuid} -> {country["name"]}') break if not server_names: if len(squad_uuids) == 1: - return "🎯 Тестовый сервер" - return f"{len(squad_uuids)} стран" + return '🎯 Тестовый сервер' + return f'{len(squad_uuids)} стран' if len(server_names) > 6: - displayed = ", ".join(server_names[:6]) + displayed = ', '.join(server_names[:6]) remaining = len(server_names) - 6 - return f"{displayed} и ещё {remaining}" - else: - return ", ".join(server_names) + return f'{displayed} и ещё {remaining}' + return ', '.join(server_names) except Exception as e: - logger.error(f"Ошибка получения названий серверов: {e}") + logger.error(f'Ошибка получения названий серверов: {e}') if len(squad_uuids) == 1: - return "🎯 Тестовый сервер" - return f"{len(squad_uuids)} стран" + return '🎯 Тестовый сервер' + return f'{len(squad_uuids)} стран' + async def get_current_devices_count(db_user: User) -> str: try: if not db_user.remnawave_uuid: - return "—" + return '—' from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -168,24 +130,20 @@ async def get_current_devices_count(db_user: User) -> str: if response and 'response' in response: total_devices = response['response'].get('total', 0) return str(total_devices) - else: - return "—" + return '—' except Exception as e: - logger.error(f"Ошибка получения количества устройств: {e}") - return "—" + logger.error(f'Ошибка получения количества устройств: {e}') + return '—' -async def handle_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer( - texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + texts.t('PAID_FEATURE_ONLY', '⚠️ Эта функция доступна только для платных подписок'), show_alert=True, ) return @@ -194,6 +152,7 @@ async def handle_change_devices( tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли изменение устройств @@ -201,25 +160,24 @@ async def handle_change_devices( if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( - texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"), - show_alert=True, - ) - return - else: - # Для обычных подписок проверяем глобальную настройку - if not settings.is_devices_selection_enabled(): - await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'), show_alert=True, ) return + # Для обычных подписок проверяем глобальную настройку + elif not settings.is_devices_selection_enabled(): + await callback.answer( + texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), + show_alert=True, + ) + return current_devices = subscription.device_limit period_hint_days = _get_period_hint_from_subscription(subscription) devices_discount_percent = _get_addon_discount_percent_for_user( db_user, - "devices", + 'devices', period_hint_days, ) @@ -228,27 +186,27 @@ async def handle_change_devices( price_per_device = tariff_device_price price_text = texts.format_price(price_per_device) prompt_text = texts.t( - "CHANGE_DEVICES_PROMPT_TARIFF", + 'CHANGE_DEVICES_PROMPT_TARIFF', ( - "📱 Изменение количества устройств\n\n" - "Текущий лимит: {current_devices} устройств\n" - "Цена за доп. устройство: {price}/мес\n" - "Выберите новое количество устройств:\n\n" - "💡 Важно:\n" - "• При увеличении - доплата пропорционально оставшемуся времени\n" - "• При уменьшении - возврат средств не производится" + '📱 Изменение количества устройств\n\n' + 'Текущий лимит: {current_devices} устройств\n' + 'Цена за доп. устройство: {price}/мес\n' + 'Выберите новое количество устройств:\n\n' + '💡 Важно:\n' + '• При увеличении - доплата пропорционально оставшемуся времени\n' + '• При уменьшении - возврат средств не производится' ), ).format(current_devices=current_devices, price=price_text) else: prompt_text = texts.t( - "CHANGE_DEVICES_PROMPT", + 'CHANGE_DEVICES_PROMPT', ( - "📱 Изменение количества устройств\n\n" - "Текущий лимит: {current_devices} устройств\n" - "Выберите новое количество устройств:\n\n" - "💡 Важно:\n" - "• При увеличении - доплата пропорционально оставшемуся времени\n" - "• При уменьшении - возврат средств не производится" + '📱 Изменение количества устройств\n\n' + 'Текущий лимит: {current_devices} устройств\n' + 'Выберите новое количество устройств:\n\n' + '💡 Важно:\n' + '• При увеличении - доплата пропорционально оставшемуся времени\n' + '• При уменьшении - возврат средств не производится' ), ).format(current_devices=current_devices) @@ -261,16 +219,13 @@ async def handle_change_devices( devices_discount_percent, tariff=tariff, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def confirm_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): new_devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -279,6 +234,7 @@ async def confirm_change_devices( tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли изменение устройств @@ -286,7 +242,7 @@ async def confirm_change_devices( if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( - texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Изменение устройств недоступно для вашего тарифа"), + texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'), show_alert=True, ) return @@ -294,7 +250,7 @@ async def confirm_change_devices( else: if not settings.is_devices_selection_enabled(): await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return @@ -304,7 +260,7 @@ async def confirm_change_devices( if new_devices_count == current_devices: await callback.answer( - texts.t("DEVICES_NO_CHANGE", "ℹ️ Количество устройств не изменилось"), + texts.t('DEVICES_NO_CHANGE', 'ℹ️ Количество устройств не изменилось'), show_alert=True, ) return @@ -312,10 +268,10 @@ async def confirm_change_devices( if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT: await callback.answer( texts.t( - "DEVICES_LIMIT_EXCEEDED", - "⚠️ Превышен максимальный лимит устройств ({limit})", + 'DEVICES_LIMIT_EXCEEDED', + '⚠️ Превышен максимальный лимит устройств ({limit})', ).format(limit=settings.MAX_DEVICES_LIMIT), - show_alert=True + show_alert=True, ) return @@ -338,7 +294,7 @@ async def confirm_change_devices( period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( db_user, - "devices", + 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( @@ -353,15 +309,15 @@ async def confirm_change_devices( if price > 0 and db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + required_text = f'{texts.format_price(price)} (за {charged_months} мес)' message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, @@ -375,44 +331,44 @@ async def confirm_change_devices( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return action_text = texts.t( - "DEVICE_CHANGE_ACTION_INCREASE", - "увеличить до {count}", + 'DEVICE_CHANGE_ACTION_INCREASE', + 'увеличить до {count}', ).format(count=new_devices_count) if price > 0: cost_text = texts.t( - "DEVICE_CHANGE_EXTRA_COST", - "Доплата: {amount} (за {months} мес)", + 'DEVICE_CHANGE_EXTRA_COST', + 'Доплата: {amount} (за {months} мес)', ).format( amount=texts.format_price(price), months=charged_months, ) if total_discount > 0: cost_text += texts.t( - "DEVICE_CHANGE_DISCOUNT_INFO", - " (скидка {percent}%: -{amount})", + 'DEVICE_CHANGE_DISCOUNT_INFO', + ' (скидка {percent}%: -{amount})', ).format( percent=devices_discount_percent, amount=texts.format_price(total_discount), ) else: - cost_text = texts.t("DEVICE_CHANGE_FREE", "Бесплатно") + cost_text = texts.t('DEVICE_CHANGE_FREE', 'Бесплатно') else: price = 0 action_text = texts.t( - "DEVICE_CHANGE_ACTION_DECREASE", - "уменьшить до {count}", + 'DEVICE_CHANGE_ACTION_DECREASE', + 'уменьшить до {count}', ).format(count=new_devices_count) - cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится") + cost_text = texts.t('DEVICE_CHANGE_NO_REFUND', 'Возврат средств не производится') # Проверяем количество подключённых устройств для предупреждения - devices_warning = "" + devices_warning = '' if new_devices_count < current_devices and db_user.remnawave_uuid: try: service = RemnaWaveService() @@ -422,26 +378,26 @@ async def confirm_change_devices( connected_count = response['response'].get('total', 0) if connected_count > new_devices_count: devices_warning = texts.t( - "DEVICE_CHANGE_RESET_WARNING", + 'DEVICE_CHANGE_RESET_WARNING', ( - "\n⚠️ Внимание!\n" - "У вас подключено {connected} устройств.\n" - "При уменьшении лимита до {new} все устройства будут сброшены.\n" - "Вам нужно будет заново подключить нужные устройства.\n" + '\n⚠️ Внимание!\n' + 'У вас подключено {connected} устройств.\n' + 'При уменьшении лимита до {new} все устройства будут сброшены.\n' + 'Вам нужно будет заново подключить нужные устройства.\n' ), ).format(connected=connected_count, new=new_devices_count) except Exception as e: - logger.error(f"Ошибка проверки устройств: {e}") + logger.error(f'Ошибка проверки устройств: {e}') confirm_text = texts.t( - "DEVICE_CHANGE_CONFIRMATION", + 'DEVICE_CHANGE_CONFIRMATION', ( - "📱 Подтверждение изменения\n\n" - "Текущее количество: {current} устройств\n" - "Новое количество: {new} устройств\n\n" - "Действие: {action}\n" - "💰 {cost}\n\n" - "Подтвердить изменение?" + '📱 Подтверждение изменения\n\n' + 'Текущее количество: {current} устройств\n' + 'Новое количество: {new} устройств\n\n' + 'Действие: {action}\n' + '💰 {cost}\n\n' + 'Подтвердить изменение?' ), ).format( current=current_devices, @@ -456,16 +412,13 @@ async def confirm_change_devices( await callback.message.edit_text( confirm_text, reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def execute_change_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def execute_change_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): callback_parts = callback.data.split('_') new_devices_count = int(callback_parts[3]) price = int(callback_parts[4]) @@ -476,7 +429,7 @@ async def execute_change_devices( if not settings.is_devices_selection_enabled(): await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return @@ -484,13 +437,12 @@ async def execute_change_devices( try: if price > 0: success = await subtract_user_balance( - db, db_user, price, - f"Изменение количества устройств с {current_devices} до {new_devices_count}" + db, db_user, price, f'Изменение количества устройств с {current_devices} до {new_devices_count}' ) if not success: await callback.answer( - texts.t("PAYMENT_CHARGE_ERROR", "⚠️ Ошибка списания средств"), + texts.t('PAYMENT_CHARGE_ERROR', '⚠️ Ошибка списания средств'), show_alert=True, ) return @@ -501,7 +453,7 @@ async def execute_change_devices( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес" + description=f'Изменение устройств с {current_devices} до {new_devices_count} на {charged_months} мес', ) subscription.device_limit = new_devices_count @@ -526,101 +478,94 @@ async def execute_change_devices( # Если подключённых устройств больше чем новый лимит - сбросить все if connected_count > new_devices_count: logger.info( - f"🔧 Сброс устройств при уменьшении лимита: " - f"подключено {connected_count}, новый лимит {new_devices_count}" + f'🔧 Сброс устройств при уменьшении лимита: ' + f'подключено {connected_count}, новый лимит {new_devices_count}' ) await api.reset_user_devices(db_user.remnawave_uuid) devices_reset_count = connected_count except Exception as reset_error: - logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}") + logger.error(f'Ошибка сброса устройств при уменьшении лимита: {reset_error}') await db.refresh(db_user) await db.refresh(subscription) try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( - db, db_user, subscription, "devices", current_devices, new_devices_count, price + db, db_user, subscription, 'devices', current_devices, new_devices_count, price ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об изменении устройств: {e}") + logger.error(f'Ошибка отправки уведомления об изменении устройств: {e}') if new_devices_count > current_devices: success_text = texts.t( - "DEVICE_CHANGE_INCREASE_SUCCESS", - "✅ Количество устройств увеличено!\n\n", + 'DEVICE_CHANGE_INCREASE_SUCCESS', + '✅ Количество устройств увеличено!\n\n', ) success_text += texts.t( - "DEVICE_CHANGE_RESULT_LINE", - "📱 Было: {old} → Стало: {new}\n", + 'DEVICE_CHANGE_RESULT_LINE', + '📱 Было: {old} → Стало: {new}\n', ).format(old=current_devices, new=new_devices_count) if price > 0: success_text += texts.t( - "DEVICE_CHANGE_CHARGED", - "💰 Списано: {amount}", + 'DEVICE_CHANGE_CHARGED', + '💰 Списано: {amount}', ).format(amount=texts.format_price(price)) else: success_text = texts.t( - "DEVICE_CHANGE_DECREASE_SUCCESS", - "✅ Количество устройств уменьшено!\n\n", + 'DEVICE_CHANGE_DECREASE_SUCCESS', + '✅ Количество устройств уменьшено!\n\n', ) success_text += texts.t( - "DEVICE_CHANGE_RESULT_LINE", - "📱 Было: {old} → Стало: {new}\n", + 'DEVICE_CHANGE_RESULT_LINE', + '📱 Было: {old} → Стало: {new}\n', ).format(old=current_devices, new=new_devices_count) if devices_reset_count > 0: success_text += texts.t( - "DEVICE_CHANGE_DEVICES_RESET", - "\n🔄 Сброшено устройств: {count}\n" - "💡 Подключите заново нужные устройства (до {limit} шт.)\n\n", + 'DEVICE_CHANGE_DEVICES_RESET', + '\n🔄 Сброшено устройств: {count}\n💡 Подключите заново нужные устройства (до {limit} шт.)\n\n', ).format(count=devices_reset_count, limit=new_devices_count) success_text += texts.t( - "DEVICE_CHANGE_NO_REFUND_INFO", - "ℹ️ Возврат средств не производится", + 'DEVICE_CHANGE_NO_REFUND_INFO', + 'ℹ️ Возврат средств не производится', ) - await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f"✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽") + f'✅ Пользователь {db_user.telegram_id} изменил количество устройств с {current_devices} на {new_devices_count}, доплата: {price / 100}₽' + ) except Exception as e: - logger.error(f"Ошибка изменения количества устройств: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка изменения количества устройств: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() -async def handle_device_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_device_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: await callback.answer( - texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"), + texts.t('PAID_FEATURE_ONLY', '⚠️ Эта функция доступна только для платных подписок'), show_alert=True, ) return if not db_user.remnawave_uuid: await callback.answer( - texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), + texts.t('DEVICE_UUID_NOT_FOUND', '❌ UUID пользователя не найден'), show_alert=True, ) return try: from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -633,8 +578,8 @@ async def handle_device_management( if total_devices == 0: await callback.message.edit_text( - texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"), - reply_markup=get_back_keyboard(db_user.language) + texts.t('DEVICE_NONE_CONNECTED', 'ℹ️ У вас нет подключенных устройств'), + reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() return @@ -643,91 +588,77 @@ async def handle_device_management( else: await callback.answer( texts.t( - "DEVICE_FETCH_INFO_ERROR", - "❌ Ошибка получения информации об устройствах", + 'DEVICE_FETCH_INFO_ERROR', + '❌ Ошибка получения информации об устройствах', ), show_alert=True, ) except Exception as e: - logger.error(f"Ошибка получения списка устройств: {e}") + logger.error(f'Ошибка получения списка устройств: {e}') await callback.answer( texts.t( - "DEVICE_FETCH_INFO_ERROR", - "❌ Ошибка получения информации об устройствах", + 'DEVICE_FETCH_INFO_ERROR', + '❌ Ошибка получения информации об устройствах', ), show_alert=True, ) await callback.answer() -async def show_devices_page( - callback: types.CallbackQuery, - db_user: User, - devices_list: List[dict], - page: int = 1 -): + +async def show_devices_page(callback: types.CallbackQuery, db_user: User, devices_list: list[dict], page: int = 1): texts = get_texts(db_user.language) devices_per_page = 5 pagination = paginate_list(devices_list, page=page, per_page=devices_per_page) devices_text = texts.t( - "DEVICE_MANAGEMENT_OVERVIEW", + 'DEVICE_MANAGEMENT_OVERVIEW', ( - "🔄 Управление устройствами\n\n" - "📊 Всего подключено: {total} устройств\n" - "📄 Страница {page} из {pages}\n\n" + '🔄 Управление устройствами\n\n' + '📊 Всего подключено: {total} устройств\n' + '📄 Страница {page} из {pages}\n\n' ), ).format(total=len(devices_list), page=pagination.page, pages=pagination.total_pages) if pagination.items: devices_text += texts.t( - "DEVICE_MANAGEMENT_CONNECTED_HEADER", - "Подключенные устройства:\n", + 'DEVICE_MANAGEMENT_CONNECTED_HEADER', + 'Подключенные устройства:\n', ) for i, device in enumerate(pagination.items, 1): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') - device_info = f"{platform} - {device_model}" + device_info = f'{platform} - {device_model}' if len(device_info) > 35: - device_info = device_info[:32] + "..." + device_info = device_info[:32] + '...' devices_text += texts.t( - "DEVICE_MANAGEMENT_LIST_ITEM", - "• {device}\n", + 'DEVICE_MANAGEMENT_LIST_ITEM', + '• {device}\n', ).format(device=device_info) devices_text += texts.t( - "DEVICE_MANAGEMENT_ACTIONS", - ( - "\n💡 Действия:\n" - "• Выберите устройство для сброса\n" - "• Или сбросьте все устройства сразу" - ), + 'DEVICE_MANAGEMENT_ACTIONS', + ('\n💡 Действия:\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу'), ) await callback.message.edit_text( devices_text, - reply_markup=get_devices_management_keyboard( - pagination.items, - pagination, - db_user.language - ), - parse_mode="HTML" + reply_markup=get_devices_management_keyboard(pagination.items, pagination, db_user.language), + parse_mode='HTML', ) -async def handle_devices_page( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession): page = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) try: from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -738,28 +669,25 @@ async def handle_devices_page( await show_devices_page(callback, db_user, devices_list, page=page) else: await callback.answer( - texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"), + texts.t('DEVICE_FETCH_ERROR', '❌ Ошибка получения устройств'), show_alert=True, ) except Exception as e: - logger.error(f"Ошибка перехода на страницу устройств: {e}") + logger.error(f'Ошибка перехода на страницу устройств: {e}') await callback.answer( - texts.t("DEVICE_PAGE_LOAD_ERROR", "❌ Ошибка загрузки страницы"), + texts.t('DEVICE_PAGE_LOAD_ERROR', '❌ Ошибка загрузки страницы'), show_alert=True, ) -async def handle_single_device_reset( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_single_device_reset(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: callback_parts = callback.data.split('_') if len(callback_parts) < 4: - logger.error(f"Некорректный формат callback_data: {callback.data}") + logger.error(f'Некорректный формат callback_data: {callback.data}') await callback.answer( - texts.t("DEVICE_RESET_INVALID_REQUEST", "❌ Ошибка: некорректный запрос"), + texts.t('DEVICE_RESET_INVALID_REQUEST', '❌ Ошибка: некорректный запрос'), show_alert=True, ) return @@ -767,12 +695,12 @@ async def handle_single_device_reset( device_index = int(callback_parts[2]) page = int(callback_parts[3]) - logger.info(f"🔧 Сброс устройства: index={device_index}, page={page}") + logger.info(f'🔧 Сброс устройства: index={device_index}, page={page}') except (ValueError, IndexError) as e: - logger.error(f"❌ Ошибка парсинга callback_data {callback.data}: {e}") + logger.error(f'❌ Ошибка парсинга callback_data {callback.data}: {e}') await callback.answer( - texts.t("DEVICE_RESET_PARSE_ERROR", "❌ Ошибка обработки запроса"), + texts.t('DEVICE_RESET_PARSE_ERROR', '❌ Ошибка обработки запроса'), show_alert=True, ) return @@ -781,6 +709,7 @@ async def handle_single_device_reset( try: from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -797,21 +726,18 @@ async def handle_single_device_reset( device_hwid = device.get('hwid') if device_hwid: - delete_data = { - "userUuid": db_user.remnawave_uuid, - "hwid": device_hwid - } + delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') - device_info = f"{platform} - {device_model}" + device_info = f'{platform} - {device_model}' await callback.answer( texts.t( - "DEVICE_RESET_SUCCESS", - "✅ Устройство {device} успешно сброшено!", + 'DEVICE_RESET_SUCCESS', + '✅ Устройство {device} успешно сброшено!', ).format(device=device_info), show_alert=True, ) @@ -821,8 +747,9 @@ async def handle_single_device_reset( updated_devices = updated_response['response'].get('devices', []) if updated_devices: - updated_pagination = paginate_list(updated_devices, page=page, - per_page=devices_per_page) + updated_pagination = paginate_list( + updated_devices, page=page, per_page=devices_per_page + ) if not updated_pagination.items and page > 1: page = page - 1 @@ -830,55 +757,53 @@ async def handle_single_device_reset( else: await callback.message.edit_text( texts.t( - "DEVICE_RESET_ALL_DONE", - "ℹ️ Все устройства сброшены", + 'DEVICE_RESET_ALL_DONE', + 'ℹ️ Все устройства сброшены', ), - reply_markup=get_back_keyboard(db_user.language) + reply_markup=get_back_keyboard(db_user.language), ) - logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}") + logger.info(f'✅ Пользователь {db_user.telegram_id} сбросил устройство {device_info}') else: await callback.answer( texts.t( - "DEVICE_RESET_ID_FAILED", - "❌ Не удалось получить ID устройства", + 'DEVICE_RESET_ID_FAILED', + '❌ Не удалось получить ID устройства', ), show_alert=True, ) else: await callback.answer( - texts.t("DEVICE_RESET_NOT_FOUND", "❌ Устройство не найдено"), + texts.t('DEVICE_RESET_NOT_FOUND', '❌ Устройство не найдено'), show_alert=True, ) else: await callback.answer( - texts.t("DEVICE_FETCH_ERROR", "❌ Ошибка получения устройств"), + texts.t('DEVICE_FETCH_ERROR', '❌ Ошибка получения устройств'), show_alert=True, ) except Exception as e: - logger.error(f"Ошибка сброса устройства: {e}") + logger.error(f'Ошибка сброса устройства: {e}') await callback.answer( - texts.t("DEVICE_RESET_ERROR", "❌ Ошибка сброса устройства"), + texts.t('DEVICE_RESET_ERROR', '❌ Ошибка сброса устройства'), show_alert=True, ) -async def handle_all_devices_reset_from_management( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_all_devices_reset_from_management(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) if not db_user.remnawave_uuid: await callback.answer( - texts.t("DEVICE_UUID_NOT_FOUND", "❌ UUID пользователя не найден"), + texts.t('DEVICE_UUID_NOT_FOUND', '❌ UUID пользователя не найден'), show_alert=True, ) return try: from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -887,8 +812,8 @@ async def handle_all_devices_reset_from_management( if not devices_response or 'response' not in devices_response: await callback.answer( texts.t( - "DEVICE_LIST_FETCH_ERROR", - "❌ Ошибка получения списка устройств", + 'DEVICE_LIST_FETCH_ERROR', + '❌ Ошибка получения списка устройств', ), show_alert=True, ) @@ -898,12 +823,12 @@ async def handle_all_devices_reset_from_management( if not devices_list: await callback.answer( - texts.t("DEVICE_NONE_CONNECTED", "ℹ️ У вас нет подключенных устройств"), + texts.t('DEVICE_NONE_CONNECTED', 'ℹ️ У вас нет подключенных устройств'), show_alert=True, ) return - logger.info(f"🔧 Найдено {len(devices_list)} устройств для сброса") + logger.info(f'🔧 Найдено {len(devices_list)} устройств для сброса') success_count = 0 failed_count = 0 @@ -912,83 +837,75 @@ async def handle_all_devices_reset_from_management( device_hwid = device.get('hwid') if device_hwid: try: - delete_data = { - "userUuid": db_user.remnawave_uuid, - "hwid": device_hwid - } + delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid} await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data) success_count += 1 - logger.info(f"✅ Устройство {device_hwid} удалено") + logger.info(f'✅ Устройство {device_hwid} удалено') except Exception as device_error: failed_count += 1 - logger.error(f"❌ Ошибка удаления устройства {device_hwid}: {device_error}") + logger.error(f'❌ Ошибка удаления устройства {device_hwid}: {device_error}') else: failed_count += 1 - logger.warning(f"⚠️ У устройства нет HWID: {device}") + logger.warning(f'⚠️ У устройства нет HWID: {device}') if success_count > 0: if failed_count == 0: await callback.message.edit_text( texts.t( - "DEVICE_RESET_ALL_SUCCESS_MESSAGE", + 'DEVICE_RESET_ALL_SUCCESS_MESSAGE', ( - "✅ Все устройства успешно сброшены!\n\n" - "🔄 Сброшено: {count} устройств\n" - "📱 Теперь вы можете заново подключить свои устройства\n\n" + '✅ Все устройства успешно сброшены!\n\n' + '🔄 Сброшено: {count} устройств\n' + '📱 Теперь вы можете заново подключить свои устройства\n\n' "💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения" ), ).format(count=success_count), reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) - logger.info(f"✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств") + logger.info(f'✅ Пользователь {db_user.telegram_id} успешно сбросил {success_count} устройств') else: await callback.message.edit_text( texts.t( - "DEVICE_RESET_PARTIAL_MESSAGE", + 'DEVICE_RESET_PARTIAL_MESSAGE', ( - "⚠️ Частичный сброс устройств\n\n" - "✅ Удалено: {success} устройств\n" - "❌ Не удалось удалить: {failed} устройств\n\n" - "Попробуйте еще раз или обратитесь в поддержку." + '⚠️ Частичный сброс устройств\n\n' + '✅ Удалено: {success} устройств\n' + '❌ Не удалось удалить: {failed} устройств\n\n' + 'Попробуйте еще раз или обратитесь в поддержку.' ), ).format(success=success_count, failed=failed_count), reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) logger.warning( - f"⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}") + f'⚠️ Частичный сброс у пользователя {db_user.telegram_id}: {success_count}/{len(devices_list)}' + ) else: await callback.message.edit_text( texts.t( - "DEVICE_RESET_ALL_FAILED_MESSAGE", + 'DEVICE_RESET_ALL_FAILED_MESSAGE', ( - "❌ Не удалось сбросить устройства\n\n" - "Попробуйте еще раз позже или обратитесь в техподдержку.\n\n" - "Всего устройств: {total}" + '❌ Не удалось сбросить устройства\n\n' + 'Попробуйте еще раз позже или обратитесь в техподдержку.\n\n' + 'Всего устройств: {total}' ), ).format(total=len(devices_list)), reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) - logger.error(f"❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}") + logger.error(f'❌ Не удалось сбросить ни одного устройства у пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка сброса всех устройств: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка сброса всех устройств: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() -async def confirm_add_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): devices_count = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -997,6 +914,7 @@ async def confirm_add_devices( tariff = None if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) # Для тарифов - проверяем разрешено ли добавление устройств @@ -1004,7 +922,7 @@ async def confirm_add_devices( if tariff: if tariff_device_price is None or tariff_device_price <= 0: await callback.answer( - texts.t("TARIFF_DEVICES_DISABLED", "⚠️ Добавление устройств недоступно для вашего тарифа"), + texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Добавление устройств недоступно для вашего тарифа'), show_alert=True, ) return @@ -1012,7 +930,7 @@ async def confirm_add_devices( else: if not settings.is_devices_selection_enabled(): await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"), + texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'), show_alert=True, ) return @@ -1024,9 +942,9 @@ async def confirm_add_devices( if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT: await callback.answer( - f"⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). " - f"У вас: {subscription.device_limit}, добавляете: {devices_count}", - show_alert=True + f'⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). ' + f'У вас: {subscription.device_limit}, добавляете: {devices_count}', + show_alert=True, ) return @@ -1035,7 +953,7 @@ async def confirm_add_devices( period_hint_days = months_hint * 30 if months_hint > 0 else None devices_discount_percent = _get_addon_discount_percent_for_user( db_user, - "devices", + 'devices', period_hint_days, ) discounted_per_month, discount_per_month = apply_percentage_discount( @@ -1049,7 +967,7 @@ async def confirm_add_devices( total_discount = discount_per_month * charged_months logger.info( - "Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)", + 'Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)', devices_count, discounted_per_month / 100, charged_months, @@ -1059,15 +977,15 @@ async def confirm_add_devices( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks - required_text = f"{texts.format_price(price)} (за {charged_months} мес)" + required_text = f'{texts.format_price(price)} (за {charged_months} мес)' message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, @@ -1082,19 +1000,18 @@ async def confirm_add_devices( resume_callback=resume_callback, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return try: success = await subtract_user_balance( - db, db_user, price, - f"Добавление {devices_count} устройств на {charged_months} мес" + db, db_user, price, f'Добавление {devices_count} устройств на {charged_months} мес' ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return await add_subscription_devices(db, subscription, devices_count) @@ -1107,59 +1024,41 @@ async def confirm_add_devices( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {devices_count} устройств на {charged_months} мес" + description=f'Добавление {devices_count} устройств на {charged_months} мес', ) await db.refresh(db_user) await db.refresh(subscription) success_text = ( - "✅ Устройства успешно добавлены!\n\n" - f"📱 Добавлено: {devices_count} устройств\n" - f"Новый лимит: {subscription.device_limit} устройств\n" + '✅ Устройства успешно добавлены!\n\n' + f'📱 Добавлено: {devices_count} устройств\n' + f'Новый лимит: {subscription.device_limit} устройств\n' ) - success_text += f"💰 Списано: {texts.format_price(price)} (за {charged_months} мес)" + success_text += f'💰 Списано: {texts.format_price(price)} (за {charged_months} мес)' if total_discount > 0: - success_text += ( - f" (скидка {devices_discount_percent}%:" - f" -{texts.format_price(total_discount)})" - ) + success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})' - await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽") + logger.info(f'✅ Пользователь {db_user.telegram_id} добавил {devices_count} устройств за {price / 100}₽') except Exception as e: - logger.error(f"Ошибка добавления устройств: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка добавления устройств: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() -async def handle_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_reset_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await handle_device_management(callback, db_user, db) -async def confirm_reset_devices( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_reset_devices(callback: types.CallbackQuery, db_user: User, db: AsyncSession): await handle_device_management(callback, db_user, db) -async def handle_device_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession): device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1167,7 +1066,7 @@ async def handle_device_guide( if not subscription_link: await callback.answer( - texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + texts.t('SUBSCRIPTION_LINK_UNAVAILABLE', '❌ Ссылка подписки недоступна'), show_alert=True, ) return @@ -1177,19 +1076,16 @@ async def handle_device_guide( if not apps: await callback.answer( - texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"), + texts.t('SUBSCRIPTION_DEVICE_APPS_NOT_FOUND', '❌ Приложения для этого устройства не найдены'), show_alert=True, ) return featured_app = next((app for app in apps if app.get('isFeatured', False)), apps[0]) featured_app_id = featured_app.get('id') - other_apps = [ - app for app in apps - if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id - ] + other_apps = [app for app in apps if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id] - other_app_names = ", ".join( + other_app_names = ', '.join( str(app.get('name')).strip() for app in other_apps if isinstance(app.get('name'), str) and app.get('name').strip() @@ -1197,96 +1093,96 @@ async def handle_device_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + + '\n' + + texts.t( + 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', + 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', + ) + + '\n\n' ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + + f'\n{subscription_link}\n\n' ) - installation_description = get_step_description(featured_app, "installationStep", db_user.language) - add_description = get_step_description(featured_app, "addSubscriptionStep", db_user.language) - connect_description = get_step_description(featured_app, "connectAndUseStep", db_user.language) + installation_description = get_step_description(featured_app, 'installationStep', db_user.language) + add_description = get_step_description(featured_app, 'addSubscriptionStep', db_user.language) + connect_description = get_step_description(featured_app, 'connectAndUseStep', db_user.language) additional_before_text = format_additional_section( - featured_app.get("additionalBeforeAddSubscriptionStep"), + featured_app.get('additionalBeforeAddSubscriptionStep'), texts, db_user.language, ) additional_after_text = format_additional_section( - featured_app.get("additionalAfterAddSubscriptionStep"), + featured_app.get('additionalAfterAddSubscriptionStep'), texts, db_user.language, ) guide_text = ( - texts.t( - "SUBSCRIPTION_DEVICE_GUIDE_TITLE", - "📱 Настройка для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section - + texts.t( - "SUBSCRIPTION_DEVICE_FEATURED_APP", - "📋 Рекомендуемое приложение: {app_name}", - ).format(app_name=featured_app.get('name', '')) + texts.t( + 'SUBSCRIPTION_DEVICE_GUIDE_TITLE', + '📱 Настройка для {device_name}', + ).format(device_name=get_device_name(device_type, db_user.language)) + + '\n\n' + + link_section + + texts.t( + 'SUBSCRIPTION_DEVICE_FEATURED_APP', + '📋 Рекомендуемое приложение: {app_name}', + ).format(app_name=featured_app.get('name', '')) ) if other_app_names: - guide_text += "\n\n" + texts.t( - "SUBSCRIPTION_DEVICE_OTHER_APPS", - "📦 Другие приложения: {app_list}", + guide_text += '\n\n' + texts.t( + 'SUBSCRIPTION_DEVICE_OTHER_APPS', + '📦 Другие приложения: {app_list}', ).format(app_list=other_app_names) - guide_text += "\n" + texts.t( - "SUBSCRIPTION_DEVICE_OTHER_APPS_HINT", - "Нажмите кнопку \"Другие приложения\" ниже, чтобы выбрать приложение.", + guide_text += '\n' + texts.t( + 'SUBSCRIPTION_DEVICE_OTHER_APPS_HINT', + 'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.', ) - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') if installation_description: - guide_text += f"\n{installation_description}" + guide_text += f'\n{installation_description}' if additional_before_text: - guide_text += f"\n\n{additional_before_text}" + guide_text += f'\n\n{additional_before_text}' - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') if add_description: - guide_text += f"\n{add_description}" + guide_text += f'\n{add_description}' - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') if connect_description: - guide_text += f"\n{connect_description}" + guide_text += f'\n{connect_description}' - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_HOW_TO_TITLE", "💡 Как подключить:") - guide_text += "\n" + "\n".join( + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 Как подключить:') + guide_text += '\n' + '\n'.join( [ texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP1", - "1. Установите приложение по ссылке выше", + 'SUBSCRIPTION_DEVICE_HOW_TO_STEP1', + '1. Установите приложение по ссылке выше', ), texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP2", - "2. Нажмите кнопку \"Подключиться\" ниже", + 'SUBSCRIPTION_DEVICE_HOW_TO_STEP2', + '2. Нажмите кнопку "Подключиться" ниже', ), texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP3", - "3. Откройте приложение и вставьте ссылку", + 'SUBSCRIPTION_DEVICE_HOW_TO_STEP3', + '3. Откройте приложение и вставьте ссылку', ), texts.t( - "SUBSCRIPTION_DEVICE_HOW_TO_STEP4", - "4. Подключитесь к серверу", + 'SUBSCRIPTION_DEVICE_HOW_TO_STEP4', + '4. Подключитесь к серверу', ), ] ) if additional_after_text: - guide_text += f"\n\n{additional_after_text}" + guide_text += f'\n\n{additional_after_text}' await callback.message.edit_text( guide_text, @@ -1297,49 +1193,40 @@ async def handle_device_guide( db_user.language, has_other_apps=bool(other_apps), ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def handle_app_selection( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db: AsyncSession): device_type = callback.data.split('_')[2] texts = get_texts(db_user.language) - subscription = db_user.subscription apps = get_apps_for_device(device_type, db_user.language) if not apps: await callback.answer( - texts.t("SUBSCRIPTION_DEVICE_APPS_NOT_FOUND", "❌ Приложения для этого устройства не найдены"), + texts.t('SUBSCRIPTION_DEVICE_APPS_NOT_FOUND', '❌ Приложения для этого устройства не найдены'), show_alert=True, ) return app_text = ( - texts.t( - "SUBSCRIPTION_APPS_TITLE", - "📱 Приложения для {device_name}", - ).format(device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + texts.t("SUBSCRIPTION_APPS_PROMPT", "Выберите приложение для подключения:") + texts.t( + 'SUBSCRIPTION_APPS_TITLE', + '📱 Приложения для {device_name}', + ).format(device_name=get_device_name(device_type, db_user.language)) + + '\n\n' + + texts.t('SUBSCRIPTION_APPS_PROMPT', 'Выберите приложение для подключения:') ) await callback.message.edit_text( - app_text, - reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language), - parse_mode="HTML" + app_text, reply_markup=get_app_selection_keyboard(device_type, apps, db_user.language), parse_mode='HTML' ) await callback.answer() -async def handle_specific_app_guide( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession): _, device_type, app_id = callback.data.split('_') texts = get_texts(db_user.language) subscription = db_user.subscription @@ -1348,7 +1235,7 @@ async def handle_specific_app_guide( if not subscription_link: await callback.answer( - texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + texts.t('SUBSCRIPTION_LINK_UNAVAILABLE', '❌ Ссылка подписки недоступна'), show_alert=True, ) return @@ -1358,7 +1245,7 @@ async def handle_specific_app_guide( if not app: await callback.answer( - texts.t("SUBSCRIPTION_APP_NOT_FOUND", "❌ Приложение не найдено"), + texts.t('SUBSCRIPTION_APP_NOT_FOUND', '❌ Приложение не найдено'), show_alert=True, ) return @@ -1367,83 +1254,75 @@ async def handle_specific_app_guide( if hide_subscription_link: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" + texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + + '\n' + + texts.t( + 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', + 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', + ) + + '\n\n' ) else: link_section = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + f"\n{subscription_link}\n\n" + texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + + f'\n{subscription_link}\n\n' ) - installation_description = get_step_description(app, "installationStep", db_user.language) - add_description = get_step_description(app, "addSubscriptionStep", db_user.language) - connect_description = get_step_description(app, "connectAndUseStep", db_user.language) + installation_description = get_step_description(app, 'installationStep', db_user.language) + add_description = get_step_description(app, 'addSubscriptionStep', db_user.language) + connect_description = get_step_description(app, 'connectAndUseStep', db_user.language) additional_before_text = format_additional_section( - app.get("additionalBeforeAddSubscriptionStep"), + app.get('additionalBeforeAddSubscriptionStep'), texts, db_user.language, ) additional_after_text = format_additional_section( - app.get("additionalAfterAddSubscriptionStep"), + app.get('additionalAfterAddSubscriptionStep'), texts, db_user.language, ) guide_text = ( - texts.t( - "SUBSCRIPTION_SPECIFIC_APP_TITLE", - "📱 {app_name} - {device_name}", - ).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language)) - + "\n\n" - + link_section + texts.t( + 'SUBSCRIPTION_SPECIFIC_APP_TITLE', + '📱 {app_name} - {device_name}', + ).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language)) + + '\n\n' + + link_section ) - guide_text += texts.t("SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE", "Шаг 1 - Установка:") + guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', 'Шаг 1 - Установка:') if installation_description: - guide_text += f"\n{installation_description}" + guide_text += f'\n{installation_description}' if additional_before_text: - guide_text += f"\n\n{additional_before_text}" + guide_text += f'\n\n{additional_before_text}' - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_ADD_TITLE", "Шаг 2 - Добавление подписки:") + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', 'Шаг 2 - Добавление подписки:') if add_description: - guide_text += f"\n{add_description}" + guide_text += f'\n{add_description}' - guide_text += "\n\n" + texts.t("SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE", "Шаг 3 - Подключение:") + guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', 'Шаг 3 - Подключение:') if connect_description: - guide_text += f"\n{connect_description}" + guide_text += f'\n{connect_description}' if additional_after_text: - guide_text += f"\n\n{additional_after_text}" + guide_text += f'\n\n{additional_after_text}' await callback.message.edit_text( guide_text, - reply_markup=get_specific_app_keyboard( - subscription_link, - app, - device_type, - db_user.language - ), - parse_mode="HTML" + reply_markup=get_specific_app_keyboard(subscription_link, app, device_type, db_user.language), + parse_mode='HTML', ) await callback.answer() -async def show_device_connection_help( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def show_device_connection_help(callback: types.CallbackQuery, db_user: User, db: AsyncSession): subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) if not subscription_link: - await callback.answer("❌ Ссылка подписки недоступна", show_alert=True) + await callback.answer('❌ Ссылка подписки недоступна', show_alert=True) return help_text = f""" @@ -1470,8 +1349,6 @@ async def show_device_connection_help( """ await callback.message.edit_text( - help_text, - reply_markup=get_device_management_help_keyboard(db_user.language), - parse_mode="HTML" + help_text, reply_markup=get_device_management_help_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() diff --git a/app/handlers/subscription/happ.py b/app/handlers/subscription/happ.py index ee020d80..3c159aaa 100644 --- a/app/handlers/subscription/happ.py +++ b/app/handlers/subscription/happ.py @@ -1,144 +1,64 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, InaccessibleMessage +from aiogram import types +from aiogram.types import InaccessibleMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) -from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart -) -from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, - apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, -) -async def handle_happ_download_request( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +from app.config import settings +from app.database.models import User +from app.keyboards.inline import get_happ_download_link_keyboard, get_happ_download_platform_keyboard +from app.localization.texts import get_texts + + +async def handle_happ_download_request(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) prompt_text = texts.t( - "HAPP_DOWNLOAD_PROMPT", - "📥 Скачать Happ\nВыберите ваше устройство:", + 'HAPP_DOWNLOAD_PROMPT', + '📥 Скачать Happ\nВыберите ваше устройство:', ) keyboard = get_happ_download_platform_keyboard(db_user.language) - await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.answer(prompt_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() -async def handle_happ_download_platform_choice( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_happ_download_platform_choice(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() return platform = callback.data.split('_')[-1] - if platform == "pc": - platform = "windows" + if platform == 'pc': + platform = 'windows' texts = get_texts(db_user.language) link = settings.get_happ_download_link(platform) if not link: await callback.answer( - texts.t("HAPP_DOWNLOAD_LINK_NOT_SET", "❌ Ссылка для этого устройства не настроена"), + texts.t('HAPP_DOWNLOAD_LINK_NOT_SET', '❌ Ссылка для этого устройства не настроена'), show_alert=True, ) return platform_names = { - "ios": texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), - "android": texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), - "macos": texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), - "windows": texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), + 'ios': texts.t('HAPP_PLATFORM_IOS', '🍎 iOS'), + 'android': texts.t('HAPP_PLATFORM_ANDROID', '🤖 Android'), + 'macos': texts.t('HAPP_PLATFORM_MACOS', '🖥️ Mac OS'), + 'windows': texts.t('HAPP_PLATFORM_WINDOWS', '💻 Windows'), } link_text = texts.t( - "HAPP_DOWNLOAD_LINK_MESSAGE", - "⬇️ Скачайте Happ для {platform}:", + 'HAPP_DOWNLOAD_LINK_MESSAGE', + '⬇️ Скачайте Happ для {platform}:', ).format(platform=platform_names.get(platform, platform.upper())) keyboard = get_happ_download_link_keyboard(db_user.language, link) - await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.edit_text(link_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() -async def handle_happ_download_close( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_happ_download_close(callback: types.CallbackQuery, db_user: User, db: AsyncSession): try: await callback.message.delete() except Exception: @@ -146,11 +66,8 @@ async def handle_happ_download_close( await callback.answer() -async def handle_happ_download_back( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_happ_download_back(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -158,11 +75,11 @@ async def handle_happ_download_back( texts = get_texts(db_user.language) prompt_text = texts.t( - "HAPP_DOWNLOAD_PROMPT", - "📥 Скачать Happ\nВыберите ваше устройство:", + 'HAPP_DOWNLOAD_PROMPT', + '📥 Скачать Happ\nВыберите ваше устройство:', ) keyboard = get_happ_download_platform_keyboard(db_user.language) - await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode="HTML") + await callback.message.edit_text(prompt_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() diff --git a/app/handlers/subscription/links.py b/app/handlers/subscription/links.py index 4789878b..e15b1a1a 100644 --- a/app/handlers/subscription/links.py +++ b/app/handlers/subscription/links.py @@ -1,88 +1,23 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, InaccessibleMessage +from aiogram import types +from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) + +from app.config import settings +from app.database.models import User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, + get_device_selection_keyboard, get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart ) from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, - apply_percentage_discount, -) from app.utils.subscription_utils import ( + convert_subscription_link_to_happ_scheme, get_display_subscription_link, get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, ) -async def handle_connect_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_connect_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -96,8 +31,8 @@ async def handle_connect_subscription( if not subscription_link: await callback.answer( texts.t( - "SUBSCRIPTION_NO_ACTIVE_LINK", - "⚠ У вас нет активной подписки или ссылка еще генерируется", + 'SUBSCRIPTION_NO_ACTIVE_LINK', + '⚠ У вас нет активной подписки или ссылка еще генерируется', ), show_alert=True, ) @@ -105,124 +40,113 @@ async def handle_connect_subscription( connect_mode = settings.CONNECT_BUTTON_MODE - if connect_mode == "miniapp_subscription": - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link) - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + if connect_mode == 'miniapp_subscription': + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], ] - ]) + ) await callback.message.edit_text( texts.t( - "SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE", + 'SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE', """📱 Подключить подписку 🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:""", ), reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) - elif connect_mode == "miniapp_custom": + elif connect_mode == 'miniapp_custom': if not settings.MINIAPP_CUSTOM_URL: await callback.answer( texts.t( - "CUSTOM_MINIAPP_URL_NOT_SET", - "⚠ Кастомная ссылка для мини-приложения не настроена", + 'CUSTOM_MINIAPP_URL_NOT_SET', + '⚠ Кастомная ссылка для мини-приложения не настроена', ), show_alert=True, ) return - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], ] - ]) + ) await callback.message.edit_text( texts.t( - "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE", + 'SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE', """🚀 Подключить подписку 📱 Нажмите кнопку ниже, чтобы открыть приложение:""", ), reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) - elif connect_mode == "link": + elif connect_mode == 'link': + rows = [[InlineKeyboardButton(text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_link)]] + happ_row = get_happ_download_button_row(texts) + if happ_row: + rows.append(happ_row) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=rows) + + await callback.message.edit_text( + texts.t( + 'SUBSCRIPTION_CONNECT_LINK_MESSAGE', + """🚀 Подключить подписку", + +🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", + ), + reply_markup=keyboard, + parse_mode='HTML', + ) + elif connect_mode == 'happ_cryptolink': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) ] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) keyboard = InlineKeyboardMarkup(inline_keyboard=rows) await callback.message.edit_text( texts.t( - "SUBSCRIPTION_CONNECT_LINK_MESSAGE", + 'SUBSCRIPTION_CONNECT_LINK_MESSAGE', """🚀 Подключить подписку", 🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", ), reply_markup=keyboard, - parse_mode="HTML" - ) - elif connect_mode == "happ_cryptolink": - rows = [ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", - ) - ] - ] - happ_row = get_happ_download_button_row(texts) - if happ_row: - rows.append(happ_row) - rows.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - - keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - - await callback.message.edit_text( - texts.t( - "SUBSCRIPTION_CONNECT_LINK_MESSAGE", - """🚀 Подключить подписку", - -🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:""", - ), - reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) else: if hide_subscription_link: device_text = texts.t( - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN", + 'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN', """📱 Подключить подписку ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка". @@ -231,7 +155,7 @@ async def handle_connect_subscription( ) else: device_text = texts.t( - "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE", + 'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE', """📱 Подключить подписку 🔗 Ссылка подписки: @@ -241,25 +165,20 @@ async def handle_connect_subscription( ).format(subscription_url=subscription_link) await callback.message.edit_text( - device_text, - reply_markup=get_device_selection_keyboard(db_user.language), - parse_mode="HTML" + device_text, reply_markup=get_device_selection_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() -async def handle_open_subscription_link( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_open_subscription_link(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) subscription = db_user.subscription subscription_link = get_display_subscription_link(subscription) if not subscription_link: await callback.answer( - texts.t("SUBSCRIPTION_LINK_UNAVAILABLE", "❌ Ссылка подписки недоступна"), + texts.t('SUBSCRIPTION_LINK_UNAVAILABLE', '❌ Ссылка подписки недоступна'), show_alert=True, ) return @@ -268,31 +187,31 @@ async def handle_open_subscription_link( redirect_link = get_happ_cryptolink_redirect_link(subscription_link) happ_scheme_link = convert_subscription_link_to_happ_scheme(subscription_link) happ_message = ( - texts.t( - "SUBSCRIPTION_HAPP_OPEN_TITLE", - "🔗 Подключение через Happ", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_LINK", - "
🔓 Открыть ссылку в Happ", - ).format(subscription_link=happ_scheme_link) - + "\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_OPEN_HINT", - "💡 Если ссылка не открывается автоматически, скопируйте её вручную:", - ) + texts.t( + 'SUBSCRIPTION_HAPP_OPEN_TITLE', + '🔗 Подключение через Happ', + ) + + '\n\n' + + texts.t( + 'SUBSCRIPTION_HAPP_OPEN_LINK', + '🔓 Открыть ссылку в Happ', + ).format(subscription_link=happ_scheme_link) + + '\n\n' + + texts.t( + 'SUBSCRIPTION_HAPP_OPEN_HINT', + '💡 Если ссылка не открывается автоматически, скопируйте её вручную:', + ) ) if redirect_link: - happ_message += "\n\n" + texts.t( - "SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT", - "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.", + happ_message += '\n\n' + texts.t( + 'SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT', + '▶️ Нажмите кнопку "Подключиться" ниже, чтобы открыть Happ и добавить подписку автоматически.', ) - happ_message += "\n\n" + texts.t( - "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK", - "
{crypto_link}
", + happ_message += '\n\n' + texts.t( + 'SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK', + '
{crypto_link}
', ).format(crypto_link=subscription_link) keyboard = get_happ_cryptolink_keyboard( @@ -303,7 +222,7 @@ async def handle_open_subscription_link( await callback.message.answer( happ_message, - parse_mode="HTML", + parse_mode='HTML', disable_web_page_preview=True, reply_markup=keyboard, ) @@ -311,49 +230,50 @@ async def handle_open_subscription_link( return link_text = ( - texts.t("SUBSCRIPTION_DEVICE_LINK_TITLE", "🔗 Ссылка подписки:") - + "\n\n" - + f"{subscription_link}\n\n" - + texts.t("SUBSCRIPTION_LINK_USAGE_TITLE", "📱 Как использовать:") - + "\n" - + "\n".join( - [ - texts.t( - "SUBSCRIPTION_LINK_STEP1", - "1. Нажмите на ссылку выше чтобы её скопировать", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP2", - "2. Откройте ваше VPN приложение", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP3", - "3. Найдите функцию \"Добавить подписку\" или \"Import\"", - ), - texts.t( - "SUBSCRIPTION_LINK_STEP4", - "4. Вставьте скопированную ссылку", - ), - ] - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HINT", - "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.", - ) + texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 Ссылка подписки:') + + '\n\n' + + f'{subscription_link}\n\n' + + texts.t('SUBSCRIPTION_LINK_USAGE_TITLE', '📱 Как использовать:') + + '\n' + + '\n'.join( + [ + texts.t( + 'SUBSCRIPTION_LINK_STEP1', + '1. Нажмите на ссылку выше чтобы её скопировать', + ), + texts.t( + 'SUBSCRIPTION_LINK_STEP2', + '2. Откройте ваше VPN приложение', + ), + texts.t( + 'SUBSCRIPTION_LINK_STEP3', + '3. Найдите функцию "Добавить подписку" или "Import"', + ), + texts.t( + 'SUBSCRIPTION_LINK_STEP4', + '4. Вставьте скопированную ссылку', + ), + ] + ) + + '\n\n' + + texts.t( + 'SUBSCRIPTION_LINK_HINT', + '💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.', + ) ) await callback.message.edit_text( link_text, - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], ] - ]), - parse_mode="HTML" + ), + parse_mode='HTML', ) await callback.answer() diff --git a/app/handlers/subscription/modem.py b/app/handlers/subscription/modem.py index 3af21996..d65e0546 100644 --- a/app/handlers/subscription/modem.py +++ b/app/handlers/subscription/modem.py @@ -6,7 +6,8 @@ """ import logging -from aiogram import Dispatcher, types, F + +from aiogram import Dispatcher, F, types from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -14,13 +15,12 @@ from app.database.models import User from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard from app.localization.texts import get_texts from app.services.modem_service import ( - get_modem_service, ModemError, - MODEM_WARNING_DAYS_CRITICAL, - MODEM_WARNING_DAYS_INFO, + get_modem_service, ) from app.utils.decorators import error_handler, modem_available + logger = logging.getLogger(__name__) @@ -30,26 +30,23 @@ def get_modem_keyboard(language: str, modem_enabled: bool): keyboard = [] if modem_enabled: - keyboard.append([ - types.InlineKeyboardButton( - text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"), - callback_data="modem_disable" - ) - ]) - else: - keyboard.append([ - types.InlineKeyboardButton( - text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"), - callback_data="modem_enable" - ) - ]) - - keyboard.append([ - types.InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_settings" + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t('MODEM_DISABLE_BUTTON', 'Отключить модем'), callback_data='modem_disable' + ) + ] ) - ]) + else: + keyboard.append( + [ + types.InlineKeyboardButton( + text=texts.t('MODEM_ENABLE_BUTTON', 'Подключить модем'), callback_data='modem_enable' + ) + ] + ) + + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')]) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -57,29 +54,21 @@ def get_modem_keyboard(language: str, modem_enabled: bool): def get_modem_confirm_keyboard(language: str): """Клавиатура подтверждения подключения модема.""" texts = get_texts(language) - return types.InlineKeyboardMarkup(inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"), - callback_data="modem_confirm" - ) - ], - [ - types.InlineKeyboardButton( - text=texts.CANCEL, - callback_data="subscription_modem" - ) + return types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('MODEM_CONFIRM_BUTTON', 'Подтвердить подключение'), callback_data='modem_confirm' + ) + ], + [types.InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_modem')], ] - ]) + ) @error_handler @modem_available() -async def handle_modem_menu( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_modem_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показывает меню управления модемом.""" texts = get_texts(db_user.language) subscription = db_user.subscription @@ -89,30 +78,30 @@ async def handle_modem_menu( modem_price = settings.get_modem_price_per_month() if modem_enabled: - status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен") + status_text = texts.t('MODEM_STATUS_ENABLED', 'Подключен') info_text = texts.t( - "MODEM_INFO_ENABLED", + 'MODEM_INFO_ENABLED', ( - "Модем\n\n" - "Статус: {status}\n\n" - "Модем подключен к вашей подписке.\n" - "Ежемесячная плата: {price}\n\n" - "При отключении модема возврат средств не производится." + 'Модем\n\n' + 'Статус: {status}\n\n' + 'Модем подключен к вашей подписке.\n' + 'Ежемесячная плата: {price}\n\n' + 'При отключении модема возврат средств не производится.' ), ).format( status=status_text, price=texts.format_price(modem_price), ) else: - status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен") + status_text = texts.t('MODEM_STATUS_DISABLED', 'Не подключен') info_text = texts.t( - "MODEM_INFO_DISABLED", + 'MODEM_INFO_DISABLED', ( - "Модем\n\n" - "Статус: {status}\n\n" - "Подключите модем к вашей подписке.\n" - "Ежемесячная плата: {price}\n\n" - "При подключении модема будет добавлено дополнительное устройство." + 'Модем\n\n' + 'Статус: {status}\n\n' + 'Подключите модем к вашей подписке.\n' + 'Ежемесячная плата: {price}\n\n' + 'При подключении модема будет добавлено дополнительное устройство.' ), ).format( status=status_text, @@ -120,20 +109,14 @@ async def handle_modem_menu( ) await callback.message.edit_text( - info_text, - reply_markup=get_modem_keyboard(db_user.language, modem_enabled), - parse_mode="HTML" + info_text, reply_markup=get_modem_keyboard(db_user.language, modem_enabled), parse_mode='HTML' ) await callback.answer() @error_handler @modem_available(for_enable=True) -async def handle_modem_enable( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_modem_enable(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Обработчик подключения модема - показывает информацию о цене.""" texts = get_texts(db_user.language) subscription = db_user.subscription @@ -147,23 +130,20 @@ async def handle_modem_enable( if not has_funds: if price_info.has_discount: required_text = ( - f"{texts.format_price(price_info.final_price)} " - f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)" + f'{texts.format_price(price_info.final_price)} ' + f'(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)' ) else: - required_text = ( - f"{texts.format_price(price_info.final_price)} " - f"(за {price_info.charged_months} мес)" - ) + required_text = f'{texts.format_price(price_info.final_price)} (за {price_info.charged_months} мес)' message_text = texts.t( - "MODEM_INSUFFICIENT_FUNDS", + 'MODEM_INSUFFICIENT_FUNDS', ( - "Недостаточно средств\n\n" - "Стоимость подключения модема: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения." + 'Недостаточно средств\n\n' + 'Стоимость подключения модема: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения.' ), ).format( required=required_text, @@ -177,33 +157,32 @@ async def handle_modem_enable( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return warning_level = service.get_period_warning_level(price_info.remaining_days) - if warning_level == "critical": + if warning_level == 'critical': warning_text = texts.t( - "MODEM_SHORT_PERIOD_WARNING", - "\nВнимание! До окончания подписки осталось всего {days} дн.\n" - "После продления подписки модем нужно будет оплатить заново!" + 'MODEM_SHORT_PERIOD_WARNING', + '\nВнимание! До окончания подписки осталось всего {days} дн.\n' + 'После продления подписки модем нужно будет оплатить заново!', ).format(days=price_info.remaining_days) - elif warning_level == "info": + elif warning_level == 'info': warning_text = texts.t( - "MODEM_PERIOD_NOTE", - "\nДо окончания подписки: {days} дн.\n" - "После продления модем нужно будет оплатить заново." + 'MODEM_PERIOD_NOTE', + '\nДо окончания подписки: {days} дн.\nПосле продления модем нужно будет оплатить заново.', ).format(days=price_info.remaining_days) else: - warning_text = "" + warning_text = '' if price_info.has_discount: price_text = texts.t( - "MODEM_PRICE_WITH_DISCOUNT", - "Стоимость: {base_price} {final_price} (за {months} мес)\n" - "Скидка {discount}%: -{discount_amount}" + 'MODEM_PRICE_WITH_DISCOUNT', + 'Стоимость: {base_price} {final_price} (за {months} мес)\n' + 'Скидка {discount}%: -{discount_amount}', ).format( base_price=texts.format_price(price_info.base_price), final_price=texts.format_price(price_info.final_price), @@ -212,52 +191,42 @@ async def handle_modem_enable( discount_amount=texts.format_price(price_info.discount_amount), ) else: - price_text = texts.t( - "MODEM_PRICE_NO_DISCOUNT", - "Стоимость: {price} (за {months} мес)" - ).format( + price_text = texts.t('MODEM_PRICE_NO_DISCOUNT', 'Стоимость: {price} (за {months} мес)').format( price=texts.format_price(price_info.final_price), months=price_info.charged_months, ) confirm_text = texts.t( - "MODEM_CONFIRM_ENABLE_BASE", + 'MODEM_CONFIRM_ENABLE_BASE', ( - "Подтверждение подключения модема\n\n" - "{price_text}\n\n" - "При подключении модема:\n" - "К подписке добавится дополнительное устройство\n" - "Ежемесячная плата увеличится на {monthly_price}\n\n" - "Подтвердить подключение?" + 'Подтверждение подключения модема\n\n' + '{price_text}\n\n' + 'При подключении модема:\n' + 'К подписке добавится дополнительное устройство\n' + 'Ежемесячная плата увеличится на {monthly_price}\n\n' + 'Подтвердить подключение?' ), ).format( price_text=price_text, monthly_price=texts.format_price(modem_price_per_month), ) - end_date_str = price_info.end_date.strftime("%d.%m.%Y") - period_info = texts.t( - "MODEM_PERIOD_INFO", - "\nМодем действует до: {end_date} ({days} дн.)" - ).format(end_date=end_date_str, days=price_info.remaining_days) + end_date_str = price_info.end_date.strftime('%d.%m.%Y') + period_info = texts.t('MODEM_PERIOD_INFO', '\nМодем действует до: {end_date} ({days} дн.)').format( + end_date=end_date_str, days=price_info.remaining_days + ) confirm_text += period_info + warning_text await callback.message.edit_text( - confirm_text, - reply_markup=get_modem_confirm_keyboard(db_user.language), - parse_mode="HTML" + confirm_text, reply_markup=get_modem_confirm_keyboard(db_user.language), parse_mode='HTML' ) await callback.answer() @error_handler @modem_available(for_enable=True) -async def handle_modem_confirm( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_modem_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Подтверждение и активация модема.""" texts = get_texts(db_user.language) subscription = db_user.subscription @@ -267,14 +236,8 @@ async def handle_modem_confirm( if not result.success: error_messages = { - ModemError.INSUFFICIENT_FUNDS: texts.t( - "MODEM_INSUFFICIENT_FUNDS_SHORT", - "Недостаточно средств на балансе" - ), - ModemError.CHARGE_ERROR: texts.t( - "PAYMENT_CHARGE_ERROR", - "Ошибка списания средств" - ), + ModemError.INSUFFICIENT_FUNDS: texts.t('MODEM_INSUFFICIENT_FUNDS_SHORT', 'Недостаточно средств на балансе'), + ModemError.CHARGE_ERROR: texts.t('PAYMENT_CHARGE_ERROR', 'Ошибка списания средств'), ModemError.UPDATE_ERROR: texts.ERROR, } @@ -282,9 +245,7 @@ async def handle_modem_confirm( if result.error == ModemError.INSUFFICIENT_FUNDS: await callback.message.edit_text( - error_text, - reply_markup=get_back_keyboard(db_user.language, "modem_enable"), - parse_mode="HTML" + error_text, reply_markup=get_back_keyboard(db_user.language, 'modem_enable'), parse_mode='HTML' ) else: await callback.answer(error_text, show_alert=True) @@ -292,42 +253,33 @@ async def handle_modem_confirm( try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( - db, db_user, subscription, "modem", False, True, result.charged_amount + db, db_user, subscription, 'modem', False, True, result.charged_amount ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о подключении модема: {e}") + logger.error(f'Ошибка отправки уведомления о подключении модема: {e}') success_text = texts.t( - "MODEM_ENABLED_SUCCESS", - ( - "Модем успешно подключен!\n\n" - "Модем активирован\n" - "Добавлено устройство для модема\n" - ), + 'MODEM_ENABLED_SUCCESS', + ('Модем успешно подключен!\n\nМодем активирован\nДобавлено устройство для модема\n'), ) if result.charged_amount > 0: success_text += texts.t( - "MODEM_CHARGED", - "Списано: {amount}", + 'MODEM_CHARGED', + 'Списано: {amount}', ).format(amount=texts.format_price(result.charged_amount)) await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language, "subscription_settings"), - parse_mode="HTML" + success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML' ) await callback.answer() @error_handler @modem_available(for_disable=True) -async def handle_modem_disable( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_modem_disable(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Отключение модема.""" texts = get_texts(db_user.language) subscription = db_user.subscription @@ -341,48 +293,31 @@ async def handle_modem_disable( try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( - db, db_user, subscription, "modem", True, False, 0 + db, db_user, subscription, 'modem', True, False, 0 ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об отключении модема: {e}") + logger.error(f'Ошибка отправки уведомления об отключении модема: {e}') success_text = texts.t( - "MODEM_DISABLED_SUCCESS", - ( - "Модем отключен\n\n" - "Модем деактивирован\n" - "Возврат средств не производится" - ), + 'MODEM_DISABLED_SUCCESS', + ('Модем отключен\n\nМодем деактивирован\nВозврат средств не производится'), ) await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language, "subscription_settings"), - parse_mode="HTML" + success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML' ) await callback.answer() def register_modem_handlers(dp: Dispatcher): """Регистрация обработчиков модема.""" - dp.callback_query.register( - handle_modem_menu, - F.data == "subscription_modem" - ) + dp.callback_query.register(handle_modem_menu, F.data == 'subscription_modem') - dp.callback_query.register( - handle_modem_enable, - F.data == "modem_enable" - ) + dp.callback_query.register(handle_modem_enable, F.data == 'modem_enable') - dp.callback_query.register( - handle_modem_confirm, - F.data == "modem_confirm" - ) + dp.callback_query.register(handle_modem_confirm, F.data == 'modem_confirm') - dp.callback_query.register( - handle_modem_disable, - F.data == "modem_disable" - ) + dp.callback_query.register(handle_modem_disable, F.data == 'modem_disable') diff --git a/app/handlers/subscription/notifications.py b/app/handlers/subscription/notifications.py index 6e2e61e7..83d3db11 100644 --- a/app/handlers/subscription/notifications.py +++ b/app/handlers/subscription/notifications.py @@ -1,101 +1,32 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from datetime import datetime + +from aiogram import types from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) -from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart -) -from app.localization.texts import get_texts + +from app.database.models import Subscription, User from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list -from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, - apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, -) from .common import logger -async def send_trial_notification(callback: types.CallbackQuery, db: AsyncSession, db_user: User, - subscription: Subscription): + +async def send_trial_notification( + callback: types.CallbackQuery, db: AsyncSession, db_user: User, subscription: Subscription +): try: notification_service = AdminNotificationService(callback.bot) await notification_service.send_trial_activation_notification(db, db_user, subscription) except Exception as e: - logger.error(f"Ошибка отправки уведомления о триале: {e}") + logger.error(f'Ошибка отправки уведомления о триале: {e}') + async def send_purchase_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - period_days: int, - was_trial_conversion: bool = False + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + period_days: int, + was_trial_conversion: bool = False, ): try: from app.database.crud.transaction import get_transaction_by_id @@ -107,16 +38,17 @@ async def send_purchase_notification( db, db_user, subscription, transaction, period_days, was_trial_conversion ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о покупке: {e}") + logger.error(f'Ошибка отправки уведомления о покупке: {e}') + async def send_extension_notification( - callback: types.CallbackQuery, - db: AsyncSession, - db_user: User, - subscription: Subscription, - transaction_id: int, - extended_days: int, - old_end_date: datetime + callback: types.CallbackQuery, + db: AsyncSession, + db_user: User, + subscription: Subscription, + transaction_id: int, + extended_days: int, + old_end_date: datetime, ): try: from app.database.crud.transaction import get_transaction_by_id @@ -128,4 +60,4 @@ async def send_extension_notification( db, db_user, subscription, transaction, extended_days, old_end_date ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о продлении: {e}") + logger.error(f'Ошибка отправки уведомления о продлении: {e}') diff --git a/app/handlers/subscription/pricing.py b/app/handlers/subscription/pricing.py index f2c07633..cf804cf1 100644 --- a/app/handlers/subscription/pricing.py +++ b/app/handlers/subscription/pricing.py @@ -1,81 +1,14 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from typing import Any + from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) -from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart -) -from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list + +from app.config import PERIOD_PRICES, settings +from app.database.models import User from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, ) from app.utils.timezone import format_local_datetime @@ -84,11 +17,12 @@ from .countries import _get_available_countries, _get_countries_info, get_countr from .devices import get_current_devices_count from .promo import _build_promo_group_discount_text, _get_promo_offer_hint + async def _prepare_subscription_summary( - db_user: User, - data: Dict[str, Any], - texts, -) -> Tuple[str, Dict[str, Any]]: + db_user: User, + data: dict[str, Any], + texts, +) -> tuple[str, dict[str, Any]]: summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -97,7 +31,7 @@ async def _prepare_subscription_summary( base_price_original = PERIOD_PRICES.get(summary_data['period_days'], 0) period_discount_percent = db_user.get_promo_discount( - "period", + 'period', summary_data['period_days'], ) base_price, base_discount_total = apply_percentage_discount( @@ -115,7 +49,7 @@ async def _prepare_subscription_summary( final_traffic_gb = traffic_gb traffic_discount_percent = db_user.get_promo_discount( - "traffic", + 'traffic', summary_data['period_days'], ) traffic_component = _apply_discount_to_monthly_component( @@ -123,12 +57,12 @@ async def _prepare_subscription_summary( traffic_discount_percent, months_in_period, ) - total_traffic_price = traffic_component["total"] + total_traffic_price = traffic_component['total'] countries_price_per_month = 0 - selected_countries_names: List[str] = [] - selected_server_prices: List[int] = [] - server_monthly_prices: List[int] = [] + selected_countries_names: list[str] = [] + selected_server_prices: list[int] = [] + server_monthly_prices: list[int] = [] selected_country_ids = set(summary_data.get('countries', [])) for country in countries: @@ -139,7 +73,7 @@ async def _prepare_subscription_summary( server_monthly_prices.append(server_price_per_month) servers_discount_percent = db_user.get_promo_discount( - "servers", + 'servers', summary_data['period_days'], ) total_countries_price = 0 @@ -160,7 +94,7 @@ async def _prepare_subscription_summary( selected_server_prices.append(total_price_for_server) devices_selection_enabled = settings.is_devices_selection_enabled() - forced_disabled_limit: Optional[int] = None + forced_disabled_limit: int | None = None if devices_selection_enabled: devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT) else: @@ -174,7 +108,7 @@ async def _prepare_subscription_summary( additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = db_user.get_promo_discount( - "devices", + 'devices', summary_data['period_days'], ) devices_component = _apply_discount_to_monthly_component( @@ -182,14 +116,14 @@ async def _prepare_subscription_summary( devices_discount_percent, months_in_period, ) - total_devices_price = devices_component["total"] + total_devices_price = devices_component['total'] total_price = base_price + total_traffic_price + total_countries_price + total_devices_price discounted_monthly_additions = ( - traffic_component["discounted_per_month"] - + discounted_servers_price_per_month - + devices_component["discounted_per_month"] + traffic_component['discounted_per_month'] + + discounted_servers_price_per_month + + devices_component['discounted_per_month'] ) is_valid = validate_pricing_calculation( @@ -200,17 +134,17 @@ async def _prepare_subscription_summary( ) if not is_valid: - raise ValueError("Subscription price calculation validation failed") + raise ValueError('Subscription price calculation validation failed') original_total_price = total_price promo_offer_component = _apply_promo_offer_discount(db_user, total_price) - if promo_offer_component["discount"] > 0: - total_price = promo_offer_component["discounted"] + if promo_offer_component['discount'] > 0: + total_price = promo_offer_component['discounted'] summary_data['total_price'] = total_price - if promo_offer_component["discount"] > 0: - summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"] - summary_data['promo_offer_discount_value'] = promo_offer_component["discount"] + if promo_offer_component['discount'] > 0: + summary_data['promo_offer_discount_percent'] = promo_offer_component['percent'] + summary_data['promo_offer_discount_value'] = promo_offer_component['discount'] summary_data['total_price_before_promo_offer'] = original_total_price else: summary_data.pop('promo_offer_discount_percent', None) @@ -224,9 +158,9 @@ async def _prepare_subscription_summary( summary_data['base_discount_total'] = base_discount_total summary_data['final_traffic_gb'] = final_traffic_gb summary_data['traffic_price_per_month'] = traffic_price_per_month - summary_data['traffic_discount_percent'] = traffic_component["discount_percent"] - summary_data['traffic_discount_total'] = traffic_component["discount_total"] - summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"] + summary_data['traffic_discount_percent'] = traffic_component['discount_percent'] + summary_data['traffic_discount_total'] = traffic_component['discount_total'] + summary_data['traffic_discounted_price_per_month'] = traffic_component['discounted_per_month'] summary_data['total_traffic_price'] = total_traffic_price summary_data['servers_price_per_month'] = countries_price_per_month summary_data['countries_price_per_month'] = countries_price_per_month @@ -236,122 +170,121 @@ async def _prepare_subscription_summary( summary_data['total_servers_price'] = total_countries_price summary_data['total_countries_price'] = total_countries_price summary_data['devices_price_per_month'] = devices_price_per_month - summary_data['devices_discount_percent'] = devices_component["discount_percent"] - summary_data['devices_discount_total'] = devices_component["discount_total"] - summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"] + summary_data['devices_discount_percent'] = devices_component['discount_percent'] + summary_data['devices_discount_total'] = devices_component['discount_total'] + summary_data['devices_discounted_price_per_month'] = devices_component['discounted_per_month'] summary_data['total_devices_price'] = total_devices_price summary_data['discounted_monthly_additions'] = discounted_monthly_additions if settings.is_traffic_fixed(): if final_traffic_gb == 0: - traffic_display = "Безлимитный" + traffic_display = 'Безлимитный' else: - traffic_display = f"{final_traffic_gb} ГБ" + traffic_display = f'{final_traffic_gb} ГБ' + elif summary_data.get('traffic_gb', 0) == 0: + traffic_display = 'Безлимитный' else: - if summary_data.get('traffic_gb', 0) == 0: - traffic_display = "Безлимитный" - else: - traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ" + traffic_display = f'{summary_data.get("traffic_gb", 0)} ГБ' details_lines = [] # Добавляем строку базового периода только если цена не равна 0 if base_discount_total > 0 and base_price > 0: base_line = ( - f"- Базовый период: {texts.format_price(base_price_original)} " - f"{texts.format_price(base_price)}" - f" (скидка {period_discount_percent}%:" - f" -{texts.format_price(base_discount_total)})" + f'- Базовый период: {texts.format_price(base_price_original)} ' + f'{texts.format_price(base_price)}' + f' (скидка {period_discount_percent}%:' + f' -{texts.format_price(base_discount_total)})' ) details_lines.append(base_line) elif base_price_original > 0: - base_line = f"- Базовый период: {texts.format_price(base_price_original)}" + base_line = f'- Базовый период: {texts.format_price(base_price_original)}' details_lines.append(base_line) if total_traffic_price > 0: traffic_line = ( - f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}" - f" = {texts.format_price(total_traffic_price)}" + f'- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}' + f' = {texts.format_price(total_traffic_price)}' ) - if traffic_component["discount_total"] > 0: + if traffic_component['discount_total'] > 0: traffic_line += ( - f" (скидка {traffic_component['discount_percent']}%:" - f" -{texts.format_price(traffic_component['discount_total'])})" + f' (скидка {traffic_component["discount_percent"]}%:' + f' -{texts.format_price(traffic_component["discount_total"])})' ) details_lines.append(traffic_line) if total_countries_price > 0: servers_line = ( - f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}" - f" = {texts.format_price(total_countries_price)}" + f'- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}' + f' = {texts.format_price(total_countries_price)}' ) if total_servers_discount > 0: - servers_line += ( - f" (скидка {servers_discount_percent}%:" - f" -{texts.format_price(total_servers_discount)})" - ) + servers_line += f' (скидка {servers_discount_percent}%: -{texts.format_price(total_servers_discount)})' details_lines.append(servers_line) if devices_selection_enabled and total_devices_price > 0: devices_line = ( - f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}" - f" = {texts.format_price(total_devices_price)}" + f'- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}' + f' = {texts.format_price(total_devices_price)}' ) - if devices_component["discount_total"] > 0: + if devices_component['discount_total'] > 0: devices_line += ( - f" (скидка {devices_component['discount_percent']}%:" - f" -{texts.format_price(devices_component['discount_total'])})" + f' (скидка {devices_component["discount_percent"]}%:' + f' -{texts.format_price(devices_component["discount_total"])})' ) details_lines.append(devices_line) - if promo_offer_component["discount"] > 0: + if promo_offer_component['discount'] > 0: details_lines.append( texts.t( - "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT", - "- Промо-предложение: -{amount} ({percent}% дополнительно)", + 'SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT', + '- Промо-предложение: -{amount} ({percent}% дополнительно)', ).format( - amount=texts.format_price(promo_offer_component["discount"]), - percent=promo_offer_component["percent"], + amount=texts.format_price(promo_offer_component['discount']), + percent=promo_offer_component['percent'], ) ) - details_text = "\n".join(details_lines) + details_text = '\n'.join(details_lines) summary_lines = [ - "📋 Сводка заказа", - "", - f"📅 Период: {period_display}", - f"📊 Трафик: {traffic_display}", - f"🌍 Страны: {', '.join(selected_countries_names)}", + '📋 Сводка заказа', + '', + f'📅 Период: {period_display}', + f'📊 Трафик: {traffic_display}', + f'🌍 Страны: {", ".join(selected_countries_names)}', ] if devices_selection_enabled: - summary_lines.append(f"📱 Устройства: {devices_selected}") + summary_lines.append(f'📱 Устройства: {devices_selected}') - summary_lines.extend([ - "", - "💰 Детализация стоимости:", - details_text, - "", - f"💎 Общая стоимость: {texts.format_price(total_price)}", - "", - "Подтверждаете покупку?", - ]) + summary_lines.extend( + [ + '', + '💰 Детализация стоимости:', + details_text, + '', + f'💎 Общая стоимость: {texts.format_price(total_price)}', + '', + 'Подтверждаете покупку?', + ] + ) - summary_text = "\n".join(summary_lines) + summary_text = '\n'.join(summary_lines) return summary_text, summary_data + async def _build_subscription_period_prompt( - db_user: User, - texts, - db: AsyncSession, + db_user: User, + texts, + db: AsyncSession, ) -> str: base_text = texts.BUY_SUBSCRIPTION_START.rstrip() - lines: List[str] = [base_text] + lines: list[str] = [base_text] promo_offer_hint = await _get_promo_offer_hint(db, db_user, texts) if promo_offer_hint: - lines.extend(["", promo_offer_hint]) + lines.extend(['', promo_offer_hint]) promo_text = await _build_promo_group_discount_text( db_user, @@ -360,9 +293,10 @@ async def _build_subscription_period_prompt( ) if promo_text: - lines.extend(["", promo_text]) + lines.extend(['', promo_text]) + + return '\n'.join(lines) + '\n' - return "\n".join(lines) + "\n" async def get_subscription_cost(subscription, db: AsyncSession) -> int: try: @@ -380,12 +314,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: except AttributeError: owner = None - promo_group_id = getattr(owner, "promo_group_id", None) if owner else None + promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None period_discount_percent = 0 if owner: try: - period_discount_percent = owner.get_promo_discount("period", 30) + period_discount_percent = owner.get_promo_discount('period', 30) except AttributeError: period_discount_percent = 0 @@ -423,29 +357,27 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int: total_cost = base_cost + servers_cost + traffic_cost + devices_cost - logger.info(f"📊 Месячная стоимость конфигурации подписки {subscription.id}:") - base_log = f" 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽" + logger.info(f'📊 Месячная стоимость конфигурации подписки {subscription.id}:') + base_log = f' 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽' if period_discount_percent > 0: discount_value = base_cost_original * period_discount_percent // 100 - base_log += ( - f" → {base_cost / 100}₽" - f" (скидка {period_discount_percent}%: -{discount_value / 100}₽)" - ) + base_log += f' → {base_cost / 100}₽ (скидка {period_discount_percent}%: -{discount_value / 100}₽)' logger.info(base_log) if servers_cost > 0: - logger.info(f" 🌍 Серверы: {servers_cost / 100}₽") + logger.info(f' 🌍 Серверы: {servers_cost / 100}₽') if traffic_cost > 0: - logger.info(f" 📊 Трафик: {traffic_cost / 100}₽") + logger.info(f' 📊 Трафик: {traffic_cost / 100}₽') if devices_cost > 0: - logger.info(f" 📱 Устройства: {devices_cost / 100}₽") - logger.info(f" 💎 ИТОГО: {total_cost / 100}₽") + logger.info(f' 📱 Устройства: {devices_cost / 100}₽') + logger.info(f' 💎 ИТОГО: {total_cost / 100}₽') return total_cost except Exception as e: - logger.error(f"⚠️ Ошибка расчета стоимости подписки: {e}") + logger.error(f'⚠️ Ошибка расчета стоимости подписки: {e}') return 0 + async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession): devices_selection_enabled = settings.is_devices_selection_enabled() @@ -454,30 +386,29 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess else: devices_used = 0 countries_info = await _get_countries_info(subscription.connected_squads) - countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет" + ', '.join([c['name'] for c in countries_info]) if countries_info else 'Нет' - subscription_url = getattr(subscription, 'subscription_url', None) or "Генерируется..." + subscription_url = getattr(subscription, 'subscription_url', None) or 'Генерируется...' if subscription.is_trial: - status_text = "🎁 Тестовая" - type_text = "Триал" + status_text = '🎁 Тестовая' + type_text = 'Триал' else: if subscription.is_active: - status_text = "✅ Оплачена" + status_text = '✅ Оплачена' else: - status_text = "⌛ Истекла" - type_text = "Платная подписка" + status_text = '⌛ Истекла' + type_text = 'Платная подписка' if subscription.traffic_limit_gb == 0: if settings.is_traffic_fixed(): - traffic_text = "∞ Безлимитный" + traffic_text = '∞ Безлимитный' else: - traffic_text = "∞ Безлимитный" + traffic_text = '∞ Безлимитный' + elif settings.is_traffic_fixed(): + traffic_text = f'{subscription.traffic_limit_gb} ГБ' else: - if settings.is_traffic_fixed(): - traffic_text = f"{subscription.traffic_limit_gb} ГБ" - else: - traffic_text = f"{subscription.traffic_limit_gb} ГБ" + traffic_text = f'{subscription.traffic_limit_gb} ГБ' subscription_cost = await get_subscription_cost(subscription, db) @@ -485,35 +416,37 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess if not devices_selection_enabled: info_template = info_template.replace( - "\n📱 Устройства: {devices_used} / {devices_limit}", - "", + '\n📱 Устройства: {devices_used} / {devices_limit}', + '', ).replace( - "\n📱 Devices: {devices_used} / {devices_limit}", - "", + '\n📱 Devices: {devices_used} / {devices_limit}', + '', ) info_text = info_template.format( status=status_text, type=type_text, - end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), days_left=max(0, subscription.days_left), traffic_used=texts.format_traffic(subscription.traffic_used_gb), traffic_limit=traffic_text, countries_count=len(subscription.connected_squads), devices_used=devices_used, devices_limit=subscription.device_limit, - autopay_status="✅ Включен" if subscription.autopay_enabled else "⌛ Выключен" + autopay_status='✅ Включен' if subscription.autopay_enabled else '⌛ Выключен', ) if subscription_cost > 0: - info_text += f"\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}" + info_text += f'\n💰 Стоимость подписки в месяц: {texts.format_price(subscription_cost)}' # Отображаем докупленный трафик if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов - from app.database.models import TrafficPurchase - from sqlalchemy import select as sql_select from datetime import datetime + from sqlalchemy import select as sql_select + + from app.database.models import TrafficPurchase + now = datetime.utcnow() purchases_query = ( sql_select(TrafficPurchase) @@ -525,7 +458,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess purchases = purchases_result.scalars().all() if purchases: - info_text += "\n\n📦 Докупленный трафик:" + info_text += '\n\n📦 Докупленный трафик:' for purchase in purchases: time_remaining = purchase.expires_at - now @@ -534,33 +467,32 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess # Генерируем прогресс-бар total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() elapsed_seconds = (now - purchase.created_at).total_seconds() - progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + progress_percent = min( + 100.0, + max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0), + ) bar_length = 10 filled = int((progress_percent / 100) * bar_length) - bar = "▰" * filled + "▱" * (bar_length - filled) + bar = '▰' * filled + '▱' * (bar_length - filled) # Форматируем дату истечения - expire_date = purchase.expires_at.strftime("%d.%m.%Y") + expire_date = purchase.expires_at.strftime('%d.%m.%Y') # Формируем текст о времени if days_remaining == 0: - time_text = "истекает сегодня" + time_text = 'истекает сегодня' elif days_remaining == 1: - time_text = "остался 1 день" + time_text = 'остался 1 день' elif days_remaining < 5: - time_text = f"осталось {days_remaining} дня" + time_text = f'осталось {days_remaining} дня' else: - time_text = f"осталось {days_remaining} дней" + time_text = f'осталось {days_remaining} дней' - info_text += f"\n• {purchase.traffic_gb} ГБ — {time_text}" - info_text += f"\n {bar} {progress_percent:.0f}% | до {expire_date}" + info_text += f'\n• {purchase.traffic_gb} ГБ — {time_text}' + info_text += f'\n {bar} {progress_percent:.0f}% | до {expire_date}' - if ( - subscription_url - and subscription_url != "Генерируется..." - and not settings.should_hide_subscription_link() - ): - info_text += f"\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}" + if subscription_url and subscription_url != 'Генерируется...' and not settings.should_hide_subscription_link(): + info_text += f'\n\n🔗 Ваша ссылка для импорта в VPN приложениe:\n{subscription_url}' return info_text diff --git a/app/handlers/subscription/promo.py b/app/handlers/subscription/promo.py index 6fefd6ac..e3524b78 100644 --- a/app/handlers/subscription/promo.py +++ b/app/handlers/subscription/promo.py @@ -1,134 +1,68 @@ -import base64 -import json -import logging from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F -from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from typing import Any + +from aiogram import types +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices + +from app.config import settings from app.database.crud.discount_offer import ( get_offer_by_id, mark_offer_claimed, ) from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) -from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) -from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart -) +from app.database.models import User from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) -from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list +from app.utils.miniapp_buttons import build_miniapp_or_callback_button from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, format_period_description, - apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, ) from app.utils.promo_offer import ( build_promo_offer_hint, - get_user_active_promo_discount_percent, ) from .common import _format_text_with_placeholders + async def _get_promo_offer_hint( - db: AsyncSession, - db_user: User, - texts, - percent: Optional[int] = None, -) -> Optional[str]: + db: AsyncSession, + db_user: User, + texts, + percent: int | None = None, +) -> str | None: return await build_promo_offer_hint(db, db_user, texts, percent) + async def _build_promo_group_discount_text( - db_user: User, - periods: Optional[List[int]] = None, - texts=None, + db_user: User, + periods: list[int] | None = None, + texts=None, ) -> str: promo_group = db_user.get_primary_promo_group() if not promo_group: - return "" + return '' if texts is None: texts = get_texts(db_user.language) - service_lines: List[str] = [] + service_lines: list[str] = [] if promo_group.server_discount_percent > 0: - service_lines.append( - texts.PROMO_GROUP_DISCOUNT_SERVERS.format( - percent=promo_group.server_discount_percent - ) - ) + service_lines.append(texts.PROMO_GROUP_DISCOUNT_SERVERS.format(percent=promo_group.server_discount_percent)) if promo_group.traffic_discount_percent > 0: - service_lines.append( - texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format( - percent=promo_group.traffic_discount_percent - ) - ) + service_lines.append(texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(percent=promo_group.traffic_discount_percent)) if promo_group.device_discount_percent > 0: - service_lines.append( - texts.PROMO_GROUP_DISCOUNT_DEVICES.format( - percent=promo_group.device_discount_percent - ) - ) + service_lines.append(texts.PROMO_GROUP_DISCOUNT_DEVICES.format(percent=promo_group.device_discount_percent)) - period_lines: List[str] = [] + period_lines: list[str] = [] period_candidates: set[int] = set(periods or []) - raw_period_discounts = getattr(promo_group, "period_discounts", None) + raw_period_discounts = getattr(promo_group, 'period_discounts', None) if isinstance(raw_period_discounts, dict): for key in raw_period_discounts.keys(): try: @@ -137,7 +71,7 @@ async def _build_promo_group_discount_text( continue for period_days in sorted(period_candidates): - percent = promo_group.get_discount_percent("period", period_days) + percent = promo_group.get_discount_percent('period', period_days) if percent <= 0: continue @@ -151,34 +85,35 @@ async def _build_promo_group_discount_text( ) if not service_lines and not period_lines: - return "" + return '' - lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER] + lines: list[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER] if service_lines: lines.extend(service_lines) if period_lines: if service_lines: - lines.append("") + lines.append('') lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER) lines.extend(period_lines) - return "\n".join(lines) + return '\n'.join(lines) + async def claim_discount_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, ): texts = get_texts(db_user.language) try: - offer_id = int(callback.data.split("_")[-1]) + offer_id = int(callback.data.split('_')[-1]) except (ValueError, AttributeError): await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + texts.get('DISCOUNT_CLAIM_NOT_FOUND', '❌ Предложение не найдено'), show_alert=True, ) return @@ -186,7 +121,7 @@ async def claim_discount_offer( offer = await get_offer_by_id(db, offer_id) if not offer or offer.user_id != db_user.id: await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + texts.get('DISCOUNT_CLAIM_NOT_FOUND', '❌ Предложение не найдено'), show_alert=True, ) return @@ -194,7 +129,7 @@ async def claim_discount_offer( now = datetime.utcnow() if offer.claimed_at is not None: await callback.answer( - texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + texts.get('DISCOUNT_CLAIM_ALREADY', 'ℹ️ Скидка уже была активирована'), show_alert=True, ) return @@ -203,16 +138,16 @@ async def claim_discount_offer( offer.is_active = False await db.commit() await callback.answer( - texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + texts.get('DISCOUNT_CLAIM_EXPIRED', '⚠️ Время действия предложения истекло'), show_alert=True, ) return - effect_type = (offer.effect_type or "percent_discount").lower() - if effect_type == "balance_bonus": - effect_type = "percent_discount" + effect_type = (offer.effect_type or 'percent_discount').lower() + if effect_type == 'balance_bonus': + effect_type = 'percent_discount' - if effect_type == "test_access": + if effect_type == 'test_access': success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( db, db_user, @@ -220,30 +155,30 @@ async def claim_discount_offer( ) if not success: - if error_code == "subscription_missing": + if error_code == 'subscription_missing': error_message = texts.get( - "TEST_ACCESS_NO_SUBSCRIPTION", - "❌ Для активации предложения необходима действующая подписка.", + 'TEST_ACCESS_NO_SUBSCRIPTION', + '❌ Для активации предложения необходима действующая подписка.', ) - elif error_code == "squads_missing": + elif error_code == 'squads_missing': error_message = texts.get( - "TEST_ACCESS_NO_SQUADS", - "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.", + 'TEST_ACCESS_NO_SQUADS', + '❌ Не удалось определить список серверов для теста. Обратитесь к администратору.', ) - elif error_code == "already_connected": + elif error_code == 'already_connected': error_message = texts.get( - "TEST_ACCESS_ALREADY_CONNECTED", - "ℹ️ Этот сервер уже подключен к вашей подписке.", + 'TEST_ACCESS_ALREADY_CONNECTED', + 'ℹ️ Этот сервер уже подключен к вашей подписке.', ) - elif error_code == "remnawave_sync_failed": + elif error_code == 'remnawave_sync_failed': error_message = texts.get( - "TEST_ACCESS_REMNAWAVE_ERROR", - "❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.", + 'TEST_ACCESS_REMNAWAVE_ERROR', + '❌ Не удалось подключить серверы. Попробуйте позже или обратитесь в поддержку.', ) else: error_message = texts.get( - "TEST_ACCESS_UNKNOWN_ERROR", - "❌ Не удалось активировать предложение. Попробуйте позже.", + 'TEST_ACCESS_UNKNOWN_ERROR', + '❌ Не удалось активировать предложение. Попробуйте позже.', ) await callback.answer(error_message, show_alert=True) return @@ -252,26 +187,26 @@ async def claim_discount_offer( db, offer, details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": expires_at.isoformat() if expires_at else None, + 'context': 'test_access_claim', + 'new_squads': newly_added, + 'expires_at': expires_at.isoformat() if expires_at else None, }, ) - expires_text = expires_at.strftime("%d.%m.%Y %H:%M") if expires_at else "" + expires_text = expires_at.strftime('%d.%m.%Y %H:%M') if expires_at else '' success_message = texts.get( - "TEST_ACCESS_ACTIVATED_MESSAGE", - "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.", + 'TEST_ACCESS_ACTIVATED_MESSAGE', + '🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.', ).format(expires_at=expires_text) - popup_text = texts.get("TEST_ACCESS_ACTIVATED_POPUP", "✅ Доступ выдан!") + popup_text = texts.get('TEST_ACCESS_ACTIVATED_POPUP', '✅ Доступ выдан!') await callback.answer(popup_text, show_alert=True) back_keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.get("BACK_TO_MENU", "🏠 В главное меню"), - callback_data="back_to_menu", + text=texts.get('BACK_TO_MENU', '🏠 В главное меню'), + callback_data='back_to_menu', ) ] ] @@ -282,7 +217,7 @@ async def claim_discount_offer( discount_percent = int(offer.discount_percent or 0) if discount_percent <= 0: await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."), + texts.get('DISCOUNT_CLAIM_ERROR', '❌ Не удалось активировать скидку. Попробуйте позже.'), show_alert=True, ) return @@ -292,10 +227,10 @@ async def claim_discount_offer( db_user.updated_at = now extra_data = offer.extra_data or {} - raw_duration = extra_data.get("active_discount_hours") - template_id = extra_data.get("template_id") + raw_duration = extra_data.get('active_discount_hours') + template_id = extra_data.get('template_id') - if raw_duration in (None, "") and template_id: + if raw_duration in (None, '') and template_id: try: template = await get_promo_offer_template_by_id(db, int(template_id)) except (ValueError, TypeError): @@ -319,59 +254,57 @@ async def claim_discount_offer( db, offer, details={ - "context": "discount_claim", - "discount_percent": discount_percent, - "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None, + 'context': 'discount_claim', + 'discount_percent': discount_percent, + 'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None, }, ) await db.refresh(db_user) success_template = texts.get( - "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.", + 'DISCOUNT_CLAIM_SUCCESS', + '🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.', ) - expires_text = ( - discount_expires_at.strftime("%d.%m.%Y %H:%M") if discount_expires_at else "" - ) + expires_text = discount_expires_at.strftime('%d.%m.%Y %H:%M') if discount_expires_at else '' - format_values: Dict[str, Any] = {"percent": discount_percent} + format_values: dict[str, Any] = {'percent': discount_percent} if duration_hours and duration_hours > 0: - format_values.setdefault("hours", duration_hours) - format_values.setdefault("duration_hours", duration_hours) + format_values.setdefault('hours', duration_hours) + format_values.setdefault('duration_hours', duration_hours) if discount_expires_at: - format_values.setdefault("expires_at", expires_text) - format_values.setdefault("expires_at_iso", discount_expires_at.isoformat()) + format_values.setdefault('expires_at', expires_text) + format_values.setdefault('expires_at_iso', discount_expires_at.isoformat()) try: expires_timestamp = int(discount_expires_at.timestamp()) except (OverflowError, OSError, ValueError): expires_timestamp = None if expires_timestamp: - format_values.setdefault("expires_at_ts", expires_timestamp) + format_values.setdefault('expires_at_ts', expires_timestamp) remaining_hours = int((discount_expires_at - now).total_seconds() // 3600) if remaining_hours > 0: - format_values.setdefault("expires_in_hours", remaining_hours) + format_values.setdefault('expires_in_hours', remaining_hours) - amount_text = "" + amount_text = '' if isinstance(extra_data, dict): raw_amount_text = ( - extra_data.get("amount_text") - or extra_data.get("discount_amount_text") - or extra_data.get("formatted_amount") + extra_data.get('amount_text') + or extra_data.get('discount_amount_text') + or extra_data.get('formatted_amount') ) if isinstance(raw_amount_text, str) and raw_amount_text.strip(): amount_text = raw_amount_text.strip() else: - raw_amount = extra_data.get("amount") or extra_data.get("discount_amount") + raw_amount = extra_data.get('amount') or extra_data.get('discount_amount') if isinstance(raw_amount, (int, float)): amount_text = settings.format_price(int(raw_amount)) elif isinstance(raw_amount, str) and raw_amount.strip(): amount_text = raw_amount.strip() if not amount_text: - for key in ("discount_amount_kopeks", "amount_kopeks", "bonus_amount_kopeks"): + for key in ('discount_amount_kopeks', 'amount_kopeks', 'bonus_amount_kopeks'): maybe_amount = extra_data.get(key) try: amount_value = int(maybe_amount) @@ -392,44 +325,42 @@ async def claim_discount_offer( if not amount_text: try: - bonus_amount = int(getattr(offer, "bonus_amount_kopeks", 0)) + bonus_amount = int(getattr(offer, 'bonus_amount_kopeks', 0)) except (TypeError, ValueError): bonus_amount = 0 if bonus_amount > 0: amount_text = settings.format_price(bonus_amount) if amount_text: - format_values.setdefault("amount", amount_text) + format_values.setdefault('amount', amount_text) success_message = _format_text_with_placeholders(success_template, format_values) - await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.answer('✅ Скидка активирована!', show_alert=True) offer_type = None if isinstance(extra_data, dict): - offer_type = extra_data.get("offer_type") + offer_type = extra_data.get('offer_type') - subscription = getattr(db_user, "subscription", None) + subscription = getattr(db_user, 'subscription', None) - if offer_type == "purchase_discount": - button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку") - button_callback = "subscription_upgrade" - elif offer_type == "extend_discount": - button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку") - button_callback = "subscription_extend" + if offer_type == 'purchase_discount': + button_text = texts.get('MENU_BUY_SUBSCRIPTION', '💎 Купить подписку') + button_callback = 'subscription_upgrade' + elif offer_type == 'extend_discount': + button_text = texts.get('SUBSCRIPTION_EXTEND', '💎 Продлить подписку') + button_callback = 'subscription_extend' else: has_active_paid_subscription = bool( - subscription - and getattr(subscription, "is_active", False) - and not getattr(subscription, "is_trial", False) + subscription and getattr(subscription, 'is_active', False) and not getattr(subscription, 'is_trial', False) ) if has_active_paid_subscription: - button_text = texts.get("SUBSCRIPTION_EXTEND", "💎 Продлить подписку") - button_callback = "subscription_extend" + button_text = texts.get('SUBSCRIPTION_EXTEND', '💎 Продлить подписку') + button_callback = 'subscription_extend' else: - button_text = texts.get("MENU_BUY_SUBSCRIPTION", "💎 Купить подписку") - button_callback = "subscription_upgrade" + button_text = texts.get('MENU_BUY_SUBSCRIPTION', '💎 Купить подписку') + button_callback = 'subscription_upgrade' buy_keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -443,10 +374,11 @@ async def claim_discount_offer( ) await callback.message.answer(success_message, reply_markup=buy_keyboard) + async def handle_promo_offer_close( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession, + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, ): try: await callback.message.delete() diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 097400ee..1ca43fb8 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -1,61 +1,46 @@ -import base64 -import json import logging from datetime import datetime, timedelta + logger = logging.getLogger(__name__) -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F +from typing import Any + +from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest from aiogram.fsm.context import FSMContext -from aiogram.types import InaccessibleMessage, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InaccessibleMessage, InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id + +from app.config import PERIOD_PRICES, settings from app.database.crud.subscription import ( - create_trial_subscription, + create_paid_subscription, create_pending_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay + create_trial_subscription, ) from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) +from app.database.models import Subscription, SubscriptionStatus, TransactionType, User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, + get_back_keyboard, + get_countries_keyboard, + get_devices_keyboard, + get_extend_subscription_keyboard_with_prices, get_happ_download_button_row, + get_insufficient_balance_keyboard, + get_insufficient_balance_keyboard_with_cart, get_payment_methods_keyboard_with_cart, + get_subscription_confirm_keyboard, get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_subscription_keyboard, + get_subscription_period_keyboard, + get_traffic_packages_keyboard, + get_trial_keyboard, + get_updated_subscription_settings_keyboard, ) -from app.services.user_cart_service import user_cart_service from app.localization.texts import get_texts -from app.utils.decorators import error_handler from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService from app.services.blacklist_service import blacklist_service +from app.services.remnawave_service import RemnaWaveConfigurationError from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, get_subscription_checkout_draft, @@ -67,26 +52,28 @@ from app.services.trial_activation_service import ( TrialPaymentChargeFailed, TrialPaymentInsufficientFunds, charge_trial_activation_if_required, - preview_trial_activation_charge, revert_trial_activation, rollback_trial_subscription_activation, ) +from app.services.user_cart_service import user_cart_service +from app.utils.decorators import error_handler + logger = logging.getLogger(__name__) -def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]: +def _serialize_markup(markup: InlineKeyboardMarkup | None) -> Any | None: if markup is None: return None - model_dump = getattr(markup, "model_dump", None) + model_dump = getattr(markup, 'model_dump', None) if callable(model_dump): try: return model_dump(exclude_none=True) except TypeError: return model_dump() - to_python = getattr(markup, "to_python", None) + to_python = getattr(markup, 'to_python', None) if callable(to_python): return to_python() @@ -96,47 +83,36 @@ def _serialize_markup(markup: Optional[InlineKeyboardMarkup]) -> Optional[Any]: def _message_needs_update( message: types.Message, new_text: str, - new_markup: Optional[InlineKeyboardMarkup], + new_markup: InlineKeyboardMarkup | None, ) -> bool: - current_text = getattr(message, "text", None) + current_text = getattr(message, 'text', None) if current_text != new_text: return True - current_markup = getattr(message, "reply_markup", None) + current_markup = getattr(message, 'reply_markup', None) return _serialize_markup(current_markup) != _serialize_markup(new_markup) -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service -from app.states import SubscriptionStates -from app.utils.pagination import paginate_list -from app.utils.pricing_utils import ( - calculate_months_from_days, - compute_simple_subscription_price, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, - apply_percentage_discount, -) -from app.utils.price_display import PriceInfo, format_price_text, calculate_user_price -from app.utils.subscription_utils import ( - convert_subscription_link_to_happ_scheme, - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - resolve_simple_subscription_device_limit, -) -from app.utils.timezone import format_local_datetime -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, -) + + from app.handlers.simple_subscription import ( _calculate_simple_subscription_price, _get_simple_subscription_payment_keyboard, ) +from app.states import SubscriptionStates +from app.utils.price_display import PriceInfo, calculate_user_price, format_price_text +from app.utils.pricing_utils import ( + apply_percentage_discount, + calculate_months_from_days, + format_period_description, + validate_pricing_calculation, +) +from app.utils.subscription_utils import ( + get_display_subscription_link, + resolve_simple_subscription_device_limit, +) +from app.utils.timezone import format_local_datetime -from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices from .autopay import ( handle_autopay_menu, handle_subscription_cancel, @@ -145,6 +121,7 @@ from .autopay import ( show_autopay_days, toggle_autopay, ) +from .common import _apply_promo_offer_discount, _get_promo_offer_discount_percent, update_traffic_prices from .countries import ( _build_countries_selection_text, _get_available_countries, @@ -188,6 +165,7 @@ from .promo import ( claim_discount_offer, handle_promo_offer_close, ) +from .summary import present_subscription_summary from .traffic import ( confirm_reset_traffic, confirm_switch_traffic, @@ -197,13 +175,9 @@ from .traffic import ( handle_switch_traffic, select_traffic, ) -from .summary import present_subscription_summary -async def show_subscription_info( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def show_subscription_info(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -215,14 +189,12 @@ async def show_subscription_info( subscription = db_user.subscription if not subscription: - await callback.message.edit_text( - texts.SUBSCRIPTION_NONE, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.SUBSCRIPTION_NONE, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return from app.database.crud.subscription import check_and_update_subscription_status + subscription = await check_and_update_subscription_status(db, subscription) subscription_service = SubscriptionService() @@ -231,89 +203,88 @@ async def show_subscription_info( # Проверяем и синхронизируем подписку с RemnaWave если необходимо sync_success, sync_error = await subscription_service.ensure_subscription_synced(db, subscription) if not sync_success: - logger.warning(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}") + logger.warning(f'Не удалось синхронизировать подписку {subscription.id} с RemnaWave: {sync_error}') await db.refresh(subscription) await db.refresh(db_user) current_time = datetime.utcnow() - if subscription.status == "expired" or subscription.end_date <= current_time: - actual_status = "expired" - status_display = texts.t("SUBSCRIPTION_STATUS_EXPIRED", "Истекла") - status_emoji = "🔴" - elif subscription.status == "active" and subscription.end_date > current_time: + if subscription.status == 'expired' or subscription.end_date <= current_time: + actual_status = 'expired' + status_display = texts.t('SUBSCRIPTION_STATUS_EXPIRED', 'Истекла') + status_emoji = '🔴' + elif subscription.status == 'active' and subscription.end_date > current_time: if subscription.is_trial: - actual_status = "trial_active" - status_display = texts.t("SUBSCRIPTION_STATUS_TRIAL", "Тестовая") - status_emoji = "🎯" + actual_status = 'trial_active' + status_display = texts.t('SUBSCRIPTION_STATUS_TRIAL', 'Тестовая') + status_emoji = '🎯' else: - actual_status = "paid_active" - status_display = texts.t("SUBSCRIPTION_STATUS_ACTIVE", "Активна") - status_emoji = "💎" + actual_status = 'paid_active' + status_display = texts.t('SUBSCRIPTION_STATUS_ACTIVE', 'Активна') + status_emoji = '💎' else: - actual_status = "unknown" - status_display = texts.t("SUBSCRIPTION_STATUS_UNKNOWN", "Неизвестно") - status_emoji = "❓" + actual_status = 'unknown' + status_display = texts.t('SUBSCRIPTION_STATUS_UNKNOWN', 'Неизвестно') + status_emoji = '❓' if subscription.end_date <= current_time: days_left = 0 - time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_EXPIRED", "истёк") - warning_text = "" + time_left_text = texts.t('SUBSCRIPTION_TIME_LEFT_EXPIRED', 'истёк') + warning_text = '' else: delta = subscription.end_date - current_time days_left = delta.days hours_left = delta.seconds // 3600 if days_left > 1: - time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left) - warning_text = "" + time_left_text = texts.t('SUBSCRIPTION_TIME_LEFT_DAYS', '{days} дн.').format(days=days_left) + warning_text = '' elif days_left == 1: - time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_DAYS", "{days} дн.").format(days=days_left) - warning_text = texts.t("SUBSCRIPTION_WARNING_TOMORROW", "\n⚠️ истекает завтра!") + time_left_text = texts.t('SUBSCRIPTION_TIME_LEFT_DAYS', '{days} дн.').format(days=days_left) + warning_text = texts.t('SUBSCRIPTION_WARNING_TOMORROW', '\n⚠️ истекает завтра!') elif hours_left > 0: - time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_HOURS", "{hours} ч.").format(hours=hours_left) - warning_text = texts.t("SUBSCRIPTION_WARNING_TODAY", "\n⚠️ истекает сегодня!") + time_left_text = texts.t('SUBSCRIPTION_TIME_LEFT_HOURS', '{hours} ч.').format(hours=hours_left) + warning_text = texts.t('SUBSCRIPTION_WARNING_TODAY', '\n⚠️ истекает сегодня!') else: minutes_left = (delta.seconds % 3600) // 60 - time_left_text = texts.t("SUBSCRIPTION_TIME_LEFT_MINUTES", "{minutes} мин.").format( - minutes=minutes_left - ) + time_left_text = texts.t('SUBSCRIPTION_TIME_LEFT_MINUTES', '{minutes} мин.').format(minutes=minutes_left) warning_text = texts.t( - "SUBSCRIPTION_WARNING_MINUTES", - "\n🔴 истекает через несколько минут!", + 'SUBSCRIPTION_WARNING_MINUTES', + '\n🔴 истекает через несколько минут!', ) subscription_type = ( - texts.t("SUBSCRIPTION_TYPE_TRIAL", "Триал") + texts.t('SUBSCRIPTION_TYPE_TRIAL', 'Триал') if subscription.is_trial - else texts.t("SUBSCRIPTION_TYPE_PAID", "Платная") + else texts.t('SUBSCRIPTION_TYPE_PAID', 'Платная') ) - used_traffic = f"{subscription.traffic_used_gb:.1f}" + used_traffic = f'{subscription.traffic_used_gb:.1f}' if subscription.traffic_limit_gb == 0: traffic_used_display = texts.t( - "SUBSCRIPTION_TRAFFIC_UNLIMITED", - "∞ (безлимит) | Использовано: {used} ГБ", + 'SUBSCRIPTION_TRAFFIC_UNLIMITED', + '∞ (безлимит) | Использовано: {used} ГБ', ).format(used=used_traffic) else: traffic_used_display = texts.t( - "SUBSCRIPTION_TRAFFIC_LIMITED", - "{used} / {limit} ГБ", + 'SUBSCRIPTION_TRAFFIC_LIMITED', + '{used} / {limit} ГБ', ).format(used=used_traffic, limit=subscription.traffic_limit_gb) - devices_used_str = "—" + devices_used_str = '—' devices_list = [] devices_count = 0 show_devices = settings.is_devices_selection_enabled() - devices_used_str = "" - devices_list: List[Dict[str, Any]] = [] + devices_used_str = '' + devices_list: list[dict[str, Any]] = [] if show_devices: try: if db_user.remnawave_uuid: from app.services.remnawave_service import RemnaWaveService + service = RemnaWaveService() async with service.get_api_client() as api: @@ -324,28 +295,25 @@ async def show_subscription_info( devices_count = devices_info.get('total', 0) devices_list = devices_info.get('devices', []) devices_used_str = str(devices_count) - logger.info(f"Найдено {devices_count} устройств для пользователя {db_user.telegram_id}") + logger.info(f'Найдено {devices_count} устройств для пользователя {db_user.telegram_id}') else: - logger.warning(f"Не удалось получить информацию об устройствах для {db_user.telegram_id}") + logger.warning(f'Не удалось получить информацию об устройствах для {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка получения устройств для отображения: {e}") + logger.error(f'Ошибка получения устройств для отображения: {e}') devices_used = await get_current_devices_count(db_user) devices_used_str = str(devices_used) servers_names = await get_servers_display_names(subscription.connected_squads) - servers_display = ( - servers_names - if servers_names - else texts.t("SUBSCRIPTION_NO_SERVERS", "Нет серверов") - ) + servers_display = servers_names if servers_names else texts.t('SUBSCRIPTION_NO_SERVERS', 'Нет серверов') # Получаем информацию о тарифе для режима тарифов - tariff_info_block = "" + tariff_info_block = '' tariff = None if settings.is_tariffs_mode() and subscription.tariff_id: try: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: # Прикрепляем тариф к подписке для использования в клавиатуре @@ -353,40 +321,42 @@ async def show_subscription_info( # Формируем блок информации о тарифе is_daily = getattr(tariff, 'is_daily', False) - tariff_type_str = "🔄 Суточный" if is_daily else "📅 Периодный" + tariff_type_str = '🔄 Суточный' if is_daily else '📅 Периодный' tariff_info_lines = [ - f"📦 {tariff.name}", - f"Тип: {tariff_type_str}", - f"Трафик: {tariff.traffic_limit_gb} ГБ" if tariff.traffic_limit_gb > 0 else "Трафик: ∞ Безлимит", - f"Устройства: {tariff.device_limit}", + f'📦 {tariff.name}', + f'Тип: {tariff_type_str}', + f'Трафик: {tariff.traffic_limit_gb} ГБ' if tariff.traffic_limit_gb > 0 else 'Трафик: ∞ Безлимит', + f'Устройства: {tariff.device_limit}', ] if is_daily: # Для суточного тарифа показываем цену и прогресс-бар daily_price = getattr(tariff, 'daily_price_kopeks', 0) / 100 - tariff_info_lines.append(f"Цена: {daily_price:.2f} ₽/день") + tariff_info_lines.append(f'Цена: {daily_price:.2f} ₽/день') # Прогресс-бар до следующего списания last_charge = getattr(subscription, 'last_daily_charge_at', None) is_paused = getattr(subscription, 'is_daily_paused', False) if is_paused: - tariff_info_lines.append("") - tariff_info_lines.append("⏸️ Подписка приостановлена") + tariff_info_lines.append('') + tariff_info_lines.append('⏸️ Подписка приостановлена') # Показываем оставшееся время даже при паузе if last_charge: from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) now = datetime.utcnow() if next_charge > now: time_until = next_charge - now hours_left = time_until.seconds // 3600 minutes_left = (time_until.seconds % 3600) // 60 - tariff_info_lines.append(f"⏳ Осталось: {hours_left}ч {minutes_left}мин") - tariff_info_lines.append("💤 Списание приостановлено") + tariff_info_lines.append(f'⏳ Осталось: {hours_left}ч {minutes_left}мин') + tariff_info_lines.append('💤 Списание приостановлено') elif last_charge: from datetime import timedelta + next_charge = last_charge + timedelta(hours=24) now = datetime.utcnow() @@ -404,19 +374,19 @@ async def show_subscription_info( bar_length = 10 filled = int(bar_length * percent / 100) empty = bar_length - filled - progress_bar = "▓" * filled + "░" * empty + progress_bar = '▓' * filled + '░' * empty - tariff_info_lines.append("") - tariff_info_lines.append(f"⏳ До списания: {hours_left}ч {minutes_left}мин") - tariff_info_lines.append(f"[{progress_bar}] {percent:.0f}%") + tariff_info_lines.append('') + tariff_info_lines.append(f'⏳ До списания: {hours_left}ч {minutes_left}мин') + tariff_info_lines.append(f'[{progress_bar}] {percent:.0f}%') else: - tariff_info_lines.append("") - tariff_info_lines.append("⏳ Первое списание скоро") + tariff_info_lines.append('') + tariff_info_lines.append('⏳ Первое списание скоро') - tariff_info_block = "\n
" + "\n".join(tariff_info_lines) + "
" + tariff_info_block = '\n
' + '\n'.join(tariff_info_lines) + '
' except Exception as e: - logger.warning(f"Ошибка получения тарифа: {e}", exc_info=True) + logger.warning(f'Ошибка получения тарифа: {e}', exc_info=True) # Определяем, суточный ли тариф для выбора шаблона is_daily_tariff = tariff and getattr(tariff, 'is_daily', False) @@ -424,7 +394,7 @@ async def show_subscription_info( if is_daily_tariff: # Для суточных тарифов другой шаблон без "Действует до" и "Осталось" message_template = texts.t( - "SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE", + 'SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE', """👤 {full_name} 💰 Баланс: {balance} 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} @@ -437,7 +407,7 @@ async def show_subscription_info( ) else: message_template = texts.t( - "SUBSCRIPTION_OVERVIEW_TEMPLATE", + 'SUBSCRIPTION_OVERVIEW_TEMPLATE', """👤 {full_name} 💰 Баланс: {balance} 📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block} @@ -453,8 +423,8 @@ async def show_subscription_info( if not show_devices: message_template = message_template.replace( - "\n📱 Устройства: {devices_used} / {device_limit}", - "", + '\n📱 Устройства: {devices_used} / {device_limit}', + '', ) # Формируем отображение лимита устройств с учётом модема @@ -462,7 +432,7 @@ async def show_subscription_info( if modem_enabled and settings.is_modem_enabled(): # Показываем лимит без модема + модем visible_device_limit = (subscription.device_limit or 1) - 1 - device_limit_display = f"{visible_device_limit} + модем" + device_limit_display = f'{visible_device_limit} + модем' else: device_limit_display = str(subscription.device_limit) @@ -474,7 +444,7 @@ async def show_subscription_info( warning=warning_text, tariff_info_block=tariff_info_block, subscription_type=subscription_type, - end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), time_left=time_left_text, traffic=traffic_used_display, servers=servers_display, @@ -483,25 +453,26 @@ async def show_subscription_info( ) if show_devices and devices_list: - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE", - "
📱 Подключенные устройства:\n", + message += '\n\n' + texts.t( + 'SUBSCRIPTION_CONNECTED_DEVICES_TITLE', + '
📱 Подключенные устройства:\n', ) for device in devices_list[:5]: platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') - device_info = f"{platform} - {device_model}" + device_info = f'{platform} - {device_model}' if len(device_info) > 35: - device_info = device_info[:32] + "..." - message += f"• {device_info}\n" - message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "
") + device_info = device_info[:32] + '...' + message += f'• {device_info}\n' + message += texts.t('SUBSCRIPTION_CONNECTED_DEVICES_FOOTER', '
') # Отображаем докупленный трафик if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов - from app.database.models import TrafficPurchase from sqlalchemy import select as sql_select + from app.database.models import TrafficPurchase + now = datetime.utcnow() purchases_query = ( sql_select(TrafficPurchase) @@ -513,9 +484,9 @@ async def show_subscription_info( purchases = purchases_result.scalars().all() if purchases: - message += "\n\n" + texts.t( - "SUBSCRIPTION_PURCHASED_TRAFFIC_TITLE", - "
📦 Докупленный трафик:\n", + message += '\n\n' + texts.t( + 'SUBSCRIPTION_PURCHASED_TRAFFIC_TITLE', + '
📦 Докупленный трафик:\n', ) for purchase in purchases: @@ -525,73 +496,64 @@ async def show_subscription_info( # Генерируем прогресс-бар total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() elapsed_seconds = (now - purchase.created_at).total_seconds() - progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + progress_percent = min( + 100.0, + max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0), + ) bar_length = 10 filled = int((progress_percent / 100) * bar_length) - bar = "▰" * filled + "▱" * (bar_length - filled) + bar = '▰' * filled + '▱' * (bar_length - filled) # Форматируем дату истечения - expire_date = purchase.expires_at.strftime("%d.%m.%Y") + expire_date = purchase.expires_at.strftime('%d.%m.%Y') # Формируем текст о времени if days_remaining == 0: - time_text = "истекает сегодня" + time_text = 'истекает сегодня' elif days_remaining == 1: - time_text = "остался 1 день" + time_text = 'остался 1 день' elif days_remaining < 5: - time_text = f"осталось {days_remaining} дня" + time_text = f'осталось {days_remaining} дня' else: - time_text = f"осталось {days_remaining} дней" + time_text = f'осталось {days_remaining} дней' - message += f"• {purchase.traffic_gb} ГБ — {time_text}\n" - message += f" {bar} {progress_percent:.0f}% | до {expire_date}\n" + message += f'• {purchase.traffic_gb} ГБ — {time_text}\n' + message += f' {bar} {progress_percent:.0f}% | до {expire_date}\n' - message += texts.t("SUBSCRIPTION_PURCHASED_TRAFFIC_FOOTER", "
") + message += texts.t('SUBSCRIPTION_PURCHASED_TRAFFIC_FOOTER', '
') subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() - if ( - subscription_link - and actual_status in ["trial_active", "paid_active"] - and not hide_subscription_link - ): + if subscription_link and actual_status in ['trial_active', 'paid_active'] and not hide_subscription_link: subscription_link_display = subscription_link if settings.is_happ_cryptolink_mode(): - subscription_link_display = ( - f"
{subscription_link}
" - ) + subscription_link_display = f'
{subscription_link}
' else: - subscription_link_display = f"{subscription_link}" + subscription_link_display = f'{subscription_link}' - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_SECTION", - "🔗 Ссылка для подключения:\n{subscription_url}", + message += '\n\n' + texts.t( + 'SUBSCRIPTION_CONNECT_LINK_SECTION', + '🔗 Ссылка для подключения:\n{subscription_url}', ).format(subscription_url=subscription_link_display) - message += "\n\n" + texts.t( - "SUBSCRIPTION_CONNECT_LINK_PROMPT", - "📱 Скопируйте ссылку и добавьте в ваше VPN приложение", + message += '\n\n' + texts.t( + 'SUBSCRIPTION_CONNECT_LINK_PROMPT', + '📱 Скопируйте ссылку и добавьте в ваше VPN приложение', ) await callback.message.edit_text( message, reply_markup=get_subscription_keyboard( - db_user.language, - has_subscription=True, - is_trial=subscription.is_trial, - subscription=subscription + db_user.language, has_subscription=True, is_trial=subscription.is_trial, subscription=subscription ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def show_trial_offer( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -611,10 +573,7 @@ async def show_trial_offer( trial_blocked = True if trial_blocked: - await callback.message.edit_text( - texts.TRIAL_ALREADY_USED, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.TRIAL_ALREADY_USED, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return @@ -623,12 +582,12 @@ async def show_trial_offer( trial_traffic = settings.TRIAL_TRAFFIC_LIMIT_GB trial_device_limit = settings.TRIAL_DEVICE_LIMIT trial_tariff = None - trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер") + trial_server_name = texts.t('TRIAL_SERVER_DEFAULT_NAME', '🎯 Тестовый сервер') # Проверяем триальный тариф if settings.is_tariffs_mode(): try: - from app.database.crud.tariff import get_trial_tariff, get_tariff_by_id as get_tariff + from app.database.crud.tariff import get_tariff_by_id as get_tariff, get_trial_tariff trial_tariff = await get_trial_tariff(db) if not trial_tariff: @@ -644,9 +603,9 @@ async def show_trial_offer( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_days = tariff_trial_days - logger.info(f"Показываем триал с тарифом {trial_tariff.name}") + logger.info(f'Показываем триал с тарифом {trial_tariff.name}') except Exception as e: - logger.error(f"Ошибка получения триального тарифа: {e}") + logger.error(f'Ошибка получения триального тарифа: {e}') try: from app.database.crud.server_squad import get_trial_eligible_server_squads @@ -654,14 +613,15 @@ async def show_trial_offer( # Для тарифа используем его сервера if trial_tariff and trial_tariff.allowed_squads: from app.database.crud.server_squad import get_server_squads_by_uuids + tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads) if tariff_squads: if len(tariff_squads) == 1: trial_server_name = tariff_squads[0].display_name else: trial_server_name = texts.t( - "TRIAL_SERVER_RANDOM_POOL", - "🎲 Случайный из {count} серверов", + 'TRIAL_SERVER_RANDOM_POOL', + '🎲 Случайный из {count} серверов', ).format(count=len(tariff_squads)) else: trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True) @@ -670,54 +630,52 @@ async def show_trial_offer( trial_server_name = trial_squads[0].display_name else: trial_server_name = texts.t( - "TRIAL_SERVER_RANDOM_POOL", - "🎲 Случайный из {count} серверов", + 'TRIAL_SERVER_RANDOM_POOL', + '🎲 Случайный из {count} серверов', ).format(count=len(trial_squads)) else: - logger.warning("Не настроены сквады для выдачи триалов") + logger.warning('Не настроены сквады для выдачи триалов') except Exception as e: - logger.error(f"Ошибка получения триального сервера: {e}") + logger.error(f'Ошибка получения триального сервера: {e}') if not settings.is_devices_selection_enabled(): forced_limit = settings.get_disabled_mode_device_limit() if forced_limit is not None: trial_device_limit = forced_limit - devices_line = "" + devices_line = '' if settings.is_devices_selection_enabled() or trial_tariff: devices_line_template = texts.t( - "TRIAL_AVAILABLE_DEVICES_LINE", - "\n📱 Устройства: {devices} шт.", + 'TRIAL_AVAILABLE_DEVICES_LINE', + '\n📱 Устройства: {devices} шт.', ) devices_line = devices_line_template.format( devices=trial_device_limit, ) - price_line = "" + price_line = '' if settings.is_trial_paid_activation_enabled(): trial_price = settings.get_trial_activation_price() if trial_price > 0: price_line = texts.t( - "TRIAL_PAYMENT_PRICE_LINE", - "\n💳 Стоимость активации: {price}", + 'TRIAL_PAYMENT_PRICE_LINE', + '\n💳 Стоимость активации: {price}', ).format(price=settings.format_price(trial_price)) trial_text = texts.TRIAL_AVAILABLE.format( days=trial_days, traffic=texts.format_traffic(trial_traffic), - devices=trial_device_limit if trial_device_limit is not None else "", + devices=trial_device_limit if trial_device_limit is not None else '', devices_line=devices_line, server_name=trial_server_name, price_line=price_line, ) - await callback.message.edit_text( - trial_text, - reply_markup=get_trial_keyboard(db_user.language) - ) + await callback.message.edit_text(trial_text, reply_markup=get_trial_keyboard(db_user.language)) await callback.answer() + def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = False) -> types.InlineKeyboardMarkup: """Создает клавиатуру с методами оплаты для платного триала.""" texts = get_texts(language) @@ -725,102 +683,72 @@ def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = Fals # Кнопка оплаты с баланса (если хватает средств) if can_pay_from_balance: - keyboard.append([types.InlineKeyboardButton( - text="✅ Оплатить с баланса", - callback_data="trial_pay_with_balance" - )]) + keyboard.append( + [types.InlineKeyboardButton(text='✅ Оплатить с баланса', callback_data='trial_pay_with_balance')] + ) # Добавляем доступные методы оплаты if settings.TELEGRAM_STARS_ENABLED: - keyboard.append([types.InlineKeyboardButton( - text="⭐ Telegram Stars", - callback_data="trial_payment_stars" - )]) + keyboard.append([types.InlineKeyboardButton(text='⭐ Telegram Stars', callback_data='trial_payment_stars')]) if settings.is_yookassa_enabled(): yookassa_methods = [] if settings.YOOKASSA_SBP_ENABLED: - yookassa_methods.append(types.InlineKeyboardButton( - text="🏦 YooKassa (СБП)", - callback_data="trial_payment_yookassa_sbp" - )) - yookassa_methods.append(types.InlineKeyboardButton( - text="💳 YooKassa (Карта)", - callback_data="trial_payment_yookassa" - )) + yookassa_methods.append( + types.InlineKeyboardButton(text='🏦 YooKassa (СБП)', callback_data='trial_payment_yookassa_sbp') + ) + yookassa_methods.append( + types.InlineKeyboardButton(text='💳 YooKassa (Карта)', callback_data='trial_payment_yookassa') + ) if yookassa_methods: keyboard.append(yookassa_methods) if settings.is_cryptobot_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="🪙 CryptoBot", - callback_data="trial_payment_cryptobot" - )]) + keyboard.append([types.InlineKeyboardButton(text='🪙 CryptoBot', callback_data='trial_payment_cryptobot')]) if settings.is_heleket_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="🪙 Heleket", - callback_data="trial_payment_heleket" - )]) + keyboard.append([types.InlineKeyboardButton(text='🪙 Heleket', callback_data='trial_payment_heleket')]) if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() - keyboard.append([types.InlineKeyboardButton( - text=f"💳 {mulenpay_name}", - callback_data="trial_payment_mulenpay" - )]) + keyboard.append( + [types.InlineKeyboardButton(text=f'💳 {mulenpay_name}', callback_data='trial_payment_mulenpay')] + ) if settings.is_pal24_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="💳 PayPalych", - callback_data="trial_payment_pal24" - )]) + keyboard.append([types.InlineKeyboardButton(text='💳 PayPalych', callback_data='trial_payment_pal24')]) if settings.is_wata_enabled(): - keyboard.append([types.InlineKeyboardButton( - text="💳 WATA", - callback_data="trial_payment_wata" - )]) + keyboard.append([types.InlineKeyboardButton(text='💳 WATA', callback_data='trial_payment_wata')]) if settings.is_platega_enabled(): platega_name = settings.get_platega_display_name() - keyboard.append([types.InlineKeyboardButton( - text=f"💳 {platega_name}", - callback_data="trial_payment_platega" - )]) + keyboard.append([types.InlineKeyboardButton(text=f'💳 {platega_name}', callback_data='trial_payment_platega')]) # Кнопка назад - keyboard.append([types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_trial" - )]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_trial')]) return types.InlineKeyboardMarkup(inline_keyboard=keyboard) -async def activate_trial( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): - from app.services.admin_notification_service import AdminNotificationService +async def activate_trial(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.services.trial_activation_service import get_trial_activation_charge_amount texts = get_texts(db_user.language) # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription')]) await callback.message.edit_text( - f"🚫 Активация подписки ограничена\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Активация подписки ограничена\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return @@ -837,10 +765,7 @@ async def activate_trial( trial_blocked = True if trial_blocked: - await callback.message.edit_text( - texts.TRIAL_ALREADY_USED, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.TRIAL_ALREADY_USED, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return @@ -849,48 +774,43 @@ async def activate_trial( if trial_price_kopeks > 0: # Платный триал - показываем экран с выбором метода оплаты - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0 + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0 can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks - traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ" + traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ' message_lines = [ - texts.t("PAID_TRIAL_HEADER", "⚡ Пробная подписка"), - "", - f"📅 {texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}", - f"📊 {texts.t('TRAFFIC', 'Трафик')}: {traffic_label}", - f"📱 {texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}", - "", - f"💰 {texts.t('PRICE', 'Стоимость')}: {settings.format_price(trial_price_kopeks)}", - f"💳 {texts.t('YOUR_BALANCE', 'Ваш баланс')}: {settings.format_price(user_balance_kopeks)}", - "", + texts.t('PAID_TRIAL_HEADER', '⚡ Пробная подписка'), + '', + f'📅 {texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}', + f'📊 {texts.t("TRAFFIC", "Трафик")}: {traffic_label}', + f'📱 {texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}', + '', + f'💰 {texts.t("PRICE", "Стоимость")}: {settings.format_price(trial_price_kopeks)}', + f'💳 {texts.t("YOUR_BALANCE", "Ваш баланс")}: {settings.format_price(user_balance_kopeks)}', + '', ] if can_pay_from_balance: - message_lines.append(texts.t( - "PAID_TRIAL_CAN_PAY_BALANCE", - "Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты." - )) + message_lines.append( + texts.t( + 'PAID_TRIAL_CAN_PAY_BALANCE', + 'Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты.', + ) + ) else: - message_lines.append(texts.t( - "PAID_TRIAL_SELECT_PAYMENT", - "Выберите подходящий способ оплаты:" - )) + message_lines.append(texts.t('PAID_TRIAL_SELECT_PAYMENT', 'Выберите подходящий способ оплаты:')) - message_text = "\n".join(message_lines) + message_text = '\n'.join(message_lines) keyboard = _get_trial_payment_keyboard(db_user.language, can_pay_from_balance) - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') await callback.answer() return # Бесплатный триал - текущее поведение charged_amount = 0 - subscription: Optional[Subscription] = None + subscription: Subscription | None = None remnawave_user = None try: @@ -929,9 +849,9 @@ async def activate_trial( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f"Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})") + logger.info(f'Используем триальный тариф {trial_tariff.name} (ID: {trial_tariff.id})') except Exception as e: - logger.error(f"Ошибка получения триального тарифа: {e}") + logger.error(f'Ошибка получения триального тарифа: {e}') subscription = await create_trial_subscription( db, @@ -949,7 +869,7 @@ async def activate_trial( charged_amount = await charge_trial_activation_if_required( db, db_user, - description="Активация триала через бота", + description='Активация триала через бота', ) except TrialPaymentInsufficientFunds as error: rollback_success = await rollback_trial_subscription_activation(db, subscription) @@ -957,15 +877,15 @@ async def activate_trial( if not rollback_success: await callback.answer( texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала. Попробуйте позже.", + 'TRIAL_ROLLBACK_FAILED', + 'Не удалось отменить активацию триала. Попробуйте позже.', ), show_alert=True, ) return logger.error( - "Insufficient funds detected after trial creation for user %s: %s", + 'Insufficient funds detected after trial creation for user %s: %s', db_user.id, error, ) @@ -973,10 +893,10 @@ async def activate_trial( balance_label = settings.format_price(error.balance_amount) missing_label = settings.format_price(error.missing_amount) message = texts.t( - "TRIAL_PAYMENT_INSUFFICIENT_FUNDS", - "⚠️ Недостаточно средств для активации триала.\n" - "Необходимо: {required}\nНа балансе: {balance}\n" - "Не хватает: {missing}\n\nПополните баланс и попробуйте снова.", + 'TRIAL_PAYMENT_INSUFFICIENT_FUNDS', + '⚠️ Недостаточно средств для активации триала.\n' + 'Необходимо: {required}\nНа балансе: {balance}\n' + 'Не хватает: {missing}\n\nПополните баланс и попробуйте снова.', ).format( required=required_label, balance=balance_label, @@ -998,8 +918,8 @@ async def activate_trial( if not rollback_success: await callback.answer( texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала. Попробуйте позже.", + 'TRIAL_ROLLBACK_FAILED', + 'Не удалось отменить активацию триала. Попробуйте позже.', ), show_alert=True, ) @@ -1007,8 +927,8 @@ async def activate_trial( await callback.answer( texts.t( - "TRIAL_PAYMENT_FAILED", - "Не удалось списать средства для активации триала. Попробуйте позже.", + 'TRIAL_PAYMENT_FAILED', + 'Не удалось списать средства для активации триала. Попробуйте позже.', ), show_alert=True, ) @@ -1021,28 +941,28 @@ async def activate_trial( subscription, ) except RemnaWaveConfigurationError as error: - logger.error("RemnaWave update skipped due to configuration error: %s", error) + logger.error('RemnaWave update skipped due to configuration error: %s', error) revert_result = await revert_trial_activation( db, db_user, subscription, charged_amount, - refund_description="Возврат оплаты за активацию триала через бота", + refund_description='Возврат оплаты за активацию триала через бота', ) if not revert_result.subscription_rolled_back: failure_text = texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.", + 'TRIAL_ROLLBACK_FAILED', + 'Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.', ) elif charged_amount > 0 and not revert_result.refunded: failure_text = texts.t( - "TRIAL_REFUND_FAILED", - "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.", + 'TRIAL_REFUND_FAILED', + 'Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.', ) else: failure_text = texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.", + 'TRIAL_PROVISIONING_FAILED', + 'Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.', ) await callback.message.edit_text( @@ -1053,8 +973,8 @@ async def activate_trial( return except Exception as error: logger.error( - "Failed to create RemnaWave user for trial subscription %s: %s", - getattr(subscription, "id", ""), + 'Failed to create RemnaWave user for trial subscription %s: %s', + getattr(subscription, 'id', ''), error, ) revert_result = await revert_trial_activation( @@ -1062,22 +982,22 @@ async def activate_trial( db_user, subscription, charged_amount, - refund_description="Возврат оплаты за активацию триала через бота", + refund_description='Возврат оплаты за активацию триала через бота', ) if not revert_result.subscription_rolled_back: failure_text = texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.", + 'TRIAL_ROLLBACK_FAILED', + 'Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.', ) elif charged_amount > 0 and not revert_result.refunded: failure_text = texts.t( - "TRIAL_REFUND_FAILED", - "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.", + 'TRIAL_REFUND_FAILED', + 'Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.', ) else: failure_text = texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.", + 'TRIAL_PROVISIONING_FAILED', + 'Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.', ) await callback.message.edit_text( @@ -1098,106 +1018,110 @@ async def activate_trial( charged_amount_kopeks=charged_amount, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о триале: {e}") + logger.error(f'Ошибка отправки уведомления о триале: {e}') subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() - payment_note = "" + payment_note = '' if charged_amount > 0: - payment_note = "\n\n" + texts.t( - "TRIAL_PAYMENT_CHARGED_NOTE", - "💳 С вашего баланса списано {amount}.", + payment_note = '\n\n' + texts.t( + 'TRIAL_PAYMENT_CHARGED_NOTE', + '💳 С вашего баланса списано {amount}.', ).format(amount=settings.format_price(charged_amount)) if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" + f'{texts.TRIAL_ACTIVATED}\n\n' + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + 'SUBSCRIPTION_HAPP_LINK_PROMPT', + '🔒 Ссылка на подписку создана. Нажмите кнопку "Подключиться" ниже, чтобы открыть её в Happ.', ) - + "\n\n" + + '\n\n' + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', ) ) elif hide_subscription_link: trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" + f'{texts.TRIAL_ACTIVATED}\n\n' + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', + 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', ) - + "\n\n" + + '\n\n' + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', ) ) else: subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + 'SUBSCRIPTION_IMPORT_LINK_SECTION', + '🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}', ).format(subscription_url=subscription_link) trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + f'{texts.TRIAL_ACTIVATED}\n\n' + f'{subscription_import_link}\n\n' + f'{texts.t("SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве")}' ) trial_success_text += payment_note connect_mode = settings.CONNECT_BUTTON_MODE - if connect_mode == "miniapp_subscription": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link), - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ]) - elif connect_mode == "miniapp_custom": + if connect_mode == 'miniapp_subscription': + connect_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + elif connect_mode == 'miniapp_custom': if not settings.MINIAPP_CUSTOM_URL: await callback.answer( texts.t( - "CUSTOM_MINIAPP_URL_NOT_SET", - "⚠ Кастомная ссылка для мини-приложения не настроена", + 'CUSTOM_MINIAPP_URL_NOT_SET', + '⚠ Кастомная ссылка для мини-приложения не настроена', ), show_alert=True, ) return - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ]) - elif connect_mode == "link": + connect_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + elif connect_mode == 'link': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_link, ) ] @@ -1208,18 +1132,18 @@ async def activate_trial( rows.append( [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', ) ] ) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": + elif connect_mode == 'happ_cryptolink': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) ] ] @@ -1229,8 +1153,8 @@ async def activate_trial( rows.append( [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', ) ] ) @@ -1240,14 +1164,14 @@ async def activate_trial( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', ) ], [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', ) ], ] @@ -1256,24 +1180,20 @@ async def activate_trial( await callback.message.edit_text( trial_success_text, reply_markup=connect_keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд." - ) + trial_success_text = f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд." trial_success_text += payment_note await callback.message.edit_text( trial_success_text, reply_markup=get_back_keyboard(db_user.language), ) - logger.info( - f"✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}" - ) + logger.info(f'✅ Активирована тестовая подписка для пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка активации триала: {e}") + logger.error(f'Ошибка активации триала: {e}') failure_text = texts.ERROR if subscription and remnawave_user is None: @@ -1282,44 +1202,43 @@ async def activate_trial( db_user, subscription, charged_amount, - refund_description="Возврат оплаты за активацию триала через бота", + refund_description='Возврат оплаты за активацию триала через бота', ) if not revert_result.subscription_rolled_back: failure_text = texts.t( - "TRIAL_ROLLBACK_FAILED", - "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.", + 'TRIAL_ROLLBACK_FAILED', + 'Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.', ) elif charged_amount > 0 and not revert_result.refunded: failure_text = texts.t( - "TRIAL_REFUND_FAILED", - "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.", + 'TRIAL_REFUND_FAILED', + 'Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.', ) else: failure_text = texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.", + 'TRIAL_PROVISIONING_FAILED', + 'Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.', ) - await callback.message.edit_text( - failure_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(failure_text, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return await callback.answer() + async def start_subscription_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession, + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, + db: AsyncSession, ): texts = get_texts(db_user.language) # Проверяем режим продаж - если tariffs, перенаправляем на выбор тарифов if settings.is_tariffs_mode(): from .tariff_purchase import show_tariffs_list + await show_tariffs_list(callback, db_user, db, state) return @@ -1345,12 +1264,7 @@ async def start_subscription_purchase( else: initial_devices = forced_limit - initial_data = { - 'period_days': None, - 'countries': [], - 'devices': initial_devices, - 'total_price': 0 - } + initial_data = {'period_days': None, 'countries': [], 'devices': initial_devices, 'total_price': 0} if settings.is_traffic_fixed(): initial_data['traffic_gb'] = settings.get_fixed_traffic_limit() @@ -1366,7 +1280,7 @@ async def _edit_message_text_or_caption( message: types.Message, text: str, reply_markup: InlineKeyboardMarkup, - parse_mode: Optional[str] = "HTML", + parse_mode: str | None = 'HTML', ) -> None: """Edits message text when possible, falls back to caption or re-sends message.""" @@ -1388,10 +1302,10 @@ async def _edit_message_text_or_caption( except TelegramBadRequest as error: error_message = str(error).lower() - if "message is not modified" in error_message: + if 'message is not modified' in error_message: return - if "there is no text in the message to edit" in error_message: + if 'there is no text in the message to edit' in error_message: if message.caption is not None: await message.edit_caption( caption=text, @@ -1410,11 +1324,9 @@ async def _edit_message_text_or_caption( raise + async def save_cart_and_redirect_to_topup( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - missing_amount: int + callback: types.CallbackQuery, state: FSMContext, db_user: User, missing_amount: int ): texts = get_texts(db_user.language) data = await state.get_data() @@ -1425,36 +1337,32 @@ async def save_cart_and_redirect_to_topup( 'saved_cart': True, 'missing_amount': missing_amount, 'return_to_cart': True, - 'user_id': db_user.id + 'user_id': db_user.id, } await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( - f"💰 Недостаточно средств для оформления подписки\n\n" - f"Требуется: {texts.format_price(missing_amount)}\n" - f"У вас: {texts.format_price(db_user.balance_kopeks)}\n\n" - f"🛒 Ваша корзина сохранена!\n" - f"После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n" - f"Выберите способ пополнения:", + f'💰 Недостаточно средств для оформления подписки\n\n' + f'Требуется: {texts.format_price(missing_amount)}\n' + f'У вас: {texts.format_price(db_user.balance_kopeks)}\n\n' + f'🛒 Ваша корзина сохранена!\n' + f'После пополнения баланса вы сможете вернуться к оформлению подписки.\n\n' + f'Выберите способ пополнения:', reply_markup=get_payment_methods_keyboard_with_cart( db_user.language, missing_amount, ), - parse_mode="HTML" + parse_mode='HTML', ) -async def return_to_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): + +async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): # Получаем данные корзины из Redis cart_data = await user_cart_service.get_user_cart(db_user.id) if not cart_data: - await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True) + await callback.answer('❌ Сохраненная корзина не найдена', show_alert=True) return texts = get_texts(db_user.language) @@ -1463,6 +1371,7 @@ async def return_to_saved_cart( cart_mode = cart_data.get('cart_mode') if cart_mode in ('tariff_purchase', 'daily_tariff_purchase', 'extend') and cart_data.get('tariff_id'): from .tariff_purchase import return_to_saved_tariff_cart + await return_to_saved_tariff_cart(callback, state, db_user, db, cart_data) return @@ -1472,11 +1381,7 @@ async def return_to_saved_cart( 'return_to_cart', 'user_id', } - preserved_metadata = { - key: cart_data[key] - for key in preserved_metadata_keys - if key in cart_data - } + preserved_metadata = {key: cart_data[key] for key in preserved_metadata_keys if key in cart_data} prepared_cart_data = dict(cart_data) @@ -1491,7 +1396,7 @@ async def return_to_saved_cart( ) except ValueError as recalculation_error: logger.error( - "Не удалось пересчитать сохраненную корзину пользователя %s: %s", + 'Не удалось пересчитать сохраненную корзину пользователя %s: %s', db_user.telegram_id, recalculation_error, ) @@ -1530,10 +1435,10 @@ async def return_to_saved_cart( missing_amount, ) insufficient_text = ( - f"❌ Все еще недостаточно средств\n\n" - f"Требуется: {texts.format_price(total_price)}\n" - f"У вас: {texts.format_price(db_user.balance_kopeks)}\n" - f"Не хватает: {texts.format_price(missing_amount)}" + f'❌ Все еще недостаточно средств\n\n' + f'Требуется: {texts.format_price(total_price)}\n' + f'У вас: {texts.format_price(db_user.balance_kopeks)}\n' + f'Не хватает: {texts.format_price(missing_amount)}' ) if _message_needs_update(callback.message, insufficient_text, insufficient_keyboard): @@ -1542,7 +1447,7 @@ async def return_to_saved_cart( reply_markup=insufficient_keyboard, ) else: - await callback.answer("ℹ️ Пополните баланс, чтобы завершить оформление.") + await callback.answer('ℹ️ Пополните баланс, чтобы завершить оформление.') return countries = await _get_available_countries(db_user.promo_group_id) @@ -1560,32 +1465,34 @@ async def return_to_saved_cart( traffic_value = prepared_cart_data.get('traffic_gb') if traffic_value is None: traffic_value = settings.get_fixed_traffic_limit() - traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ" + traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ' else: traffic_value = prepared_cart_data.get('traffic_gb', 0) or 0 - traffic_display = "Безлимитный" if traffic_value == 0 else f"{traffic_value} ГБ" + traffic_display = 'Безлимитный' if traffic_value == 0 else f'{traffic_value} ГБ' summary_lines = [ - "🛒 Восстановленная корзина", - "", - f"📅 Период: {period_display}", - f"📊 Трафик: {traffic_display}", - f"🌍 Страны: {', '.join(selected_countries_names)}", + '🛒 Восстановленная корзина', + '', + f'📅 Период: {period_display}', + f'📊 Трафик: {traffic_display}', + f'🌍 Страны: {", ".join(selected_countries_names)}', ] if settings.is_devices_selection_enabled(): devices_value = prepared_cart_data.get('devices') if devices_value is not None: - summary_lines.append(f"📱 Устройства: {devices_value}") + summary_lines.append(f'📱 Устройства: {devices_value}') - summary_lines.extend([ - "", - f"💎 Общая стоимость: {texts.format_price(total_price)}", - "", - "Подтверждаете покупку?", - ]) + summary_lines.extend( + [ + '', + f'💎 Общая стоимость: {texts.format_price(total_price)}', + '', + 'Подтверждаете покупку?', + ] + ) - summary_text = "\n".join(summary_lines) + summary_text = '\n'.join(summary_lines) # Устанавливаем данные в FSM для продолжения процесса await state.set_data(prepared_cart_data) @@ -1594,19 +1501,12 @@ async def return_to_saved_cart( confirm_keyboard = get_subscription_confirm_keyboard_with_cart(db_user.language) if _message_needs_update(callback.message, summary_text, confirm_keyboard): - await callback.message.edit_text( - summary_text, - reply_markup=confirm_keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(summary_text, reply_markup=confirm_keyboard, parse_mode='HTML') - await callback.answer("✅ Корзина восстановлена!") + await callback.answer('✅ Корзина восстановлена!') -async def handle_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -1616,7 +1516,7 @@ async def handle_extend_subscription( subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True) + await callback.answer('⚠ Продление доступно только для платных подписок', show_alert=True) return # В режиме тарифов проверяем наличие tariff_id @@ -1624,29 +1524,25 @@ async def handle_extend_subscription( if subscription.tariff_id: # У подписки есть тариф - перенаправляем на продление по тарифу from .tariff_purchase import show_tariff_extend + await show_tariff_extend(callback, db_user, db) return - else: - # У подписки нет тарифа - предлагаем выбрать тариф - await callback.message.edit_text( - "📦 Выберите тариф для продления\n\n" - "Ваша текущая подписка была создана до введения тарифов.\n" - "Для продления необходимо выбрать один из доступных тарифов.\n\n" - "⚠️ Ваша текущая подписка продолжит действовать до окончания срока.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="📦 Выбрать тариф", - callback_data="tariff_switch" - )], - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - )] - ]), - parse_mode="HTML" - ) - await callback.answer() - return + # У подписки нет тарифа - предлагаем выбрать тариф + await callback.message.edit_text( + '📦 Выберите тариф для продления\n\n' + 'Ваша текущая подписка была создана до введения тарифов.\n' + 'Для продления необходимо выбрать один из доступных тарифов.\n\n' + '⚠️ Ваша текущая подписка продолжит действовать до окончания срока.', + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='📦 Выбрать тариф', callback_data='tariff_switch')], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], + ] + ), + parse_mode='HTML', + ) + await callback.answer() + return subscription_service = SubscriptionService() @@ -1662,7 +1558,7 @@ async def handle_extend_subscription( # 1. Calculate period price with promo group discount using unified system base_price_original = PERIOD_PRICES.get(days, 0) - period_price_info = calculate_user_price(db_user, base_price_original, days, "period") + period_price_info = calculate_user_price(db_user, base_price_original, days, 'period') # 2. Calculate servers price with promo group discount servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids( @@ -1671,7 +1567,7 @@ async def handle_extend_subscription( promo_group_id=db_user.promo_group_id, ) servers_total_base = servers_price_per_month * months_in_period - servers_price_info = calculate_user_price(db_user, servers_total_base, days, "servers") + servers_price_info = calculate_user_price(db_user, servers_total_base, days, 'servers') # 3. Calculate devices price with promo group discount device_limit = subscription.device_limit @@ -1688,7 +1584,7 @@ async def handle_extend_subscription( additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_total_base = devices_price_per_month * months_in_period - devices_price_info = calculate_user_price(db_user, devices_total_base, days, "devices") + devices_price_info = calculate_user_price(db_user, devices_total_base, days, 'devices') # 4. Calculate traffic price with promo group discount # В режиме fixed_with_topup при продлении трафик сбрасывается до фиксированного лимита @@ -1698,22 +1594,22 @@ async def handle_extend_subscription( renewal_traffic_gb = subscription.traffic_limit_gb traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_total_base = traffic_price_per_month * months_in_period - traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, "traffic") + traffic_price_info = calculate_user_price(db_user, traffic_total_base, days, 'traffic') # 5. Calculate ORIGINAL price (before ALL discounts) total_original_price = ( - period_price_info.base_price + - servers_price_info.base_price + - devices_price_info.base_price + - traffic_price_info.base_price + period_price_info.base_price + + servers_price_info.base_price + + devices_price_info.base_price + + traffic_price_info.base_price ) # 6. Sum prices with promo group discounts applied total_price = ( - period_price_info.final_price + - servers_price_info.final_price + - devices_price_info.final_price + - traffic_price_info.final_price + period_price_info.final_price + + servers_price_info.final_price + + devices_price_info.final_price + + traffic_price_info.final_price ) # 7. Apply promo offer discount on top of promo group discounts @@ -1721,19 +1617,19 @@ async def handle_extend_subscription( # Store: original = price before discounts, final = price with all discounts renewal_prices[days] = { - "final": promo_component["discounted"], - "original": total_original_price, + 'final': promo_component['discounted'], + 'original': total_original_price, } except Exception as e: - logger.error(f"Ошибка расчета цены для периода {days}: {e}") + logger.error(f'Ошибка расчета цены для периода {days}: {e}') continue if not renewal_prices: - await callback.answer("⚠ Нет доступных периодов для продления", show_alert=True) + await callback.answer('⚠ Нет доступных периодов для продления', show_alert=True) return - prices_text = "" + prices_text = '' for days in available_periods: if days not in renewal_prices: @@ -1742,10 +1638,10 @@ async def handle_extend_subscription( price_info = renewal_prices[days] if isinstance(price_info, dict): - final_price = price_info.get("final") + final_price = price_info.get('final') if final_price is None: - final_price = price_info.get("original", 0) - original_price = price_info.get("original", final_price) + final_price = price_info.get('original', 0) + original_price = price_info.get('original', final_price) else: final_price = price_info original_price = final_price @@ -1759,16 +1655,15 @@ async def handle_extend_subscription( # Create PriceInfo and format text using unified system price_info_obj = PriceInfo( - base_price=original_price, - final_price=final_price, - discount_percent=discount_percent + base_price=original_price, final_price=final_price, discount_percent=discount_percent ) - prices_text += format_price_text( - period_label=period_display, - price_info=price_info_obj, - format_price_func=texts.format_price - ) + "\n" + prices_text += ( + format_price_text( + period_label=period_display, price_info=price_info_obj, format_price_func=texts.format_price + ) + + '\n' + ) promo_discounts_text = await _build_promo_group_discount_text( db_user, @@ -1777,29 +1672,31 @@ async def handle_extend_subscription( ) renewal_lines = [ - "⏰ Продление подписки", - "", - f"Осталось дней: {subscription.days_left}", - "", - "Ваша текущая конфигурация:", - f"🌍 Серверов: {len(subscription.connected_squads)}", - f"📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}", + '⏰ Продление подписки', + '', + f'Осталось дней: {subscription.days_left}', + '', + 'Ваша текущая конфигурация:', + f'🌍 Серверов: {len(subscription.connected_squads)}', + f'📊 Трафик: {texts.format_traffic(subscription.traffic_limit_gb)}', ] if settings.is_devices_selection_enabled(): - renewal_lines.append(f"📱 Устройств: {subscription.device_limit}") + renewal_lines.append(f'📱 Устройств: {subscription.device_limit}') - renewal_lines.extend([ - "", - "Выберите период продления:", - prices_text.rstrip(), - "", - ]) + renewal_lines.extend( + [ + '', + 'Выберите период продления:', + prices_text.rstrip(), + '', + ] + ) - message_text = "\n".join(renewal_lines) + message_text = '\n'.join(renewal_lines) if promo_discounts_text: - message_text += f"{promo_discounts_text}\n\n" + message_text += f'{promo_discounts_text}\n\n' promo_offer_hint = await _get_promo_offer_hint( db, @@ -1808,44 +1705,38 @@ async def handle_extend_subscription( promo_offer_percent, ) if promo_offer_hint: - message_text += f"{promo_offer_hint}\n\n" + message_text += f'{promo_offer_hint}\n\n' - message_text += "💡 Цена включает все ваши текущие серверы и настройки" + message_text += '💡 Цена включает все ваши текущие серверы и настройки' await callback.message.edit_text( message_text, reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def confirm_extend_subscription( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - callback.from_user.id, - callback.from_user.username + callback.from_user.id, callback.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}') try: await callback.answer( - f"🚫 Продление подписки невозможно\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку.", - show_alert=True + f'🚫 Продление подписки невозможно\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.', + show_alert=True, ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return - from app.services.admin_notification_service import AdminNotificationService - days = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) @@ -1853,26 +1744,25 @@ async def confirm_extend_subscription( available_renewal_periods = settings.get_available_renewal_periods() if days not in available_renewal_periods: await callback.answer( - texts.t("RENEWAL_PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен для продления"), - show_alert=True + texts.t('RENEWAL_PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен для продления'), show_alert=True ) return subscription = db_user.subscription if not subscription: - await callback.answer("⚠ У вас нет активной подписки", show_alert=True) + await callback.answer('⚠ У вас нет активной подписки', show_alert=True) return months_in_period = calculate_months_from_days(days) old_end_date = subscription.end_date - server_uuid_prices: Dict[str, int] = {} + server_uuid_prices: dict[str, int] = {} try: from app.config import PERIOD_PRICES base_price_original = PERIOD_PRICES.get(days, 0) - period_discount_percent = db_user.get_promo_discount("period", days) + period_discount_percent = db_user.get_promo_discount('period', days) base_price, base_discount_total = apply_percentage_discount( base_price_original, period_discount_percent, @@ -1885,13 +1775,15 @@ async def confirm_extend_subscription( promo_group_id=db_user.promo_group_id, ) servers_discount_percent = db_user.get_promo_discount( - "servers", + 'servers', days, ) total_servers_price = 0 total_servers_discount = 0 - for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices): + for squad_uuid, server_monthly_price in zip( + subscription.connected_squads, per_server_monthly_prices, strict=False + ): discount_per_month = server_monthly_price * servers_discount_percent // 100 discounted_per_month = server_monthly_price - discount_per_month total_servers_price += discounted_per_month * months_in_period @@ -1899,7 +1791,7 @@ async def confirm_extend_subscription( server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period discounted_servers_price_per_month = servers_price_per_month - ( - servers_price_per_month * servers_discount_percent // 100 + servers_price_per_month * servers_discount_percent // 100 ) device_limit = subscription.device_limit @@ -1916,7 +1808,7 @@ async def confirm_extend_subscription( additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = db_user.get_promo_discount( - "devices", + 'devices', days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1930,7 +1822,7 @@ async def confirm_extend_subscription( renewal_traffic_gb = subscription.traffic_limit_gb traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb) traffic_discount_percent = db_user.get_promo_discount( - "traffic", + 'traffic', days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1940,86 +1832,78 @@ async def confirm_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price original_price = price promo_component = _apply_promo_offer_discount(db_user, price) - if promo_component["discount"] > 0: - price = promo_component["discounted"] + if promo_component['discount'] > 0: + price = promo_component['discounted'] monthly_additions = ( - discounted_servers_price_per_month - + discounted_devices_price_per_month - + discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month + discounted_traffic_price_per_month ) is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price) if not is_valid: - logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}") - await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) + logger.error(f'Ошибка в расчете цены продления для пользователя {db_user.telegram_id}') + await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True) return - logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):") - base_log = f" 📅 Период {days} дней: {base_price_original / 100}₽" + logger.info(f'💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):') + base_log = f' 📅 Период {days} дней: {base_price_original / 100}₽' if base_discount_total > 0: - base_log += ( - f" → {base_price / 100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' logger.info(base_log) if total_servers_price > 0: logger.info( - f" 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_servers_price / 100}₽" + f' 🌐 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period}' + f' = {total_servers_price / 100}₽' + ( - f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount / 100}₽)" + f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' if total_servers_discount > 0 - else "" + else '' ) ) if total_devices_price > 0: logger.info( - f" 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_devices_price / 100}₽" + f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}' + f' = {total_devices_price / 100}₽' + ( - f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_per_month * months_in_period / 100}₽)" + f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' if devices_discount_percent > 0 and devices_discount_per_month > 0 - else "" + else '' ) ) if total_traffic_price > 0: logger.info( - f" 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_traffic_price / 100}₽" + f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}' + f' = {total_traffic_price / 100}₽' + ( - f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_per_month * months_in_period / 100}₽)" + f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' if traffic_discount_percent > 0 and traffic_discount_per_month > 0 - else "" + else '' ) ) - if promo_component["discount"] > 0: + if promo_component['discount'] > 0: logger.info( - " 🎯 Промо-предложение: -%s₽ (%s%%)", - promo_component["discount"] / 100, - promo_component["percent"], + ' 🎯 Промо-предложение: -%s₽ (%s%%)', + promo_component['discount'] / 100, + promo_component['percent'], ) - logger.info(f" 💎 ИТОГО: {price / 100}₽") + logger.info(f' 💎 ИТОГО: {price / 100}₽') except Exception as e: - logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}") - await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True) + logger.error(f'⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}') + await callback.answer('⚠ Ошибка расчета стоимости', show_alert=True) return if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks required_text = texts.format_price(price) message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=required_text, @@ -2037,8 +1921,8 @@ async def confirm_extend_subscription( 'saved_cart': True, 'missing_amount': missing_kopeks, 'return_to_cart': True, - 'description': f"Продление подписки на {days} дней", - 'consume_promo_offer': bool(promo_component["discount"] > 0), + 'description': f'Продление подписки на {days} дней', + 'consume_promo_offer': bool(promo_component['discount'] > 0), } await user_cart_service.save_user_cart(db_user.id, cart_data) @@ -2048,9 +1932,9 @@ async def confirm_extend_subscription( reply_markup=get_insufficient_balance_keyboard( db_user.language, amount_kopeks=missing_kopeks, - has_saved_cart=True # Указываем, что есть сохраненная корзина + has_saved_cart=True, # Указываем, что есть сохраненная корзина ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -2060,12 +1944,12 @@ async def confirm_extend_subscription( db, db_user, price, - f"Продление подписки на {days} дней", - consume_promo_offer=promo_component["discount"] > 0, + f'Продление подписки на {days} дней', + consume_promo_offer=promo_component['discount'] > 0, ) if not success: - await callback.answer("⚠ Ошибка списания средств", show_alert=True) + await callback.answer('⚠ Ошибка списания средств', show_alert=True) return current_time = datetime.utcnow() @@ -2090,7 +1974,7 @@ async def confirm_extend_subscription( subscription.traffic_limit_gb = fixed_limit subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика - logger.info(f"🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ") + logger.info(f'🔄 Сброс трафика при продлении: {old_traffic_limit} ГБ → {fixed_limit} ГБ') await db.commit() await db.refresh(subscription) @@ -2107,6 +1991,7 @@ async def confirm_extend_subscription( server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads) if server_ids: from sqlalchemy import select + from app.database.models import ServerSquad result = await db.execute( @@ -2115,8 +2000,7 @@ async def confirm_extend_subscription( id_to_uuid = {row.id: row.squad_uuid for row in result} default_price = total_servers_price // len(server_ids) if server_ids else 0 server_prices_for_period = [ - server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price) - for server_id in server_ids + server_uuid_prices.get(id_to_uuid.get(server_id, ''), default_price) for server_id in server_ids ] await add_subscription_servers(db, subscription, server_ids, server_prices_for_period) @@ -2125,21 +2009,21 @@ async def confirm_extend_subscription( db, subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="продление подписки", + reset_reason='продление подписки', ) if remnawave_result: - logger.info("✅ RemnaWave обновлен успешно") + logger.info('✅ RemnaWave обновлен успешно') else: - logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") + logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE') except Exception as e: - logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") + logger.error(f'⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}') transaction = await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Продление подписки на {days} дней ({months_in_period} мес)" + description=f'Продление подписки на {days} дней ({months_in_period} мес)', ) try: @@ -2155,60 +2039,52 @@ async def confirm_extend_subscription( balance_after=refreshed_balance, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о продлении: {e}") + logger.error(f'Ошибка отправки уведомления о продлении: {e}') success_message = ( - "✅ Подписка успешно продлена!\n\n" - f"⏰ Добавлено: {days} дней\n" - f"Действует до: {format_local_datetime(refreshed_end_date, '%d.%m.%Y %H:%M')}\n\n" - f"💰 Списано: {texts.format_price(price)}" + '✅ Подписка успешно продлена!\n\n' + f'⏰ Добавлено: {days} дней\n' + f'Действует до: {format_local_datetime(refreshed_end_date, "%d.%m.%Y %H:%M")}\n\n' + f'💰 Списано: {texts.format_price(price)}' ) # Добавляем уведомление о сбросе трафика if traffic_was_reset: fixed_limit = settings.get_fixed_traffic_limit() - success_message += f"\n\n📊 Трафик сброшен до {fixed_limit} ГБ" + success_message += f'\n\n📊 Трафик сброшен до {fixed_limit} ГБ' - if promo_component["discount"] > 0: + if promo_component['discount'] > 0: success_message += ( - f" (включая доп. скидку {promo_component['percent']}%:" - f" -{texts.format_price(promo_component['discount'])})" + f' (включая доп. скидку {promo_component["percent"]}%:' + f' -{texts.format_price(promo_component["discount"])})' ) - await callback.message.edit_text( - success_message, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽") + logger.info(f'✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price / 100}₽') except Exception as e: - logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}") + logger.error(f'⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}') import traceback - logger.error(f"TRACEBACK: {traceback.format_exc()}") + + logger.error(f'TRACEBACK: {traceback.format_exc()}') await callback.message.edit_text( - "⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.", - reply_markup=get_back_keyboard(db_user.language) + '⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.', + reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() -async def select_period( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): + +async def select_period(callback: types.CallbackQuery, state: FSMContext, db_user: User): period_days = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) # Валидация что период доступен available_periods = settings.get_available_subscription_periods() if period_days not in available_periods: - await callback.answer( - texts.t("PERIOD_NOT_AVAILABLE", "❌ Этот период больше недоступен"), - show_alert=True - ) + await callback.answer(texts.t('PERIOD_NOT_AVAILABLE', '❌ Этот период больше недоступен'), show_alert=True) return # Получаем цену с защитой от KeyError @@ -2229,12 +2105,11 @@ async def select_period( available_packages = [pkg for pkg in settings.get_traffic_packages() if pkg['enabled']] if not available_packages: - await callback.answer("⚠️ Пакеты трафика не настроены", show_alert=True) + await callback.answer('⚠️ Пакеты трафика не настроены', show_alert=True) return await callback.message.edit_text( - texts.SELECT_TRAFFIC, - reply_markup=get_traffic_packages_keyboard(db_user.language) + texts.SELECT_TRAFFIC, reply_markup=get_traffic_packages_keyboard(db_user.language) ) await state.set_state(SubscriptionStates.selecting_traffic) await callback.answer() @@ -2251,7 +2126,7 @@ async def select_period( await callback.message.edit_text( selection_text, reply_markup=get_countries_keyboard(countries, preselected, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_countries) await callback.answer() @@ -2266,8 +2141,7 @@ async def select_period( selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) + texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) await callback.answer() @@ -2276,28 +2150,25 @@ async def select_period( if await present_subscription_summary(callback, state, db_user, texts): await callback.answer() -async def select_devices( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): + +async def select_devices(callback: types.CallbackQuery, state: FSMContext, db_user: User): texts = get_texts(db_user.language) if not settings.is_devices_selection_enabled(): await callback.answer( - texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Выбор количества устройств недоступен"), + texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Выбор количества устройств недоступен'), show_alert=True, ) return - if not callback.data.startswith("devices_") or callback.data == "devices_continue": - await callback.answer(texts.t("DEVICES_INVALID_REQUEST", "❌ Некорректный запрос"), show_alert=True) + if not callback.data.startswith('devices_') or callback.data == 'devices_continue': + await callback.answer(texts.t('DEVICES_INVALID_REQUEST', '❌ Некорректный запрос'), show_alert=True) return try: devices = int(callback.data.split('_')[1]) except (ValueError, IndexError): - await callback.answer(texts.t("DEVICES_INVALID_COUNT", "❌ Некорректное количество устройств"), show_alert=True) + await callback.answer(texts.t('DEVICES_INVALID_COUNT', '❌ Некорректное количество устройств'), show_alert=True) return data = await state.get_data() @@ -2306,23 +2177,16 @@ async def select_devices( period_days = data.get('period_days') if not period_days or period_days not in PERIOD_PRICES: await callback.answer( - texts.t("PERIOD_NOT_AVAILABLE", "❌ Период больше недоступен, начните заново"), - show_alert=True + texts.t('PERIOD_NOT_AVAILABLE', '❌ Период больше недоступен, начните заново'), show_alert=True ) return - base_price = ( - PERIOD_PRICES.get(period_days, 0) + - settings.get_traffic_price(data.get('traffic_gb', 0)) - ) + base_price = PERIOD_PRICES.get(period_days, 0) + settings.get_traffic_price(data.get('traffic_gb', 0)) countries = await _get_available_countries(db_user.promo_group_id) # Проверяем, что ключ 'countries' существует в данных перед доступом к нему selected_countries = data.get('countries', []) - countries_price = sum( - c['price_kopeks'] for c in countries - if c['uuid'] in selected_countries - ) + countries_price = sum(c['price_kopeks'] for c in countries if c['uuid'] in selected_countries) devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE @@ -2334,73 +2198,58 @@ async def select_devices( if devices != previous_devices: try: - await callback.message.edit_reply_markup( - reply_markup=get_devices_keyboard(devices, db_user.language) - ) + await callback.message.edit_reply_markup(reply_markup=get_devices_keyboard(devices, db_user.language)) except TelegramBadRequest as error: - if "message is not modified" in str(error).lower(): - logger.debug( - "ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось" - ) + if 'message is not modified' in str(error).lower(): + logger.debug('ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось') else: raise await callback.answer() -async def devices_continue( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - if not callback.data == "devices_continue": - await callback.answer("⚠️ Некорректный запрос", show_alert=True) + +async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): + if callback.data != 'devices_continue': + await callback.answer('⚠️ Некорректный запрос', show_alert=True) return if await present_subscription_summary(callback, state, db_user): await callback.answer() -async def confirm_purchase( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): - from app.services.admin_notification_service import AdminNotificationService +async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - callback.from_user.id, - callback.from_user.username + callback.from_user.id, callback.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}') try: await callback.answer( - f"🚫 Покупка подписки невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку.", - show_alert=True + f'🚫 Покупка подписки невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.', + show_alert=True, ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором" + reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' texts = get_texts(db_user.language) support_url = settings.get_support_contact_url() keyboard = [] if support_url: - keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]) - keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription")]) + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription')]) await callback.message.edit_text( - f"🚫 Покупка/продление подписки ограничено\n\n{reason}\n\n" - "Если вы считаете это ошибкой, вы можете обжаловать решение.", - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + f'🚫 Покупка/продление подписки ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), ) await callback.answer() return @@ -2409,19 +2258,15 @@ async def confirm_purchase( texts = get_texts(db_user.language) await save_subscription_checkout_draft(db_user.id, dict(data)) - resume_callback = ( - "subscription_resume_checkout" - if should_offer_checkout_resume(db_user, True) - else None - ) + resume_callback = 'subscription_resume_checkout' if should_offer_checkout_resume(db_user, True) else None countries = await _get_available_countries(db_user.promo_group_id) period_days = data.get('period_days') if period_days is None: await callback.message.edit_text( - texts.t("SUBSCRIPTION_PURCHASE_ERROR", "Ошибка при оформлении подписки. Попробуйте начать сначала."), - reply_markup=get_back_keyboard(db_user.language) + texts.t('SUBSCRIPTION_PURCHASE_ERROR', 'Ошибка при оформлении подписки. Попробуйте начать сначала.'), + reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() return @@ -2431,7 +2276,7 @@ async def confirm_purchase( # (не доверяем кэшированным значениям из FSM данных) base_price_original = PERIOD_PRICES.get(period_days, 0) base_discount_percent = db_user.get_promo_discount( - "period", + 'period', period_days, ) base_price, base_discount_total = apply_percentage_discount( @@ -2442,7 +2287,7 @@ async def confirm_purchase( if not server_prices: countries_price_per_month = 0 - per_month_prices: List[int] = [] + per_month_prices: list[int] = [] for country in countries: # Проверяем, что ключ 'countries' существует в данных перед доступом к нему selected_countries = data.get('countries', []) @@ -2452,7 +2297,7 @@ async def confirm_purchase( per_month_prices.append(server_price_per_month) servers_discount_percent = db_user.get_promo_discount( - "servers", + 'servers', period_days, ) total_servers_price = 0 @@ -2482,7 +2327,7 @@ async def confirm_purchase( servers_discount_percent = data.get('servers_discount_percent', 0) devices_selection_enabled = settings.is_devices_selection_enabled() - forced_disabled_limit: Optional[int] = None + forced_disabled_limit: int | None = None if devices_selection_enabled: devices_selected = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) else: @@ -2493,9 +2338,7 @@ async def confirm_purchase( devices_selected = forced_disabled_limit additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT) - devices_price_per_month = data.get( - 'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE - ) + devices_price_per_month = data.get('devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE) devices_discount_percent = 0 discounted_devices_price_per_month = 0 @@ -2505,16 +2348,12 @@ async def confirm_purchase( if devices_selection_enabled and additional_devices > 0: if 'devices_discount_percent' in data: devices_discount_percent = data.get('devices_discount_percent', 0) - discounted_devices_price_per_month = data.get( - 'devices_discounted_price_per_month', devices_price_per_month - ) + discounted_devices_price_per_month = data.get('devices_discounted_price_per_month', devices_price_per_month) devices_discount_total = data.get('devices_discount_total', 0) - total_devices_price = data.get( - 'total_devices_price', discounted_devices_price_per_month * months_in_period - ) + total_devices_price = data.get('total_devices_price', discounted_devices_price_per_month * months_in_period) else: devices_discount_percent = db_user.get_promo_discount( - "devices", + 'devices', period_days, ) discounted_devices_price_per_month, discount_per_month = apply_percentage_discount( @@ -2526,33 +2365,23 @@ async def confirm_purchase( if settings.is_traffic_fixed(): final_traffic_gb = settings.get_fixed_traffic_limit() - traffic_price_per_month = data.get( - 'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb) - ) + traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)) else: final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb')) traffic_gb = data.get('traffic_gb') if traffic_gb is not None: - traffic_price_per_month = data.get( - 'traffic_price_per_month', settings.get_traffic_price(traffic_gb) - ) + traffic_price_per_month = data.get('traffic_price_per_month', settings.get_traffic_price(traffic_gb)) else: - traffic_price_per_month = data.get( - 'traffic_price_per_month', 0 - ) + traffic_price_per_month = data.get('traffic_price_per_month', 0) if 'traffic_discount_percent' in data: traffic_discount_percent = data.get('traffic_discount_percent', 0) - discounted_traffic_price_per_month = data.get( - 'traffic_discounted_price_per_month', traffic_price_per_month - ) + discounted_traffic_price_per_month = data.get('traffic_discounted_price_per_month', traffic_price_per_month) traffic_discount_total = data.get('traffic_discount_total', 0) - total_traffic_price = data.get( - 'total_traffic_price', discounted_traffic_price_per_month * months_in_period - ) + total_traffic_price = data.get('total_traffic_price', discounted_traffic_price_per_month * months_in_period) else: traffic_discount_percent = db_user.get_promo_discount( - "traffic", + 'traffic', period_days, ) discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount( @@ -2569,9 +2398,7 @@ async def confirm_purchase( # Всегда пересчитываем monthly_additions из компонентов для безопасности discounted_monthly_additions = ( - discounted_traffic_price_per_month - + discounted_servers_price_per_month - + discounted_devices_price_per_month + discounted_traffic_price_per_month + discounted_servers_price_per_month + discounted_devices_price_per_month ) # Вычисляем ожидаемую цену до промо-скидки из компонентов @@ -2604,86 +2431,66 @@ async def confirm_purchase( if price_difference > max_allowed_difference: # Слишком большое расхождение - блокируем покупку logger.error( - f"Критическое расхождение цены для пользователя {db_user.telegram_id}: " - f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽, " - f"разница={price_difference/100}₽ (>{max_allowed_difference/100}₽). " - f"Покупка заблокирована." - ) - await callback.answer( - "Цена изменилась. Пожалуйста, начните оформление заново.", - show_alert=True + f'Критическое расхождение цены для пользователя {db_user.telegram_id}: ' + f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, ' + f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). ' + f'Покупка заблокирована.' ) + await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True) return - elif price_difference > 100: # допуск 1₽ + if price_difference > 100: # допуск 1₽ # Небольшое расхождение - логируем предупреждение но продолжаем logger.warning( - f"Расхождение цены для пользователя {db_user.telegram_id}: " - f"кэш={cached_total_price/100}₽, пересчет={final_price/100}₽. " - f"Используем пересчитанную цену." + f'Расхождение цены для пользователя {db_user.telegram_id}: ' + f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. ' + f'Используем пересчитанную цену.' ) # Используем пересчитанную цену validation_total_price = calculated_total_before_promo - logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") - base_log = f" Период: {base_price_original / 100}₽" + logger.info(f'Расчет покупки подписки на {data["period_days"]} дней ({months_in_period} мес):') + base_log = f' Период: {base_price_original / 100}₽' if base_discount_total and base_discount_total > 0: - base_log += ( - f" → {base_price / 100}₽" - f" (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {base_discount_percent}%: -{base_discount_total / 100}₽)' logger.info(base_log) if total_traffic_price > 0: - message = ( - f" Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_traffic_price / 100}₽" - ) + message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽' if traffic_discount_total > 0: - message += ( - f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_total / 100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_total / 100}₽)' logger.info(message) if total_servers_price > 0: message = ( - f" Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_servers_price / 100}₽" + f' Серверы: {countries_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_price / 100}₽' ) if total_servers_discount > 0: - message += ( - f" (скидка {servers_discount_percent}%:" - f" -{total_servers_discount / 100}₽)" - ) + message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)' logger.info(message) if total_devices_price > 0: message = ( - f" Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period}" - f" = {total_devices_price / 100}₽" + f' Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_price / 100}₽' ) if devices_discount_total > 0: - message += ( - f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_total / 100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{devices_discount_total / 100}₽)' logger.info(message) if promo_offer_discount_value > 0: logger.info( - " 🎯 Промо-предложение: -%s₽ (%s%%)", + ' 🎯 Промо-предложение: -%s₽ (%s%%)', promo_offer_discount_value / 100, promo_offer_discount_percent, ) - logger.info(f" ИТОГО: {final_price / 100}₽") + logger.info(f' ИТОГО: {final_price / 100}₽') if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(final_price), @@ -2697,7 +2504,7 @@ async def confirm_purchase( 'saved_cart': True, 'missing_amount': missing_kopeks, 'return_to_cart': True, - 'user_id': db_user.id + 'user_id': db_user.id, } await user_cart_service.save_user_cart(db_user.id, cart_data) @@ -2708,9 +2515,9 @@ async def confirm_purchase( db_user.language, resume_callback=resume_callback, amount_kopeks=missing_kopeks, - has_saved_cart=True # Указываем, что есть сохраненная корзина + has_saved_cart=True, # Указываем, что есть сохраненная корзина ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -2722,20 +2529,20 @@ async def confirm_purchase( db, db_user, final_price, - f"Покупка подписки на {data['period_days']} дней", + f'Покупка подписки на {data["period_days"]} дней', consume_promo_offer=promo_offer_discount_value > 0, ) if not success: missing_kopeks = final_price - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(final_price), @@ -2750,7 +2557,7 @@ async def confirm_purchase( resume_callback=resume_callback, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -2767,12 +2574,12 @@ async def confirm_purchase( current_time = datetime.utcnow() if existing_subscription: - logger.info(f"Обновляем существующую подписку пользователя {db_user.telegram_id}") + logger.info(f'Обновляем существующую подписку пользователя {db_user.telegram_id}') bonus_period = timedelta() if existing_subscription.is_trial: - logger.info(f"Конверсия из триала в платную для пользователя {db_user.telegram_id}") + logger.info(f'Конверсия из триала в платную для пользователя {db_user.telegram_id}') was_trial_conversion = True trial_duration = (current_time - existing_subscription.start_date).days @@ -2782,25 +2589,27 @@ async def confirm_purchase( if remaining_trial_delta.total_seconds() > 0: bonus_period = remaining_trial_delta logger.info( - "Добавляем оставшееся время триала (%s) к новой подписке пользователя %s", + 'Добавляем оставшееся время триала (%s) к новой подписке пользователя %s', bonus_period, db_user.telegram_id, ) try: from app.database.crud.subscription_conversion import create_subscription_conversion + await create_subscription_conversion( db=db, user_id=db_user.id, trial_duration_days=trial_duration, - payment_method="balance", + payment_method='balance', first_payment_amount_kopeks=final_price, - first_paid_period_days=period_days + first_paid_period_days=period_days, ) logger.info( - f"Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}₽") + f'Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}₽' + ) except Exception as conversion_error: - logger.error(f"Ошибка записи конверсии: {conversion_error}") + logger.error(f'Ошибка записи конверсии: {conversion_error}') existing_subscription.is_trial = False existing_subscription.status = SubscriptionStatus.ACTIVE.value @@ -2820,10 +2629,10 @@ async def confirm_purchase( texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "COUNTRIES_MINIMUM_REQUIRED", - "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." + 'COUNTRIES_MINIMUM_REQUIRED', + '❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.', ), - reply_markup=get_back_keyboard(db_user.language) + reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() return @@ -2848,18 +2657,17 @@ async def confirm_purchase( subscription = existing_subscription else: - logger.info(f"Создаем новую подписку для пользователя {db_user.telegram_id}") - default_device_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) + logger.info(f'Создаем новую подписку для пользователя {db_user.telegram_id}') + default_device_limit = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1) resolved_device_limit = selected_devices if resolved_device_limit is None: if devices_selection_enabled: resolved_device_limit = default_device_limit + elif forced_disabled_limit is not None: + resolved_device_limit = forced_disabled_limit else: - if forced_disabled_limit is not None: - resolved_device_limit = forced_disabled_limit - else: - resolved_device_limit = default_device_limit + resolved_device_limit = default_device_limit if resolved_device_limit is None and devices_selection_enabled: resolved_device_limit = default_device_limit @@ -2873,10 +2681,10 @@ async def confirm_purchase( texts = get_texts(db_user.language) await callback.message.edit_text( texts.t( - "COUNTRIES_MINIMUM_REQUIRED", - "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." + 'COUNTRIES_MINIMUM_REQUIRED', + '❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.', ), - reply_markup=get_back_keyboard(db_user.language) + reply_markup=get_back_keyboard(db_user.language), ) await callback.answer() return @@ -2887,13 +2695,14 @@ async def confirm_purchase( duration_days=period_days, device_limit=resolved_device_limit, connected_squads=new_subscription_countries, - traffic_gb=final_traffic_gb + traffic_gb=final_traffic_gb, ) from app.utils.user_utils import mark_user_as_had_paid_subscription + await mark_user_as_had_paid_subscription(db, db_user) - from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers + from app.database.crud.server_squad import add_user_to_servers, get_server_ids_by_uuids from app.database.crud.subscription import add_subscription_servers server_ids = await get_server_ids_by_uuids(db, data.get('countries', [])) @@ -2902,7 +2711,7 @@ async def confirm_purchase( await add_subscription_servers(db, subscription, server_ids, server_prices) await add_user_to_servers(db, server_ids) - logger.info(f"Сохранены цены серверов за весь период: {server_prices}") + logger.info(f'Сохранены цены серверов за весь период: {server_prices}') await db.refresh(db_user) @@ -2913,23 +2722,23 @@ async def confirm_purchase( db, subscription, reset_traffic=True, - reset_reason="покупка подписки", + reset_reason='покупка подписки', ) else: remnawave_user = await subscription_service.create_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="покупка подписки", + reset_reason='покупка подписки', ) if not remnawave_user: - logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") + logger.error(f'Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}') remnawave_user = await subscription_service.create_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="покупка подписки (повторная попытка)", + reset_reason='покупка подписки (повторная попытка)', ) transaction = await create_transaction( @@ -2937,7 +2746,7 @@ async def confirm_purchase( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=final_price, - description=f"Подписка на {period_days} дней ({months_in_period} мес)" + description=f'Подписка на {period_days} дней ({months_in_period} мес)', ) try: @@ -2946,7 +2755,7 @@ async def confirm_purchase( db, db_user, subscription, transaction, period_days, was_trial_conversion ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о покупке: {e}") + logger.error(f'Ошибка отправки уведомления о покупке: {e}') await db.refresh(db_user) await db.refresh(subscription) @@ -2954,11 +2763,11 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() - discount_note = "" + discount_note = '' if promo_offer_discount_value > 0: discount_note = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", - "⚡ Доп. скидка {percent}%: -{amount}", + 'SUBSCRIPTION_PROMO_DISCOUNT_NOTE', + '⚡ Доп. скидка {percent}%: -{amount}', ).format( percent=promo_offer_discount_percent, amount=texts.format_price(promo_offer_discount_value), @@ -2967,139 +2776,164 @@ async def confirm_purchase( if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f'{texts.SUBSCRIPTION_PURCHASED}\n\n' + + texts.t( + 'SUBSCRIPTION_HAPP_LINK_PROMPT', + '🔒 Ссылка на подписку создана. Нажмите кнопку "Подключиться" ниже, чтобы открыть её в Happ.', + ) + + '\n\n' + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) ) elif hide_subscription_link: success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", - ) - + "\n\n" - + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", - ) + f'{texts.SUBSCRIPTION_PURCHASED}\n\n' + + texts.t( + 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', + 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', + ) + + '\n\n' + + texts.t( + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', + ) ) else: import_link_section = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}", + 'SUBSCRIPTION_IMPORT_LINK_SECTION', + '🔗 Ваша ссылка для импорта в VPN приложение:\\n{subscription_url}', ).format(subscription_url=subscription_link) success_text = ( - f"{texts.SUBSCRIPTION_PURCHASED}\n\n" - f"{import_link_section}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + f'{texts.SUBSCRIPTION_PURCHASED}\n\n' + f'{import_link_section}\n\n' + f'{texts.t("SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве")}' ) if discount_note: - success_text = f"{success_text}\n\n{discount_note}" + success_text = f'{success_text}\n\n{discount_note}' connect_mode = settings.CONNECT_BUTTON_MODE - if connect_mode == "miniapp_subscription": - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link), - ) - ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) - elif connect_mode == "miniapp_custom": + if connect_mode == 'miniapp_subscription': + connect_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + elif connect_mode == 'miniapp_custom': if not settings.MINIAPP_CUSTOM_URL: await callback.answer( texts.t( - "CUSTOM_MINIAPP_URL_NOT_SET", - "⚠ Кастомная ссылка для мини-приложения не настроена", + 'CUSTOM_MINIAPP_URL_NOT_SET', + '⚠ Кастомная ссылка для мини-приложения не настроена', ), show_alert=True, ) return - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), - ) - ], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) - elif connect_mode == "link": + connect_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + elif connect_mode == 'link': rows = [ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)] + [InlineKeyboardButton(text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_link)] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")]) + rows.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), callback_data='back_to_menu' + ) + ] + ) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": + elif connect_mode == 'happ_cryptolink': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) ] ] happ_row = get_happ_download_button_row(texts) if happ_row: rows.append(happ_row) - rows.append([InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")]) + rows.append( + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), callback_data='back_to_menu' + ) + ] + ) connect_keyboard = InlineKeyboardMarkup(inline_keyboard=rows) else: - connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect")], - [InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu")], - ]) + connect_keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), callback_data='subscription_connect' + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) - await callback.message.edit_text( - success_text, - reply_markup=connect_keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(success_text, reply_markup=connect_keyboard, parse_mode='HTML') else: purchase_text = texts.SUBSCRIPTION_PURCHASED if discount_note: - purchase_text = f"{purchase_text}\n\n{discount_note}" + purchase_text = f'{purchase_text}\n\n{discount_note}' await callback.message.edit_text( texts.t( - "SUBSCRIPTION_LINK_GENERATING_NOTICE", + 'SUBSCRIPTION_LINK_GENERATING_NOTICE', "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", ).format(purchase_text=purchase_text), - reply_markup=get_back_keyboard(db_user.language) + reply_markup=get_back_keyboard(db_user.language), ) purchase_completed = True logger.info( - f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽") + f'Пользователь {db_user.telegram_id} купил подписку на {data["period_days"]} дней за {final_price / 100}₽' + ) except Exception as e: - logger.error(f"Ошибка покупки подписки: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка покупки подписки: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) if purchase_completed: await clear_subscription_checkout_draft(db_user.id) @@ -3107,10 +2941,11 @@ async def confirm_purchase( await state.clear() await callback.answer() + async def resume_subscription_checkout( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, + callback: types.CallbackQuery, + state: FSMContext, + db_user: User, ): texts = get_texts(db_user.language) @@ -3123,9 +2958,7 @@ async def resume_subscription_checkout( try: summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) except ValueError as exc: - logger.error( - f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" - ) + logger.error(f'Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}') await clear_subscription_checkout_draft(db_user.id) await callback.answer(texts.NO_SAVED_SUBSCRIPTION_ORDER, show_alert=True) return @@ -3137,18 +2970,19 @@ async def resume_subscription_checkout( await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() + async def create_paid_subscription_with_traffic_mode( - db: AsyncSession, - user_id: int, - duration_days: int, - device_limit: Optional[int], - connected_squads: List[str], - traffic_gb: Optional[int] = None + db: AsyncSession, + user_id: int, + duration_days: int, + device_limit: int | None, + connected_squads: list[str], + traffic_gb: int | None = None, ): from app.config import settings @@ -3174,15 +3008,12 @@ async def create_paid_subscription_with_traffic_mode( subscription = await create_paid_subscription(**create_kwargs) - logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})") + logger.info(f'📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})') return subscription -async def handle_subscription_settings( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_subscription_settings(callback: types.CallbackQuery, db_user: User, db: AsyncSession): # Проверяем, доступно ли сообщение для редактирования if isinstance(callback.message, InaccessibleMessage): await callback.answer() @@ -3195,13 +3026,14 @@ async def handle_subscription_settings( tariff = None if subscription and subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) if not subscription or subscription.is_trial: await callback.answer( texts.t( - "SUBSCRIPTION_SETTINGS_PAID_ONLY", - "⚠️ Настройки доступны только для платных подписок", + 'SUBSCRIPTION_SETTINGS_PAID_ONLY', + '⚠️ Настройки доступны только для платных подписок', ), show_alert=True, ) @@ -3215,28 +3047,28 @@ async def handle_subscription_settings( devices_used = 0 settings_template = texts.t( - "SUBSCRIPTION_SETTINGS_OVERVIEW", + 'SUBSCRIPTION_SETTINGS_OVERVIEW', ( - "⚙️ Настройки подписки\n\n" - "📊 Текущие параметры:\n" - "🌐 Стран: {countries_count}\n" - "📈 Трафик: {traffic_used} / {traffic_limit}\n" - "📱 Устройства: {devices_used} / {devices_limit}\n\n" - "Выберите что хотите изменить:" + '⚙️ Настройки подписки\n\n' + '📊 Текущие параметры:\n' + '🌐 Стран: {countries_count}\n' + '📈 Трафик: {traffic_used} / {traffic_limit}\n' + '📱 Устройства: {devices_used} / {devices_limit}\n\n' + 'Выберите что хотите изменить:' ), ) if not show_devices: settings_template = settings_template.replace( - "\n📱 Устройства: {devices_used} / {devices_limit}", - "", + '\n📱 Устройства: {devices_used} / {devices_limit}', + '', ) # Формируем отображение лимита устройств с учётом модема modem_enabled = getattr(subscription, 'modem_enabled', False) or False if modem_enabled and settings.is_modem_enabled(): visible_device_limit = (subscription.device_limit or 1) - 1 - devices_limit_display = f"{visible_device_limit} + модем" + devices_limit_display = f'{visible_device_limit} + модем' else: devices_limit_display = str(subscription.device_limit) @@ -3253,38 +3085,29 @@ async def handle_subscription_settings( await callback.message.edit_text( settings_text, reply_markup=get_updated_subscription_settings_keyboard( - db_user.language, - show_countries, - tariff=tariff, - subscription=subscription + db_user.language, show_countries, tariff=tariff, subscription=subscription ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def clear_saved_cart( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession -): + +async def clear_saved_cart(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): # Очищаем как FSM, так и Redis await state.clear() await user_cart_service.delete_user_cart(db_user.id) from app.handlers.menu import show_main_menu + await show_main_menu(callback, db_user, db) - await callback.answer("🗑️ Корзина очищена") + await callback.answer('🗑️ Корзина очищена') # ============== ХЕНДЛЕР ПАУЗЫ СУТОЧНОЙ ПОДПИСКИ ============== -async def handle_toggle_daily_subscription_pause( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Переключает паузу суточной подписки.""" from app.database.crud.subscription import toggle_daily_subscription_pause from app.database.crud.tariff import get_tariff_by_id @@ -3293,10 +3116,7 @@ async def handle_toggle_daily_subscription_pause( subscription = db_user.subscription if not subscription: - await callback.answer( - texts.t("NO_SUBSCRIPTION_ERROR", "❌ У вас нет активной подписки"), - show_alert=True - ) + await callback.answer(texts.t('NO_SUBSCRIPTION_ERROR', '❌ У вас нет активной подписки'), show_alert=True) return # Проверяем что это суточный тариф @@ -3306,8 +3126,7 @@ async def handle_toggle_daily_subscription_pause( if not tariff or not getattr(tariff, 'is_daily', False): await callback.answer( - texts.t("NOT_DAILY_TARIFF_ERROR", "❌ Эта функция доступна только для суточных тарифов"), - show_alert=True + texts.t('NOT_DAILY_TARIFF_ERROR', '❌ Эта функция доступна только для суточных тарифов'), show_alert=True ) return @@ -3323,10 +3142,10 @@ async def handle_toggle_daily_subscription_pause( if daily_price > 0 and db_user.balance_kopeks < daily_price: await callback.answer( texts.t( - "INSUFFICIENT_BALANCE_FOR_RESUME", - f"❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}" + 'INSUFFICIENT_BALANCE_FOR_RESUME', + f'❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}', ), - show_alert=True + show_alert=True, ) return @@ -3334,13 +3153,11 @@ async def handle_toggle_daily_subscription_pause( if was_paused: # Была пауза, теперь возобновили - message = texts.t( - "DAILY_SUBSCRIPTION_RESUMED", - "▶️ Подписка возобновлена!" - ) + message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!') # Синхронизируем с Remnawave - активируем пользователя try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.create_remnawave_user( db, @@ -3348,15 +3165,12 @@ async def handle_toggle_daily_subscription_pause( reset_traffic=False, reset_reason=None, ) - logger.info(f"✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}") + logger.info(f'✅ Синхронизировано с Remnawave после возобновления суточной подписки {subscription.id}') except Exception as e: - logger.error(f"Ошибка синхронизации с Remnawave при возобновлении: {e}") + logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}') else: # Была активна, теперь на паузе - message = texts.t( - "DAILY_SUBSCRIPTION_PAUSED", - "⏸️ Подписка приостановлена!" - ) + message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!') # При паузе можно отключить пользователя в Remnawave (опционально) # Пока оставляем активным, т.к. пауза - это только остановка списания @@ -3369,15 +3183,11 @@ async def handle_toggle_daily_subscription_pause( # ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ============== + @error_handler -async def handle_trial_pay_with_balance( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Обрабатывает оплату триала с баланса.""" from app.services.trial_activation_service import get_trial_activation_charge_amount - from app.services.admin_notification_service import AdminNotificationService texts = get_texts(db_user.language) @@ -3393,24 +3203,18 @@ async def handle_trial_pay_with_balance( trial_blocked = True if trial_blocked: - await callback.message.edit_text( - texts.TRIAL_ALREADY_USED, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.TRIAL_ALREADY_USED, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return trial_price_kopeks = get_trial_activation_charge_amount() if trial_price_kopeks <= 0: - await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True) + await callback.answer('❌ Ошибка: триал бесплатный', show_alert=True) return - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0 + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) or 0 if user_balance_kopeks < trial_price_kopeks: - await callback.answer( - texts.t("INSUFFICIENT_BALANCE", "❌ Недостаточно средств на балансе"), - show_alert=True - ) + await callback.answer(texts.t('INSUFFICIENT_BALANCE', '❌ Недостаточно средств на балансе'), show_alert=True) return # Списываем с баланса @@ -3418,20 +3222,17 @@ async def handle_trial_pay_with_balance( db, db_user, trial_price_kopeks, - texts.t("TRIAL_PAYMENT_DESCRIPTION", "Оплата пробной подписки"), + texts.t('TRIAL_PAYMENT_DESCRIPTION', 'Оплата пробной подписки'), ) if not success: - await callback.answer( - texts.t("PAYMENT_FAILED", "❌ Не удалось списать средства"), - show_alert=True - ) + await callback.answer(texts.t('PAYMENT_FAILED', '❌ Не удалось списать средства'), show_alert=True) return await db.refresh(db_user) # Создаем триальную подписку - subscription: Optional[Subscription] = None + subscription: Subscription | None = None remnawave_user = None try: @@ -3454,23 +3255,24 @@ async def handle_trial_pay_with_balance( subscription, ) except RemnaWaveConfigurationError as error: - logger.error("RemnaWave update skipped due to configuration error: %s", error) + logger.error('RemnaWave update skipped due to configuration error: %s', error) # Откатываем подписку и возвращаем деньги await rollback_trial_subscription_activation(db, subscription) from app.database.crud.user import add_user_balance + await add_user_balance( db, db_user, trial_price_kopeks, - texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"), + texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'), transaction_type=TransactionType.REFUND, ) await db.refresh(db_user) await callback.message.edit_text( texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Средства возвращены на баланс.", + 'TRIAL_PROVISIONING_FAILED', + 'Не удалось завершить активацию триала. Средства возвращены на баланс.', ), reply_markup=get_back_keyboard(db_user.language), ) @@ -3478,26 +3280,27 @@ async def handle_trial_pay_with_balance( return except Exception as error: logger.error( - "Failed to create RemnaWave user for trial subscription %s: %s", - getattr(subscription, "id", ""), + 'Failed to create RemnaWave user for trial subscription %s: %s', + getattr(subscription, 'id', ''), error, ) # Откатываем подписку и возвращаем деньги await rollback_trial_subscription_activation(db, subscription) from app.database.crud.user import add_user_balance + await add_user_balance( db, db_user, trial_price_kopeks, - texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"), + texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'), transaction_type=TransactionType.REFUND, ) await db.refresh(db_user) await callback.message.edit_text( texts.t( - "TRIAL_PROVISIONING_FAILED", - "Не удалось завершить активацию триала. Средства возвращены на баланс.", + 'TRIAL_PROVISIONING_FAILED', + 'Не удалось завершить активацию триала. Средства возвращены на баланс.', ), reply_markup=get_back_keyboard(db_user.language), ) @@ -3514,54 +3317,54 @@ async def handle_trial_pay_with_balance( charged_amount_kopeks=trial_price_kopeks, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о триале: {e}") + logger.error(f'Ошибка отправки уведомления о триале: {e}') # Показываем успешное сообщение с ссылкой subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() - payment_note = "\n\n" + texts.t( - "TRIAL_PAYMENT_CHARGED_NOTE", - "💳 С вашего баланса списано {amount}.", + payment_note = '\n\n' + texts.t( + 'TRIAL_PAYMENT_CHARGED_NOTE', + '💳 С вашего баланса списано {amount}.', ).format(amount=settings.format_price(trial_price_kopeks)) if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" + f'{texts.TRIAL_ACTIVATED}\n\n' + texts.t( - "SUBSCRIPTION_HAPP_LINK_PROMPT", - "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", + 'SUBSCRIPTION_HAPP_LINK_PROMPT', + '🔒 Ссылка на подписку создана. Нажмите кнопку "Подключиться" ниже, чтобы открыть её в Happ.', ) - + "\n\n" + + '\n\n' + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', ) ) elif hide_subscription_link: trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" + f'{texts.TRIAL_ACTIVATED}\n\n' + texts.t( - "SUBSCRIPTION_LINK_HIDDEN_NOTICE", - "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".", + 'SUBSCRIPTION_LINK_HIDDEN_NOTICE', + 'ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе "Моя подписка".', ) - + "\n\n" + + '\n\n' + texts.t( - "SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", - "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве", + 'SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', + '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве', ) ) else: subscription_import_link = texts.t( - "SUBSCRIPTION_IMPORT_LINK_SECTION", - "🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}", + 'SUBSCRIPTION_IMPORT_LINK_SECTION', + '🔗 Ваша ссылка для импорта в VPN приложение:\n{subscription_url}', ).format(subscription_url=subscription_link) trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n" - f"{subscription_import_link}\n\n" - f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" + f'{texts.TRIAL_ACTIVATED}\n\n' + f'{subscription_import_link}\n\n' + f'{texts.t("SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT", "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве")}' ) trial_success_text += payment_note @@ -3572,25 +3375,23 @@ async def handle_trial_pay_with_balance( await callback.message.edit_text( trial_success_text, reply_markup=connect_keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: - trial_success_text = ( - f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд." - ) + trial_success_text = f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд." trial_success_text += payment_note await callback.message.edit_text( trial_success_text, reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() except Exception as error: logger.error( - "Unexpected error during paid trial activation for user %s: %s", + 'Unexpected error during paid trial activation for user %s: %s', db_user.id, error, ) @@ -3598,19 +3399,20 @@ async def handle_trial_pay_with_balance( if subscription: await rollback_trial_subscription_activation(db, subscription) from app.database.crud.user import add_user_balance + await add_user_balance( db, db_user, trial_price_kopeks, - texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"), + texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'), transaction_type=TransactionType.REFUND, ) await db.refresh(db_user) await callback.message.edit_text( texts.t( - "TRIAL_ACTIVATION_ERROR", - "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.", + 'TRIAL_ACTIVATION_ERROR', + '❌ Произошла ошибка при активации триала. Средства возвращены на баланс.', ), reply_markup=get_back_keyboard(db_user.language), ) @@ -3620,44 +3422,48 @@ async def handle_trial_pay_with_balance( def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: str) -> InlineKeyboardMarkup: """Создает клавиатуру успешной активации триала.""" - if connect_mode == "miniapp_subscription": - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link), - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ]) - elif connect_mode == "miniapp_custom": + if connect_mode == 'miniapp_subscription': + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + if connect_mode == 'miniapp_custom': if not settings.MINIAPP_CUSTOM_URL: return get_back_keyboard(texts.language if hasattr(texts, 'language') else 'ru') - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ]) - elif connect_mode == "link": + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) + if connect_mode == 'link': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_link, ) ] @@ -3668,18 +3474,18 @@ def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: s rows.append( [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', ) ] ) return InlineKeyboardMarkup(inline_keyboard=rows) - elif connect_mode == "happ_cryptolink": + if connect_mode == 'happ_cryptolink': rows = [ [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) ] ] @@ -3689,40 +3495,35 @@ def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: s rows.append( [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', ) ] ) return InlineKeyboardMarkup(inline_keyboard=rows) - else: - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), - callback_data="back_to_menu", - ) - ], - ] - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '⬅️ В главное меню'), + callback_data='back_to_menu', + ) + ], + ] + ) @error_handler -async def handle_trial_payment_method( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_trial_payment_method(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Обрабатывает выбор метода оплаты для платного триала.""" - from app.services.trial_activation_service import get_trial_activation_charge_amount from app.services.payment_service import PaymentService + from app.services.trial_activation_service import get_trial_activation_charge_amount texts = get_texts(db_user.language) @@ -3738,26 +3539,24 @@ async def handle_trial_payment_method( trial_blocked = True if trial_blocked: - await callback.message.edit_text( - texts.TRIAL_ALREADY_USED, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(texts.TRIAL_ALREADY_USED, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() return trial_price_kopeks = get_trial_activation_charge_amount() if trial_price_kopeks <= 0: - await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True) + await callback.answer('❌ Ошибка: триал бесплатный', show_alert=True) return # Определяем метод оплаты - payment_method = callback.data.replace("trial_payment_", "") + payment_method = callback.data.replace('trial_payment_', '') try: payment_service = PaymentService(callback.bot) # Получаем случайный сквад для триала from app.database.crud.server_squad import get_random_trial_squad_uuid + trial_squad_uuid = await get_random_trial_squad_uuid(db) # Создаем pending триальную подписку @@ -3768,127 +3567,129 @@ async def handle_trial_payment_method( traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB, device_limit=settings.TRIAL_DEVICE_LIMIT, connected_squads=[trial_squad_uuid] if trial_squad_uuid else [], - payment_method=f"trial_{payment_method}", + payment_method=f'trial_{payment_method}', total_price_kopeks=trial_price_kopeks, ) if not pending_subscription: - await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True) + await callback.answer('❌ Не удалось подготовить заказ. Попробуйте позже.', show_alert=True) return - traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ" + traffic_label = 'Безлимит' if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f'{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ' - if payment_method == "stars": + if payment_method == 'stars': # Оплата через Telegram Stars stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(trial_price_kopeks)) await callback.bot.send_invoice( chat_id=callback.from_user.id, - title=texts.t("PAID_TRIAL_INVOICE_TITLE", "Пробная подписка на {days} дней").format( + title=texts.t('PAID_TRIAL_INVOICE_TITLE', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), description=( - f"{texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}\n" - f"{texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}\n" - f"{texts.t('TRAFFIC', 'Трафик')}: {traffic_label}" + f'{texts.t("PERIOD", "Период")}: {settings.TRIAL_DURATION_DAYS} {texts.t("DAYS", "дней")}\n' + f'{texts.t("DEVICES", "Устройства")}: {settings.TRIAL_DEVICE_LIMIT}\n' + f'{texts.t("TRAFFIC", "Трафик")}: {traffic_label}' ), - payload=f"trial_{pending_subscription.id}", - provider_token="", - currency="XTR", - prices=[types.LabeledPrice( - label=texts.t("PAID_TRIAL_STARS_LABEL", "Пробная подписка"), - amount=stars_count - )], + payload=f'trial_{pending_subscription.id}', + provider_token='', + currency='XTR', + prices=[ + types.LabeledPrice(label=texts.t('PAID_TRIAL_STARS_LABEL', 'Пробная подписка'), amount=stars_count) + ], ) await callback.message.edit_text( texts.t( - "PAID_TRIAL_STARS_WAITING", - "⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\n" - "После успешной оплаты подписка будет активирована автоматически." + 'PAID_TRIAL_STARS_WAITING', + '⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\n' + 'После успешной оплаты подписка будет активирована автоматически.', ), reply_markup=get_back_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) - elif payment_method == "yookassa_sbp": + elif payment_method == 'yookassa_sbp': # Оплата через YooKassa СБП payment_result = await payment_service.create_yookassa_sbp_payment( db=db, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), user_id=db_user.id, metadata={ - "type": "trial", - "subscription_id": pending_subscription.id, - "user_id": db_user.id, + 'type': 'trial', + 'subscription_id': pending_subscription.id, + 'user_id': db_user.id, }, ) - if not payment_result or not payment_result.get("confirmation_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('confirmation_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return - qr_url = payment_result.get("qr_code_url") or payment_result.get("confirmation_url") + qr_url = payment_result.get('qr_code_url') or payment_result.get('confirmation_url') await callback.message.edit_text( texts.t( - "PAID_TRIAL_YOOKASSA_SBP", - "🏦 Оплата через СБП\n\n" - "Отсканируйте QR-код или перейдите по ссылке для оплаты.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_YOOKASSA_SBP', + '🏦 Оплата через СБП\n\n' + 'Отсканируйте QR-код или перейдите по ссылке для оплаты.\n\n' + '💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=qr_url)], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=qr_url)], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "yookassa": + elif payment_method == 'yookassa': # Оплата через YooKassa карта payment_result = await payment_service.create_yookassa_payment( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), metadata={ - "type": "trial", - "subscription_id": pending_subscription.id, - "user_id": db_user.id, + 'type': 'trial', + 'subscription_id': pending_subscription.id, + 'user_id': db_user.id, }, ) - if not payment_result or not payment_result.get("confirmation_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('confirmation_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return await callback.message.edit_text( texts.t( - "PAID_TRIAL_YOOKASSA_CARD", - "💳 Оплата картой\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_YOOKASSA_CARD', + '💳 Оплата картой\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["confirmation_url"])], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=payment_result['confirmation_url'])], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "cryptobot": + elif payment_method == 'cryptobot': # Оплата через CryptoBot # Конвертируем копейки в USD from app.utils.currency_converter import currency_converter + try: usd_rate = await currency_converter.get_usd_to_rub_rate() except Exception as rate_error: - logger.warning("Не удалось получить курс USD: %s", rate_error) + logger.warning('Не удалось получить курс USD: %s', rate_error) usd_rate = 95.0 amount_rubles = trial_price_kopeks / 100 @@ -3901,182 +3702,202 @@ async def handle_trial_payment_method( user_id=db_user.id, amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), - payload=f"trial_{pending_subscription.id}_{db_user.id}", + payload=f'trial_{pending_subscription.id}_{db_user.id}', ) payment_url = ( - payment_result.get("mini_app_invoice_url") - or payment_result.get("bot_invoice_url") - or payment_result.get("web_app_invoice_url") - ) if payment_result else None + ( + payment_result.get('mini_app_invoice_url') + or payment_result.get('bot_invoice_url') + or payment_result.get('web_app_invoice_url') + ) + if payment_result + else None + ) if not payment_result or not payment_url: - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return await callback.message.edit_text( texts.t( - "PAID_TRIAL_CRYPTOBOT", - "🪙 Оплата криптовалютой\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_CRYPTOBOT', + '🪙 Оплата криптовалютой\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n\n' + '💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_cryptobot_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🪙 Оплатить', url=payment_url)], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_cryptobot_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "heleket": + elif payment_method == 'heleket': # Оплата через Heleket payment_result = await payment_service.create_heleket_payment( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), language=db_user.language, ) - if not payment_result or not payment_result.get("payment_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('payment_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return await callback.message.edit_text( texts.t( - "PAID_TRIAL_HELEKET", - "🪙 Оплата криптовалютой (Heleket)\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_HELEKET', + '🪙 Оплата криптовалютой (Heleket)\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n\n' + '💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["payment_url"])], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_heleket_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🪙 Оплатить', url=payment_result['payment_url'])], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_heleket_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "mulenpay": + elif payment_method == 'mulenpay': # Оплата через MulenPay payment_result = await payment_service.create_mulenpay_payment( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), language=db_user.language, ) - if not payment_result or not payment_result.get("payment_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('payment_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return mulenpay_name = settings.get_mulenpay_display_name() await callback.message.edit_text( texts.t( - "PAID_TRIAL_MULENPAY", - "💳 Оплата через {name}\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_MULENPAY', + '💳 Оплата через {name}\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}', ).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_mulenpay_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=payment_result['payment_url'])], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_mulenpay_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "pal24": + elif payment_method == 'pal24': # Оплата через PAL24 payment_result = await payment_service.create_pal24_payment( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), language=db_user.language, ) - if not payment_result or not payment_result.get("payment_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('payment_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return await callback.message.edit_text( texts.t( - "PAID_TRIAL_PAL24", - "💳 Оплата через PayPalych\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_PAL24', + '💳 Оплата через PayPalych\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n\n' + '💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_pal24_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=payment_result['payment_url'])], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_pal24_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "wata": + elif payment_method == 'wata': # Оплата через WATA payment_result = await payment_service.create_wata_payment( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), language=db_user.language, ) - if not payment_result or not payment_result.get("payment_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('payment_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return await callback.message.edit_text( texts.t( - "PAID_TRIAL_WATA", - "💳 Оплата через WATA\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_WATA', + '💳 Оплата через WATA\n\nНажмите кнопку ниже для перехода к оплате.\n\n💰 Сумма: {amount}', ).format(amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["payment_url"])], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_wata_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=payment_result['payment_url'])], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_wata_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) - elif payment_method == "platega": + elif payment_method == 'platega': # Оплата через Platega active_methods = settings.get_platega_active_methods() if not active_methods: - await callback.answer("❌ Platega не настроена", show_alert=True) + await callback.answer('❌ Platega не настроена', show_alert=True) return # Используем первый активный метод @@ -4086,362 +3907,214 @@ async def handle_trial_payment_method( db=db, user_id=db_user.id, amount_kopeks=trial_price_kopeks, - description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format( + description=texts.t('PAID_TRIAL_PAYMENT_DESC', 'Пробная подписка на {days} дней').format( days=settings.TRIAL_DURATION_DAYS ), language=db_user.language, payment_method_code=method_code, ) - if not payment_result or not payment_result.get("redirect_url"): - await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True) + if not payment_result or not payment_result.get('redirect_url'): + await callback.answer('❌ Не удалось создать платеж. Попробуйте позже.', show_alert=True) return platega_name = settings.get_platega_display_name() await callback.message.edit_text( texts.t( - "PAID_TRIAL_PLATEGA", - "💳 Оплата через {provider}\n\n" - "Нажмите кнопку ниже для перехода к оплате.\n\n" - "💰 Сумма: {amount}" + 'PAID_TRIAL_PLATEGA', + '💳 Оплата через {provider}\n\n' + 'Нажмите кнопку ниже для перехода к оплате.\n\n' + '💰 Сумма: {amount}', ).format(provider=platega_name, amount=settings.format_price(trial_price_kopeks)), - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Оплатить", url=payment_result["redirect_url"])], - [InlineKeyboardButton( - text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"), - callback_data=f"check_trial_platega_{pending_subscription.id}" - )], - [InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")], - ]), - parse_mode="HTML", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Оплатить', url=payment_result['redirect_url'])], + [ + InlineKeyboardButton( + text=texts.t('CHECK_PAYMENT', '🔄 Проверить оплату'), + callback_data=f'check_trial_platega_{pending_subscription.id}', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='trial_activate')], + ] + ), + parse_mode='HTML', ) else: - await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True) + await callback.answer(f'❌ Неизвестный метод оплаты: {payment_method}', show_alert=True) return await callback.answer() except Exception as error: - logger.error(f"Error processing trial payment method {payment_method}: {error}") - await callback.answer("❌ Произошла ошибка при создании платежа. Попробуйте позже.", show_alert=True) + logger.error(f'Error processing trial payment method {payment_method}: {error}') + await callback.answer('❌ Произошла ошибка при создании платежа. Попробуйте позже.', show_alert=True) def register_handlers(dp: Dispatcher): update_traffic_prices() - dp.callback_query.register( - show_subscription_info, - F.data == "menu_subscription" - ) + dp.callback_query.register(show_subscription_info, F.data == 'menu_subscription') - dp.callback_query.register( - show_trial_offer, - F.data == "menu_trial" - ) + dp.callback_query.register(show_trial_offer, F.data == 'menu_trial') - dp.callback_query.register( - activate_trial, - F.data == "trial_activate" - ) + dp.callback_query.register(activate_trial, F.data == 'trial_activate') # Хендлеры платного триала - dp.callback_query.register( - handle_trial_pay_with_balance, - F.data == "trial_pay_with_balance" - ) + dp.callback_query.register(handle_trial_pay_with_balance, F.data == 'trial_pay_with_balance') + + dp.callback_query.register(handle_trial_payment_method, F.data.startswith('trial_payment_')) dp.callback_query.register( - handle_trial_payment_method, - F.data.startswith("trial_payment_") + start_subscription_purchase, F.data.in_(['menu_buy', 'subscription_upgrade', 'subscription_purchase']) ) - dp.callback_query.register( - start_subscription_purchase, - F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"]) - ) + dp.callback_query.register(handle_add_countries, F.data == 'subscription_add_countries') - dp.callback_query.register( - handle_add_countries, - F.data == "subscription_add_countries" - ) + dp.callback_query.register(handle_switch_traffic, F.data == 'subscription_switch_traffic') - dp.callback_query.register( - handle_switch_traffic, - F.data == "subscription_switch_traffic" - ) + dp.callback_query.register(confirm_switch_traffic, F.data.startswith('switch_traffic_')) - dp.callback_query.register( - confirm_switch_traffic, - F.data.startswith("switch_traffic_") - ) + dp.callback_query.register(execute_switch_traffic, F.data.startswith('confirm_switch_traffic_')) - dp.callback_query.register( - execute_switch_traffic, - F.data.startswith("confirm_switch_traffic_") - ) + dp.callback_query.register(handle_change_devices, F.data == 'subscription_change_devices') - dp.callback_query.register( - handle_change_devices, - F.data == "subscription_change_devices" - ) + dp.callback_query.register(confirm_change_devices, F.data.startswith('change_devices_')) - dp.callback_query.register( - confirm_change_devices, - F.data.startswith("change_devices_") - ) + dp.callback_query.register(execute_change_devices, F.data.startswith('confirm_change_devices_')) - dp.callback_query.register( - execute_change_devices, - F.data.startswith("confirm_change_devices_") - ) + dp.callback_query.register(handle_extend_subscription, F.data == 'subscription_extend') - dp.callback_query.register( - handle_extend_subscription, - F.data == "subscription_extend" - ) + dp.callback_query.register(handle_reset_traffic, F.data == 'subscription_reset_traffic') - dp.callback_query.register( - handle_reset_traffic, - F.data == "subscription_reset_traffic" - ) + dp.callback_query.register(confirm_add_devices, F.data.startswith('add_devices_')) - dp.callback_query.register( - confirm_add_devices, - F.data.startswith("add_devices_") - ) + dp.callback_query.register(confirm_extend_subscription, F.data.startswith('extend_period_')) - dp.callback_query.register( - confirm_extend_subscription, - F.data.startswith("extend_period_") - ) + dp.callback_query.register(confirm_reset_traffic, F.data == 'confirm_reset_traffic') - dp.callback_query.register( - confirm_reset_traffic, - F.data == "confirm_reset_traffic" - ) + dp.callback_query.register(handle_reset_devices, F.data == 'subscription_reset_devices') - dp.callback_query.register( - handle_reset_devices, - F.data == "subscription_reset_devices" - ) + dp.callback_query.register(confirm_reset_devices, F.data == 'confirm_reset_devices') - dp.callback_query.register( - confirm_reset_devices, - F.data == "confirm_reset_devices" - ) + dp.callback_query.register(select_period, F.data.startswith('period_'), SubscriptionStates.selecting_period) - dp.callback_query.register( - select_period, - F.data.startswith("period_"), - SubscriptionStates.selecting_period - ) - - dp.callback_query.register( - select_traffic, - F.data.startswith("traffic_"), - SubscriptionStates.selecting_traffic - ) + dp.callback_query.register(select_traffic, F.data.startswith('traffic_'), SubscriptionStates.selecting_traffic) dp.callback_query.register( select_devices, - F.data.startswith("devices_") & ~F.data.in_(["devices_continue"]), - SubscriptionStates.selecting_devices + F.data.startswith('devices_') & ~F.data.in_(['devices_continue']), + SubscriptionStates.selecting_devices, ) - dp.callback_query.register( - devices_continue, - F.data == "devices_continue", - SubscriptionStates.selecting_devices - ) + dp.callback_query.register(devices_continue, F.data == 'devices_continue', SubscriptionStates.selecting_devices) dp.callback_query.register( - confirm_purchase, - F.data == "subscription_confirm", - SubscriptionStates.confirming_purchase + confirm_purchase, F.data == 'subscription_confirm', SubscriptionStates.confirming_purchase ) dp.callback_query.register( resume_subscription_checkout, - F.data == "subscription_resume_checkout", + F.data == 'subscription_resume_checkout', ) dp.callback_query.register( return_to_saved_cart, - F.data == "return_to_saved_cart", + F.data == 'return_to_saved_cart', ) dp.callback_query.register( clear_saved_cart, - F.data == "clear_saved_cart", + F.data == 'clear_saved_cart', ) - dp.callback_query.register( - handle_autopay_menu, - F.data == "subscription_autopay" - ) + dp.callback_query.register(handle_autopay_menu, F.data == 'subscription_autopay') + + dp.callback_query.register(toggle_autopay, F.data.in_(['autopay_enable', 'autopay_disable'])) + + dp.callback_query.register(show_autopay_days, F.data == 'autopay_set_days') + + dp.callback_query.register(handle_subscription_config_back, F.data == 'subscription_config_back') + + dp.callback_query.register(handle_subscription_cancel, F.data == 'subscription_cancel') + + dp.callback_query.register(set_autopay_days, F.data.startswith('autopay_days_')) + + dp.callback_query.register(select_country, F.data.startswith('country_'), SubscriptionStates.selecting_countries) dp.callback_query.register( - toggle_autopay, - F.data.in_(["autopay_enable", "autopay_disable"]) + countries_continue, F.data == 'countries_continue', SubscriptionStates.selecting_countries ) - dp.callback_query.register( - show_autopay_days, - F.data == "autopay_set_days" - ) + dp.callback_query.register(handle_manage_country, F.data.startswith('country_manage_')) - dp.callback_query.register( - handle_subscription_config_back, - F.data == "subscription_config_back" - ) + dp.callback_query.register(apply_countries_changes, F.data == 'countries_apply') - dp.callback_query.register( - handle_subscription_cancel, - F.data == "subscription_cancel" - ) - - dp.callback_query.register( - set_autopay_days, - F.data.startswith("autopay_days_") - ) - - dp.callback_query.register( - select_country, - F.data.startswith("country_"), - SubscriptionStates.selecting_countries - ) - - dp.callback_query.register( - countries_continue, - F.data == "countries_continue", - SubscriptionStates.selecting_countries - ) - - dp.callback_query.register( - handle_manage_country, - F.data.startswith("country_manage_") - ) - - dp.callback_query.register( - apply_countries_changes, - F.data == "countries_apply" - ) - - dp.callback_query.register( - claim_discount_offer, - F.data.startswith("claim_discount_") - ) + dp.callback_query.register(claim_discount_offer, F.data.startswith('claim_discount_')) dp.callback_query.register( handle_promo_offer_close, - F.data == "promo_offer_close", + F.data == 'promo_offer_close', ) - dp.callback_query.register( - handle_happ_download_request, - F.data == "subscription_happ_download" - ) + dp.callback_query.register(handle_happ_download_request, F.data == 'subscription_happ_download') dp.callback_query.register( handle_happ_download_platform_choice, - F.data.in_([ - "happ_download_ios", - "happ_download_android", - "happ_download_pc", - "happ_download_macos", - "happ_download_windows", - ]) + F.data.in_( + [ + 'happ_download_ios', + 'happ_download_android', + 'happ_download_pc', + 'happ_download_macos', + 'happ_download_windows', + ] + ), ) - dp.callback_query.register( - handle_happ_download_close, - F.data == "happ_download_close" - ) + dp.callback_query.register(handle_happ_download_close, F.data == 'happ_download_close') - dp.callback_query.register( - handle_happ_download_back, - F.data == "happ_download_back" - ) + dp.callback_query.register(handle_happ_download_back, F.data == 'happ_download_back') - dp.callback_query.register( - handle_connect_subscription, - F.data == "subscription_connect" - ) + dp.callback_query.register(handle_connect_subscription, F.data == 'subscription_connect') - dp.callback_query.register( - handle_device_guide, - F.data.startswith("device_guide_") - ) + dp.callback_query.register(handle_device_guide, F.data.startswith('device_guide_')) - dp.callback_query.register( - handle_app_selection, - F.data.startswith("app_list_") - ) + dp.callback_query.register(handle_app_selection, F.data.startswith('app_list_')) - dp.callback_query.register( - handle_specific_app_guide, - F.data.startswith("app_") - ) + dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_')) - dp.callback_query.register( - handle_open_subscription_link, - F.data == "open_subscription_link" - ) + dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link') - dp.callback_query.register( - handle_subscription_settings, - F.data == "subscription_settings" - ) + dp.callback_query.register(handle_subscription_settings, F.data == 'subscription_settings') - dp.callback_query.register( - handle_toggle_daily_subscription_pause, - F.data == "toggle_daily_subscription_pause" - ) + dp.callback_query.register(handle_toggle_daily_subscription_pause, F.data == 'toggle_daily_subscription_pause') - dp.callback_query.register( - handle_no_traffic_packages, - F.data == "no_traffic_packages" - ) + dp.callback_query.register(handle_no_traffic_packages, F.data == 'no_traffic_packages') - dp.callback_query.register( - handle_device_management, - F.data == "subscription_manage_devices" - ) + dp.callback_query.register(handle_device_management, F.data == 'subscription_manage_devices') - dp.callback_query.register( - handle_devices_page, - F.data.startswith("devices_page_") - ) + dp.callback_query.register(handle_devices_page, F.data.startswith('devices_page_')) - dp.callback_query.register( - handle_single_device_reset, - F.data.regexp(r"^reset_device_\d+_\d+$") - ) + dp.callback_query.register(handle_single_device_reset, F.data.regexp(r'^reset_device_\d+_\d+$')) - dp.callback_query.register( - handle_all_devices_reset_from_management, - F.data == "reset_all_devices" - ) + dp.callback_query.register(handle_all_devices_reset_from_management, F.data == 'reset_all_devices') - dp.callback_query.register( - show_device_connection_help, - F.data == "device_connection_help" - ) + dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help') # Регистрируем обработчики модема from .modem import register_modem_handlers + register_modem_handlers(dp) # Регистрируем обработчики покупки по тарифам from .tariff_purchase import register_tariff_purchase_handlers + register_tariff_purchase_handlers(dp) # Регистрируем обработчик для простой покупки - dp.callback_query.register( - handle_simple_subscription_purchase, - F.data == "simple_subscription_purchase" - ) + dp.callback_query.register(handle_simple_subscription_purchase, F.data == 'simple_subscription_purchase') async def handle_simple_subscription_purchase( @@ -4453,27 +4126,26 @@ async def handle_simple_subscription_purchase( """Обрабатывает простую покупку подписки.""" # Проверяем, находится ли пользователь в черном списке is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( - callback.from_user.id, - callback.from_user.username + callback.from_user.id, callback.from_user.username ) if is_blacklisted: - logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}') try: await callback.answer( - f"🚫 Простая покупка подписки невозможна\n\n" - f"Причина: {blacklist_reason}\n\n" - f"Если вы считаете, что это ошибка, обратитесь в поддержку.", - show_alert=True + f'🚫 Простая покупка подписки невозможна\n\n' + f'Причина: {blacklist_reason}\n\n' + f'Если вы считаете, что это ошибка, обратитесь в поддержку.', + show_alert=True, ) except Exception as e: - logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + logger.error(f'Ошибка при отправке сообщения о блокировке: {e}') return texts = get_texts(db_user.language) if not settings.SIMPLE_SUBSCRIPTION_ENABLED: - await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True) + await callback.answer('❌ Простая покупка подписки временно недоступна', show_alert=True) return # Определяем ограничение по устройствам для текущего режима @@ -4481,6 +4153,7 @@ async def handle_simple_subscription_purchase( # Проверяем, есть ли у пользователя активная подписка from app.database.crud.subscription import get_subscription_by_user_id + current_subscription = await get_subscription_by_user_id(db, db_user.id) # Если у пользователя уже есть активная подписка, продлеваем её @@ -4494,104 +4167,112 @@ async def handle_simple_subscription_purchase( period_days=settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, device_limit=simple_device_limit, traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, - squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID + squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID, ) return # Подготовим параметры простой подписки subscription_params = { - "period_days": settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, - "device_limit": simple_device_limit, - "traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, - "squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID + 'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS, + 'device_limit': simple_device_limit, + 'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, + 'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID, } # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) # Проверяем баланс пользователя - user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) + user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0) # Рассчитываем цену подписки price_kopeks, price_breakdown = await _calculate_simple_subscription_price( db, subscription_params, user=db_user, - resolved_squad_uuid=subscription_params.get("squad_uuid"), + resolved_squad_uuid=subscription_params.get('squad_uuid'), ) logger.debug( - "SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + 'SIMPLE_SUBSCRIPTION_PURCHASE_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s', db_user.id, price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), ) traffic_text = ( - "Безлимит" - if subscription_params["traffic_limit_gb"] == 0 - else f"{subscription_params['traffic_limit_gb']} ГБ" + 'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params["traffic_limit_gb"]} ГБ' ) if user_balance_kopeks >= price_kopeks: # Если баланс достаточный, предлагаем оплатить с баланса simple_lines = [ - "⚡ Простая покупка подписки", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '⚡ Простая покупка подписки', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if settings.is_devices_selection_enabled(): - simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + simple_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - simple_lines.extend([ - f"📊 Трафик: {traffic_text}", - f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}", - "", - f"💰 Стоимость: {settings.format_price(price_kopeks)}", - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", - "", - "Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.", - ]) + simple_lines.extend( + [ + f'📊 Трафик: {traffic_text}', + f'🌍 Сервер: {"Любой доступный" if not subscription_params["squad_uuid"] else "Выбранный"}', + '', + f'💰 Стоимость: {settings.format_price(price_kopeks)}', + f'💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}', + '', + 'Вы можете оплатить подписку с баланса или выбрать другой способ оплаты.', + ] + ) - message_text = "\n".join(simple_lines) + message_text = '\n'.join(simple_lines) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")], - [types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription_purchase")] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='✅ Оплатить с баланса', callback_data='simple_subscription_pay_with_balance' + ) + ], + [ + types.InlineKeyboardButton( + text='💳 Другие способы оплаты', callback_data='simple_subscription_other_payment_methods' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_purchase')], + ] + ) else: # Если баланс недостаточный, предлагаем внешние способы оплаты simple_lines = [ - "⚡ Простая покупка подписки", - "", - f"📅 Период: {subscription_params['period_days']} дней", + '⚡ Простая покупка подписки', + '', + f'📅 Период: {subscription_params["period_days"]} дней', ] if settings.is_devices_selection_enabled(): - simple_lines.append(f"📱 Устройства: {subscription_params['device_limit']}") + simple_lines.append(f'📱 Устройства: {subscription_params["device_limit"]}') - simple_lines.extend([ - f"📊 Трафик: {traffic_text}", - f"🌍 Сервер: {'Любой доступный' if not subscription_params['squad_uuid'] else 'Выбранный'}", - "", - f"💰 Стоимость: {settings.format_price(price_kopeks)}", - f"💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}", - "", - "Выберите способ оплаты:", - ]) + simple_lines.extend( + [ + f'📊 Трафик: {traffic_text}', + f'🌍 Сервер: {"Любой доступный" if not subscription_params["squad_uuid"] else "Выбранный"}', + '', + f'💰 Стоимость: {settings.format_price(price_kopeks)}', + f'💳 Ваш баланс: {settings.format_price(user_balance_kopeks)}', + '', + 'Выберите способ оплаты:', + ] + ) - message_text = "\n".join(simple_lines) + message_text = '\n'.join(simple_lines) keyboard = _get_simple_subscription_payment_keyboard(db_user.language) - await callback.message.edit_text( - message_text, - reply_markup=keyboard, - parse_mode="HTML" - ) + await callback.message.edit_text(message_text, reply_markup=keyboard, parse_mode='HTML') await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method) await callback.answer() @@ -4605,25 +4286,24 @@ async def _extend_existing_subscription( period_days: int, device_limit: int, traffic_limit_gb: int, - squad_uuid: str + squad_uuid: str, ): """Продлевает существующую подписку.""" - from app.services.admin_notification_service import AdminNotificationService + from datetime import datetime, timedelta + from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance from app.database.models import TransactionType from app.services.subscription_service import SubscriptionService - from app.utils.pricing_utils import calculate_months_from_days - from datetime import datetime, timedelta texts = get_texts(db_user.language) # Рассчитываем цену подписки subscription_params = { - "period_days": period_days, - "device_limit": device_limit, - "traffic_limit_gb": traffic_limit_gb, - "squad_uuid": squad_uuid + 'period_days': period_days, + 'device_limit': device_limit, + 'traffic_limit_gb': traffic_limit_gb, + 'squad_uuid': squad_uuid, } price_kopeks, price_breakdown = await _calculate_simple_subscription_price( db, @@ -4632,27 +4312,27 @@ async def _extend_existing_subscription( resolved_squad_uuid=squad_uuid, ) logger.debug( - "SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s", + 'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s', db_user.id, price_kopeks, - price_breakdown.get("base_price", 0), - price_breakdown.get("traffic_price", 0), - price_breakdown.get("devices_price", 0), - price_breakdown.get("servers_price", 0), - price_breakdown.get("total_discount", 0), + price_breakdown.get('base_price', 0), + price_breakdown.get('traffic_price', 0), + price_breakdown.get('devices_price', 0), + price_breakdown.get('servers_price', 0), + price_breakdown.get('total_discount', 0), ) # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: missing_kopeks = price_kopeks - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(price_kopeks), @@ -4662,6 +4342,7 @@ async def _extend_existing_subscription( # Подготовим данные для сохранения в корзину from app.services.user_cart_service import user_cart_service + cart_data = { 'cart_mode': 'extend', 'subscription_id': current_subscription.id, @@ -4671,7 +4352,7 @@ async def _extend_existing_subscription( 'saved_cart': True, 'missing_amount': missing_kopeks, 'return_to_cart': True, - 'description': f"Продление подписки на {period_days} дней", + 'description': f'Продление подписки на {period_days} дней', 'device_limit': device_limit, 'traffic_limit_gb': traffic_limit_gb, 'squad_uuid': squad_uuid, @@ -4683,11 +4364,9 @@ async def _extend_existing_subscription( await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( - db_user.language, - amount_kopeks=missing_kopeks, - has_saved_cart=True + db_user.language, amount_kopeks=missing_kopeks, has_saved_cart=True ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -4697,12 +4376,12 @@ async def _extend_existing_subscription( db, db_user, price_kopeks, - f"Продление подписки на {period_days} дней", + f'Продление подписки на {period_days} дней', consume_promo_offer=False, # Простая покупка не использует промо-скидки ) if not success: - await callback.answer("⚠ Ошибка списания средств", show_alert=True) + await callback.answer('⚠ Ошибка списания средств', show_alert=True) return # Обновляем параметры подписки @@ -4713,7 +4392,7 @@ async def _extend_existing_subscription( if current_subscription.is_trial: # При продлении триальной подписки переводим её в обычную current_subscription.is_trial = False - current_subscription.status = "active" + current_subscription.status = 'active' # Убираем ограничения с триальной подписки current_subscription.traffic_limit_gb = traffic_limit_gb current_subscription.device_limit = device_limit @@ -4726,8 +4405,7 @@ async def _extend_existing_subscription( # Обновляем трафик и устройства, если нужно if traffic_limit_gb != 0: # Если не безлимит, обновляем current_subscription.traffic_limit_gb = traffic_limit_gb - if device_limit > current_subscription.device_limit: - current_subscription.device_limit = device_limit + current_subscription.device_limit = max(current_subscription.device_limit, device_limit) # Если указан squad_uuid и его ещё нет в подписке, добавляем if squad_uuid and squad_uuid not in current_subscription.connected_squads: # Используем += для безопасного добавления в список SQLAlchemy @@ -4756,14 +4434,14 @@ async def _extend_existing_subscription( db, current_subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="продление подписки", + reset_reason='продление подписки', ) if remnawave_result: - logger.info("✅ RemnaWave обновлен успешно") + logger.info('✅ RemnaWave обновлен успешно') else: - logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") + logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE') except Exception as e: - logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") + logger.error(f'⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}') # Создаём транзакцию transaction = await create_transaction( @@ -4771,7 +4449,7 @@ async def _extend_existing_subscription( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_kopeks, - description=f"Продление подписки на {period_days} дней" + description=f'Продление подписки на {period_days} дней', ) # Отправляем уведомление админу @@ -4788,24 +4466,23 @@ async def _extend_existing_subscription( balance_after=db_user.balance_kopeks, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о продлении: {e}") + logger.error(f'Ошибка отправки уведомления о продлении: {e}') # Отправляем сообщение пользователю success_message = ( - "✅ Подписка успешно продлена!\n\n" - f"⏰ Добавлено: {period_days} дней\n" - f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n" - f"💰 Списано: {texts.format_price(price_kopeks)}" + '✅ Подписка успешно продлена!\n\n' + f'⏰ Добавлено: {period_days} дней\n' + f'Действует до: {format_local_datetime(new_end_date, "%d.%m.%Y %H:%M")}\n\n' + f'💰 Списано: {texts.format_price(price_kopeks)}' ) # Если это была триальная подписка, добавляем информацию о преобразовании if current_subscription.is_trial: - success_message += "\n🎯 Триальная подписка преобразована в платную" + success_message += '\n🎯 Триальная подписка преобразована в платную' - await callback.message.edit_text( - success_message, - reply_markup=get_back_keyboard(db_user.language) + await callback.message.edit_text(success_message, reply_markup=get_back_keyboard(db_user.language)) + + logger.info( + f'✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽' ) - - logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽") await callback.answer() diff --git a/app/handlers/subscription/summary.py b/app/handlers/subscription/summary.py index ab65b0f9..9a1c9502 100644 --- a/app/handlers/subscription/summary.py +++ b/app/handlers/subscription/summary.py @@ -1,26 +1,23 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import Optional from aiogram import types from aiogram.fsm.context import FSMContext +from app.keyboards.inline import get_subscription_confirm_keyboard from app.localization.texts import get_texts from app.services.subscription_checkout_service import save_subscription_checkout_draft from app.states import SubscriptionStates -from app.keyboards.inline import get_subscription_confirm_keyboard - -if TYPE_CHECKING: # pragma: no cover - only for type checking - from .pricing import _prepare_subscription_summary logger = logging.getLogger(__name__) async def present_subscription_summary( - callback: types.CallbackQuery, - state: FSMContext, - db_user, - texts: Optional = None, + callback: types.CallbackQuery, + state: FSMContext, + db_user, + texts: Optional = None, ) -> bool: """Render the subscription purchase summary and switch to the confirmation state. @@ -39,11 +36,11 @@ async def present_subscription_summary( summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) except ValueError as exc: logger.error( - "Ошибка в расчете цены подписки для пользователя %s: %s", + 'Ошибка в расчете цены подписки для пользователя %s: %s', db_user.telegram_id, exc, ) - await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) + await callback.answer('Ошибка расчета цены. Обратитесь в поддержку.', show_alert=True) return False await state.set_data(prepared_data) @@ -52,7 +49,7 @@ async def present_subscription_summary( await callback.message.edit_text( summary_text, reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await state.set_state(SubscriptionStates.confirming_purchase) diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index d695a8c5..785f2498 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,26 +1,24 @@ """Покупка подписки по тарифам.""" + import logging from datetime import timedelta -from typing import List, Optional -from aiogram import Dispatcher, types, F +from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.exceptions import TelegramBadRequest +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id -from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription +from app.database.crud.subscription import create_paid_subscription, extend_subscription, get_subscription_by_user_id +from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import User, Tariff, TransactionType +from app.database.models import Tariff, TransactionType, User from app.localization.texts import get_texts -from app.states import SubscriptionStates -from app.utils.decorators import error_handler -from app.services.subscription_service import SubscriptionService from app.services.admin_notification_service import AdminNotificationService +from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service +from app.utils.decorators import error_handler from app.utils.promo_offer import get_user_active_promo_discount_percent @@ -30,8 +28,8 @@ logger = logging.getLogger(__name__) def _format_traffic(gb: int) -> str: """Форматирует трафик.""" if gb == 0: - return "Безлимит" - return f"{gb} ГБ" + return 'Безлимит' + return f'{gb} ГБ' def _format_price_kopeks(kopeks: int, compact: bool = False) -> str: @@ -39,24 +37,23 @@ def _format_price_kopeks(kopeks: int, compact: bool = False) -> str: rubles = kopeks / 100 if compact: # Компактный формат - округляем до рублей - return f"{int(round(rubles))}₽" + return f'{int(round(rubles))}₽' if rubles == int(rubles): - return f"{int(rubles)} ₽" - return f"{rubles:.2f} ₽" + return f'{int(rubles)} ₽' + return f'{rubles:.2f} ₽' def _format_period(days: int) -> str: """Форматирует период.""" if days == 1: - return "1 день" - elif days < 5: - return f"{days} дня" - elif days < 21 or days % 10 >= 5 or days % 10 == 0: - return f"{days} дней" - elif days % 10 == 1: - return f"{days} день" - else: - return f"{days} дня" + return '1 день' + if days < 5: + return f'{days} дня' + if days < 21 or days % 10 >= 5 or days % 10 == 0: + return f'{days} дней' + if days % 10 == 1: + return f'{days} день' + return f'{days} дня' def _apply_promo_discount(price: int, discount_percent: int) -> int: @@ -72,7 +69,7 @@ def _get_user_period_discount(db_user: User, period_days: int) -> int: promo_group = getattr(db_user, 'promo_group', None) if promo_group: - discount = promo_group.get_discount_percent("period", period_days) + discount = promo_group.get_discount_percent('period', period_days) if discount > 0: return discount @@ -81,32 +78,32 @@ def _get_user_period_discount(db_user: User, period_days: int) -> int: def format_tariffs_list_text( - tariffs: List[Tariff], - db_user: Optional[User] = None, + tariffs: list[Tariff], + db_user: User | None = None, has_period_discounts: bool = False, ) -> str: """Форматирует текст со списком тарифов для отображения.""" - lines = ["📦 Выберите тариф"] + lines = ['📦 Выберите тариф'] if has_period_discounts: - lines.append("🎁 Скидки по периодам") + lines.append('🎁 Скидки по периодам') - lines.append("") + lines.append('') for tariff in tariffs: # Трафик компактно traffic_gb = tariff.traffic_limit_gb - traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + traffic = '∞' if traffic_gb == 0 else f'{traffic_gb}ГБ' # Цена is_daily = getattr(tariff, 'is_daily', False) - price_text = "" - discount_icon = "" + price_text = '' + discount_icon = '' if is_daily: # Для суточных тарифов показываем цену за день daily_price = getattr(tariff, 'daily_price_kopeks', 0) - price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + price_text = f'🔄 {_format_price_kopeks(daily_price, compact=True)}/день' else: # Для периодных тарифов показываем минимальную цену prices = tariff.period_prices or {} @@ -118,23 +115,23 @@ def format_tariffs_list_text( discount_percent = _get_user_period_discount(db_user, int(min_period)) if discount_percent > 0: min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + discount_icon = '🔥' + price_text = f'от {_format_price_kopeks(min_price, compact=True)}{discount_icon}' # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 - lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + lines.append(f'{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}') # Описание тарифа если есть if tariff.description: - lines.append(f"{tariff.description}") + lines.append(f'{tariff.description}') - lines.append("") + lines.append('') - return "\n".join(lines) + return '\n'.join(lines) def get_tariffs_keyboard( - tariffs: List[Tariff], + tariffs: list[Tariff], language: str, ) -> InlineKeyboardMarkup: """Создает компактную клавиатуру выбора тарифов (только названия).""" @@ -142,16 +139,9 @@ def get_tariffs_keyboard( buttons = [] for tariff in tariffs: - buttons.append([ - InlineKeyboardButton( - text=tariff.name, - callback_data=f"tariff_select:{tariff.id}" - ) - ]) + buttons.append([InlineKeyboardButton(text=tariff.name, callback_data=f'tariff_select:{tariff.id}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -159,7 +149,7 @@ def get_tariffs_keyboard( def get_tariff_periods_keyboard( tariff: Tariff, language: str, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> InlineKeyboardMarkup: """Создает клавиатуру выбора периода для тарифа с учетом скидок по периодам.""" texts = get_texts(language) @@ -176,23 +166,15 @@ def get_tariff_periods_keyboard( discount_percent = _get_user_period_discount(db_user, period) if discount_percent > 0: - original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" + price_text = f'{_format_price_kopeks(price)} 🔥−{discount_percent}%' else: price_text = _format_price_kopeks(price) - button_text = f"{_format_period(period)} — {price_text}" - buttons.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_period:{tariff.id}:{period}" - ) - ]) + button_text = f'{_format_period(period)} — {price_text}' + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'tariff_period:{tariff.id}:{period}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='tariff_list')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -200,7 +182,7 @@ def get_tariff_periods_keyboard( def get_tariff_periods_keyboard_with_traffic( tariff: Tariff, language: str, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> InlineKeyboardMarkup: """Клавиатура выбора периода для тарифа с кастомным трафиком (переход к настройке трафика).""" texts = get_texts(language) @@ -218,22 +200,17 @@ def get_tariff_periods_keyboard_with_traffic( if discount_percent > 0: price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" + price_text = f'{_format_price_kopeks(price)} 🔥−{discount_percent}%' else: price_text = _format_price_kopeks(price) - button_text = f"{_format_period(period)} — {price_text}" + button_text = f'{_format_period(period)} — {price_text}' # Используем другой callback для перехода к настройке трафика - buttons.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_period_traffic:{tariff.id}:{period}" - ) - ]) + buttons.append( + [InlineKeyboardButton(text=button_text, callback_data=f'tariff_period_traffic:{tariff.id}:{period}')] + ) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='tariff_list')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -245,20 +222,12 @@ def get_tariff_confirm_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру подтверждения покупки тарифа.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить покупку", - callback_data=f"tariff_confirm:{tariff_id}:{period}" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"tariff_select:{tariff_id}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✅ Подтвердить покупку', callback_data=f'tariff_confirm:{tariff_id}:{period}')], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_select:{tariff_id}')], ] - ]) + ) def get_tariff_insufficient_balance_keyboard( @@ -268,20 +237,12 @@ def get_tariff_insufficient_balance_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру при недостаточном балансе.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="balance_topup" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"tariff_select:{tariff_id}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_select:{tariff_id}')], ] - ]) + ) def format_tariff_info_for_user( @@ -290,7 +251,7 @@ def format_tariff_info_for_user( discount_percent: int = 0, ) -> str: """Форматирует информацию о тарифе для пользователя.""" - texts = get_texts(language) + get_texts(language) traffic = _format_traffic(tariff.traffic_limit_gb) @@ -302,15 +263,15 @@ def format_tariff_info_for_user( """ if tariff.description: - text += f"\n📝 {tariff.description}\n" + text += f'\n📝 {tariff.description}\n' if discount_percent > 0: - text += f"\n🎁 Ваша скидка: {discount_percent}%\n" + text += f'\n🎁 Ваша скидка: {discount_percent}%\n' # Для суточных тарифов не показываем выбор периода is_daily = getattr(tariff, 'is_daily', False) if not is_daily: - text += "\nВыберите период подписки:" + text += '\nВыберите период подписки:' return text @@ -321,20 +282,12 @@ def get_daily_tariff_confirm_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру подтверждения покупки суточного тарифа.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить покупку", - callback_data=f"daily_tariff_confirm:{tariff_id}" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="tariff_list" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✅ Подтвердить покупку', callback_data=f'daily_tariff_confirm:{tariff_id}')], + [InlineKeyboardButton(text=texts.BACK, callback_data='tariff_list')], ] - ]) + ) def get_daily_tariff_insufficient_balance_keyboard( @@ -343,20 +296,12 @@ def get_daily_tariff_insufficient_balance_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру при недостаточном балансе для суточного тарифа.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="balance_topup" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="tariff_list" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=texts.BACK, callback_data='tariff_list')], ] - ]) + ) # ==================== Кастомные дни/трафик ==================== @@ -384,21 +329,21 @@ def get_custom_tariff_keyboard( # -30 / -7 / -1 if days > min_days: if days - 30 >= min_days: - days_row.append(InlineKeyboardButton(text="-30", callback_data=f"custom_days:{tariff_id}:-30")) + days_row.append(InlineKeyboardButton(text='-30', callback_data=f'custom_days:{tariff_id}:-30')) if days - 7 >= min_days: - days_row.append(InlineKeyboardButton(text="-7", callback_data=f"custom_days:{tariff_id}:-7")) - days_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_days:{tariff_id}:-1")) + days_row.append(InlineKeyboardButton(text='-7', callback_data=f'custom_days:{tariff_id}:-7')) + days_row.append(InlineKeyboardButton(text='-1', callback_data=f'custom_days:{tariff_id}:-1')) # Текущее значение - days_row.append(InlineKeyboardButton(text=f"📅 {days} дн.", callback_data="noop")) + days_row.append(InlineKeyboardButton(text=f'📅 {days} дн.', callback_data='noop')) # +1 / +7 / +30 if days < max_days: - days_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_days:{tariff_id}:1")) + days_row.append(InlineKeyboardButton(text='+1', callback_data=f'custom_days:{tariff_id}:1')) if days + 7 <= max_days: - days_row.append(InlineKeyboardButton(text="+7", callback_data=f"custom_days:{tariff_id}:7")) + days_row.append(InlineKeyboardButton(text='+7', callback_data=f'custom_days:{tariff_id}:7')) if days + 30 <= max_days: - days_row.append(InlineKeyboardButton(text="+30", callback_data=f"custom_days:{tariff_id}:30")) + days_row.append(InlineKeyboardButton(text='+30', callback_data=f'custom_days:{tariff_id}:30')) if days_row: buttons.append(days_row) @@ -409,37 +354,30 @@ def get_custom_tariff_keyboard( # -100 / -10 / -1 if traffic_gb > min_traffic: if traffic_gb - 100 >= min_traffic: - traffic_row.append(InlineKeyboardButton(text="-100", callback_data=f"custom_traffic:{tariff_id}:-100")) + traffic_row.append(InlineKeyboardButton(text='-100', callback_data=f'custom_traffic:{tariff_id}:-100')) if traffic_gb - 10 >= min_traffic: - traffic_row.append(InlineKeyboardButton(text="-10", callback_data=f"custom_traffic:{tariff_id}:-10")) - traffic_row.append(InlineKeyboardButton(text="-1", callback_data=f"custom_traffic:{tariff_id}:-1")) + traffic_row.append(InlineKeyboardButton(text='-10', callback_data=f'custom_traffic:{tariff_id}:-10')) + traffic_row.append(InlineKeyboardButton(text='-1', callback_data=f'custom_traffic:{tariff_id}:-1')) # Текущее значение - traffic_row.append(InlineKeyboardButton(text=f"📊 {traffic_gb} ГБ", callback_data="noop")) + traffic_row.append(InlineKeyboardButton(text=f'📊 {traffic_gb} ГБ', callback_data='noop')) # +1 / +10 / +100 if traffic_gb < max_traffic: - traffic_row.append(InlineKeyboardButton(text="+1", callback_data=f"custom_traffic:{tariff_id}:1")) + traffic_row.append(InlineKeyboardButton(text='+1', callback_data=f'custom_traffic:{tariff_id}:1')) if traffic_gb + 10 <= max_traffic: - traffic_row.append(InlineKeyboardButton(text="+10", callback_data=f"custom_traffic:{tariff_id}:10")) + traffic_row.append(InlineKeyboardButton(text='+10', callback_data=f'custom_traffic:{tariff_id}:10')) if traffic_gb + 100 <= max_traffic: - traffic_row.append(InlineKeyboardButton(text="+100", callback_data=f"custom_traffic:{tariff_id}:100")) + traffic_row.append(InlineKeyboardButton(text='+100', callback_data=f'custom_traffic:{tariff_id}:100')) if traffic_row: buttons.append(traffic_row) # Кнопка подтверждения - buttons.append([ - InlineKeyboardButton( - text="✅ Подтвердить покупку", - callback_data=f"custom_confirm:{tariff_id}" - ) - ]) + buttons.append([InlineKeyboardButton(text='✅ Подтвердить покупку', callback_data=f'custom_confirm:{tariff_id}')]) # Кнопка назад - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="tariff_list") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='tariff_list')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -486,15 +424,13 @@ def format_custom_tariff_preview( discount_percent: int = 0, ) -> str: """Форматирует предпросмотр покупки с кастомными параметрами.""" - period_price, traffic_price, total_price = _calculate_custom_tariff_price( - tariff, days, traffic_gb - ) + period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, days, traffic_gb) # Применяем скидку if discount_percent > 0: total_price = _apply_promo_discount(total_price, discount_percent) - traffic_display = f"{traffic_gb} ГБ" if traffic_gb > 0 else _format_traffic(tariff.traffic_limit_gb) + traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else _format_traffic(tariff.traffic_limit_gb) text = f"""📦 {tariff.name} @@ -502,23 +438,23 @@ def format_custom_tariff_preview( """ if tariff.can_purchase_custom_days(): - text += f"📅 Дней: {days} (от {tariff.min_days} до {tariff.max_days})\n" - text += f" 💰 {_format_price_kopeks(period_price)}\n" + text += f'📅 Дней: {days} (от {tariff.min_days} до {tariff.max_days})\n' + text += f' 💰 {_format_price_kopeks(period_price)}\n' else: # Фиксированный период - показываем без возможности изменения - text += f"📅 Период: {_format_period(days)}\n" - text += f" 💰 {_format_price_kopeks(period_price)}\n" + text += f'📅 Период: {_format_period(days)}\n' + text += f' 💰 {_format_price_kopeks(period_price)}\n' if tariff.can_purchase_custom_traffic(): - text += f"📊 Трафик: {traffic_gb} ГБ (от {tariff.min_traffic_gb} до {tariff.max_traffic_gb})\n" - text += f" 💰 +{_format_price_kopeks(traffic_price)}\n" + text += f'📊 Трафик: {traffic_gb} ГБ (от {tariff.min_traffic_gb} до {tariff.max_traffic_gb})\n' + text += f' 💰 +{_format_price_kopeks(traffic_price)}\n' else: - text += f"📊 Трафик: {traffic_display}\n" + text += f'📊 Трафик: {traffic_display}\n' - text += f"📱 Устройств: {tariff.device_limit}\n" + text += f'📱 Устройств: {tariff.device_limit}\n' if discount_percent > 0: - text += f"\n🎁 Скидка: {discount_percent}%\n" + text += f'\n🎁 Скидка: {discount_percent}%\n' text += f""" 💰 Итого: {_format_price_kopeks(total_price)} @@ -527,9 +463,9 @@ def format_custom_tariff_preview( if user_balance < total_price: missing = total_price - user_balance - text += f"\n⚠️ Не хватает: {_format_price_kopeks(missing)}" + text += f'\n⚠️ Не хватает: {_format_price_kopeks(missing)}' else: - text += f"\nПосле оплаты: {_format_price_kopeks(user_balance - total_price)}" + text += f'\nПосле оплаты: {_format_price_kopeks(user_balance - total_price)}' return text @@ -551,12 +487,11 @@ async def show_tariffs_list( if not tariffs: await callback.message.edit_text( - "😔 Нет доступных тарифов\n\n" - "К сожалению, сейчас нет тарифов для покупки.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + '😔 Нет доступных тарифов\n\nК сожалению, сейчас нет тарифов для покупки.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]] + ), + parse_mode='HTML', ) await callback.answer() return @@ -573,9 +508,7 @@ async def show_tariffs_list( tariffs_text = format_tariffs_list_text(tariffs, db_user, has_period_discounts) await callback.message.edit_text( - tariffs_text, - reply_markup=get_tariffs_keyboard(tariffs, db_user.language), - parse_mode="HTML" + tariffs_text, reply_markup=get_tariffs_keyboard(tariffs, db_user.language), parse_mode='HTML' ) await callback.answer() @@ -589,11 +522,11 @@ async def select_tariff( state: FSMContext, ): """Обрабатывает выбор тарифа.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Проверяем, суточный ли это тариф @@ -607,17 +540,17 @@ async def select_tariff( if user_balance >= daily_price: await callback.message.edit_text( - f"✅ Подтверждение покупки\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"🔄 Тип: Суточный\n\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n" - f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" - f"Вы можете приостановить подписку в любой момент.", + f'✅ Подтверждение покупки\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'🔄 Тип: Суточный\n\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n\n' + f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' + f'Вы можете приостановить подписку в любой момент.', reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: missing = daily_price - user_balance @@ -633,7 +566,7 @@ async def select_tariff( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Покупка суточного тарифа {tariff.name}", + 'description': f'Покупка суточного тарифа {tariff.name}', 'traffic_limit_gb': tariff.traffic_limit_gb, 'device_limit': tariff.device_limit, 'allowed_squads': tariff.allowed_squads or [], @@ -641,15 +574,15 @@ async def select_tariff( await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"🔄 Тип: Суточный\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n" - f"🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.", + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'🔄 Тип: Суточный\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n' + f'🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.', reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # Проверяем, есть ли кастомные дни или трафик @@ -695,25 +628,23 @@ async def select_tariff( min_traffic=tariff.min_traffic_gb, max_traffic=tariff.max_traffic_gb, ), - parse_mode="HTML" + parse_mode='HTML', ) elif can_custom_traffic: # Только кастомный трафик - сначала выбираем период из period_prices # Показываем обычный выбор периода, трафик будет на следующем шаге await callback.message.edit_text( - format_tariff_info_for_user(tariff, db_user.language) + - "\n\n📊 После выбора периода вы сможете настроить трафик", - reply_markup=get_tariff_periods_keyboard_with_traffic( - tariff, db_user.language, db_user=db_user - ), - parse_mode="HTML" + format_tariff_info_for_user(tariff, db_user.language) + + '\n\n📊 После выбора периода вы сможете настроить трафик', + reply_markup=get_tariff_periods_keyboard_with_traffic(tariff, db_user.language, db_user=db_user), + parse_mode='HTML', ) else: # Для обычного тарифа показываем выбор периода await callback.message.edit_text( format_tariff_info_for_user(tariff, db_user.language), reply_markup=get_tariff_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data(selected_tariff_id=tariff_id) @@ -728,13 +659,13 @@ async def handle_custom_days_change( state: FSMContext, ): """Обрабатывает изменение количества дней.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) delta = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return state_data = await state.get_data() @@ -774,7 +705,7 @@ async def handle_custom_days_change( min_traffic=tariff.min_traffic_gb, max_traffic=tariff.max_traffic_gb, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -787,13 +718,13 @@ async def handle_custom_traffic_change( state: FSMContext, ): """Обрабатывает изменение количества трафика.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) delta = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return state_data = await state.get_data() @@ -831,7 +762,7 @@ async def handle_custom_traffic_change( min_traffic=tariff.min_traffic_gb, max_traffic=tariff.max_traffic_gb, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -844,11 +775,11 @@ async def handle_custom_confirm( state: FSMContext, ): """Подтверждает покупку тарифа с кастомными параметрами.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return state_data = await state.get_data() @@ -857,14 +788,12 @@ async def handle_custom_confirm( discount_percent = state_data.get('period_discount_percent', 0) # Рассчитываем цену (используем общую функцию) - period_price, traffic_price, total_price = _calculate_custom_tariff_price( - tariff, custom_days, custom_traffic - ) + period_price, traffic_price, total_price = _calculate_custom_tariff_price(tariff, custom_days, custom_traffic) # Проверяем, что цена за период валидна if period_price == 0 and not tariff.can_purchase_custom_days(): # Период не найден в period_prices - ошибка - await callback.answer("Выбранный период недоступен для этого тарифа", show_alert=True) + await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True) return # Применяем скидку к цене периода (не к трафику) @@ -875,7 +804,7 @@ async def handle_custom_confirm( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < total_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return texts = get_texts(db_user.language) @@ -883,11 +812,10 @@ async def handle_custom_confirm( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, total_price, - f"Покупка тарифа {tariff.name} на {custom_days} дней" + db, db_user, total_price, f'Покупка тарифа {tariff.name} на {custom_days} дней' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из тарифа @@ -896,6 +824,7 @@ async def handle_custom_confirm( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -936,10 +865,10 @@ async def handle_custom_confirm( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа", + reset_reason='покупка тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -947,7 +876,7 @@ async def handle_custom_confirm( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-total_price, - description=f"Покупка тарифа {tariff.name} на {custom_days} дней", + description=f'Покупка тарифа {tariff.name} на {custom_days} дней', ) # Отправляем уведомление админу @@ -963,37 +892,39 @@ async def handle_custom_confirm( amount_kopeks=total_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) except Exception as e: - logger.error(f"Ошибка очистки корзины: {e}") + logger.error(f'Ошибка очистки корзины: {e}') await state.clear() traffic_display = _format_traffic(traffic_limit) await callback.message.edit_text( - f"🎉 Подписка успешно оформлена!\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic_display}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(custom_days)}\n" - f"💰 Списано: {_format_price_kopeks(total_price)}\n\n" - f"Перейдите в раздел «Подписка» для подключения.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Подписка успешно оформлена!\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic_display}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(custom_days)}\n' + f'💰 Списано: {_format_price_kopeks(total_price)}\n\n' + f'Перейдите в раздел «Подписка» для подключения.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Подписка оформлена!", show_alert=True) + await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при покупке тарифа с кастомными параметрами: {e}", exc_info=True) - await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + logger.error(f'Ошибка при покупке тарифа с кастомными параметрами: {e}', exc_info=True) + await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) @error_handler @@ -1004,17 +935,17 @@ async def select_tariff_period_with_traffic( state: FSMContext, ): """Обрабатывает выбор периода для тарифа с кастомным трафиком - показывает экран настройки трафика.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return if not tariff.can_purchase_custom_traffic(): - await callback.answer("Кастомный трафик недоступен для этого тарифа", show_alert=True) + await callback.answer('Кастомный трафик недоступен для этого тарифа', show_alert=True) return user_balance = db_user.balance_kopeks or 0 @@ -1053,7 +984,7 @@ async def select_tariff_period_with_traffic( min_traffic=tariff.min_traffic_gb, max_traffic=tariff.max_traffic_gb, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -1066,13 +997,13 @@ async def select_tariff_period( state: FSMContext, ): """Обрабатывает выбор периода для тарифа.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем скидку для выбранного периода @@ -1090,22 +1021,22 @@ async def select_tariff_period( if user_balance >= final_price: # Показываем подтверждение - discount_text = "" + discount_text = '' if discount_percent > 0: - discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})' await callback.message.edit_text( - f"✅ Подтверждение покупки\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(period)}\n" - f"{discount_text}\n" - f"💰 Итого: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + f'✅ Подтверждение покупки\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(period)}\n' + f'{discount_text}\n' + f'💰 Итого: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - final_price)}', reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # Недостаточно средств - сохраняем корзину для автопокупки @@ -1121,7 +1052,7 @@ async def select_tariff_period( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Покупка тарифа {tariff.name} на {period} дней", + 'description': f'Покупка тарифа {tariff.name} на {period} дней', 'traffic_limit_gb': tariff.traffic_limit_gb, 'device_limit': tariff.device_limit, 'allowed_squads': tariff.allowed_squads or [], @@ -1130,15 +1061,15 @@ async def select_tariff_period( await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 Стоимость: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n" - f"🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.", + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 Стоимость: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n' + f'🛒 Корзина сохранена! После пополнения баланса подписка будет оформлена автоматически.', reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -1158,13 +1089,13 @@ async def confirm_tariff_purchase( state: FSMContext, ): """Подтверждает покупку тарифа и создает подписку.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем скидку для выбранного периода @@ -1178,7 +1109,7 @@ async def confirm_tariff_purchase( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < final_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return texts = get_texts(db_user.language) @@ -1186,11 +1117,10 @@ async def confirm_tariff_purchase( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, - f"Покупка тарифа {tariff.name} на {period} дней" + db, db_user, final_price, f'Покупка тарифа {tariff.name} на {period} дней' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из тарифа @@ -1199,6 +1129,7 @@ async def confirm_tariff_purchase( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -1236,10 +1167,10 @@ async def confirm_tariff_purchase( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа", + reset_reason='покупка тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -1247,7 +1178,7 @@ async def confirm_tariff_purchase( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-final_price, - description=f"Покупка тарифа {tariff.name} на {period} дней", + description=f'Покупка тарифа {tariff.name} на {period} дней', ) # Отправляем уведомление админу @@ -1263,42 +1194,45 @@ async def confirm_tariff_purchase( amount_kopeks=final_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f"Корзина очищена после покупки тарифа для пользователя {db_user.telegram_id}") + logger.info(f'Корзина очищена после покупки тарифа для пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка очистки корзины: {e}") + logger.error(f'Ошибка очистки корзины: {e}') await state.clear() traffic = _format_traffic(tariff.traffic_limit_gb) await callback.message.edit_text( - f"🎉 Подписка успешно оформлена!\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 Списано: {_format_price_kopeks(final_price)}\n\n" - f"Перейдите в раздел «Подписка» для подключения.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Подписка успешно оформлена!\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 Списано: {_format_price_kopeks(final_price)}\n\n' + f'Перейдите в раздел «Подписка» для подключения.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Подписка оформлена!", show_alert=True) + await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при покупке тарифа: {e}", exc_info=True) - await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + logger.error(f'Ошибка при покупке тарифа: {e}', exc_info=True) + await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) # ==================== Покупка суточного тарифа ==================== + @error_handler async def confirm_daily_tariff_purchase( callback: types.CallbackQuery, @@ -1309,27 +1243,27 @@ async def confirm_daily_tariff_purchase( """Подтверждает покупку суточного тарифа.""" from datetime import datetime - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return is_daily = getattr(tariff, 'is_daily', False) if not is_daily: - await callback.answer("Это не суточный тариф", show_alert=True) + await callback.answer('Это не суточный тариф', show_alert=True) return daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price <= 0: - await callback.answer("Некорректная цена тарифа", show_alert=True) + await callback.answer('Некорректная цена тарифа', show_alert=True) return # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < daily_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return texts = get_texts(db_user.language) @@ -1337,11 +1271,10 @@ async def confirm_daily_tariff_purchase( try: # Списываем первый день сразу success = await subtract_user_balance( - db, db_user, daily_price, - f"Покупка суточного тарифа {tariff.name} (первый день)" + db, db_user, daily_price, f'Покупка суточного тарифа {tariff.name} (первый день)' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из тарифа @@ -1350,6 +1283,7 @@ async def confirm_daily_tariff_purchase( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -1362,7 +1296,7 @@ async def confirm_daily_tariff_purchase( existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb existing_subscription.device_limit = tariff.device_limit existing_subscription.connected_squads = squads - existing_subscription.status = "active" + existing_subscription.status = 'active' existing_subscription.is_trial = False # Сбрасываем триальный статус existing_subscription.is_daily_paused = False existing_subscription.last_daily_charge_at = datetime.utcnow() @@ -1370,9 +1304,13 @@ async def confirm_daily_tariff_purchase( existing_subscription.end_date = datetime.utcnow() + timedelta(days=1) # Сбрасываем докупленный трафик при смене тарифа - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete - await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == existing_subscription.id)) + + from app.database.models import TrafficPurchase + + await db.execute( + sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == existing_subscription.id) + ) existing_subscription.purchased_traffic_gb = 0 existing_subscription.traffic_reset_at = None @@ -1404,10 +1342,10 @@ async def confirm_daily_tariff_purchase( db, subscription, reset_traffic=True, - reset_reason="покупка суточного тарифа", + reset_reason='покупка суточного тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -1415,7 +1353,7 @@ async def confirm_daily_tariff_purchase( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-daily_price, - description=f"Покупка суточного тарифа {tariff.name} (первый день)", + description=f'Покупка суточного тарифа {tariff.name} (первый день)', ) # Отправляем уведомление админу @@ -1431,47 +1369,50 @@ async def confirm_daily_tariff_purchase( amount_kopeks=daily_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f"Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}") + logger.info(f'Корзина очищена после покупки суточного тарифа для пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка очистки корзины: {e}") + logger.error(f'Ошибка очистки корзины: {e}') await state.clear() traffic = _format_traffic(tariff.traffic_limit_gb) await callback.message.edit_text( - f"🎉 Суточная подписка оформлена!\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"🔄 Тип: Суточный\n" - f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" - f"ℹ️ Следующее списание через 24 часа.\n" - f"Перейдите в раздел «Подписка» для подключения.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Суточная подписка оформлена!\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'🔄 Тип: Суточный\n' + f'💰 Списано: {_format_price_kopeks(daily_price)}\n\n' + f'ℹ️ Следующее списание через 24 часа.\n' + f'Перейдите в раздел «Подписка» для подключения.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Подписка оформлена!", show_alert=True) + await callback.answer('Подписка оформлена!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при покупке суточного тарифа: {e}", exc_info=True) - await callback.answer("Произошла ошибка при оформлении подписки", show_alert=True) + logger.error(f'Ошибка при покупке суточного тарифа: {e}', exc_info=True) + await callback.answer('Произошла ошибка при оформлении подписки', show_alert=True) # ==================== Продление по тарифу ==================== + def get_tariff_extend_keyboard( tariff: Tariff, language: str, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> InlineKeyboardMarkup: """Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам.""" texts = get_texts(language) @@ -1488,23 +1429,15 @@ def get_tariff_extend_keyboard( discount_percent = _get_user_period_discount(db_user, period) if discount_percent > 0: - original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" + price_text = f'{_format_price_kopeks(price)} 🔥−{discount_percent}%' else: price_text = _format_price_kopeks(price) - button_text = f"{_format_period(period)} — {price_text}" - buttons.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_extend:{tariff.id}:{period}" - ) - ]) + button_text = f'{_format_period(period)} — {price_text}' + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'tariff_extend:{tariff.id}:{period}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -1516,20 +1449,16 @@ def get_tariff_extend_confirm_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру подтверждения продления по тарифу.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить продление", - callback_data=f"tariff_ext_confirm:{tariff_id}:{period}" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_extend" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Подтвердить продление', callback_data=f'tariff_ext_confirm:{tariff_id}:{period}' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='subscription_extend')], ] - ]) + ) async def show_tariff_extend( @@ -1538,16 +1467,16 @@ async def show_tariff_extend( db: AsyncSession, ): """Показывает экран продления по текущему тарифу.""" - texts = get_texts(db_user.language) + get_texts(db_user.language) subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription or not subscription.tariff_id: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return tariff = await get_tariff_by_id(db, subscription.tariff_id) if not tariff: - await callback.answer("Тариф не найден", show_alert=True) + await callback.answer('Тариф не найден', show_alert=True) return traffic = _format_traffic(tariff.traffic_limit_gb) @@ -1560,18 +1489,18 @@ async def show_tariff_extend( if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: has_period_discounts = True - discount_hint = "" + discount_hint = '' if has_period_discounts: - discount_hint = "\n🎁 Скидки зависят от выбранного периода" + discount_hint = '\n🎁 Скидки зависят от выбранного периода' await callback.message.edit_text( - f"🔄 Продление подписки{discount_hint}\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n\n" - "Выберите период продления:", + f'🔄 Продление подписки{discount_hint}\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n\n' + 'Выберите период продления:', reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -1585,13 +1514,13 @@ async def select_tariff_extend_period( ): """Обрабатывает выбор периода для продления.""" texts = get_texts(db_user.language) - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем скидку для выбранного периода @@ -1608,22 +1537,22 @@ async def select_tariff_extend_period( traffic = _format_traffic(tariff.traffic_limit_gb) if user_balance >= final_price: - discount_text = "" + discount_text = '' if discount_percent > 0: - discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})' await callback.message.edit_text( - f"✅ Подтверждение продления\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(period)}\n" - f"{discount_text}\n" - f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + f'✅ Подтверждение продления\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(period)}\n' + f'{discount_text}\n' + f'💰 К оплате: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - final_price)}', reply_markup=get_tariff_extend_confirm_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: missing = final_price - user_balance @@ -1642,7 +1571,7 @@ async def select_tariff_extend_period( 'saved_cart': True, 'missing_amount': missing, 'return_to_cart': True, - 'description': f"Продление тарифа {tariff.name} на {period} дней", + 'description': f'Продление тарифа {tariff.name} на {period} дней', 'traffic_limit_gb': tariff.traffic_limit_gb, 'device_limit': tariff.device_limit, 'allowed_squads': tariff.allowed_squads or [], @@ -1651,18 +1580,20 @@ async def select_tariff_extend_period( await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n" - f"🛒 Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")], - [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_extend")] - ]), - parse_mode="HTML" + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 К оплате: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}\n\n' + f'🛒 Корзина сохранена! После пополнения баланса подписка будет продлена автоматически.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=texts.BACK, callback_data='subscription_extend')], + ] + ), + parse_mode='HTML', ) await state.update_data( @@ -1681,18 +1612,18 @@ async def confirm_tariff_extend( state: FSMContext, ): """Подтверждает продление по тарифу.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("Подписка не найдена", show_alert=True) + await callback.answer('Подписка не найдена', show_alert=True) return data = await state.get_data() @@ -1706,7 +1637,7 @@ async def confirm_tariff_extend( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < final_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return texts = get_texts(db_user.language) @@ -1714,11 +1645,10 @@ async def confirm_tariff_extend( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, - f"Продление тарифа {tariff.name} на {period} дней" + db, db_user, final_price, f'Продление тарифа {tariff.name} на {period} дней' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Продлеваем подписку (параметры тарифа не меняются, только добавляется время) @@ -1735,10 +1665,10 @@ async def confirm_tariff_extend( db, subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="продление тарифа", + reset_reason='продление тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -1746,7 +1676,7 @@ async def confirm_tariff_extend( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-final_price, - description=f"Продление тарифа {tariff.name} на {period} дней", + description=f'Продление тарифа {tariff.name} на {period} дней', ) # Отправляем уведомление админу @@ -1762,77 +1692,80 @@ async def confirm_tariff_extend( amount_kopeks=final_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f"Корзина очищена после продления тарифа для пользователя {db_user.telegram_id}") + logger.info(f'Корзина очищена после продления тарифа для пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка очистки корзины: {e}") + logger.error(f'Ошибка очистки корзины: {e}') await state.clear() traffic = _format_traffic(tariff.traffic_limit_gb) await callback.message.edit_text( - f"🎉 Подписка успешно продлена!\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Добавлено: {_format_period(period)}\n" - f"💰 Списано: {_format_price_kopeks(final_price)}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Подписка успешно продлена!\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Добавлено: {_format_period(period)}\n' + f'💰 Списано: {_format_price_kopeks(final_price)}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Подписка продлена!", show_alert=True) + await callback.answer('Подписка продлена!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при продлении тарифа: {e}", exc_info=True) - await callback.answer("Произошла ошибка при продлении подписки", show_alert=True) + logger.error(f'Ошибка при продлении тарифа: {e}', exc_info=True) + await callback.answer('Произошла ошибка при продлении подписки', show_alert=True) # ==================== Переключение тарифов ==================== + def format_tariff_switch_list_text( - tariffs: List[Tariff], - current_tariff_id: Optional[int], + tariffs: list[Tariff], + current_tariff_id: int | None, current_tariff_name: str, - db_user: Optional[User] = None, + db_user: User | None = None, has_period_discounts: bool = False, ) -> str: """Форматирует текст со списком тарифов для переключения.""" lines = [ - "📦 Смена тарифа", - f"📌 Текущий: {current_tariff_name}", + '📦 Смена тарифа', + f'📌 Текущий: {current_tariff_name}', ] if has_period_discounts: - lines.append("🎁 Скидки по периодам") + lines.append('🎁 Скидки по периодам') - lines.append("") - lines.append("⚠️ Оплачивается полная стоимость.") - lines.append("") + lines.append('') + lines.append('⚠️ Оплачивается полная стоимость.') + lines.append('') for tariff in tariffs: if tariff.id == current_tariff_id: continue traffic_gb = tariff.traffic_limit_gb - traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + traffic = '∞' if traffic_gb == 0 else f'{traffic_gb}ГБ' # Проверяем суточный ли тариф is_daily = getattr(tariff, 'is_daily', False) - price_text = "" - discount_icon = "" + price_text = '' + discount_icon = '' if is_daily: # Для суточных тарифов показываем цену за день daily_price = getattr(tariff, 'daily_price_kopeks', 0) - price_text = f"🔄 {_format_price_kopeks(daily_price, compact=True)}/день" + price_text = f'🔄 {_format_price_kopeks(daily_price, compact=True)}/день' else: prices = tariff.period_prices or {} if prices: @@ -1843,22 +1776,22 @@ def format_tariff_switch_list_text( discount_percent = _get_user_period_discount(db_user, int(min_period)) if discount_percent > 0: min_price = _apply_promo_discount(min_price, discount_percent) - discount_icon = "🔥" - price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + discount_icon = '🔥' + price_text = f'от {_format_price_kopeks(min_price, compact=True)}{discount_icon}' - lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + lines.append(f'{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}') if tariff.description: - lines.append(f"{tariff.description}") + lines.append(f'{tariff.description}') - lines.append("") + lines.append('') - return "\n".join(lines) + return '\n'.join(lines) def get_tariff_switch_keyboard( - tariffs: List[Tariff], - current_tariff_id: Optional[int], + tariffs: list[Tariff], + current_tariff_id: int | None, language: str, ) -> InlineKeyboardMarkup: """Создает компактную клавиатуру выбора тарифа для переключения.""" @@ -1869,16 +1802,9 @@ def get_tariff_switch_keyboard( if tariff.id == current_tariff_id: continue - buttons.append([ - InlineKeyboardButton( - text=tariff.name, - callback_data=f"tariff_sw_select:{tariff.id}" - ) - ]) + buttons.append([InlineKeyboardButton(text=tariff.name, callback_data=f'tariff_sw_select:{tariff.id}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -1886,7 +1812,7 @@ def get_tariff_switch_keyboard( def get_tariff_switch_periods_keyboard( tariff: Tariff, language: str, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> InlineKeyboardMarkup: """Создает клавиатуру выбора периода для переключения тарифа с учетом скидок по периодам.""" texts = get_texts(language) @@ -1903,23 +1829,15 @@ def get_tariff_switch_periods_keyboard( discount_percent = _get_user_period_discount(db_user, period) if discount_percent > 0: - original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" + price_text = f'{_format_price_kopeks(price)} 🔥−{discount_percent}%' else: price_text = _format_price_kopeks(price) - button_text = f"{_format_period(period)} — {price_text}" - buttons.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"tariff_sw_period:{tariff.id}:{period}" - ) - ]) + button_text = f'{_format_period(period)} — {price_text}' + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'tariff_sw_period:{tariff.id}:{period}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="tariff_switch") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='tariff_switch')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -1931,20 +1849,16 @@ def get_tariff_switch_confirm_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру подтверждения переключения тарифа.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить переключение", - callback_data=f"tariff_sw_confirm:{tariff_id}:{period}" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"tariff_sw_select:{tariff_id}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Подтвердить переключение', callback_data=f'tariff_sw_confirm:{tariff_id}:{period}' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_sw_select:{tariff_id}')], ] - ]) + ) def get_tariff_switch_insufficient_balance_keyboard( @@ -1954,20 +1868,12 @@ def get_tariff_switch_insufficient_balance_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру при недостаточном балансе для переключения.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="balance_topup" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"tariff_sw_select:{tariff_id}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_sw_select:{tariff_id}')], ] - ]) + ) @error_handler @@ -1984,7 +1890,7 @@ async def show_tariff_switch_list( # Проверяем наличие активной подписки subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("У вас нет активной подписки", show_alert=True) + await callback.answer('У вас нет активной подписки', show_alert=True) return current_tariff_id = subscription.tariff_id @@ -1998,18 +1904,17 @@ async def show_tariff_switch_list( if not available_tariffs: await callback.message.edit_text( - "😔 Нет доступных тарифов для переключения\n\n" - "Вы уже используете единственный доступный тариф.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] - ]), - parse_mode="HTML" + '😔 Нет доступных тарифов для переключения\n\nВы уже используете единственный доступный тариф.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]] + ), + parse_mode='HTML', ) await callback.answer() return # Получаем текущий тариф для отображения - current_tariff_name = "Неизвестно" + current_tariff_name = 'Неизвестно' if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: @@ -2031,7 +1936,7 @@ async def show_tariff_switch_list( await callback.message.edit_text( switch_text, reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -2048,11 +1953,11 @@ async def select_tariff_switch( state: FSMContext, ): """Обрабатывает выбор тарифа для переключения.""" - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return traffic = _format_traffic(tariff.traffic_limit_gb) @@ -2067,59 +1972,56 @@ async def select_tariff_switch( # Проверяем текущую подписку на оставшиеся дни current_subscription = await get_subscription_by_user_id(db, db_user.id) - days_warning = "" + days_warning = '' if current_subscription and current_subscription.end_date: from datetime import datetime + remaining = current_subscription.end_date - datetime.utcnow() remaining_days = max(0, remaining.days) if remaining_days > 1: - days_warning = f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + days_warning = f'\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!' if user_balance >= daily_price: await callback.message.edit_text( - f"✅ Подтверждение смены тарифа\n\n" - f"📦 Новый тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"🔄 Тип: Суточный\n\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" - f"{days_warning}\n\n" - f"ℹ️ Средства будут списываться автоматически раз в сутки.\n" - f"Вы можете приостановить подписку в любой момент.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="✅ Подтвердить смену", - callback_data=f"daily_tariff_switch_confirm:{tariff_id}" - )], - [InlineKeyboardButton( - text=get_texts(db_user.language).BACK, - callback_data="tariff_switch" - )] - ]), - parse_mode="HTML" + f'✅ Подтверждение смены тарифа\n\n' + f'📦 Новый тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'🔄 Тип: Суточный\n\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}' + f'{days_warning}\n\n' + f'ℹ️ Средства будут списываться автоматически раз в сутки.\n' + f'Вы можете приостановить подписку в любой момент.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Подтвердить смену', callback_data=f'daily_tariff_switch_confirm:{tariff_id}' + ) + ], + [InlineKeyboardButton(text=get_texts(db_user.language).BACK, callback_data='tariff_switch')], + ] + ), + parse_mode='HTML', ) else: missing = daily_price - user_balance await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"🔄 Тип: Суточный\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}" - f"{days_warning}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="balance_topup" - )], - [InlineKeyboardButton( - text=get_texts(db_user.language).BACK, - callback_data="tariff_switch" - )] - ]), - parse_mode="HTML" + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'🔄 Тип: Суточный\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}' + f'{days_warning}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=get_texts(db_user.language).BACK, callback_data='tariff_switch')], + ] + ), + parse_mode='HTML', ) else: # Для обычного тарифа показываем выбор периода @@ -2131,14 +2033,14 @@ async def select_tariff_switch( """ if tariff.description: - info_text += f"\n📝 {tariff.description}\n" + info_text += f'\n📝 {tariff.description}\n' - info_text += "\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:" + info_text += '\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:' await callback.message.edit_text( info_text, reply_markup=get_tariff_switch_periods_keyboard(tariff, db_user.language, db_user=db_user), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data(switch_tariff_id=tariff_id) @@ -2155,13 +2057,13 @@ async def select_tariff_switch_period( """Обрабатывает выбор периода для переключения тарифа.""" from datetime import datetime - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return data = await state.get_data() @@ -2181,7 +2083,7 @@ async def select_tariff_switch_period( traffic = _format_traffic(tariff.traffic_limit_gb) # Получаем текущий тариф для отображения - current_tariff_name = "Неизвестно" + current_tariff_name = 'Неизвестно' if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: @@ -2189,43 +2091,42 @@ async def select_tariff_switch_period( # Получаем текущую подписку для расчёта оставшегося времени subscription = await get_subscription_by_user_id(db, db_user.id) - remaining_days = 0 if subscription and subscription.end_date: - remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) + max(0, (subscription.end_date - datetime.utcnow()).days) # При смене тарифа устанавливается ровно оплаченный период - time_info = f"⏰ Будет установлено: {period} дней" + time_info = f'⏰ Будет установлено: {period} дней' if user_balance >= final_price: - discount_text = "" + discount_text = '' if discount_percent > 0: - discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})" + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(base_price - final_price)})' await callback.message.edit_text( - f"✅ Подтверждение переключения тарифа\n\n" - f"📌 Текущий тариф: {current_tariff_name}\n" - f"📦 Новый тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"{time_info}\n" - f"{discount_text}\n" - f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - final_price)}", + f'✅ Подтверждение переключения тарифа\n\n' + f'📌 Текущий тариф: {current_tariff_name}\n' + f'📦 Новый тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'{time_info}\n' + f'{discount_text}\n' + f'💰 К оплате: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - final_price)}', reply_markup=get_tariff_switch_confirm_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: missing = final_price - user_balance await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 К оплате: {_format_price_kopeks(final_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 К оплате: {_format_price_kopeks(final_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}', reply_markup=get_tariff_switch_insufficient_balance_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -2244,13 +2145,13 @@ async def confirm_tariff_switch( state: FSMContext, ): """Подтверждает переключение тарифа.""" - parts = callback.data.split(":") + parts = callback.data.split(':') tariff_id = int(parts[1]) period = int(parts[2]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем скидку для выбранного периода @@ -2264,13 +2165,13 @@ async def confirm_tariff_switch( # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < final_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return # Проверяем наличие подписки subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("У вас нет активной подписки", show_alert=True) + await callback.answer('У вас нет активной подписки', show_alert=True) return texts = get_texts(db_user.language) @@ -2278,11 +2179,10 @@ async def confirm_tariff_switch( try: # Списываем баланс success = await subtract_user_balance( - db, db_user, final_price, - f"Смена тарифа на {tariff.name} ({period} дней)" + db, db_user, final_price, f'Смена тарифа на {tariff.name} ({period} дней)' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из тарифа @@ -2291,6 +2191,7 @@ async def confirm_tariff_switch( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -2316,10 +2217,10 @@ async def confirm_tariff_switch( db, subscription, reset_traffic=True, - reset_reason="переключение тарифа", + reset_reason='переключение тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave при переключении тарифа: {e}") + logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}') # Создаем транзакцию await create_transaction( @@ -2327,7 +2228,7 @@ async def confirm_tariff_switch( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-final_price, - description=f"Смена тарифа на {tariff.name}", + description=f'Смена тарифа на {tariff.name}', ) # Отправляем уведомление админу @@ -2343,45 +2244,48 @@ async def confirm_tariff_switch( amount_kopeks=final_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') # Очищаем корзину после успешной покупки try: await user_cart_service.delete_user_cart(db_user.id) - logger.info(f"Корзина очищена после смены тарифа для пользователя {db_user.telegram_id}") + logger.info(f'Корзина очищена после смены тарифа для пользователя {db_user.telegram_id}') except Exception as e: - logger.error(f"Ошибка очистки корзины: {e}") + logger.error(f'Ошибка очистки корзины: {e}') await state.clear() traffic = _format_traffic(tariff.traffic_limit_gb) # При смене тарифа устанавливается оплаченный период - time_info = f"📅 Период: {days_for_new_tariff} дней" + time_info = f'📅 Период: {days_for_new_tariff} дней' await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"💰 Списано: {_format_price_kopeks(final_price)}\n" - f"{time_info}\n\n" - f"Перейдите в раздел «Подписка» для просмотра деталей.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Тариф успешно изменён!\n\n' + f'📦 Новый тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'💰 Списано: {_format_price_kopeks(final_price)}\n' + f'{time_info}\n\n' + f'Перейдите в раздел «Подписка» для просмотра деталей.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Тариф изменён!", show_alert=True) + await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при переключении тарифа: {e}", exc_info=True) - await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + logger.error(f'Ошибка при переключении тарифа: {e}', exc_info=True) + await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) # ==================== Смена на суточный тариф ==================== + @error_handler async def confirm_daily_tariff_switch( callback: types.CallbackQuery, @@ -2392,33 +2296,33 @@ async def confirm_daily_tariff_switch( """Подтверждает смену на суточный тариф.""" from datetime import datetime - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return is_daily = getattr(tariff, 'is_daily', False) if not is_daily: - await callback.answer("Это не суточный тариф", show_alert=True) + await callback.answer('Это не суточный тариф', show_alert=True) return daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price <= 0: - await callback.answer("Некорректная цена тарифа", show_alert=True) + await callback.answer('Некорректная цена тарифа', show_alert=True) return # Проверяем баланс user_balance = db_user.balance_kopeks or 0 if user_balance < daily_price: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return # Проверяем наличие подписки subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("У вас нет активной подписки", show_alert=True) + await callback.answer('У вас нет активной подписки', show_alert=True) return texts = get_texts(db_user.language) @@ -2426,11 +2330,10 @@ async def confirm_daily_tariff_switch( try: # Списываем первый день сразу success = await subtract_user_balance( - db, db_user, daily_price, - f"Смена на суточный тариф {tariff.name} (первый день)" + db, db_user, daily_price, f'Смена на суточный тариф {tariff.name} (первый день)' ) if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из тарифа @@ -2439,6 +2342,7 @@ async def confirm_daily_tariff_switch( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -2447,7 +2351,7 @@ async def confirm_daily_tariff_switch( subscription.traffic_limit_gb = tariff.traffic_limit_gb subscription.device_limit = tariff.device_limit subscription.connected_squads = squads - subscription.status = "active" + subscription.status = 'active' subscription.is_trial = False # Сбрасываем триальный статус subscription.is_daily_paused = False subscription.last_daily_charge_at = datetime.utcnow() @@ -2455,8 +2359,10 @@ async def confirm_daily_tariff_switch( subscription.end_date = datetime.utcnow() + timedelta(days=1) # Сбрасываем докупленный трафик при смене тарифа - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None @@ -2471,10 +2377,10 @@ async def confirm_daily_tariff_switch( db, subscription, reset_traffic=True, - reset_reason="смена на суточный тариф", + reset_reason='смена на суточный тариф', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave: {e}") + logger.error(f'Ошибка обновления Remnawave: {e}') # Создаем транзакцию await create_transaction( @@ -2482,7 +2388,7 @@ async def confirm_daily_tariff_switch( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-daily_price, - description=f"Смена на суточный тариф {tariff.name} (первый день)", + description=f'Смена на суточный тариф {tariff.name} (первый день)', ) # Отправляем уведомление админу @@ -2498,35 +2404,38 @@ async def confirm_daily_tariff_switch( amount_kopeks=daily_price, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') await state.clear() traffic = _format_traffic(tariff.traffic_limit_gb) await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"🔄 Тип: Суточный\n" - f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" - f"ℹ️ Следующее списание через 24 часа.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Тариф успешно изменён!\n\n' + f'📦 Новый тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'🔄 Тип: Суточный\n' + f'💰 Списано: {_format_price_kopeks(daily_price)}\n\n' + f'ℹ️ Следующее списание через 24 часа.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Тариф изменён!", show_alert=True) + await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при смене на суточный тариф: {e}", exc_info=True) - await callback.answer("Произошла ошибка при смене тарифа", show_alert=True) + logger.error(f'Ошибка при смене на суточный тариф: {e}', exc_info=True) + await callback.answer('Произошла ошибка при смене тарифа', show_alert=True) # ==================== Мгновенное переключение тарифов (без выбора периода) ==================== + def _get_tariff_monthly_price(tariff: Tariff) -> int: """Получает месячную цену тарифа (30 дней) с fallback на пропорциональный расчёт.""" price = tariff.get_price_for_period(30) @@ -2548,7 +2457,7 @@ def _calculate_instant_switch_cost( current_tariff: Tariff, new_tariff: Tariff, remaining_days: int, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> tuple[int, bool]: """ Рассчитывает стоимость мгновенного переключения тарифа. @@ -2583,21 +2492,21 @@ def _calculate_instant_switch_cost( def format_instant_switch_list_text( - tariffs: List[Tariff], + tariffs: list[Tariff], current_tariff: Tariff, remaining_days: int, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> str: """Форматирует текст со списком тарифов для мгновенного переключения.""" lines = [ - "📦 Мгновенная смена тарифа", - f"📌 Текущий: {current_tariff.name}", - f"⏰ Осталось: {remaining_days} дн.", - "", - "💡 При переключении остаток дней сохраняется.", - "⬆️ Повышение тарифа = доплата за разницу", - "⬇️ Понижение = бесплатно", - "", + '📦 Мгновенная смена тарифа', + f'📌 Текущий: {current_tariff.name}', + f'⏰ Осталось: {remaining_days} дн.', + '', + '💡 При переключении остаток дней сохраняется.', + '⬆️ Повышение тарифа = доплата за разницу', + '⬇️ Понижение = бесплатно', + '', ] for tariff in tariffs: @@ -2605,34 +2514,32 @@ def format_instant_switch_list_text( continue traffic_gb = tariff.traffic_limit_gb - traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + traffic = '∞' if traffic_gb == 0 else f'{traffic_gb}ГБ' # Рассчитываем стоимость переключения - cost, is_upgrade = _calculate_instant_switch_cost( - current_tariff, tariff, remaining_days, db_user - ) + cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user) if is_upgrade: - cost_text = f"⬆️ +{_format_price_kopeks(cost, compact=True)}" + cost_text = f'⬆️ +{_format_price_kopeks(cost, compact=True)}' else: - cost_text = "⬇️ Бесплатно" + cost_text = '⬇️ Бесплатно' - lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {cost_text}") + lines.append(f'{tariff.name} — {traffic}/{tariff.device_limit}📱 {cost_text}') if tariff.description: - lines.append(f"{tariff.description}") + lines.append(f'{tariff.description}') - lines.append("") + lines.append('') - return "\n".join(lines) + return '\n'.join(lines) def get_instant_switch_keyboard( - tariffs: List[Tariff], + tariffs: list[Tariff], current_tariff: Tariff, remaining_days: int, language: str, - db_user: Optional[User] = None, + db_user: User | None = None, ) -> InlineKeyboardMarkup: """Создает клавиатуру для мгновенного переключения тарифа.""" texts = get_texts(language) @@ -2643,25 +2550,16 @@ def get_instant_switch_keyboard( continue # Рассчитываем стоимость - cost, is_upgrade = _calculate_instant_switch_cost( - current_tariff, tariff, remaining_days, db_user - ) + cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, tariff, remaining_days, db_user) if is_upgrade: - btn_text = f"{tariff.name} (+{_format_price_kopeks(cost, compact=True)})" + btn_text = f'{tariff.name} (+{_format_price_kopeks(cost, compact=True)})' else: - btn_text = f"{tariff.name} (бесплатно)" + btn_text = f'{tariff.name} (бесплатно)' - buttons.append([ - InlineKeyboardButton( - text=btn_text, - callback_data=f"instant_sw_preview:{tariff.id}" - ) - ]) + buttons.append([InlineKeyboardButton(text=btn_text, callback_data=f'instant_sw_preview:{tariff.id}')]) - buttons.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -2672,20 +2570,12 @@ def get_instant_switch_confirm_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру подтверждения мгновенного переключения.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="✅ Подтвердить переключение", - callback_data=f"instant_sw_confirm:{tariff_id}" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="instant_switch" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✅ Подтвердить переключение', callback_data=f'instant_sw_confirm:{tariff_id}')], + [InlineKeyboardButton(text=texts.BACK, callback_data='instant_switch')], ] - ]) + ) def get_instant_switch_insufficient_balance_keyboard( @@ -2694,20 +2584,12 @@ def get_instant_switch_insufficient_balance_keyboard( ) -> InlineKeyboardMarkup: """Создает клавиатуру при недостаточном балансе для мгновенного переключения.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="balance_topup" - ) - ], - [ - InlineKeyboardButton( - text=texts.BACK, - callback_data="instant_switch" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='balance_topup')], + [InlineKeyboardButton(text=texts.BACK, callback_data='instant_switch')], ] - ]) + ) @error_handler @@ -2726,17 +2608,17 @@ async def show_instant_switch_list( # Проверяем наличие активной подписки subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("У вас нет активной подписки", show_alert=True) + await callback.answer('У вас нет активной подписки', show_alert=True) return if not subscription.tariff_id: - await callback.answer("У вашей подписки нет тарифа", show_alert=True) + await callback.answer('У вашей подписки нет тарифа', show_alert=True) return # Получаем текущий тариф current_tariff = await get_tariff_by_id(db, subscription.tariff_id) if not current_tariff: - await callback.answer("Текущий тариф не найден", show_alert=True) + await callback.answer('Текущий тариф не найден', show_alert=True) return # Рассчитываем оставшиеся дни @@ -2746,13 +2628,13 @@ async def show_instant_switch_list( if remaining_days == 0: await callback.message.edit_text( - "❌ Переключение недоступно\n\n" - "У вашей подписки не осталось активных дней.\n" - "Используйте продление или покупку нового тарифа.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] - ]), - parse_mode="HTML" + '❌ Переключение недоступно\n\n' + 'У вашей подписки не осталось активных дней.\n' + 'Используйте продление или покупку нового тарифа.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]] + ), + parse_mode='HTML', ) await callback.answer() return @@ -2766,27 +2648,22 @@ async def show_instant_switch_list( if not available_tariffs: await callback.message.edit_text( - "😔 Нет доступных тарифов для переключения\n\n" - "Вы уже используете единственный доступный тариф.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")] - ]), - parse_mode="HTML" + '😔 Нет доступных тарифов для переключения\n\nВы уже используете единственный доступный тариф.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]] + ), + parse_mode='HTML', ) await callback.answer() return # Формируем текст со списком тарифов - switch_text = format_instant_switch_list_text( - tariffs, current_tariff, remaining_days, db_user - ) + switch_text = format_instant_switch_list_text(tariffs, current_tariff, remaining_days, db_user) await callback.message.edit_text( switch_text, - reply_markup=get_instant_switch_keyboard( - tariffs, current_tariff, remaining_days, db_user.language, db_user - ), - parse_mode="HTML" + reply_markup=get_instant_switch_keyboard(tariffs, current_tariff, remaining_days, db_user.language, db_user), + parse_mode='HTML', ) await state.update_data( @@ -2806,11 +2683,11 @@ async def preview_instant_switch( """Показывает превью мгновенного переключения тарифа.""" from datetime import datetime - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) new_tariff = await get_tariff_by_id(db, tariff_id) if not new_tariff or not new_tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем данные из состояния @@ -2821,22 +2698,20 @@ async def preview_instant_switch( # Если данных нет в state, получаем заново subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription or not subscription.tariff_id: - await callback.answer("Подписка не найдена", show_alert=True) + await callback.answer('Подписка не найдена', show_alert=True) return current_tariff_id = current_tariff_id or subscription.tariff_id current_tariff = await get_tariff_by_id(db, current_tariff_id) if not current_tariff: - await callback.answer("Текущий тариф не найден", show_alert=True) + await callback.answer('Текущий тариф не найден', show_alert=True) return if not remaining_days and subscription.end_date: remaining_days = max(0, (subscription.end_date - datetime.utcnow()).days) # Рассчитываем стоимость переключения - upgrade_cost, is_upgrade = _calculate_instant_switch_cost( - current_tariff, new_tariff, remaining_days, db_user - ) + upgrade_cost, is_upgrade = _calculate_instant_switch_cost(current_tariff, new_tariff, remaining_days, db_user) # Проверяем баланс user_balance = db_user.balance_kopeks or 0 @@ -2848,11 +2723,11 @@ async def preview_instant_switch( # Проверяем, суточный ли новый тариф is_new_daily = getattr(new_tariff, 'is_daily', False) - daily_warning = "" + daily_warning = '' if is_new_daily and remaining_days > 1: daily_warning = texts.t( - "DAILY_SWITCH_WARNING", - f"\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!" + 'DAILY_SWITCH_WARNING', + f'\n\n⚠️ Внимание! У вас осталось {remaining_days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!', ).format(days=remaining_days) # Для суточного тарифа особая логика показа @@ -2862,33 +2737,33 @@ async def preview_instant_switch( if user_balance >= daily_price: await callback.message.edit_text( - f"🔄 Переключение на суточный тариф\n\n" - f"📌 Текущий: {current_tariff.name}\n" - f" • Трафик: {current_traffic}\n" - f" • Устройств: {current_tariff.device_limit}\n\n" - f"📦 Новый: {new_tariff.name}\n" - f" • Трафик: {traffic}\n" - f" • Устройств: {new_tariff.device_limit}\n" - f" • Тип: 🔄 Суточный\n\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}" - f"{daily_warning}\n\n" - f"ℹ️ Средства будут списываться автоматически раз в сутки.", + f'🔄 Переключение на суточный тариф\n\n' + f'📌 Текущий: {current_tariff.name}\n' + f' • Трафик: {current_traffic}\n' + f' • Устройств: {current_tariff.device_limit}\n\n' + f'📦 Новый: {new_tariff.name}\n' + f' • Трафик: {traffic}\n' + f' • Устройств: {new_tariff.device_limit}\n' + f' • Тип: 🔄 Суточный\n\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}' + f'{daily_warning}\n\n' + f'ℹ️ Средства будут списываться автоматически раз в сутки.', reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: missing = daily_price - user_balance await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Тариф: {new_tariff.name}\n" - f"🔄 Тип: Суточный\n" - f"💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}" - f"{daily_warning}", + f'❌ Недостаточно средств\n\n' + f'📦 Тариф: {new_tariff.name}\n' + f'🔄 Тип: Суточный\n' + f'💰 Цена: {_format_price_kopeks(daily_price)}/день\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}' + f'{daily_warning}', reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -2905,45 +2780,45 @@ async def preview_instant_switch( # Upgrade - нужна доплата if user_balance >= upgrade_cost: await callback.message.edit_text( - f"⬆️ Повышение тарифа\n\n" - f"📌 Текущий: {current_tariff.name}\n" - f" • Трафик: {current_traffic}\n" - f" • Устройств: {current_tariff.device_limit}\n\n" - f"📦 Новый: {new_tariff.name}\n" - f" • Трафик: {traffic}\n" - f" • Устройств: {new_tariff.device_limit}\n\n" - f"⏰ Осталось дней: {remaining_days}\n" - f"💰 Доплата: {_format_price_kopeks(upgrade_cost)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}", + f'⬆️ Повышение тарифа\n\n' + f'📌 Текущий: {current_tariff.name}\n' + f' • Трафик: {current_traffic}\n' + f' • Устройств: {current_tariff.device_limit}\n\n' + f'📦 Новый: {new_tariff.name}\n' + f' • Трафик: {traffic}\n' + f' • Устройств: {new_tariff.device_limit}\n\n' + f'⏰ Осталось дней: {remaining_days}\n' + f'💰 Доплата: {_format_price_kopeks(upgrade_cost)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - upgrade_cost)}', reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: missing = upgrade_cost - user_balance await callback.message.edit_text( - f"❌ Недостаточно средств\n\n" - f"📦 Новый тариф: {new_tariff.name}\n" - f"💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f'❌ Недостаточно средств\n\n' + f'📦 Новый тариф: {new_tariff.name}\n' + f'💰 Требуется доплата: {_format_price_kopeks(upgrade_cost)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}', reply_markup=get_instant_switch_insufficient_balance_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # Downgrade или тот же уровень - бесплатно await callback.message.edit_text( - f"⬇️ Переключение тарифа\n\n" - f"📌 Текущий: {current_tariff.name}\n" - f" • Трафик: {current_traffic}\n" - f" • Устройств: {current_tariff.device_limit}\n\n" - f"📦 Новый: {new_tariff.name}\n" - f" • Трафик: {traffic}\n" - f" • Устройств: {new_tariff.device_limit}\n\n" - f"⏰ Осталось дней: {remaining_days}\n" - f"💰 Бесплатно (понижение/равный тариф)", + f'⬇️ Переключение тарифа\n\n' + f'📌 Текущий: {current_tariff.name}\n' + f' • Трафик: {current_traffic}\n' + f' • Устройств: {current_tariff.device_limit}\n\n' + f'📦 Новый: {new_tariff.name}\n' + f' • Трафик: {traffic}\n' + f' • Устройств: {new_tariff.device_limit}\n\n' + f'⏰ Осталось дней: {remaining_days}\n' + f'💰 Бесплатно (понижение/равный тариф)', reply_markup=get_instant_switch_confirm_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.update_data( @@ -2966,11 +2841,11 @@ async def confirm_instant_switch( """Подтверждает мгновенное переключение тарифа.""" from datetime import datetime, timedelta - tariff_id = int(callback.data.split(":")[1]) + tariff_id = int(callback.data.split(':')[1]) new_tariff = await get_tariff_by_id(db, tariff_id) if not new_tariff or not new_tariff.is_active: - await callback.answer("Тариф недоступен", show_alert=True) + await callback.answer('Тариф недоступен', show_alert=True) return # Получаем данные из состояния @@ -2982,13 +2857,13 @@ async def confirm_instant_switch( # Проверяем подписку subscription = await get_subscription_by_user_id(db, db_user.id) if not subscription: - await callback.answer("Подписка не найдена", show_alert=True) + await callback.answer('Подписка не найдена', show_alert=True) return # Проверяем баланс если это upgrade user_balance = db_user.balance_kopeks or 0 if is_upgrade and user_balance < upgrade_cost: - await callback.answer("Недостаточно средств на балансе", show_alert=True) + await callback.answer('Недостаточно средств на балансе', show_alert=True) return texts = get_texts(db_user.language) @@ -2996,12 +2871,9 @@ async def confirm_instant_switch( try: # Списываем баланс если это upgrade if is_upgrade and upgrade_cost > 0: - success = await subtract_user_balance( - db, db_user, upgrade_cost, - f"Переключение на тариф {new_tariff.name}" - ) + success = await subtract_user_balance(db, db_user, upgrade_cost, f'Переключение на тариф {new_tariff.name}') if not success: - await callback.answer("Ошибка списания баланса", show_alert=True) + await callback.answer('Ошибка списания баланса', show_alert=True) return # Получаем список серверов из нового тарифа @@ -3010,6 +2882,7 @@ async def confirm_instant_switch( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -3023,8 +2896,10 @@ async def confirm_instant_switch( subscription.connected_squads = squads # Сбрасываем докупленный трафик при смене тарифа - from app.database.models import TrafficPurchase from sqlalchemy import delete as sql_delete + + from app.database.models import TrafficPurchase + await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None @@ -3037,15 +2912,14 @@ async def confirm_instant_switch( if upgrade_cost == 0 and daily_price > 0: if user_balance >= daily_price: await subtract_user_balance( - db, db_user, daily_price, - f"Переключение на суточный тариф {new_tariff.name} (первый день)" + db, db_user, daily_price, f'Переключение на суточный тариф {new_tariff.name} (первый день)' ) await create_transaction( db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-daily_price, - description=f"Переключение на суточный тариф {new_tariff.name} (первый день)", + description=f'Переключение на суточный тариф {new_tariff.name} (первый день)', ) subscription.end_date = datetime.utcnow() + timedelta(days=1) @@ -3063,10 +2937,10 @@ async def confirm_instant_switch( db, subscription, reset_traffic=False, # Не сбрасываем трафик при переключении - reset_reason="мгновенное переключение тарифа", + reset_reason='мгновенное переключение тарифа', ) except Exception as e: - logger.error(f"Ошибка обновления Remnawave при мгновенном переключении: {e}") + logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}') # Создаем транзакцию если была оплата if is_upgrade and upgrade_cost > 0: @@ -3075,7 +2949,7 @@ async def confirm_instant_switch( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=-upgrade_cost, - description=f"Переключение на тариф {new_tariff.name}", + description=f'Переключение на тариф {new_tariff.name}', ) # Отправляем уведомление админу @@ -3091,7 +2965,7 @@ async def confirm_instant_switch( amount_kopeks=upgrade_cost, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") + logger.error(f'Ошибка отправки уведомления админу: {e}') await state.clear() @@ -3101,43 +2975,47 @@ async def confirm_instant_switch( if is_new_daily: daily_price = getattr(new_tariff, 'daily_price_kopeks', 0) await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {new_tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {new_tariff.device_limit}\n" - f"🔄 Тип: Суточный\n" - f"💰 Списано: {_format_price_kopeks(daily_price)}\n\n" - f"ℹ️ Следующее списание через 24 часа.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Тариф успешно изменён!\n\n' + f'📦 Новый тариф: {new_tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {new_tariff.device_limit}\n' + f'🔄 Тип: Суточный\n' + f'💰 Списано: {_format_price_kopeks(daily_price)}\n\n' + f'ℹ️ Следующее списание через 24 часа.', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) else: if is_upgrade: - cost_text = f"💰 Списано: {_format_price_kopeks(upgrade_cost)}" + cost_text = f'💰 Списано: {_format_price_kopeks(upgrade_cost)}' else: - cost_text = "💰 Бесплатно" + cost_text = '💰 Бесплатно' await callback.message.edit_text( - f"🎉 Тариф успешно изменён!\n\n" - f"📦 Новый тариф: {new_tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {new_tariff.device_limit}\n" - f"⏰ Осталось дней: {remaining_days}\n" - f"{cost_text}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]), - parse_mode="HTML" + f'🎉 Тариф успешно изменён!\n\n' + f'📦 Новый тариф: {new_tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {new_tariff.device_limit}\n' + f'⏰ Осталось дней: {remaining_days}\n' + f'{cost_text}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ), + parse_mode='HTML', ) - await callback.answer("Тариф изменён!", show_alert=True) + await callback.answer('Тариф изменён!', show_alert=True) except Exception as e: - logger.error(f"Ошибка при мгновенном переключении тарифа: {e}", exc_info=True) - await callback.answer("Произошла ошибка при переключении тарифа", show_alert=True) + logger.error(f'Ошибка при мгновенном переключении тарифа: {e}', exc_info=True) + await callback.answer('Произошла ошибка при переключении тарифа', show_alert=True) async def return_to_saved_tariff_cart( @@ -3153,12 +3031,12 @@ async def return_to_saved_tariff_cart( tariff_id = cart_data.get('tariff_id') if not tariff_id: - await callback.answer("❌ Данные корзины повреждены", show_alert=True) + await callback.answer('❌ Данные корзины повреждены', show_alert=True) return tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: - await callback.answer("❌ Тариф больше недоступен", show_alert=True) + await callback.answer('❌ Тариф больше недоступен', show_alert=True) # Очищаем корзину await user_cart_service.delete_user_cart(db_user.id) return @@ -3173,38 +3051,38 @@ async def return_to_saved_tariff_cart( if cart_mode == 'daily_tariff_purchase': await callback.message.edit_text( - f"❌ Все еще недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"🔄 Тип: Суточный\n" - f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f'❌ Все еще недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'🔄 Тип: Суточный\n' + f'💰 Стоимость: {_format_price_kopeks(total_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}', reply_markup=get_daily_tariff_insufficient_balance_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) elif cart_mode == 'extend': period = cart_data.get('period_days', 30) await callback.message.edit_text( - f"❌ Все еще недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f'❌ Все еще недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 Стоимость: {_format_price_kopeks(total_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}', reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) else: # tariff_purchase period = cart_data.get('period_days', 30) await callback.message.edit_text( - f"❌ Все еще недостаточно средств\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📅 Период: {_format_period(period)}\n" - f"💰 Стоимость: {_format_price_kopeks(total_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"⚠️ Не хватает: {_format_price_kopeks(missing)}", + f'❌ Все еще недостаточно средств\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📅 Период: {_format_period(period)}\n' + f'💰 Стоимость: {_format_price_kopeks(total_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'⚠️ Не хватает: {_format_price_kopeks(missing)}', reply_markup=get_tariff_insufficient_balance_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() return @@ -3216,110 +3094,110 @@ async def return_to_saved_tariff_cart( daily_price = cart_data.get('daily_price_kopeks', total_price) await callback.message.edit_text( - f"✅ Подтверждение покупки\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"🔄 Тип: Суточный\n" - f"💰 Стоимость в день: {_format_price_kopeks(daily_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - daily_price)}", + f'✅ Подтверждение покупки\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'🔄 Тип: Суточный\n' + f'💰 Стоимость в день: {_format_price_kopeks(daily_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - daily_price)}', reply_markup=get_daily_tariff_confirm_keyboard(tariff_id, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) elif cart_mode == 'extend': period = cart_data.get('period_days', 30) - discount_text = "" + discount_text = '' if discount_percent > 0: original_price = int(total_price / (1 - discount_percent / 100)) - discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})" + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})' await callback.message.edit_text( - f"✅ Подтверждение продления\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(period)}\n" - f"{discount_text}\n" - f"💰 Итого: {_format_price_kopeks(total_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - total_price)}", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="✅ Подтвердить продление", - callback_data=f"tariff_ext_confirm:{tariff_id}:{period}" - )], - [InlineKeyboardButton( - text=texts.BACK, - callback_data=f"tariff_extend:{tariff_id}" - )] - ]), - parse_mode="HTML" + f'✅ Подтверждение продления\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(period)}\n' + f'{discount_text}\n' + f'💰 Итого: {_format_price_kopeks(total_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - total_price)}', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text='✅ Подтвердить продление', callback_data=f'tariff_ext_confirm:{tariff_id}:{period}' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data=f'tariff_extend:{tariff_id}')], + ] + ), + parse_mode='HTML', ) else: # tariff_purchase period = cart_data.get('period_days', 30) - discount_text = "" + discount_text = '' if discount_percent > 0: original_price = int(total_price / (1 - discount_percent / 100)) - discount_text = f"\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})" + discount_text = f'\n🎁 Скидка: {discount_percent}% (-{_format_price_kopeks(original_price - total_price)})' await callback.message.edit_text( - f"✅ Подтверждение покупки\n\n" - f"📦 Тариф: {tariff.name}\n" - f"📊 Трафик: {traffic}\n" - f"📱 Устройств: {tariff.device_limit}\n" - f"📅 Период: {_format_period(period)}\n" - f"{discount_text}\n" - f"💰 Итого: {_format_price_kopeks(total_price)}\n\n" - f"💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n" - f"После оплаты: {_format_price_kopeks(user_balance - total_price)}", + f'✅ Подтверждение покупки\n\n' + f'📦 Тариф: {tariff.name}\n' + f'📊 Трафик: {traffic}\n' + f'📱 Устройств: {tariff.device_limit}\n' + f'📅 Период: {_format_period(period)}\n' + f'{discount_text}\n' + f'💰 Итого: {_format_price_kopeks(total_price)}\n\n' + f'💳 Ваш баланс: {_format_price_kopeks(user_balance)}\n' + f'После оплаты: {_format_price_kopeks(user_balance - total_price)}', reply_markup=get_tariff_confirm_keyboard(tariff_id, period, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) - await callback.answer("✅ Корзина восстановлена!") + await callback.answer('✅ Корзина восстановлена!') def register_tariff_purchase_handlers(dp: Dispatcher): """Регистрирует обработчики покупки по тарифам.""" # Список тарифов (для режима tariffs) - dp.callback_query.register(show_tariffs_list, F.data == "tariff_list") - dp.callback_query.register(show_tariffs_list, F.data == "buy_subscription_tariffs") + dp.callback_query.register(show_tariffs_list, F.data == 'tariff_list') + dp.callback_query.register(show_tariffs_list, F.data == 'buy_subscription_tariffs') # Выбор тарифа - dp.callback_query.register(select_tariff, F.data.startswith("tariff_select:")) + dp.callback_query.register(select_tariff, F.data.startswith('tariff_select:')) # Выбор периода - dp.callback_query.register(select_tariff_period, F.data.startswith("tariff_period:")) + dp.callback_query.register(select_tariff_period, F.data.startswith('tariff_period:')) # Подтверждение покупки - dp.callback_query.register(confirm_tariff_purchase, F.data.startswith("tariff_confirm:")) + dp.callback_query.register(confirm_tariff_purchase, F.data.startswith('tariff_confirm:')) # Подтверждение покупки суточного тарифа - dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith("daily_tariff_confirm:")) + dp.callback_query.register(confirm_daily_tariff_purchase, F.data.startswith('daily_tariff_confirm:')) # Кастомные дни/трафик - dp.callback_query.register(handle_custom_days_change, F.data.startswith("custom_days:")) - dp.callback_query.register(handle_custom_traffic_change, F.data.startswith("custom_traffic:")) - dp.callback_query.register(handle_custom_confirm, F.data.startswith("custom_confirm:")) - dp.callback_query.register(select_tariff_period_with_traffic, F.data.startswith("tariff_period_traffic:")) + dp.callback_query.register(handle_custom_days_change, F.data.startswith('custom_days:')) + dp.callback_query.register(handle_custom_traffic_change, F.data.startswith('custom_traffic:')) + dp.callback_query.register(handle_custom_confirm, F.data.startswith('custom_confirm:')) + dp.callback_query.register(select_tariff_period_with_traffic, F.data.startswith('tariff_period_traffic:')) # Продление по тарифу - dp.callback_query.register(select_tariff_extend_period, F.data.startswith("tariff_extend:")) - dp.callback_query.register(confirm_tariff_extend, F.data.startswith("tariff_ext_confirm:")) + dp.callback_query.register(select_tariff_extend_period, F.data.startswith('tariff_extend:')) + dp.callback_query.register(confirm_tariff_extend, F.data.startswith('tariff_ext_confirm:')) # Переключение тарифов (с выбором периода) - dp.callback_query.register(show_tariff_switch_list, F.data == "tariff_switch") - dp.callback_query.register(select_tariff_switch, F.data.startswith("tariff_sw_select:")) - dp.callback_query.register(select_tariff_switch_period, F.data.startswith("tariff_sw_period:")) - dp.callback_query.register(confirm_tariff_switch, F.data.startswith("tariff_sw_confirm:")) + dp.callback_query.register(show_tariff_switch_list, F.data == 'tariff_switch') + dp.callback_query.register(select_tariff_switch, F.data.startswith('tariff_sw_select:')) + dp.callback_query.register(select_tariff_switch_period, F.data.startswith('tariff_sw_period:')) + dp.callback_query.register(confirm_tariff_switch, F.data.startswith('tariff_sw_confirm:')) # Смена на суточный тариф - dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith("daily_tariff_switch_confirm:")) + dp.callback_query.register(confirm_daily_tariff_switch, F.data.startswith('daily_tariff_switch_confirm:')) # Мгновенное переключение тарифов (без выбора периода) - dp.callback_query.register(show_instant_switch_list, F.data == "instant_switch") - dp.callback_query.register(preview_instant_switch, F.data.startswith("instant_sw_preview:")) - dp.callback_query.register(confirm_instant_switch, F.data.startswith("instant_sw_confirm:")) + dp.callback_query.register(show_instant_switch_list, F.data == 'instant_switch') + dp.callback_query.register(preview_instant_switch, F.data.startswith('instant_sw_preview:')) + dp.callback_query.register(confirm_instant_switch, F.data.startswith('instant_sw_confirm:')) diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 23833f3b..2ac3f5b0 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -1,84 +1,41 @@ -import base64 -import json -import logging -from datetime import datetime, timedelta -from typing import Dict, List, Any, Tuple, Optional -from urllib.parse import quote -from aiogram import Dispatcher, types, F +from datetime import datetime + +from aiogram import types from aiogram.fsm.context import FSMContext -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - get_offer_by_id, - mark_offer_claimed, -) -from app.database.crud.promo_offer_template import get_promo_offer_template_by_id -from app.database.crud.subscription import ( - create_trial_subscription, - create_paid_subscription, add_subscription_traffic, add_subscription_devices, - update_subscription_autopay -) + +from app.config import PERIOD_PRICES, settings +from app.database.crud.subscription import add_subscription_traffic from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance -from app.database.models import ( - User, TransactionType, SubscriptionStatus, - Subscription -) +from app.database.models import TransactionType, User from app.keyboards.inline import ( - get_subscription_keyboard, get_trial_keyboard, - get_subscription_period_keyboard, get_traffic_packages_keyboard, - get_countries_keyboard, get_devices_keyboard, - get_subscription_confirm_keyboard, get_autopay_keyboard, - get_autopay_days_keyboard, get_back_keyboard, - get_add_traffic_keyboard, get_add_traffic_keyboard_from_tariff, - get_change_devices_keyboard, get_reset_traffic_confirm_keyboard, - get_manage_countries_keyboard, - get_device_selection_keyboard, get_connection_guide_keyboard, - get_app_selection_keyboard, get_specific_app_keyboard, - get_updated_subscription_settings_keyboard, get_insufficient_balance_keyboard, - get_extend_subscription_keyboard_with_prices, get_confirm_change_devices_keyboard, - get_devices_management_keyboard, get_device_management_help_keyboard, - get_happ_cryptolink_keyboard, - get_happ_download_platform_keyboard, get_happ_download_link_keyboard, - get_happ_download_button_row, - get_payment_methods_keyboard_with_cart, - get_subscription_confirm_keyboard_with_cart, - get_insufficient_balance_keyboard_with_cart + get_add_traffic_keyboard, + get_add_traffic_keyboard_from_tariff, + get_back_keyboard, + get_countries_keyboard, + get_devices_keyboard, + get_insufficient_balance_keyboard, + get_reset_traffic_confirm_keyboard, ) from app.localization.texts import get_texts -from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveService -from app.services.subscription_checkout_service import ( - clear_subscription_checkout_draft, - get_subscription_checkout_draft, - save_subscription_checkout_draft, - should_offer_checkout_resume, -) from app.services.subscription_service import SubscriptionService -from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.services.promo_offer_service import promo_offer_service from app.states import SubscriptionStates -from app.utils.pagination import paginate_list from app.utils.pricing_utils import ( - calculate_months_from_days, - get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation, - format_period_description, apply_percentage_discount, -) -from app.utils.subscription_utils import ( - get_display_subscription_link, - get_happ_cryptolink_redirect_link, - convert_subscription_link_to_happ_scheme, -) -from app.utils.promo_offer import ( - build_promo_offer_hint, - get_user_active_promo_discount_percent, + calculate_prorated_price, + get_remaining_months, ) -from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger +from .common import ( + _apply_addon_discount, + _get_addon_discount_percent_for_user, + _get_period_hint_from_subscription, + get_confirm_switch_traffic_keyboard, + get_traffic_switch_keyboard, + logger, +) from .countries import ( _build_countries_selection_text, _get_available_countries, @@ -87,11 +44,8 @@ from .countries import ( ) from .summary import present_subscription_summary -async def handle_add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.config import settings from app.database.crud.tariff import get_tariff_by_id @@ -100,14 +54,14 @@ async def handle_add_traffic( if not subscription or subscription.is_trial: await callback.answer( - texts.t("PAID_FEATURE_ONLY", "⚠ Эта функция доступна только для платных подписок"), + texts.t('PAID_FEATURE_ONLY', '⚠ Эта функция доступна только для платных подписок'), show_alert=True, ) return if subscription.traffic_limit_gb == 0: await callback.answer( - texts.t("TRAFFIC_ALREADY_UNLIMITED", "⚠ У вас уже безлимитный трафик"), + texts.t('TRAFFIC_ALREADY_UNLIMITED', '⚠ У вас уже безлимитный трафик'), show_alert=True, ) return @@ -118,8 +72,8 @@ async def handle_add_traffic( if not tariff or not tariff.can_topup_traffic(): await callback.answer( texts.t( - "TARIFF_TRAFFIC_TOPUP_DISABLED", - "⚠️ На вашем тарифе докупка трафика недоступна", + 'TARIFF_TRAFFIC_TOPUP_DISABLED', + '⚠️ На вашем тарифе докупка трафика недоступна', ), show_alert=True, ) @@ -132,16 +86,16 @@ async def handle_add_traffic( period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( db_user, - "traffic", + 'traffic', period_hint_days, ) prompt_text = texts.t( - "ADD_TRAFFIC_PROMPT", + 'ADD_TRAFFIC_PROMPT', ( - "📈 Добавить трафик к подписке\n\n" - "Текущий лимит: {current_traffic}\n" - "Выберите дополнительный трафик:" + '📈 Добавить трафик к подписке\n\n' + 'Текущий лимит: {current_traffic}\n' + 'Выберите дополнительный трафик:' ), ).format(current_traffic=texts.format_traffic(current_traffic)) @@ -153,7 +107,7 @@ async def handle_add_traffic( subscription.end_date, traffic_discount_percent, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() @@ -163,8 +117,8 @@ async def handle_add_traffic( if not settings.is_traffic_topup_enabled(): await callback.answer( texts.t( - "TRAFFIC_TOPUP_DISABLED", - "⚠️ Функция докупки трафика отключена", + 'TRAFFIC_TOPUP_DISABLED', + '⚠️ Функция докупки трафика отключена', ), show_alert=True, ) @@ -173,8 +127,8 @@ async def handle_add_traffic( if settings.is_traffic_topup_blocked(): await callback.answer( texts.t( - "TRAFFIC_FIXED_MODE", - "⚠️ В текущем режиме трафик фиксированный и не может быть изменен", + 'TRAFFIC_FIXED_MODE', + '⚠️ В текущем режиме трафик фиксированный и не может быть изменен', ), show_alert=True, ) @@ -184,17 +138,13 @@ async def handle_add_traffic( period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( db_user, - "traffic", + 'traffic', period_hint_days, ) prompt_text = texts.t( - "ADD_TRAFFIC_PROMPT", - ( - "📈 Добавить трафик к подписке\n\n" - "Текущий лимит: {current_traffic}\n" - "Выберите дополнительный трафик:" - ), + 'ADD_TRAFFIC_PROMPT', + ('📈 Добавить трафик к подписке\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:'), ).format(current_traffic=texts.format_traffic(current_traffic)) await callback.message.edit_text( @@ -204,117 +154,111 @@ async def handle_add_traffic( subscription.end_date, traffic_discount_percent, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() + def _calculate_traffic_reset_price(subscription) -> int: """Рассчитывает цену сброса трафика в зависимости от настроек.""" mode = settings.get_traffic_reset_price_mode() base_price = settings.get_traffic_reset_base_price() - + # Если базовая цена не задана, используем цену периода 30 дней if base_price == 0: base_price = PERIOD_PRICES.get(30, 0) - - if mode == "period": + + if mode == 'period': # Старое поведение: фиксированная цена = стоимость периода return base_price - - elif mode == "traffic": + + if mode == 'traffic': # Цена = стоимость текущего пакета трафика traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb) return max(traffic_price, base_price) - - elif mode == "traffic_with_purchased": + + if mode == 'traffic_with_purchased': # Цена = стоимость базового трафика + докупленного # Базовый трафик = текущий лимит - докупленный purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0 base_traffic_gb = subscription.traffic_limit_gb - purchased_gb - + # Получаем цену базового трафика base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0 - + # Получаем цену докупленного трафика purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0 - + total_price = base_traffic_price + purchased_traffic_price return max(total_price, base_price) - + # Fallback на базовую цену return base_price -async def handle_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def handle_reset_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.config import settings if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True) + await callback.answer('⚠️ В текущем режиме трафик фиксированный и не может быть сброшен', show_alert=True) return texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⌛ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer('⌛ Эта функция доступна только для платных подписок', show_alert=True) return if subscription.traffic_limit_gb == 0: - await callback.answer("⌛ У вас безлимитный трафик", show_alert=True) + await callback.answer('⌛ У вас безлимитный трафик', show_alert=True) return reset_price = _calculate_traffic_reset_price(subscription) # Формируем информацию о расчете цены purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0 - price_info = "" - if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased": + price_info = '' + if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == 'traffic_with_purchased': base_traffic_gb = subscription.traffic_limit_gb - purchased_gb price_info = ( - f"\n\n💡 Расчет цены:\n" - f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n" - f"• Докупленный: {texts.format_traffic(purchased_gb)}" + f'\n\n💡 Расчет цены:\n' + f'• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n' + f'• Докупленный: {texts.format_traffic(purchased_gb)}' ) # Проверяем достаточно ли средств has_enough_balance = db_user.balance_kopeks >= reset_price missing_kopeks = max(0, reset_price - db_user.balance_kopeks) - + # Формируем текст о балансе - balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}" + balance_info = f'\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}' if not has_enough_balance: - balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}" + balance_info += f'\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}' await callback.message.edit_text( - f"🔄 Сброс трафика\n\n" - f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n" - f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n" - f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n" - "После сброса счетчик использованного трафика станет равным 0.", + f'🔄 Сброс трафика\n\n' + f'Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n' + f'Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n' + f'Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n' + 'После сброса счетчик использованного трафика станет равным 0.', reply_markup=get_reset_traffic_confirm_keyboard( - reset_price, + reset_price, db_user.language, has_enough_balance=has_enough_balance, missing_kopeks=missing_kopeks, - ) + ), ) await callback.answer() -async def confirm_reset_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_reset_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.config import settings if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True) return texts = get_texts(db_user.language) @@ -325,13 +269,13 @@ async def confirm_reset_traffic( if db_user.balance_kopeks < reset_price: missing_kopeks = reset_price - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(reset_price), @@ -345,26 +289,23 @@ async def confirm_reset_traffic( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return try: - success = await subtract_user_balance( - db, db_user, reset_price, - "Сброс трафика" - ) + success = await subtract_user_balance(db, db_user, reset_price, 'Сброс трафика') if not success: - await callback.answer("⌛ Ошибка списания средств", show_alert=True) + await callback.answer('⌛ Ошибка списания средств', show_alert=True) return subscription.traffic_used_gb = 0.0 subscription.updated_at = datetime.utcnow() await db.commit() - subscription_service = SubscriptionService() + SubscriptionService() remnawave_service = RemnaWaveService() user = db_user @@ -377,85 +318,82 @@ async def confirm_reset_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=reset_price, - description="Сброс трафика" + description='Сброс трафика', ) await db.refresh(db_user) await db.refresh(subscription) await callback.message.edit_text( - f"✅ Трафик успешно сброшен!\n\n" - f"🔄 Использованный трафик обнулен\n" - f"📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}", - reply_markup=get_back_keyboard(db_user.language) + f'✅ Трафик успешно сброшен!\n\n' + f'🔄 Использованный трафик обнулен\n' + f'📊 Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}', + reply_markup=get_back_keyboard(db_user.language), ) - logger.info(f"✅ Пользователь {db_user.telegram_id} сбросил трафик") + logger.info(f'✅ Пользователь {db_user.telegram_id} сбросил трафик') except Exception as e: - logger.error(f"Ошибка сброса трафика: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка сброса трафика: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() + async def refresh_traffic_config(): try: from app.config import refresh_traffic_prices + refresh_traffic_prices() packages = settings.get_traffic_packages() enabled_count = sum(1 for pkg in packages if pkg['enabled']) - logger.info(f"🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов") + logger.info(f'🔄 Конфигурация трафика обновлена: {enabled_count} активных пакетов') for pkg in packages: if pkg['enabled']: - gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - logger.info(f" 📦 {gb_text}: {pkg['price'] / 100}₽") + gb_text = '♾️ Безлимит' if pkg['gb'] == 0 else f'{pkg["gb"]} ГБ' + logger.info(f' 📦 {gb_text}: {pkg["price"] / 100}₽') return True except Exception as e: - logger.error(f"⚠️ Ошибка обновления конфигурации трафика: {e}") + logger.error(f'⚠️ Ошибка обновления конфигурации трафика: {e}') return False + async def get_traffic_packages_info() -> str: try: packages = settings.get_traffic_packages() - info_lines = ["📦 Настроенные пакеты трафика:"] + info_lines = ['📦 Настроенные пакеты трафика:'] enabled_packages = [pkg for pkg in packages if pkg['enabled']] disabled_packages = [pkg for pkg in packages if not pkg['enabled']] if enabled_packages: - info_lines.append("\n✅ Активные:") + info_lines.append('\n✅ Активные:') for pkg in enabled_packages: - gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + gb_text = '♾️ Безлимит' if pkg['gb'] == 0 else f'{pkg["gb"]} ГБ' + info_lines.append(f' • {gb_text}: {pkg["price"] // 100}₽') if disabled_packages: - info_lines.append("\n❌ Отключенные:") + info_lines.append('\n❌ Отключенные:') for pkg in disabled_packages: - gb_text = "♾️ Безлимит" if pkg['gb'] == 0 else f"{pkg['gb']} ГБ" - info_lines.append(f" • {gb_text}: {pkg['price'] // 100}₽") + gb_text = '♾️ Безлимит' if pkg['gb'] == 0 else f'{pkg["gb"]} ГБ' + info_lines.append(f' • {gb_text}: {pkg["price"] // 100}₽') - info_lines.append(f"\n📊 Всего пакетов: {len(packages)}") - info_lines.append(f"🟢 Активных: {len(enabled_packages)}") - info_lines.append(f"🔴 Отключенных: {len(disabled_packages)}") + info_lines.append(f'\n📊 Всего пакетов: {len(packages)}') + info_lines.append(f'🟢 Активных: {len(enabled_packages)}') + info_lines.append(f'🔴 Отключенных: {len(disabled_packages)}') - return "\n".join(info_lines) + return '\n'.join(info_lines) except Exception as e: - return f"⚠️ Ошибка получения информации: {e}" + return f'⚠️ Ошибка получения информации: {e}' -async def select_traffic( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): + +async def select_traffic(callback: types.CallbackQuery, state: FSMContext, db_user: User): traffic_gb = int(callback.data.split('_')[1]) texts = get_texts(db_user.language) @@ -478,7 +416,7 @@ async def select_traffic( await callback.message.edit_text( selection_text, reply_markup=get_countries_keyboard(countries, preselected, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await state.set_state(SubscriptionStates.selecting_countries) await callback.answer() @@ -493,8 +431,7 @@ async def select_traffic( selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT) await callback.message.edit_text( - texts.SELECT_DEVICES, - reply_markup=get_devices_keyboard(selected_devices, db_user.language) + texts.SELECT_DEVICES, reply_markup=get_devices_keyboard(selected_devices, db_user.language) ) await state.set_state(SubscriptionStates.selecting_devices) await callback.answer() @@ -503,11 +440,8 @@ async def select_traffic( if await present_subscription_summary(callback, state, db_user, texts): await callback.answer() -async def add_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.database.crud.tariff import get_tariff_by_id traffic_gb = int(callback.data.split('_')[2]) @@ -524,29 +458,29 @@ async def add_traffic( if tariff and tariff.can_topup_traffic(): base_price = tariff.get_traffic_topup_price(traffic_gb) or 0 else: - await callback.answer("⚠️ На вашем тарифе докупка трафика недоступна", show_alert=True) + await callback.answer('⚠️ На вашем тарифе докупка трафика недоступна', show_alert=True) return else: # Стандартный режим if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True) return base_price = settings.get_traffic_topup_price(traffic_gb) if base_price == 0 and traffic_gb != 0: - await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True) + await callback.answer('⚠️ Цена для этого пакета не настроена', show_alert=True) return period_hint_days = _get_period_hint_from_subscription(subscription) discount_result = _apply_addon_discount( db_user, - "traffic", + 'traffic', base_price, period_hint_days, ) - discounted_per_month = discount_result["discounted"] - discount_per_month = discount_result["discount"] + discounted_per_month = discount_result['discounted'] + discount_per_month = discount_result['discount'] charged_months = 1 if subscription: @@ -562,13 +496,13 @@ async def add_traffic( if db_user.balance_kopeks < price: missing_kopeks = price - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( required=texts.format_price(price), @@ -582,7 +516,7 @@ async def add_traffic( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return @@ -592,18 +526,20 @@ async def add_traffic( db, db_user, price, - f"Добавление {traffic_gb} ГБ трафика", + f'Добавление {traffic_gb} ГБ трафика', ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return if traffic_gb == 0: subscription.traffic_limit_gb = 0 # При переходе на безлимит сбрасываем все докупки - from app.database.models import TrafficPurchase from sqlalchemy import delete + + from app.database.models import TrafficPurchase + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None @@ -619,77 +555,63 @@ async def add_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Добавление {traffic_gb} ГБ трафика", + description=f'Добавление {traffic_gb} ГБ трафика', ) await db.refresh(db_user) await db.refresh(subscription) - success_text = f"✅ Трафик успешно добавлен!\n\n" + success_text = '✅ Трафик успешно добавлен!\n\n' if traffic_gb == 0: - success_text += "🎉 Теперь у вас безлимитный трафик!" + success_text += '🎉 Теперь у вас безлимитный трафик!' else: - success_text += f"📈 Добавлено: {traffic_gb} ГБ\n" - success_text += f"Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}" + success_text += f'📈 Добавлено: {traffic_gb} ГБ\n' + success_text += f'Новый лимит: {texts.format_traffic(subscription.traffic_limit_gb)}' if price > 0: - success_text += f"\n💰 Списано: {texts.format_price(price)}" + success_text += f'\n💰 Списано: {texts.format_price(price)}' if total_discount_value > 0: - success_text += ( - f" (скидка {discount_result['percent']}%:" - f" -{texts.format_price(total_discount_value)})" - ) + success_text += f' (скидка {discount_result["percent"]}%: -{texts.format_price(total_discount_value)})' - await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) - logger.info(f"✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика") + logger.info(f'✅ Пользователь {db_user.telegram_id} добавил {traffic_gb} ГБ трафика') except Exception as e: - logger.error(f"Ошибка добавления трафика: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) - ) + logger.error(f'Ошибка добавления трафика: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) await callback.answer() -async def handle_no_traffic_packages( - callback: types.CallbackQuery, - db_user: User -): + +async def handle_no_traffic_packages(callback: types.CallbackQuery, db_user: User): await callback.answer( - "⚠️ В данный момент нет доступных пакетов трафика. " - "Обратитесь в техподдержку для получения информации.", - show_alert=True + '⚠️ В данный момент нет доступных пакетов трафика. Обратитесь в техподдержку для получения информации.', + show_alert=True, ) -async def handle_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def handle_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): from app.config import settings if settings.is_traffic_topup_blocked(): - await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True) + await callback.answer('⚠️ В текущем режиме трафик фиксированный', show_alert=True) return texts = get_texts(db_user.language) subscription = db_user.subscription if not subscription or subscription.is_trial: - await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True) + await callback.answer('⚠️ Эта функция доступна только для платных подписок', show_alert=True) return # Проверяем настройку тарифа if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff and not tariff.allow_traffic_topup: - await callback.answer("⚠️ Для вашего тарифа переключение трафика недоступно", show_alert=True) + await callback.answer('⚠️ Для вашего тарифа переключение трафика недоступно', show_alert=True) return current_traffic = subscription.traffic_limit_gb @@ -700,23 +622,23 @@ async def handle_switch_traffic( period_hint_days = _get_period_hint_from_subscription(subscription) traffic_discount_percent = _get_addon_discount_percent_for_user( db_user, - "traffic", + 'traffic', period_hint_days, ) # Показываем информацию о докупленном трафике, если он есть - purchased_info = "" + purchased_info = '' if purchased_traffic > 0: - purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n➕ Докуплено: {texts.format_traffic(purchased_traffic)}" + purchased_info = f'\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n➕ Докуплено: {texts.format_traffic(purchased_traffic)}' await callback.message.edit_text( - f"🔄 Переключение лимита трафика\n\n" - f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n" - f"Выберите новый лимит трафика:\n\n" - f"💡 Важно:\n" - f"• При увеличении - доплата за разницу\n" - f"• При уменьшении - возврат средств не производится\n" - f"• Докупленный трафик будет сброшен", + f'🔄 Переключение лимита трафика\n\n' + f'Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n' + f'Выберите новый лимит трафика:\n\n' + f'💡 Важно:\n' + f'• При увеличении - доплата за разницу\n' + f'• При уменьшении - возврат средств не производится\n' + f'• Докупленный трафик будет сброшен', reply_markup=get_traffic_switch_keyboard( current_traffic, db_user.language, @@ -724,16 +646,13 @@ async def handle_switch_traffic( traffic_discount_percent, base_traffic_gb=base_traffic, ), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def confirm_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def confirm_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): new_traffic_gb = int(callback.data.split('_')[2]) texts = get_texts(db_user.language) subscription = db_user.subscription @@ -745,7 +664,7 @@ async def confirm_switch_traffic( base_traffic = current_traffic - purchased_traffic if new_traffic_gb == current_traffic: - await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True) + await callback.answer('ℹ️ Лимит трафика не изменился', show_alert=True) return # Используем базовый трафик для определения текущей цены пакета @@ -756,7 +675,7 @@ async def confirm_switch_traffic( period_hint_days = months_remaining * 30 if months_remaining > 0 else None traffic_discount_percent = _get_addon_discount_percent_for_user( db_user, - "traffic", + 'traffic', period_hint_days, ) @@ -769,9 +688,7 @@ async def confirm_switch_traffic( traffic_discount_percent, ) price_difference_per_month = discounted_new_per_month - discounted_old_per_month - discount_savings_per_month = ( - (new_price_per_month - old_price_per_month) - price_difference_per_month - ) + discount_savings_per_month = (new_price_per_month - old_price_per_month) - price_difference_per_month if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining @@ -779,16 +696,16 @@ async def confirm_switch_traffic( if db_user.balance_kopeks < total_price_difference: missing_kopeks = total_price_difference - db_user.balance_kopeks message_text = texts.t( - "ADDON_INSUFFICIENT_FUNDS_MESSAGE", + 'ADDON_INSUFFICIENT_FUNDS_MESSAGE', ( - "⚠️ Недостаточно средств\n\n" - "Стоимость услуги: {required}\n" - "На балансе: {balance}\n" - "Не хватает: {missing}\n\n" - "Выберите способ пополнения. Сумма подставится автоматически." + '⚠️ Недостаточно средств\n\n' + 'Стоимость услуги: {required}\n' + 'На балансе: {balance}\n' + 'Не хватает: {missing}\n\n' + 'Выберите способ пополнения. Сумма подставится автоматически.' ), ).format( - required=f"{texts.format_price(total_price_difference)} (за {months_remaining} мес)", + required=f'{texts.format_price(total_price_difference)} (за {months_remaining} мес)', balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) @@ -799,44 +716,38 @@ async def confirm_switch_traffic( db_user.language, amount_kopeks=missing_kopeks, ), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() return - action_text = f"увеличить до {texts.format_traffic(new_traffic_gb)}" - cost_text = f"Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)" + action_text = f'увеличить до {texts.format_traffic(new_traffic_gb)}' + cost_text = f'Доплата: {texts.format_price(total_price_difference)} (за {months_remaining} мес)' if discount_savings_per_month > 0: total_discount_savings = discount_savings_per_month * months_remaining - cost_text += ( - f" (скидка {traffic_discount_percent}%:" - f" -{texts.format_price(total_discount_savings)})" - ) + cost_text += f' (скидка {traffic_discount_percent}%: -{texts.format_price(total_discount_savings)})' else: total_price_difference = 0 - action_text = f"уменьшить до {texts.format_traffic(new_traffic_gb)}" - cost_text = "Возврат средств не производится" + action_text = f'уменьшить до {texts.format_traffic(new_traffic_gb)}' + cost_text = 'Возврат средств не производится' - confirm_text = f"🔄 Подтверждение переключения трафика\n\n" - confirm_text += f"Текущий лимит: {texts.format_traffic(current_traffic)}\n" - confirm_text += f"Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n" - confirm_text += f"Действие: {action_text}\n" - confirm_text += f"💰 {cost_text}\n\n" - confirm_text += "Подтвердить переключение?" + confirm_text = '🔄 Подтверждение переключения трафика\n\n' + confirm_text += f'Текущий лимит: {texts.format_traffic(current_traffic)}\n' + confirm_text += f'Новый лимит: {texts.format_traffic(new_traffic_gb)}\n\n' + confirm_text += f'Действие: {action_text}\n' + confirm_text += f'💰 {cost_text}\n\n' + confirm_text += 'Подтвердить переключение?' await callback.message.edit_text( confirm_text, reply_markup=get_confirm_switch_traffic_keyboard(new_traffic_gb, total_price_difference, db_user.language), - parse_mode="HTML" + parse_mode='HTML', ) await callback.answer() -async def execute_switch_traffic( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): + +async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSession): callback_parts = callback.data.split('_') new_traffic_gb = int(callback_parts[3]) price_difference = int(callback_parts[4]) @@ -848,12 +759,11 @@ async def execute_switch_traffic( try: if price_difference > 0: success = await subtract_user_balance( - db, db_user, price_difference, - f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB" + db, db_user, price_difference, f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB' ) if not success: - await callback.answer("⚠️ Ошибка списания средств", show_alert=True) + await callback.answer('⚠️ Ошибка списания средств', show_alert=True) return months_remaining = get_remaining_months(subscription.end_date) @@ -862,13 +772,15 @@ async def execute_switch_traffic( user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_difference, - description=f"Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес" + description=f'Переключение трафика с {current_traffic}GB на {new_traffic_gb}GB на {months_remaining} мес', ) subscription.traffic_limit_gb = new_traffic_gb # Сбрасываем все докупки трафика при переключении пакета - from app.database.models import TrafficPurchase from sqlalchemy import delete + + from app.database.models import TrafficPurchase + await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id)) subscription.purchased_traffic_gb = 0 subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика @@ -884,38 +796,34 @@ async def execute_switch_traffic( try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(callback.bot) await notification_service.send_subscription_update_notification( - db, db_user, subscription, "traffic", current_traffic, new_traffic_gb, price_difference + db, db_user, subscription, 'traffic', current_traffic, new_traffic_gb, price_difference ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об изменении трафика: {e}") + logger.error(f'Ошибка отправки уведомления об изменении трафика: {e}') if new_traffic_gb > current_traffic: - success_text = f"✅ Лимит трафика увеличен!\n\n" - success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " - success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" + success_text = '✅ Лимит трафика увеличен!\n\n' + success_text += f'📊 Было: {texts.format_traffic(current_traffic)} → ' + success_text += f'Стало: {texts.format_traffic(new_traffic_gb)}\n' if price_difference > 0: - success_text += f"💰 Списано: {texts.format_price(price_difference)}" + success_text += f'💰 Списано: {texts.format_price(price_difference)}' elif new_traffic_gb < current_traffic: - success_text = f"✅ Лимит трафика уменьшен!\n\n" - success_text += f"📊 Было: {texts.format_traffic(current_traffic)} → " - success_text += f"Стало: {texts.format_traffic(new_traffic_gb)}\n" - success_text += f"ℹ️ Возврат средств не производится" + success_text = '✅ Лимит трафика уменьшен!\n\n' + success_text += f'📊 Было: {texts.format_traffic(current_traffic)} → ' + success_text += f'Стало: {texts.format_traffic(new_traffic_gb)}\n' + success_text += 'ℹ️ Возврат средств не производится' - await callback.message.edit_text( - success_text, - reply_markup=get_back_keyboard(db_user.language) - ) + await callback.message.edit_text(success_text, reply_markup=get_back_keyboard(db_user.language)) logger.info( - f"✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽") - - except Exception as e: - logger.error(f"Ошибка переключения трафика: {e}") - await callback.message.edit_text( - texts.ERROR, - reply_markup=get_back_keyboard(db_user.language) + f'✅ Пользователь {db_user.telegram_id} переключил трафик с {current_traffic}GB на {new_traffic_gb}GB, доплата: {price_difference / 100}₽' ) + except Exception as e: + logger.error(f'Ошибка переключения трафика: {e}') + await callback.message.edit_text(texts.ERROR, reply_markup=get_back_keyboard(db_user.language)) + await callback.answer() diff --git a/app/handlers/support.py b/app/handlers/support.py index bff90940..0030fdc7 100644 --- a/app/handlers/support.py +++ b/app/handlers/support.py @@ -1,36 +1,28 @@ import logging -from aiogram import Dispatcher, types, F -from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from aiogram import Dispatcher, F, types + from app.database.models import User from app.keyboards.inline import get_support_keyboard -from app.services.support_settings_service import SupportSettingsService from app.localization.texts import get_texts +from app.services.support_settings_service import SupportSettingsService from app.utils.photo_message import edit_or_answer_photo + logger = logging.getLogger(__name__) -async def show_support_info( - callback: types.CallbackQuery, - db_user: User -): - - texts = get_texts(db_user.language) +async def show_support_info(callback: types.CallbackQuery, db_user: User): + get_texts(db_user.language) support_info = SupportSettingsService.get_support_info_text(db_user.language) await edit_or_answer_photo( callback=callback, caption=support_info, keyboard=get_support_keyboard(db_user.language), - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() def register_handlers(dp: Dispatcher): - - dp.callback_query.register( - show_support_info, - F.data == "menu_support" - ) \ No newline at end of file + dp.callback_query.register(show_support_info, F.data == 'menu_support') diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index b882145c..6a209a99 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -1,32 +1,29 @@ -import logging -from typing import List, Dict, Any import asyncio +import logging import time -from aiogram import Dispatcher, types, F, Bot + +from aiogram import Bot, Dispatcher, F, types from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import StatesGroup, State +from aiogram.fsm.state import State, StatesGroup from aiogram.types import InaccessibleMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.utils.timezone import format_local_datetime - -from app.database.models import User, Ticket, TicketStatus + +from app.config import settings from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD from app.database.crud.user import get_user_by_id +from app.database.models import Ticket, TicketStatus, User from app.keyboards.inline import ( - get_ticket_cancel_keyboard, get_my_tickets_keyboard, - get_ticket_view_keyboard, + get_ticket_cancel_keyboard, get_ticket_reply_cancel_keyboard, - get_admin_tickets_keyboard, - get_admin_ticket_view_keyboard, - get_admin_ticket_reply_cancel_keyboard + get_ticket_view_keyboard, ) from app.localization.texts import get_texts -from app.config import settings from app.services.admin_notification_service import AdminNotificationService -from app.utils.pagination import paginate_list, get_pagination_info -from app.utils.photo_message import edit_or_answer_photo from app.utils.cache import RateLimitCache, cache, cache_key +from app.utils.photo_message import edit_or_answer_photo +from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) @@ -38,36 +35,37 @@ class TicketStates(StatesGroup): async def show_ticket_priority_selection( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User, - db: AsyncSession + callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession ): """Начать создание тикета без выбора приоритета: сразу просим заголовок""" texts = get_texts(db_user.language) - + # Глобальный блок и наличие активного тикета from app.database.crud.ticket import TicketCRUD + blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id) if blocked_until: if blocked_until.year > 9999 - 1: - await callback.answer(texts.t("USER_BLOCKED_FOREVER", "Вы заблокированы для обращений в поддержку."), show_alert=True) + await callback.answer( + texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.'), show_alert=True + ) else: await callback.answer( - texts.t("USER_BLOCKED_UNTIL", "Вы заблокированы до {time}").format(time=blocked_until.strftime('%d.%m.%Y %H:%M')), - show_alert=True + texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format( + time=blocked_until.strftime('%d.%m.%Y %H:%M') + ), + show_alert=True, ) return if await TicketCRUD.user_has_active_ticket(db, db_user.id): await callback.answer( - texts.t("TICKET_ALREADY_OPEN", "У вас уже есть незакрытый тикет. Сначала закройте его."), - show_alert=True + texts.t('TICKET_ALREADY_OPEN', 'У вас уже есть незакрытый тикет. Сначала закройте его.'), show_alert=True ) return - + await callback.message.edit_text( - texts.t("TICKET_TITLE_INPUT", "Введите заголовок тикета:"), - reply_markup=get_ticket_cancel_keyboard(db_user.language) + texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:'), + reply_markup=get_ticket_cancel_keyboard(db_user.language), ) # Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.message_id) @@ -75,29 +73,26 @@ async def show_ticket_priority_selection( await callback.answer() -async def handle_ticket_title_input( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_ticket_title_input(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != TicketStates.waiting_for_title: return - + """Обработать ввод заголовка тикета""" title = message.text.strip() - + data_prompt = await state.get_data() - prompt_chat_id = data_prompt.get("prompt_chat_id") - prompt_message_id = data_prompt.get("prompt_message_id") + prompt_chat_id = data_prompt.get('prompt_chat_id') + prompt_message_id = data_prompt.get('prompt_message_id') # Удалим сообщение пользователя через 2 секунды, чтобы не засорять чат asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0)) if len(title) < 5: texts = get_texts(db_user.language) if prompt_chat_id and prompt_message_id: - text_val = texts.t("TICKET_TITLE_TOO_SHORT", "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:") + text_val = texts.t( + 'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:' + ) if settings.ENABLE_LOGO_MODE: await message.bot.edit_message_caption( chat_id=prompt_chat_id, @@ -115,14 +110,16 @@ async def handle_ticket_title_input( ) else: await message.answer( - texts.t("TICKET_TITLE_TOO_SHORT", "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:") + texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:') ) return - + if len(title) > 255: texts = get_texts(db_user.language) if prompt_chat_id and prompt_message_id: - text_val = texts.t("TICKET_TITLE_TOO_LONG", "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:") + text_val = texts.t( + 'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:' + ) if settings.ENABLE_LOGO_MODE: await message.bot.edit_message_caption( chat_id=prompt_chat_id, @@ -140,30 +137,35 @@ async def handle_ticket_title_input( ) else: await message.answer( - texts.t("TICKET_TITLE_TOO_LONG", "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:") + texts.t( + 'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:' + ) ) return - + # Глобальный блок from app.database.crud.ticket import TicketCRUD + blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id) if blocked_until: texts = get_texts(db_user.language) if blocked_until.year > 9999 - 1: - await message.answer(texts.t("USER_BLOCKED_FOREVER", "Вы заблокированы для обращений в поддержку.")) + await message.answer(texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.')) else: await message.answer( - texts.t("USER_BLOCKED_UNTIL", "Вы заблокированы до {time}").format(time=blocked_until.strftime('%d.%m.%Y %H:%M')) + texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format( + time=blocked_until.strftime('%d.%m.%Y %H:%M') + ) ) await state.clear() return await state.update_data(title=title) - + texts = get_texts(db_user.language) - + if prompt_chat_id and prompt_message_id: - text_val = texts.t("TICKET_MESSAGE_INPUT", "Опишите проблему (до 500 символов) или отправьте фото с подписью:") + text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:') if settings.ENABLE_LOGO_MODE: await message.bot.edit_message_caption( chat_id=prompt_chat_id, @@ -181,35 +183,30 @@ async def handle_ticket_title_input( ) else: await message.answer( - texts.t("TICKET_MESSAGE_INPUT", "Опишите проблему (до 500 символов) или отправьте фото с подписью:"), - reply_markup=get_ticket_cancel_keyboard(db_user.language) + texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'), + reply_markup=get_ticket_cancel_keyboard(db_user.language), ) - + await state.set_state(TicketStates.waiting_for_message) -async def handle_ticket_message_input( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_ticket_message_input(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != TicketStates.waiting_for_message: return - + # Защита от спама: принимаем только первое сообщение в коротком окне try: # Глобальный мягкий супрессор на 6 секунд после создания тикета try: - from_cache = await cache.get(cache_key("suppress_user_input", db_user.id)) + from_cache = await cache.get(cache_key('suppress_user_input', db_user.id)) if from_cache: asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0)) return except Exception: pass - limited = await RateLimitCache.is_rate_limited(db_user.id, "ticket_create_message", limit=1, window=2) + limited = await RateLimitCache.is_rate_limited(db_user.id, 'ticket_create_message', limit=1, window=2) if limited: # Удаляем лишние части длинного сообщения try: @@ -221,7 +218,7 @@ async def handle_ticket_message_input( pass try: data_rl = await state.get_data() - last_ts = data_rl.get("rl_ts_create") + last_ts = data_rl.get('rl_ts_create') now_ts = time.time() if last_ts and (now_ts - float(last_ts)) < 2: try: @@ -235,7 +232,7 @@ async def handle_ticket_message_input( """Обработать ввод сообщения тикета и создать тикет""" # Поддержка фото: если прислали фото с подписью — берём caption, сохраняем file_id - message_text = (message.text or message.caption or "").strip() + message_text = (message.text or message.caption or '').strip() # Ограничим длину текста описания тикета, чтобы избежать проблем с caption/рендером if len(message_text) > 500: message_text = message_text[:500] @@ -243,21 +240,30 @@ async def handle_ticket_message_input( media_file_id = None media_caption = None if message.photo: - media_type = "photo" + media_type = 'photo' media_file_id = message.photo[-1].file_id media_caption = message.caption # Глобальный блок from app.database.crud.ticket import TicketCRUD + blocked_until = await TicketCRUD.is_user_globally_blocked(db, db_user.id) if blocked_until: texts = get_texts(db_user.language) data_prompt = await state.get_data() - prompt_chat_id = data_prompt.get("prompt_chat_id") - prompt_message_id = data_prompt.get("prompt_message_id") - text_msg = texts.t("USER_BLOCKED_FOREVER", "Вы заблокированы для обращений в поддержку.") if blocked_until.year > 9999 - 1 else texts.t("USER_BLOCKED_UNTIL", "Вы заблокированы до {time}").format(time=blocked_until.strftime('%d.%m.%Y %H:%M')) + prompt_chat_id = data_prompt.get('prompt_chat_id') + prompt_message_id = data_prompt.get('prompt_message_id') + text_msg = ( + texts.t('USER_BLOCKED_FOREVER', 'Вы заблокированы для обращений в поддержку.') + if blocked_until.year > 9999 - 1 + else texts.t('USER_BLOCKED_UNTIL', 'Вы заблокированы до {time}').format( + time=blocked_until.strftime('%d.%m.%Y %H:%M') + ) + ) if prompt_chat_id and prompt_message_id: if settings.ENABLE_LOGO_MODE: - await message.bot.edit_message_caption(chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None) + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None + ) else: await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg) else: @@ -271,22 +277,35 @@ async def handle_ticket_message_input( if (not message_text or len(message_text) < 10) and not message.photo: texts = get_texts(db_user.language) data_prompt = await state.get_data() - prompt_chat_id = data_prompt.get("prompt_chat_id") - prompt_message_id = data_prompt.get("prompt_message_id") - err_text = texts.t("TICKET_MESSAGE_TOO_SHORT", "Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:") + prompt_chat_id = data_prompt.get('prompt_chat_id') + prompt_message_id = data_prompt.get('prompt_message_id') + err_text = texts.t( + 'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:' + ) if prompt_chat_id and prompt_message_id: if settings.ENABLE_LOGO_MODE: - await message.bot.edit_message_caption(chat_id=prompt_chat_id, message_id=prompt_message_id, caption=err_text, reply_markup=get_ticket_cancel_keyboard(db_user.language), parse_mode=None) + await message.bot.edit_message_caption( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + caption=err_text, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + parse_mode=None, + ) else: - await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=err_text, reply_markup=get_ticket_cancel_keyboard(db_user.language)) + await message.bot.edit_message_text( + chat_id=prompt_chat_id, + message_id=prompt_message_id, + text=err_text, + reply_markup=get_ticket_cancel_keyboard(db_user.language), + ) else: await message.answer(err_text) return - + data = await state.get_data() - title = data.get("title") - priority = "normal" - + title = data.get('title') + priority = 'normal' + try: ticket = await TicketCRUD.create_ticket( db, @@ -300,35 +319,39 @@ async def handle_ticket_message_input( ) # Включим временное подавление лишних сообщений пользователя (на случай разбиения длинного текста) try: - await cache.set(cache_key("suppress_user_input", db_user.id), True, 6) + await cache.set(cache_key('suppress_user_input', db_user.id), True, 6) except Exception: pass - + texts = get_texts(db_user.language) # Ограничим длину подтверждения чтобы не упереться в лимиты - safe_title = title if len(title) <= 200 else (title[:197] + "...") + safe_title = title if len(title) <= 200 else (title[:197] + '...') creation_text = ( - f"✅ Тикет #{ticket.id} создан\n\n" - f"📝 Заголовок: {safe_title}\n" - f"📊 Статус: {ticket.status_emoji} " - f"{texts.t('TICKET_STATUS_OPEN','Открыт')}\n" - f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n" - + ("📎 Вложение: фото\n" if media_type == 'photo' else "") + f'✅ Тикет #{ticket.id} создан\n\n' + f'📝 Заголовок: {safe_title}\n' + f'📊 Статус: {ticket.status_emoji} ' + f'{texts.t("TICKET_STATUS_OPEN", "Открыт")}\n' + f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n' + + ('📎 Вложение: фото\n' if media_type == 'photo' else '') ) data_prompt = await state.get_data() - prompt_chat_id = data_prompt.get("prompt_chat_id") - prompt_message_id = data_prompt.get("prompt_message_id") - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), - callback_data=f"view_ticket_{ticket.id}" - )], - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_MENU", "🏠 В главное меню"), - callback_data="back_to_menu" - )] - ]) + prompt_chat_id = data_prompt.get('prompt_chat_id') + prompt_message_id = data_prompt.get('prompt_message_id') + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), callback_data=f'view_ticket_{ticket.id}' + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_MENU', '🏠 В главное меню'), callback_data='back_to_menu' + ) + ], + ] + ) if prompt_chat_id and prompt_message_id: if settings.ENABLE_LOGO_MODE: await message.bot.edit_message_caption( @@ -336,7 +359,7 @@ async def handle_ticket_message_input( message_id=prompt_message_id, caption=creation_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: await message.bot.edit_message_text( @@ -344,97 +367,111 @@ async def handle_ticket_message_input( message_id=prompt_message_id, text=creation_text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) else: - await message.answer(creation_text, reply_markup=keyboard, parse_mode="HTML") - + await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML') + await state.clear() - + # Уведомить админов await notify_admins_about_new_ticket(ticket, db) - + except Exception as e: - logger.error(f"Error creating ticket: {e}") + logger.error(f'Error creating ticket: {e}') texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CREATE_ERROR", "❌ Произошла ошибка при создании тикета. Попробуйте позже.") + texts.t('TICKET_CREATE_ERROR', '❌ Произошла ошибка при создании тикета. Попробуйте позже.') ) -async def show_my_tickets( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_my_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) - + # Определяем текущую страницу current_page = 1 - if callback.data.startswith("my_tickets_page_"): + if callback.data.startswith('my_tickets_page_'): try: - current_page = int(callback.data.replace("my_tickets_page_", "")) + current_page = int(callback.data.replace('my_tickets_page_', '')) except ValueError: current_page = 1 - + # Пагинация открытых тикетов из БД per_page = 10 - total_open = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value]) + total_open = await TicketCRUD.count_user_tickets_by_statuses( + db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value] + ) total_pages = max(1, (total_open + per_page - 1) // per_page) current_page = max(1, min(current_page, total_pages)) offset = (current_page - 1) * per_page - open_tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], limit=per_page, offset=offset) + open_tickets = await TicketCRUD.get_user_tickets_by_statuses( + db, + db_user.id, + [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], + limit=per_page, + offset=offset, + ) # Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых) has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0 if not open_tickets and not has_closed_any: await callback.message.edit_text( - texts.t("NO_TICKETS", "У вас пока нет тикетов."), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("CREATE_TICKET_BUTTON", "🎫 Создать тикет"), - callback_data="create_ticket" - )], - [types.InlineKeyboardButton( - text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), - callback_data="my_tickets_closed" - )], - [types.InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_support" - )] - ]) + texts.t('NO_TICKETS', 'У вас пока нет тикетов.'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CREATE_TICKET_BUTTON', '🎫 Создать тикет'), callback_data='create_ticket' + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_CLOSED_TICKETS', '🟢 Закрытые тикеты'), callback_data='my_tickets_closed' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_support')], + ] + ), ) await callback.answer() return - + # Открытые с пагинацией (DB) open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets] - keyboard = get_my_tickets_keyboard(open_data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_page_") + keyboard = get_my_tickets_keyboard( + open_data, + current_page=current_page, + total_pages=total_pages, + language=db_user.language, + page_prefix='my_tickets_page_', + ) # Добавим кнопку перехода к закрытым - keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")]) + keyboard.inline_keyboard.insert( + 0, + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_CLOSED_TICKETS', '🟢 Закрытые тикеты'), callback_data='my_tickets_closed' + ) + ], + ) # Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости) await edit_or_answer_photo( callback=callback, - caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), + caption=texts.t('MY_TICKETS_TITLE', '📋 Ваши тикеты:'), keyboard=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() -async def show_my_tickets_closed( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def show_my_tickets_closed(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) # Пагинация закрытых current_page = 1 data_str = callback.data - if data_str.startswith("my_tickets_closed_page_"): + if data_str.startswith('my_tickets_closed_page_'): try: - current_page = int(data_str.replace("my_tickets_closed_page_", "")) + current_page = int(data_str.replace('my_tickets_closed_page_', '')) except ValueError: current_page = 1 @@ -442,26 +479,47 @@ async def show_my_tickets_closed( total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) if total_closed == 0: await callback.message.edit_text( - texts.t("NO_CLOSED_TICKETS", "Закрытых тикетов пока нет."), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")], - [types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_support")] - ]) + texts.t('NO_CLOSED_TICKETS', 'Закрытых тикетов пока нет.'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_OPEN_TICKETS', '🔴 Открытые тикеты'), callback_data='my_tickets' + ) + ], + [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_support')], + ] + ), ) await callback.answer() return total_pages = max(1, (total_closed + per_page - 1) // per_page) current_page = max(1, min(current_page, total_pages)) offset = (current_page - 1) * per_page - tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset) + tickets = await TicketCRUD.get_user_tickets_by_statuses( + db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset + ) data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets] - kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_") - kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")]) + kb = get_my_tickets_keyboard( + data, + current_page=current_page, + total_pages=total_pages, + language=db_user.language, + page_prefix='my_tickets_closed_page_', + ) + kb.inline_keyboard.insert( + 0, + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_OPEN_TICKETS', '🔴 Открытые тикеты'), callback_data='my_tickets' + ) + ], + ) await edit_or_answer_photo( callback=callback, - caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), + caption=texts.t('CLOSED_TICKETS_TITLE', '🟢 Закрытые тикеты:'), keyboard=kb, - parse_mode="HTML", + parse_mode='HTML', ) await callback.answer() @@ -524,95 +582,98 @@ def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int return pages if pages else [header] -async def view_ticket( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Показать детали тикета с пагинацией""" data_str = callback.data page = 1 ticket_id = None - if data_str.startswith("ticket_view_page_"): + if data_str.startswith('ticket_view_page_'): # format: ticket_view_page_{ticket_id}_{page} try: - _, _, _, tid, p = data_str.split("_") + _, _, _, tid, p = data_str.split('_') ticket_id = int(tid) page = max(1, int(p)) except Exception: pass if ticket_id is None: - ticket_id = int(data_str.replace("view_ticket_", "")) - + ticket_id = int(data_str.replace('view_ticket_', '')) + ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True) - + if not ticket or ticket.user_id != db_user.id: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return - + texts = get_texts(db_user.language) - + # Формируем текст тикета status_text = { - TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"), - TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"), - TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"), - TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании") + TicketStatus.OPEN.value: texts.t('TICKET_STATUS_OPEN', 'Открыт'), + TicketStatus.ANSWERED.value: texts.t('TICKET_STATUS_ANSWERED', 'Отвечен'), + TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'), + TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'), }.get(ticket.status, ticket.status) - + header = ( - f"🎫 Тикет #{ticket.id}\n\n" - f"📝 Заголовок: {ticket.title}\n" - f"📊 Статус: {ticket.status_emoji} {status_text}\n" - f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n\n" + f'🎫 Тикет #{ticket.id}\n\n' + f'📝 Заголовок: {ticket.title}\n' + f'📊 Статус: {ticket.status_emoji} {status_text}\n' + f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n\n' ) message_blocks: list[str] = [] if ticket.messages: - message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n") + message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n') for msg in ticket.messages: - sender = "👤 Вы" if msg.is_user_message else "🛠️ Поддержка" - block = ( - f"{sender} ({format_local_datetime(msg.created_at, '%d.%m %H:%M')}):\n" - f"{msg.message_text}\n\n" - ) - if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo": - block += "📎 Вложение: фото\n\n" + sender = '👤 Вы' if msg.is_user_message else '🛠️ Поддержка' + block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{msg.message_text}\n\n' + if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': + block += '📎 Вложение: фото\n\n' message_blocks.append(block) pages = _split_text_into_pages(header, message_blocks, max_len=3500) total_pages = len(pages) - if page > total_pages: - page = total_pages - + page = min(page, total_pages) + keyboard = get_ticket_view_keyboard( ticket_id, ticket.is_closed, db_user.language, ) # Если есть вложения фото — добавим кнопку для просмотра - has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or []) + has_photos = any( + getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' for m in ticket.messages or [] + ) if has_photos: try: - keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"ticket_attachments_{ticket_id}")]) + keyboard.inline_keyboard.insert( + 0, + [ + types.InlineKeyboardButton( + text=texts.t('TICKET_ATTACHMENTS', '📎 Вложения'), + callback_data=f'ticket_attachments_{ticket_id}', + ) + ], + ) except Exception: pass # Пагинация if total_pages > 1: nav_row = [] if page > 1: - nav_row.append(types.InlineKeyboardButton(text="⬅️", callback_data=f"ticket_view_page_{ticket_id}_{page-1}")) - nav_row.append(types.InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop")) + nav_row.append( + types.InlineKeyboardButton(text='⬅️', callback_data=f'ticket_view_page_{ticket_id}_{page - 1}') + ) + nav_row.append(types.InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='noop')) if page < total_pages: - nav_row.append(types.InlineKeyboardButton(text="➡️", callback_data=f"ticket_view_page_{ticket_id}_{page+1}")) + nav_row.append( + types.InlineKeyboardButton(text='➡️', callback_data=f'ticket_view_page_{ticket_id}_{page + 1}') + ) try: keyboard.inline_keyboard.insert(0, nav_row) except Exception: pass # Показываем как текст (чтобы не упереться в caption лимит) - page_text = pages[page-1] + page_text = pages[page - 1] try: await callback.message.edit_text(page_text, reply_markup=keyboard) except Exception: @@ -624,35 +685,36 @@ async def view_ticket( await callback.answer() -async def send_ticket_attachments( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def send_ticket_attachments(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) try: - await callback.answer(texts.t("SENDING_ATTACHMENTS", "📎 Отправляю вложения...")) + await callback.answer(texts.t('SENDING_ATTACHMENTS', '📎 Отправляю вложения...')) except Exception: pass try: - ticket_id = int(callback.data.replace("ticket_attachments_", "")) + ticket_id = int(callback.data.replace('ticket_attachments_', '')) except ValueError: - await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True) if not ticket or ticket.user_id != db_user.id: - await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return - photos = [m.media_file_id for m in ticket.messages if getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" and m.media_file_id] + photos = [ + m.media_file_id + for m in ticket.messages + if getattr(m, 'has_media', False) and getattr(m, 'media_type', None) == 'photo' and m.media_file_id + ] if not photos: - await callback.answer(texts.t("NO_ATTACHMENTS", "Вложений нет."), show_alert=True) + await callback.answer(texts.t('NO_ATTACHMENTS', 'Вложений нет.'), show_alert=True) return # Telegram ограничивает media group до 10 элементов. Отправим чанками. from aiogram.types import InputMediaPhoto - chunks = [photos[i:i+10] for i in range(0, len(photos), 10)] + + chunks = [photos[i : i + 10] for i in range(0, len(photos), 10)] last_group_message = None for chunk in chunks: media = [InputMediaPhoto(media=pid) for pid in chunk] @@ -664,31 +726,40 @@ async def send_ticket_attachments( pass if last_group_message: try: - kb = types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data=f"user_delete_message_{last_group_message.message_id}")]]) - await callback.message.bot.send_message(chat_id=callback.from_user.id, text=texts.t("ATTACHMENTS_SENT", "Вложения отправлены."), reply_markup=kb) + kb = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('DELETE_MESSAGE', '🗑 Удалить'), + callback_data=f'user_delete_message_{last_group_message.message_id}', + ) + ] + ] + ) + await callback.message.bot.send_message( + chat_id=callback.from_user.id, text=texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.'), reply_markup=kb + ) except Exception: pass else: try: - await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены.")) + await callback.answer(texts.t('ATTACHMENTS_SENT', 'Вложения отправлены.')) except Exception: pass -async def user_delete_message( - callback: types.CallbackQuery -): +async def user_delete_message(callback: types.CallbackQuery): try: - msg_id = int(callback.data.replace("user_delete_message_", "")) + msg_id = int(callback.data.replace('user_delete_message_', '')) except ValueError: - await callback.answer("❌") + await callback.answer('❌') return try: await callback.message.bot.delete_message(chat_id=callback.from_user.id, message_id=msg_id) await callback.message.delete() except Exception: pass - await callback.answer("✅") + await callback.answer('✅') async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0): @@ -700,43 +771,34 @@ async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, del pass -async def reply_to_ticket( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): +async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_user: User): """Начать ответ на тикет""" - ticket_id = int(callback.data.replace("reply_ticket_", "")) - + ticket_id = int(callback.data.replace('reply_ticket_', '')) + await state.update_data(ticket_id=ticket_id) - + texts = get_texts(db_user.language) - + await callback.message.edit_text( - texts.t("TICKET_REPLY_INPUT", "Введите ваш ответ:"), - reply_markup=get_ticket_reply_cancel_keyboard(db_user.language) + texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'), + reply_markup=get_ticket_reply_cancel_keyboard(db_user.language), ) - + await state.set_state(TicketStates.waiting_for_reply) await callback.answer() -async def handle_ticket_reply( - message: types.Message, - state: FSMContext, - db_user: User, - db: AsyncSession -): +async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user: User, db: AsyncSession): # Проверяем, что пользователь в правильном состоянии current_state = await state.get_state() if current_state != TicketStates.waiting_for_reply: return - + # Защита от спама: по тикету принимаем только первое сообщение в коротком окне try: data_rl = await state.get_data() - rl_ticket_id = data_rl.get("ticket_id") or "reply" - limited = await RateLimitCache.is_rate_limited(db_user.id, f"ticket_reply_{rl_ticket_id}", limit=1, window=2) + rl_ticket_id = data_rl.get('ticket_id') or 'reply' + limited = await RateLimitCache.is_rate_limited(db_user.id, f'ticket_reply_{rl_ticket_id}', limit=1, window=2) if limited: try: asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0)) @@ -747,7 +809,7 @@ async def handle_ticket_reply( pass try: data_rl = await state.get_data() - last_ts = data_rl.get("rl_ts_reply") + last_ts = data_rl.get('rl_ts_reply') now_ts = time.time() if last_ts and (now_ts - float(last_ts)) < 2: try: @@ -762,7 +824,7 @@ async def handle_ticket_reply( """Обработать ответ на тикет""" # Поддержка фото для ответа пользователя # Ограничение ответа пользователя 500 символов - reply_text = (message.text or message.caption or "").strip() + reply_text = (message.text or message.caption or '').strip() # Строже режем до 400, чтобы учесть форматирование/смайлы if len(reply_text) > 400: reply_text = reply_text[:400] @@ -770,57 +832,67 @@ async def handle_ticket_reply( media_file_id = None media_caption = None if message.photo: - media_type = "photo" + media_type = 'photo' media_file_id = message.photo[-1].file_id media_caption = message.caption - + if len(reply_text) < 5: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_REPLY_TOO_SHORT", "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:") + texts.t('TICKET_REPLY_TOO_SHORT', 'Ответ должен содержать минимум 5 символов. Попробуйте еще раз:') ) return - + data = await state.get_data() - ticket_id = data.get("ticket_id") - + ticket_id = data.get('ticket_id') + if not ticket_id: texts = get_texts(db_user.language) - await message.answer( - texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.") - ) + await message.answer(texts.t('TICKET_REPLY_ERROR', 'Ошибка: не найден ID тикета.')) await state.clear() return - + try: # Проверяем, что тикет принадлежит пользователю и не закрыт ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if not ticket or ticket.user_id != db_user.id: texts = get_texts(db_user.language) - await message.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден.") - ) + await message.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.')) await state.clear() return if ticket.status == TicketStatus.CLOSED.value: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), + texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] - ) + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CLOSE_NOTIFICATION', '❌ Закрыть уведомление'), + callback_data=f'close_ticket_notification_{ticket.id}', + ) + ] + ] + ), ) await state.clear() return - + # Блокируем добавление сообщения, если тикет закрыт или заблокирован админом if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked: texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно."), + texts.t('TICKET_CLOSED_NO_REPLY', '❌ Тикет закрыт, ответить невозможно.'), reply_markup=types.InlineKeyboardMarkup( - inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]] - ) + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('CLOSE_NOTIFICATION', '❌ Закрыть уведомление'), + callback_data=f'close_ticket_notification_{ticket.id}', + ) + ] + ] + ), ) await state.clear() return @@ -836,134 +908,113 @@ async def handle_ticket_reply( media_file_id=media_file_id, media_caption=media_caption, ) - + texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), - callback_data=f"view_ticket_{ticket_id}" - )], - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_MENU", "🏠 В главное меню"), - callback_data="back_to_menu" - )] - ]) + texts.t('TICKET_REPLY_SENT', '✅ Ваш ответ отправлен!'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('VIEW_TICKET', '👁️ Посмотреть тикет'), callback_data=f'view_ticket_{ticket_id}' + ) + ], + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_MENU', '🏠 В главное меню'), callback_data='back_to_menu' + ) + ], + ] + ), ) await state.clear() # Уведомить админов об ответе пользователя - logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}") + logger.info(f'Attempting to notify admins about ticket reply #{ticket_id}') await notify_admins_about_ticket_reply(ticket, reply_text, db) except Exception as e: - logger.error(f"Error adding ticket reply: {e}") + logger.error(f'Error adding ticket reply: {e}') texts = get_texts(db_user.language) await message.answer( - texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка при отправке ответа. Попробуйте позже.") + texts.t('TICKET_REPLY_ERROR', '❌ Произошла ошибка при отправке ответа. Попробуйте позже.') ) -async def close_ticket( - callback: types.CallbackQuery, - db_user: User, - db: AsyncSession -): +async def close_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSession): """Закрыть тикет""" - ticket_id = int(callback.data.replace("close_ticket_", "")) - + ticket_id = int(callback.data.replace('close_ticket_', '')) + try: # Проверяем, что тикет принадлежит пользователю ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False) if not ticket or ticket.user_id != db_user.id: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_NOT_FOUND", "Тикет не найден."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_NOT_FOUND', 'Тикет не найден.'), show_alert=True) return - + # Запрещаем закрытие, если заблокирован для ответа? (не требуется) Закрываем тикет success = await TicketCRUD.close_ticket(db, ticket_id) - + if success: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), - show_alert=True - ) - + await callback.answer(texts.t('TICKET_CLOSED', '✅ Тикет закрыт.'), show_alert=True) + # Обновляем inline-клавиатуру текущего сообщения (убираем кнопки) await callback.message.edit_reply_markup( reply_markup=get_ticket_view_keyboard(ticket_id, True, db_user.language) ) else: texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."), - show_alert=True - ) - + await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) + except Exception as e: - logger.error(f"Error closing ticket: {e}") + logger.error(f'Error closing ticket: {e}') texts = get_texts(db_user.language) - await callback.answer( - texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."), - show_alert=True - ) + await callback.answer(texts.t('TICKET_CLOSE_ERROR', '❌ Ошибка при закрытии тикета.'), show_alert=True) -async def cancel_ticket_creation( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): +async def cancel_ticket_creation(callback: types.CallbackQuery, state: FSMContext, db_user: User): """Отменить создание тикета""" await state.clear() - + texts = get_texts(db_user.language) - + await callback.message.edit_text( - texts.t("TICKET_CREATION_CANCELLED", "Создание тикета отменено."), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_SUPPORT", "⬅️ К поддержке"), - callback_data="menu_support" - )] - ]) + texts.t('TICKET_CREATION_CANCELLED', 'Создание тикета отменено.'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('BACK_TO_SUPPORT', '⬅️ К поддержке'), callback_data='menu_support' + ) + ] + ] + ), ) await callback.answer() -async def cancel_ticket_reply( - callback: types.CallbackQuery, - state: FSMContext, - db_user: User -): +async def cancel_ticket_reply(callback: types.CallbackQuery, state: FSMContext, db_user: User): """Отменить ответ на тикет""" await state.clear() - + texts = get_texts(db_user.language) - + await callback.message.edit_text( - texts.t("TICKET_REPLY_CANCELLED", "Ответ отменен."), - reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"), - callback_data="my_tickets" - )] - ]) + texts.t('TICKET_REPLY_CANCELLED', 'Ответ отменен.'), + reply_markup=types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.t('BACK_TO_TICKETS', '⬅️ К тикетам'), callback_data='my_tickets')] + ] + ), ) await callback.answer() -async def close_ticket_notification( - callback: types.CallbackQuery, - db_user: User -): +async def close_ticket_notification(callback: types.CallbackQuery, db_user: User): """Закрыть уведомление о тикете""" texts = get_texts(db_user.language) @@ -973,41 +1024,42 @@ async def close_ticket_notification( return await callback.message.delete() - await callback.answer(texts.t("NOTIFICATION_CLOSED", "Уведомление закрыто.")) + await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.')) async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): """Уведомить админов о новом тикете""" try: from app.config import settings + if not settings.is_admin_notifications_enabled(): - logger.info(f"Admin notifications disabled. Ticket #{ticket.id} created by user {ticket.user_id}") + logger.info(f'Admin notifications disabled. Ticket #{ticket.id} created by user {ticket.user_id}') return # Получаем язык пользователя для локализации заголовков в уведомлении # и формируем удобный текст уведомления для админов - user_texts = get_texts(settings.DEFAULT_LANGUAGE) + get_texts(settings.DEFAULT_LANGUAGE) title = (ticket.title or '').strip() if len(title) > 60: - title = title[:57] + "..." + 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 or user.email or f"#{user.id}") if user else "—" - username_display = (user.username or "отсутствует") if user else "отсутствует" + full_name = user.full_name if user else 'Unknown' + telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—' + username_display = (user.username or 'отсутствует') if user else 'отсутствует' notification_text = ( - f"🎫 НОВЫЙ ТИКЕТ\n\n" - f"🆔 ID: {ticket.id}\n" - f"👤 Пользователь: {full_name}\n" - f"🆔 ID: {telegram_id_display}\n" - f"📱 Username: @{username_display}\n" - f"📝 Заголовок: {title or '—'}\n" - f"📅 Создан: {format_local_datetime(ticket.created_at, '%d.%m.%Y %H:%M')}\n" + f'🎫 НОВЫЙ ТИКЕТ\n\n' + f'🆔 ID: {ticket.id}\n' + f'👤 Пользователь: {full_name}\n' + f'🆔 ID: {telegram_id_display}\n' + f'📱 Username: @{username_display}\n' + f'📝 Заголовок: {title or "—"}\n' + f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n' ) # Клавиатура с быстрыми действиями для админов в топике @@ -1015,150 +1067,105 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): # bot доступен из Dispatcher в middlewares; безопаснее взять из уже используемого контекста # Здесь используем lazy импорт из maintenance_service, где хранится бот from app.services.maintenance_service import maintenance_service + bot = maintenance_service._bot or None if bot is None: - logger.warning("Bot instance is not available for admin notifications") + logger.warning('Bot instance is not available for admin notifications') return service = AdminNotificationService(bot) await service.send_ticket_event_notification(notification_text, None) except Exception as e: - logger.error(f"Error notifying admins about new ticket: {e}") + logger.error(f'Error notifying admins about new ticket: {e}') async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession): """Уведомить админов об ответе пользователя на тикет""" - logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}") + logger.info(f'notify_admins_about_ticket_reply called for ticket #{ticket.id}') try: from app.config import settings + if not settings.is_admin_notifications_enabled(): - logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}") + logger.info(f'Admin notifications disabled. Reply to ticket #{ticket.id}') return title = (ticket.title or '').strip() if len(title) > 60: - title = title[:57] + "..." + title = title[:57] + '...' # Загрузим пользователя 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 or user.email or f"#{user.id}") if user else "—" - username_display = (user.username or "отсутствует") if user else "отсутствует" + full_name = user.full_name if user else 'Unknown' + telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—' + username_display = (user.username or 'отсутствует') if user else 'отсутствует' # Обрезаем текст ответа для уведомления - reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text + reply_preview = reply_text[:150] + '...' if len(reply_text) > 150 else reply_text notification_text = ( - f"💬 ОТВЕТ НА ТИКЕТ\n\n" - f"🆔 ID тикета: {ticket.id}\n" - f"📝 Заголовок: {title or '—'}\n" - f"👤 Пользователь: {full_name}\n" - f"🆔 ID: {telegram_id_display}\n" - f"📱 Username: @{username_display}\n\n" - f"📩 Сообщение:\n{reply_preview}\n" + f'💬 ОТВЕТ НА ТИКЕТ\n\n' + f'🆔 ID тикета: {ticket.id}\n' + f'📝 Заголовок: {title or "—"}\n' + f'👤 Пользователь: {full_name}\n' + f'🆔 ID: {telegram_id_display}\n' + f'📱 Username: @{username_display}\n\n' + f'📩 Сообщение:\n{reply_preview}\n' ) from app.services.maintenance_service import maintenance_service + bot = maintenance_service._bot or None if bot is None: - logger.warning("Bot instance is not available for admin notifications") + logger.warning('Bot instance is not available for admin notifications') return service = AdminNotificationService(bot) result = await service.send_ticket_event_notification(notification_text, None) - logger.info(f"Ticket #{ticket.id} reply notification sent: {result}") + logger.info(f'Ticket #{ticket.id} reply notification sent: {result}') except Exception as e: - logger.error(f"Error notifying admins about ticket reply: {e}") + logger.error(f'Error notifying admins about ticket reply: {e}') def register_handlers(dp: Dispatcher): """Регистрация обработчиков тикетов""" - + # Создание тикета (теперь без приоритета) - dp.callback_query.register( - show_ticket_priority_selection, - F.data == "create_ticket" - ) - - dp.message.register( - handle_ticket_title_input, - TicketStates.waiting_for_title - ) - - dp.message.register( - handle_ticket_message_input, - TicketStates.waiting_for_message - ) - + dp.callback_query.register(show_ticket_priority_selection, F.data == 'create_ticket') + + dp.message.register(handle_ticket_title_input, TicketStates.waiting_for_title) + + dp.message.register(handle_ticket_message_input, TicketStates.waiting_for_message) + # Просмотр тикетов - dp.callback_query.register( - show_my_tickets, - F.data == "my_tickets" - ) - dp.callback_query.register( - show_my_tickets_closed, - F.data == "my_tickets_closed" - ) - dp.callback_query.register( - show_my_tickets_closed, - F.data.startswith("my_tickets_closed_page_") - ) - - dp.callback_query.register( - view_ticket, - F.data.startswith("view_ticket_") | F.data.startswith("ticket_view_page_") - ) + dp.callback_query.register(show_my_tickets, F.data == 'my_tickets') + dp.callback_query.register(show_my_tickets_closed, F.data == 'my_tickets_closed') + dp.callback_query.register(show_my_tickets_closed, F.data.startswith('my_tickets_closed_page_')) + + dp.callback_query.register(view_ticket, F.data.startswith('view_ticket_') | F.data.startswith('ticket_view_page_')) # Вложения пользователя - dp.callback_query.register( - send_ticket_attachments, - F.data.startswith("ticket_attachments_") - ) + dp.callback_query.register(send_ticket_attachments, F.data.startswith('ticket_attachments_')) + + dp.callback_query.register(user_delete_message, F.data.startswith('user_delete_message_')) - dp.callback_query.register( - user_delete_message, - F.data.startswith("user_delete_message_") - ) - # Ответы на тикеты - dp.callback_query.register( - reply_to_ticket, - F.data.startswith("reply_ticket_") - ) - - dp.message.register( - handle_ticket_reply, - TicketStates.waiting_for_reply - ) - + dp.callback_query.register(reply_to_ticket, F.data.startswith('reply_ticket_')) + + dp.message.register(handle_ticket_reply, TicketStates.waiting_for_reply) + # Закрытие тикетов - dp.callback_query.register( - close_ticket, - F.data.regexp(r"^close_ticket_\d+$") - ) - + dp.callback_query.register(close_ticket, F.data.regexp(r'^close_ticket_\d+$')) + # Отмена операций - dp.callback_query.register( - cancel_ticket_creation, - F.data == "cancel_ticket_creation" - ) - - dp.callback_query.register( - cancel_ticket_reply, - F.data == "cancel_ticket_reply" - ) - + dp.callback_query.register(cancel_ticket_creation, F.data == 'cancel_ticket_creation') + + dp.callback_query.register(cancel_ticket_reply, F.data == 'cancel_ticket_reply') + # Пагинация тикетов - dp.callback_query.register( - show_my_tickets, - F.data.startswith("my_tickets_page_") - ) - + dp.callback_query.register(show_my_tickets, F.data.startswith('my_tickets_page_')) + # Закрытие уведомлений - dp.callback_query.register( - close_ticket_notification, - F.data.startswith("close_ticket_notification_") - ) + dp.callback_query.register(close_ticket_notification, F.data.startswith('close_ticket_notification_')) diff --git a/app/handlers/webhooks.py b/app/handlers/webhooks.py index d7a81759..580e59da 100644 --- a/app/handlers/webhooks.py +++ b/app/handlers/webhooks.py @@ -1,15 +1,16 @@ import logging + from aiogram import types from aiohttp import web -from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.database import AsyncSessionLocal -from app.database.crud.user import get_user_by_id, add_user_balance from app.database.crud.transaction import create_transaction, get_transaction_by_external_id -from app.database.models import TransactionType, PaymentMethod +from app.database.crud.user import add_user_balance, get_user_by_id +from app.database.database import AsyncSessionLocal +from app.database.models import PaymentMethod, TransactionType from app.external.tribute import TributeService + logger = logging.getLogger(__name__) @@ -17,20 +18,20 @@ async def tribute_webhook(request): try: signature = request.headers.get('trbt-signature', '') payload = await request.text() - + tribute_service = TributeService() - + if not tribute_service.verify_webhook_signature(payload, signature): - logger.warning("Неверная подпись Tribute webhook") - return web.Response(status=400, text="Invalid signature") - + logger.warning('Неверная подпись Tribute webhook') + return web.Response(status=400, text='Invalid signature') + webhook_data = await request.json() processed_data = await tribute_service.process_webhook(webhook_data) - + if not processed_data: - logger.error("Ошибка обработки Tribute webhook") - return web.Response(status=400, text="Invalid webhook data") - + logger.error('Ошибка обработки Tribute webhook') + return web.Response(status=400, text='Invalid webhook data') + async with AsyncSessionLocal() as db: try: existing_transaction = await get_transaction_by_external_id( @@ -38,16 +39,18 @@ async def tribute_webhook(request): ) if existing_transaction: - logger.info(f"Платеж {processed_data['payment_id']} уже обработан") - return web.Response(status=200, text="Already processed") + logger.info(f'Платеж {processed_data["payment_id"]} уже обработан') + return web.Response(status=200, text='Already processed') if processed_data['status'] == 'completed': user = await get_user_by_id(db, processed_data['user_id']) if user: await add_user_balance( - db, user, processed_data['amount_kopeks'], - f"Пополнение через Tribute: {processed_data['payment_id']}" + db, + user, + processed_data['amount_kopeks'], + f'Пополнение через Tribute: {processed_data["payment_id"]}', ) await create_transaction( @@ -55,35 +58,35 @@ async def tribute_webhook(request): user_id=user.id, type=TransactionType.DEPOSIT, amount_kopeks=processed_data['amount_kopeks'], - description=f"Пополнение через Tribute", + description='Пополнение через Tribute', payment_method=PaymentMethod.TRIBUTE, - external_id=processed_data['payment_id'] + external_id=processed_data['payment_id'], ) - logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}") + logger.info(f'✅ Обработан Tribute платеж: {processed_data["payment_id"]}') await db.commit() - return web.Response(status=200, text="OK") + return web.Response(status=200, text='OK') except Exception as e: - logger.error(f"Ошибка обработки Tribute webhook: {e}") + logger.error(f'Ошибка обработки Tribute webhook: {e}') await db.rollback() - return web.Response(status=500, text="Internal error") - + return web.Response(status=500, text='Internal error') + except Exception as e: - logger.error(f"Ошибка в Tribute webhook: {e}") - return web.Response(status=500, text="Internal error") + logger.error(f'Ошибка в Tribute webhook: {e}') + return web.Response(status=500, text='Internal error') async def handle_successful_payment(message: types.Message): try: payment = message.successful_payment - + payload_parts = payment.invoice_payload.split('_') if len(payload_parts) >= 3 and payload_parts[0] == 'balance': user_id = int(payload_parts[1]) amount_kopeks = int(payload_parts[2]) - + async with AsyncSessionLocal() as db: try: existing_transaction = await get_transaction_by_external_id( @@ -91,52 +94,49 @@ async def handle_successful_payment(message: types.Message): ) if existing_transaction: - logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан") + logger.info(f'Stars платеж {payment.telegram_payment_charge_id} уже обработан') return user = await get_user_by_id(db, user_id) if user: - await add_user_balance( - db, user, amount_kopeks, - f"Пополнение через Telegram Stars" - ) + await add_user_balance(db, user, amount_kopeks, 'Пополнение через Telegram Stars') await create_transaction( db=db, user_id=user.id, type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, - description=f"Пополнение через Telegram Stars", + description='Пополнение через Telegram Stars', payment_method=PaymentMethod.TELEGRAM_STARS, - external_id=payment.telegram_payment_charge_id + external_id=payment.telegram_payment_charge_id, ) await message.answer( - f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса." + f'✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n' + '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + 'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.' ) - logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}") + logger.info(f'✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}') await db.commit() except Exception as e: - logger.error(f"Ошибка обработки Stars платежа: {e}") + logger.error(f'Ошибка обработки Stars платежа: {e}') await db.rollback() - + except Exception as e: - logger.error(f"Ошибка в обработчике Stars платежа: {e}") + logger.error(f'Ошибка в обработчике Stars платежа: {e}') async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery): try: await pre_checkout_query.answer(ok=True) - logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}") - + logger.info(f'Pre-checkout query принят: {pre_checkout_query.id}') + except Exception as e: - logger.error(f"Ошибка в pre-checkout query: {e}") - await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа") \ No newline at end of file + logger.error(f'Ошибка в pre-checkout query: {e}') + await pre_checkout_query.answer(ok=False, error_message='Ошибка обработки платежа') diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index f5c26d68..07fa68d2 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -1,5 +1,6 @@ -from typing import List, Optional, Tuple, Any -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from typing import Any + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from app.localization.texts import get_texts @@ -9,549 +10,519 @@ def _t(texts, key: str, default: str) -> str: return texts.t(key, default) -def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_USERS_SUBSCRIPTIONS", "👥 Юзеры/Подписки"), - callback_data="admin_submenu_users", - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_SERVERS", "🌐 Серверы"), - callback_data="admin_servers", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_TARIFFS", "📦 Тарифы"), - callback_data="admin_tariffs", - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_PRICING", "💰 Цены"), - callback_data="admin_pricing", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_PROMO_STATS", "💰 Промокоды/Статистика"), - callback_data="admin_submenu_promo", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_SUPPORT", "🛟 Поддержка"), - callback_data="admin_submenu_support", - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_MESSAGES", "📨 Сообщения"), - callback_data="admin_submenu_communications", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_SETTINGS", "⚙️ Настройки"), - callback_data="admin_submenu_settings", - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_SYSTEM", "🛠️ Система"), - callback_data="admin_submenu_system", - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"), - callback_data="admin_trials", - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"), - callback_data="admin_payments", - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")] - ]) - - -def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"), - InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals") - ], - [ - InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"), - InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics") - ], - [ - InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"), - callback_data="admin_contests", - ) - ], - [ - InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages") - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"), - callback_data="admin_polls", - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"), - callback_data="admin_promo_offers" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_WELCOME_TEXT", "👋 Приветственный текст"), - callback_data="welcome_text_panel" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_COMMUNICATIONS_MENU_MESSAGES", "📢 Сообщения в меню"), - callback_data="user_messages_panel" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), - callback_data="admin_tickets" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUPPORT_AUDIT", "🧾 Аудит модераторов"), - callback_data="admin_support_audit" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUPPORT_SETTINGS", "🛟 Настройки поддержки"), - callback_data="admin_support_settings" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), - InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SETTINGS_BOT_CONFIG", "🧩 Конфигурация бота"), - callback_data="admin_bot_config" - ), - ], - [ - InlineKeyboardButton( - text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"), - callback_data="admin_mon_settings" - ) - ], - [ - InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SETTINGS_MAINTENANCE", "🔧 Техработы"), - callback_data="maintenance_panel" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SETTINGS_PRIVACY_POLICY", "🛡️ Политика конф."), - callback_data="admin_privacy_policy", - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SETTINGS_PUBLIC_OFFER", "📄 Публичная оферта"), - callback_data="admin_public_offer", - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SETTINGS_FAQ", "❓ FAQ"), - callback_data="admin_faq", - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYSTEM_UPDATES", "📄 Обновления"), - callback_data="admin_updates" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYSTEM_BACKUPS", "🗄️ Бекапы"), - callback_data="backup_panel" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYSTEM_LOGS", "🧾 Логи"), - callback_data="admin_system_logs" - ) - ], - [InlineKeyboardButton(text=texts.t("ADMIN_REPORTS", "📊 Отчеты"), callback_data="admin_reports")], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel") - ] - ]) - - -def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"), - callback_data="admin_trials_reset", - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")], - ]) - - -def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REPORTS_PREVIOUS_DAY", "📆 За вчера"), - callback_data="admin_reports_daily" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REPORTS_LAST_WEEK", "🗓️ За неделю"), - callback_data="admin_reports_weekly" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REPORTS_LAST_MONTH", "📅 За месяц"), - callback_data="admin_reports_monthly" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")] - ]) - - -def get_admin_report_result_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.t("REPORT_CLOSE", "❌ Закрыть"), callback_data="admin_close_report")] - ]) - - -def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_ALL", "👥 Все пользователи"), - callback_data="admin_users_list" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_SEARCH", "🔍 Поиск"), - callback_data="admin_users_search" - ) - ], - [ - InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_users_stats"), - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_INACTIVE", "🗑️ Неактивные"), - callback_data="admin_users_inactive" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTERS", "⚙️ Фильтры"), - callback_data="admin_users_filters" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_BLACKLIST", "🔐 Черный список"), - callback_data="admin_blacklist_settings" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_BULK_BAN", "🛑 Массовый бан"), - callback_data="admin_bulk_ban_start" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users") - ] - ]) - - -def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_BALANCE", "💰 По балансу"), - callback_data="admin_users_balance_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_TRAFFIC", "📶 По трафику"), - callback_data="admin_users_traffic_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_ACTIVITY", "🕒 По активности"), - callback_data="admin_users_activity_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_SPENDING", "💳 По сумме трат"), - callback_data="admin_users_spending_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_PURCHASES", "🛒 По количеству покупок"), - callback_data="admin_users_purchases_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"), - callback_data="admin_users_ready_to_renew_filter" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"), - callback_data="admin_users_campaign_filter" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_users") - ] - ]) - - -def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUBSCRIPTIONS_ALL", "📱 Все подписки"), - callback_data="admin_subs_list" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUBSCRIPTIONS_EXPIRING", "⏰ Истекающие"), - callback_data="admin_subs_expiring" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SUBSCRIPTIONS_COUNTRIES", "🌍 Управление странами"), - callback_data="admin_subs_countries" - ) - ], - [ - InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_subs_stats") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users") - ] - ]) - - -def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODES_ALL", "🎫 Все промокоды"), - callback_data="admin_promo_list" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"), - callback_data="admin_promo_create" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODES_GENERAL_STATS", "📊 Общая статистика"), - callback_data="admin_promo_general_stats" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") - ] - ]) - - -def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGNS_LIST", "📋 Список кампаний"), - callback_data="admin_campaigns_list" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGNS_CREATE", "➕ Создать"), - callback_data="admin_campaigns_create" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGNS_GENERAL_STATS", "📊 Общая статистика"), - callback_data="admin_campaigns_stats" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") - ] - ]) - - -def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_main_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"), - callback_data="admin_contests_referral", + text=_t(texts, 'ADMIN_MAIN_USERS_SUBSCRIPTIONS', '👥 Юзеры/Подписки'), + callback_data='admin_submenu_users', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_SERVERS', '🌐 Серверы'), + callback_data='admin_servers', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_TARIFFS', '📦 Тарифы'), + callback_data='admin_tariffs', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_PRICING', '💰 Цены'), + callback_data='admin_pricing', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_PROMO_STATS', '💰 Промокоды/Статистика'), + callback_data='admin_submenu_promo', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_SUPPORT', '🛟 Поддержка'), + callback_data='admin_submenu_support', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_MESSAGES', '📨 Сообщения'), + callback_data='admin_submenu_communications', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_SETTINGS', '⚙️ Настройки'), + callback_data='admin_submenu_settings', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_SYSTEM', '🛠️ Система'), + callback_data='admin_submenu_system', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_TRIALS', '🧪 Триалы'), + callback_data='admin_trials', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAIN_PAYMENTS', '💳 Пополнения'), + callback_data='admin_payments', + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], + ] + ) + + +def get_admin_users_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data='admin_users'), + InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data='admin_referrals'), + ], + [InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data='admin_subscriptions')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_promo_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data='admin_promocodes'), + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data='admin_statistics'), + ], + [InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data='admin_campaigns')], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CONTESTS', '🏆 Конкурсы'), + callback_data='admin_contests', + ) + ], + [InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data='admin_promo_groups')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_communications_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data='admin_messages')], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_COMMUNICATIONS_POLLS', '🗳️ Опросы'), + callback_data='admin_polls', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"), - callback_data="admin_contests_daily", + text=_t(texts, 'ADMIN_COMMUNICATIONS_PROMO_OFFERS', '🎯 Промо-предложения'), + callback_data='admin_promo_offers', ) ], [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_COMMUNICATIONS_WELCOME_TEXT', '👋 Приветственный текст'), + callback_data='welcome_text_panel', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_COMMUNICATIONS_MENU_MESSAGES', '📢 Сообщения в меню'), + callback_data='user_messages_panel', + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_support_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUPPORT_TICKETS', '🎫 Тикеты поддержки'), callback_data='admin_tickets' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUPPORT_AUDIT', '🧾 Аудит модераторов'), callback_data='admin_support_audit' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUPPORT_SETTINGS', '🛟 Настройки поддержки'), + callback_data='admin_support_settings', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_settings_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data='admin_remnawave'), + InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data='admin_monitoring'), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_BOT_CONFIG', '🧩 Конфигурация бота'), + callback_data='admin_bot_config', + ), + ], + [ + InlineKeyboardButton( + text=texts.t('ADMIN_MONITORING_SETTINGS', '⚙️ Настройки мониторинга'), + callback_data='admin_mon_settings', + ) + ], + [ + InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data='admin_rules'), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_MAINTENANCE', '🔧 Техработы'), callback_data='maintenance_panel' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_PRIVACY_POLICY', '🛡️ Политика конф.'), + callback_data='admin_privacy_policy', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_PUBLIC_OFFER', '📄 Публичная оферта'), + callback_data='admin_public_offer', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SETTINGS_FAQ', '❓ FAQ'), + callback_data='admin_faq', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_system_submenu_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYSTEM_UPDATES', '📄 Обновления'), callback_data='admin_updates' + ), + InlineKeyboardButton(text=_t(texts, 'ADMIN_SYSTEM_BACKUPS', '🗄️ Бекапы'), callback_data='backup_panel'), + ], + [InlineKeyboardButton(text=_t(texts, 'ADMIN_SYSTEM_LOGS', '🧾 Логи'), callback_data='admin_system_logs')], + [InlineKeyboardButton(text=texts.t('ADMIN_REPORTS', '📊 Отчеты'), callback_data='admin_reports')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_trials_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_TRIALS_RESET_BUTTON', '♻️ Сбросить все триалы'), + callback_data='admin_trials_reset', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_reports_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REPORTS_PREVIOUS_DAY', '📆 За вчера'), callback_data='admin_reports_daily' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REPORTS_LAST_WEEK', '🗓️ За неделю'), callback_data='admin_reports_weekly' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REPORTS_LAST_MONTH', '📅 За месяц'), callback_data='admin_reports_monthly' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')], + ] + ) + + +def get_admin_report_result_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('REPORT_CLOSE', '❌ Закрыть'), callback_data='admin_close_report')] + ] + ) + + +def get_admin_users_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_ALL', '👥 Все пользователи'), callback_data='admin_users_list' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_SEARCH', '🔍 Поиск'), callback_data='admin_users_search' + ), + ], + [ + InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data='admin_users_stats'), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_INACTIVE', '🗑️ Неактивные'), callback_data='admin_users_inactive' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTERS', '⚙️ Фильтры'), callback_data='admin_users_filters' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_BLACKLIST', '🔐 Черный список'), + callback_data='admin_blacklist_settings', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_BULK_BAN', '🛑 Массовый бан'), callback_data='admin_bulk_ban_start' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')], + ] + ) + + +def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_BALANCE', '💰 По балансу'), + callback_data='admin_users_balance_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'), + callback_data='admin_users_traffic_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'), + callback_data='admin_users_activity_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'), + callback_data='admin_users_spending_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'), + callback_data='admin_users_purchases_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'), + callback_data='admin_users_ready_to_renew_filter', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'), + callback_data='admin_users_campaign_filter', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_users')], + ] + ) + + +def get_admin_subscriptions_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUBSCRIPTIONS_ALL', '📱 Все подписки'), callback_data='admin_subs_list' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUBSCRIPTIONS_EXPIRING', '⏰ Истекающие'), callback_data='admin_subs_expiring' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SUBSCRIPTIONS_COUNTRIES', '🌍 Управление странами'), + callback_data='admin_subs_countries', + ) + ], + [InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data='admin_subs_stats')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')], + ] + ) + + +def get_admin_promocodes_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODES_ALL', '🎫 Все промокоды'), callback_data='admin_promo_list' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODES_CREATE', '➕ Создать'), callback_data='admin_promo_create' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODES_GENERAL_STATS', '📊 Общая статистика'), + callback_data='admin_promo_general_stats', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_promo')], + ] + ) + + +def get_admin_campaigns_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGNS_LIST', '📋 Список кампаний'), callback_data='admin_campaigns_list' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGNS_CREATE', '➕ Создать'), callback_data='admin_campaigns_create' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGNS_GENERAL_STATS', '📊 Общая статистика'), + callback_data='admin_campaigns_stats', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_promo')], + ] + ) + + +def get_admin_contests_root_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CONTESTS_REFERRAL', '🤝 Реферальные конкурсы'), + callback_data='admin_contests_referral', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CONTESTS_DAILY', '📆 Ежедневные конкурсы'), + callback_data='admin_contests_daily', + ) + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_promo'), ], ] ) -def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_contests_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"), - callback_data="admin_contests_list", + text=_t(texts, 'ADMIN_CONTESTS_LIST', '📋 Текущие конкурсы'), + callback_data='admin_contests_list', ), InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTESTS_CREATE", "➕ Новый конкурс"), - callback_data="admin_contests_create", + text=_t(texts, 'ADMIN_CONTESTS_CREATE', '➕ Новый конкурс'), + callback_data='admin_contests_create', ), ], [ InlineKeyboardButton( text=texts.BACK, - callback_data="admin_contests", + callback_data='admin_contests', ) ], ] ) -def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_contest_mode_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"), - callback_data="admin_contest_mode_paid", + text=_t(texts, 'ADMIN_CONTEST_MODE_PAID', '💳 Реферал с покупкой'), + callback_data='admin_contest_mode_paid', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑‍🤝‍🧑 Просто реферал"), - callback_data="admin_contest_mode_registered", + text=_t(texts, 'ADMIN_CONTEST_MODE_REGISTERED', '🧑‍🤝‍🧑 Просто реферал'), + callback_data='admin_contest_mode_registered', ) ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral") - ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_contests_referral')], ] ) @@ -559,92 +530,133 @@ def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup: def get_daily_contest_manage_keyboard( template_id: int, is_enabled: bool, - language: str = "ru", + language: str = 'ru', ) -> InlineKeyboardMarkup: texts = get_texts(language) - toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить") + toggle_text = ( + _t(texts, 'ADMIN_CONTEST_DISABLE', '⏸️ Остановить') + if is_enabled + else _t(texts, 'ADMIN_CONTEST_ENABLE', '▶️ Запустить') + ) return InlineKeyboardMarkup( inline_keyboard=[ [ - InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"), - InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"), - InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"), + InlineKeyboardButton(text=toggle_text, callback_data=f'admin_daily_toggle_{template_id}'), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CONTEST_START_NOW', '🚀 Запустить раунд'), + callback_data=f'admin_daily_start_{template_id}', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CONTEST_START_MANUAL', '🧪 Ручной старт'), + callback_data=f'admin_daily_manual_{template_id}', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_TYPE", "🏅 Тип приза"), callback_data=f"admin_daily_edit_{template_id}_prize_type"), - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE_VALUE", "💰 Значение приза"), callback_data=f"admin_daily_edit_{template_id}_prize_value"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_PRIZE_TYPE', '🏅 Тип приза'), + callback_data=f'admin_daily_edit_{template_id}_prize_type', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_PRIZE_VALUE', '💰 Значение приза'), + callback_data=f'admin_daily_edit_{template_id}_prize_value', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"), - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_MAX_WINNERS', '👥 Победителей'), + callback_data=f'admin_daily_edit_{template_id}_max_winners', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_ATTEMPTS', '🔁 Попытки'), + callback_data=f'admin_daily_edit_{template_id}_attempts_per_user', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_TIMES', '⏰ Раундов/день'), + callback_data=f'admin_daily_edit_{template_id}_times_per_day', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"), - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_SCHEDULE', '🕒 Расписание'), + callback_data=f'admin_daily_edit_{template_id}_schedule_times', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_COOLDOWN', '⌛ Длительность'), + callback_data=f'admin_daily_edit_{template_id}_cooldown_hours', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_EDIT_PAYLOAD', '🧩 Payload'), + callback_data=f'admin_daily_payload_{template_id}', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_RESET_ATTEMPTS', '🔄 Сбросить попытки'), + callback_data=f'admin_daily_reset_attempts_{template_id}', + ), ], [ - InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CLOSE_ROUND', '❌ Закрыть раунд'), + callback_data=f'admin_daily_close_{template_id}', + ), ], [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"), + InlineKeyboardButton(text=texts.BACK, callback_data='admin_contests_daily'), ], ] ) + def get_referral_contest_manage_keyboard( contest_id: int, *, is_active: bool, can_delete: bool = False, - language: str = "ru", + language: str = 'ru', ) -> InlineKeyboardMarkup: texts = get_texts(language) toggle_text = ( - _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") + _t(texts, 'ADMIN_CONTEST_DISABLE', '⏸️ Остановить') if is_active - else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить") + else _t(texts, 'ADMIN_CONTEST_ENABLE', '▶️ Запустить') ) rows = [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"), - callback_data=f"admin_contest_leaderboard_{contest_id}", + text=_t(texts, 'ADMIN_CONTEST_LEADERBOARD', '📊 Лидеры'), + callback_data=f'admin_contest_leaderboard_{contest_id}', ), InlineKeyboardButton( text=toggle_text, - callback_data=f"admin_contest_toggle_{contest_id}", + callback_data=f'admin_contest_toggle_{contest_id}', ), ], [ InlineKeyboardButton( - text="📈 Детальная статистика", - callback_data=f"admin_contest_detailed_stats_{contest_id}", + text='📈 Детальная статистика', + callback_data=f'admin_contest_detailed_stats_{contest_id}', ), ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"), - callback_data=f"admin_contest_edit_times_{contest_id}", + text=_t(texts, 'ADMIN_CONTEST_EDIT_SUMMARY_TIMES', '🕒 Итоги в день'), + callback_data=f'admin_contest_edit_times_{contest_id}', ), ], [ InlineKeyboardButton( - text="🔄 Синхронизация", - callback_data=f"admin_contest_sync_{contest_id}", + text='🔄 Синхронизация', + callback_data=f'admin_contest_sync_{contest_id}', ), InlineKeyboardButton( - text="🔍 Отладка", - callback_data=f"admin_contest_debug_{contest_id}", + text='🔍 Отладка', + callback_data=f'admin_contest_debug_{contest_id}', ), ], ] @@ -653,8 +665,8 @@ def get_referral_contest_manage_keyboard( rows.append( [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"), - callback_data=f"admin_contest_delete_{contest_id}", + text=_t(texts, 'ADMIN_CONTEST_DELETE', '🗑 Удалить'), + callback_data=f'admin_contest_delete_{contest_id}', ) ] ) @@ -662,8 +674,8 @@ def get_referral_contest_manage_keyboard( rows.append( [ InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), - callback_data="admin_contests_list", + text=_t(texts, 'ADMIN_BACK_TO_LIST', '⬅️ К списку'), + callback_data='admin_contests_list', ) ] ) @@ -671,44 +683,41 @@ def get_referral_contest_manage_keyboard( return InlineKeyboardMarkup(inline_keyboard=rows) -def get_campaign_management_keyboard( - campaign_id: int, is_active: bool, language: str = "ru" -) -> InlineKeyboardMarkup: +def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) status_text = ( - _t(texts, "ADMIN_CAMPAIGN_DISABLE", "🔴 Выключить") + _t(texts, 'ADMIN_CAMPAIGN_DISABLE', '🔴 Выключить') if is_active - else _t(texts, "ADMIN_CAMPAIGN_ENABLE", "🟢 Включить") + else _t(texts, 'ADMIN_CAMPAIGN_ENABLE', '🟢 Включить') ) return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_STATS", "📊 Статистика"), - callback_data=f"admin_campaign_stats_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_STATS', '📊 Статистика'), + callback_data=f'admin_campaign_stats_{campaign_id}', ), InlineKeyboardButton( text=status_text, - callback_data=f"admin_campaign_toggle_{campaign_id}", + callback_data=f'admin_campaign_toggle_{campaign_id}', ), ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_EDIT", "✏️ Редактировать"), - callback_data=f"admin_campaign_edit_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_EDIT', '✏️ Редактировать'), + callback_data=f'admin_campaign_edit_{campaign_id}', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_DELETE", "🗑️ Удалить"), - callback_data=f"admin_campaign_delete_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_DELETE', '🗑️ Удалить'), + callback_data=f'admin_campaign_delete_{campaign_id}', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), - callback_data="admin_campaigns_list" + text=_t(texts, 'ADMIN_BACK_TO_LIST', '⬅️ К списку'), callback_data='admin_campaigns_list' ) ], ] @@ -720,469 +729,451 @@ def get_campaign_edit_keyboard( *, bonus_type: str = None, is_balance_bonus: bool = None, # deprecated, for backwards compatibility - language: str = "ru", + language: str = 'ru', ) -> InlineKeyboardMarkup: texts = get_texts(language) # Поддержка старого API if bonus_type is None and is_balance_bonus is not None: - bonus_type = "balance" if is_balance_bonus else "subscription" + bonus_type = 'balance' if is_balance_bonus else 'subscription' - keyboard: List[List[InlineKeyboardButton]] = [ + keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_EDIT_NAME", "✏️ Название"), - callback_data=f"admin_campaign_edit_name_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_EDIT_NAME', '✏️ Название'), + callback_data=f'admin_campaign_edit_name_{campaign_id}', ), InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_EDIT_START", "🔗 Параметр"), - callback_data=f"admin_campaign_edit_start_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_EDIT_START', '🔗 Параметр'), + callback_data=f'admin_campaign_edit_start_{campaign_id}', ), ] ] - if bonus_type == "balance": + if bonus_type == 'balance': keyboard.append( [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"), - callback_data=f"admin_campaign_edit_balance_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_BONUS_BALANCE', '💰 Бонус на баланс'), + callback_data=f'admin_campaign_edit_balance_{campaign_id}', ) ] ) - elif bonus_type == "subscription": + elif bonus_type == 'subscription': keyboard.extend( [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_DURATION", "📅 Длительность"), - callback_data=f"admin_campaign_edit_sub_days_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_DURATION', '📅 Длительность'), + callback_data=f'admin_campaign_edit_sub_days_{campaign_id}', ), InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_TRAFFIC", "🌐 Трафик"), - callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_TRAFFIC', '🌐 Трафик'), + callback_data=f'admin_campaign_edit_sub_traffic_{campaign_id}', ), ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_DEVICES", "📱 Устройства"), - callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_DEVICES', '📱 Устройства'), + callback_data=f'admin_campaign_edit_sub_devices_{campaign_id}', ), InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_SERVERS", "🌍 Серверы"), - callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_SERVERS', '🌍 Серверы'), + callback_data=f'admin_campaign_edit_sub_servers_{campaign_id}', ), ], ] ) - elif bonus_type == "tariff": + elif bonus_type == 'tariff': keyboard.extend( [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_TARIFF", "🎁 Тариф"), - callback_data=f"admin_campaign_edit_tariff_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_TARIFF', '🎁 Тариф'), + callback_data=f'admin_campaign_edit_tariff_{campaign_id}', ), InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_DURATION", "📅 Длительность"), - callback_data=f"admin_campaign_edit_tariff_days_{campaign_id}", + text=_t(texts, 'ADMIN_CAMPAIGN_DURATION', '📅 Длительность'), + callback_data=f'admin_campaign_edit_tariff_days_{campaign_id}', ), ], ] ) # bonus_type == "none" - только базовые кнопки (название и параметр) - keyboard.append( - [ - InlineKeyboardButton( - text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}" - ) - ] - ) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_campaign_manage_{campaign_id}')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_campaign_bonus_type_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_BONUS_BALANCE", "💰 Бонус на баланс"), - callback_data="campaign_bonus_balance" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION", "📱 Пробная подписка"), - callback_data="campaign_bonus_subscription" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_BONUS_TARIFF", "🎁 Тариф"), - callback_data="campaign_bonus_tariff" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CAMPAIGN_BONUS_NONE", "🔗 Только ссылка"), - callback_data="campaign_bonus_none" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGN_BONUS_BALANCE', '💰 Бонус на баланс'), + callback_data='campaign_bonus_balance', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGN_BONUS_SUBSCRIPTION', '📱 Пробная подписка'), + callback_data='campaign_bonus_subscription', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGN_BONUS_TARIFF', '🎁 Тариф'), callback_data='campaign_bonus_tariff' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CAMPAIGN_BONUS_NONE', '🔗 Только ссылка'), callback_data='campaign_bonus_none' + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_campaigns')], ] - ]) + ) -def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_promocode_management_keyboard(promo_id: int, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_EDIT", "✏️ Редактировать"), - callback_data=f"promo_edit_{promo_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TOGGLE", "🔄 Статус"), - callback_data=f"promo_toggle_{promo_id}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_STATS", "📊 Статистика"), - callback_data=f"promo_stats_{promo_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_DELETE", "🗑️ Удалить"), - callback_data=f"promo_delete_{promo_id}" - ) - ], - [ - InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), callback_data="admin_promo_list") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_EDIT', '✏️ Редактировать'), callback_data=f'promo_edit_{promo_id}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TOGGLE', '🔄 Статус'), callback_data=f'promo_toggle_{promo_id}' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_STATS', '📊 Статистика'), callback_data=f'promo_stats_{promo_id}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_DELETE', '🗑️ Удалить'), callback_data=f'promo_delete_{promo_id}' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_LIST', '⬅️ К списку'), callback_data='admin_promo_list' + ) + ], ] - ]) + ) -def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_messages_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MESSAGES_ALL_USERS", "📨 Всем пользователям"), - callback_data="admin_msg_all" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MESSAGES_BY_SUBSCRIPTIONS", "🎯 По подпискам"), - callback_data="admin_msg_by_sub" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MESSAGES_BY_CRITERIA", "🔍 По критериям"), - callback_data="admin_msg_custom" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MESSAGES_HISTORY", "📋 История"), - callback_data="admin_msg_history" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_MESSAGE", "📌 Закрепленное сообщение"), - callback_data="admin_pinned_message", - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MESSAGES_ALL_USERS', '📨 Всем пользователям'), callback_data='admin_msg_all' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MESSAGES_BY_SUBSCRIPTIONS', '🎯 По подпискам'), + callback_data='admin_msg_by_sub', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MESSAGES_BY_CRITERIA', '🔍 По критериям'), callback_data='admin_msg_custom' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MESSAGES_HISTORY', '📋 История'), callback_data='admin_msg_history' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PINNED_MESSAGE', '📌 Закрепленное сообщение'), + callback_data='admin_pinned_message', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_communications')], ] - ]) + ) def get_pinned_message_keyboard( - language: str = "ru", + language: str = 'ru', send_before_menu: bool = True, send_on_every_start: bool = True, ) -> InlineKeyboardMarkup: texts = get_texts(language) position_label = ( - _t(texts, "ADMIN_PINNED_POSITION_BEFORE", "⬆️ Показать перед меню") + _t(texts, 'ADMIN_PINNED_POSITION_BEFORE', '⬆️ Показать перед меню') if send_before_menu - else _t(texts, "ADMIN_PINNED_POSITION_AFTER", "⬇️ Показать после меню") + else _t(texts, 'ADMIN_PINNED_POSITION_AFTER', '⬇️ Показать после меню') ) - toggle_callback = "admin_pinned_message_position" + toggle_callback = 'admin_pinned_message_position' start_mode_label = ( - _t(texts, "ADMIN_PINNED_START_EVERY_TIME", "🔁 Показать при каждом /start") + _t(texts, 'ADMIN_PINNED_START_EVERY_TIME', '🔁 Показать при каждом /start') if send_on_every_start - else _t(texts, "ADMIN_PINNED_START_ONCE", "🚫 Показывать только один раз") + else _t(texts, 'ADMIN_PINNED_START_ONCE', '🚫 Показывать только один раз') ) - start_mode_callback = "admin_pinned_message_start_mode" + start_mode_callback = 'admin_pinned_message_start_mode' - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_MESSAGE_UPDATE", "✏️ Обновить"), - callback_data="admin_pinned_message_edit", - ) - ], - [ - InlineKeyboardButton( - text=position_label, - callback_data=toggle_callback, - ) - ], - [ - InlineKeyboardButton( - text=start_mode_label, - callback_data=start_mode_callback, - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_MESSAGE_DELETE", "🗑️ Удалить и отключить"), - callback_data="admin_pinned_message_delete", - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")], - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PINNED_MESSAGE_UPDATE', '✏️ Обновить'), + callback_data='admin_pinned_message_edit', + ) + ], + [ + InlineKeyboardButton( + text=position_label, + callback_data=toggle_callback, + ) + ], + [ + InlineKeyboardButton( + text=start_mode_label, + callback_data=start_mode_callback, + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PINNED_MESSAGE_DELETE', '🗑️ Удалить и отключить'), + callback_data='admin_pinned_message_delete', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_messages')], + ] + ) def get_pinned_broadcast_confirm_keyboard( - language: str = "ru", + language: str = 'ru', pinned_message_id: int = 0, ) -> InlineKeyboardMarkup: """Клавиатура для выбора: разослать сейчас или только при /start.""" texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_BROADCAST_NOW", "📨 Разослать сейчас всем"), - callback_data=f"admin_pinned_broadcast_now:{pinned_message_id}", - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PINNED_BROADCAST_ON_START", "⏳ Только при /start"), - callback_data=f"admin_pinned_broadcast_skip:{pinned_message_id}", - ) - ], - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PINNED_BROADCAST_NOW', '📨 Разослать сейчас всем'), + callback_data=f'admin_pinned_broadcast_now:{pinned_message_id}', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PINNED_BROADCAST_ON_START', '⏳ Только при /start'), + callback_data=f'admin_pinned_broadcast_skip:{pinned_message_id}', + ) + ], + ] + ) -def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_monitoring_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), - callback_data="admin_mon_start" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STOP", "⏸️ Остановить"), - callback_data="admin_mon_stop" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STATUS", "📊 Статус"), - callback_data="admin_mon_status" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), - callback_data="admin_mon_logs" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_SETTINGS_BUTTON", "⚙️ Настройки"), - callback_data="admin_mon_settings" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_START', '▶️ Запустить'), callback_data='admin_mon_start' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_STOP', '⏸️ Остановить'), callback_data='admin_mon_stop' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_STATUS', '📊 Статус'), callback_data='admin_mon_status' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_LOGS', '📋 Логи'), callback_data='admin_mon_logs' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_SETTINGS_BUTTON', '⚙️ Настройки'), + callback_data='admin_mon_settings', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')], ] - ]) + ) -def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_remnawave_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_SYSTEM_STATS", "📊 Системная статистика"), - callback_data="admin_rw_system" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_NODES", "🖥️ Управление нодами"), - callback_data="admin_rw_nodes" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_SYNC", "🔄 Синхронизация"), - callback_data="admin_rw_sync" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_MANAGE_SQUADS", "🌐 Управление сквадами"), - callback_data="admin_rw_squads" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_MIGRATION", "🚚 Переезд"), - callback_data="admin_rw_migration" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REMNAWAVE_TRAFFIC", "📈 Трафик"), - callback_data="admin_rw_traffic" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_SYSTEM_STATS', '📊 Системная статистика'), + callback_data='admin_rw_system', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_MANAGE_NODES', '🖥️ Управление нодами'), + callback_data='admin_rw_nodes', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_SYNC', '🔄 Синхронизация'), callback_data='admin_rw_sync' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_MANAGE_SQUADS', '🌐 Управление сквадами'), + callback_data='admin_rw_squads', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_MIGRATION', '🚚 Переезд'), callback_data='admin_rw_migration' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_REMNAWAVE_TRAFFIC', '📈 Трафик'), callback_data='admin_rw_traffic' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')], ] - ]) + ) -def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_admin_statistics_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_USERS", "👥 Пользователи"), - callback_data="admin_stats_users" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_SUBSCRIPTIONS", "📱 Подписки"), - callback_data="admin_stats_subs" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_REVENUE", "💰 Доходы"), - callback_data="admin_stats_revenue" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_REFERRALS", "🤝 Партнерка"), - callback_data="admin_stats_referrals" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_SUMMARY", "📊 Общая сводка"), - callback_data="admin_stats_summary" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_USERS', '👥 Пользователи'), callback_data='admin_stats_users' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_SUBSCRIPTIONS', '📱 Подписки'), callback_data='admin_stats_subs' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_REVENUE', '💰 Доходы'), callback_data='admin_stats_revenue' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_REFERRALS', '🤝 Партнерка'), callback_data='admin_stats_referrals' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_SUMMARY', '📊 Общая сводка'), callback_data='admin_stats_summary' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_promo')], ] - ]) + ) -def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup: +def get_user_management_keyboard( + user_id: int, user_status: str, language: str = 'ru', back_callback: str = 'admin_users_list' +) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_BALANCE", "💰 Баланс"), - callback_data=f"admin_user_balance_{user_id}" + text=_t(texts, 'ADMIN_USER_BALANCE', '💰 Баланс'), callback_data=f'admin_user_balance_{user_id}' ), InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_SUBSCRIPTION_SETTINGS", "📱 Подписка и настройки"), - callback_data=f"admin_user_subscription_{user_id}" + text=_t(texts, 'ADMIN_USER_SUBSCRIPTION_SETTINGS', '📱 Подписка и настройки'), + callback_data=f'admin_user_subscription_{user_id}', + ), + ], + [ + InlineKeyboardButton( + text=texts.ADMIN_USER_PROMO_GROUP_BUTTON, callback_data=f'admin_user_promo_group_{user_id}' ) ], [ InlineKeyboardButton( - text=texts.ADMIN_USER_PROMO_GROUP_BUTTON, - callback_data=f"admin_user_promo_group_{user_id}" + text=_t(texts, 'ADMIN_USER_REFERRALS_BUTTON', '🤝 Рефералы'), + callback_data=f'admin_user_referrals_{user_id}', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"), - callback_data=f"admin_user_referrals_{user_id}" + text=_t(texts, 'ADMIN_USER_STATISTICS', '📊 Статистика'), + callback_data=f'admin_user_statistics_{user_id}', ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"), - callback_data=f"admin_user_statistics_{user_id}" + text=_t(texts, 'ADMIN_USER_TRANSACTIONS', '📋 Транзакции'), + callback_data=f'admin_user_transactions_{user_id}', ) ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_TRANSACTIONS", "📋 Транзакции"), - callback_data=f"admin_user_transactions_{user_id}" - ) - ] ] - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_SEND_MESSAGE", "✉️ Отправить сообщение"), - callback_data=f"admin_user_send_message_{user_id}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_SEND_MESSAGE', '✉️ Отправить сообщение'), + callback_data=f'admin_user_send_message_{user_id}', + ) + ] + ) # Кнопка управления ограничениями - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_RESTRICTIONS", "⚠️ Ограничить"), - callback_data=f"admin_user_restrictions_{user_id}" + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_RESTRICTIONS', '⚠️ Ограничить'), + callback_data=f'admin_user_restrictions_{user_id}', + ) + ] + ) + + if user_status == 'active': + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_BLOCK', '🚫 Заблокировать'), callback_data=f'admin_user_block_{user_id}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_DELETE', '🗑️ Удалить'), callback_data=f'admin_user_delete_{user_id}' + ), + ] + ) + elif user_status == 'blocked': + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_UNBLOCK', '✅ Разблокировать'), + callback_data=f'admin_user_unblock_{user_id}', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_DELETE', '🗑️ Удалить'), callback_data=f'admin_user_delete_{user_id}' + ), + ] + ) + elif user_status == 'deleted': + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_USER_ALREADY_DELETED', '❌ Пользователь удален'), callback_data='noop' + ) + ] ) - ]) - if user_status == "active": - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_BLOCK", "🚫 Заблокировать"), - callback_data=f"admin_user_block_{user_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"), - callback_data=f"admin_user_delete_{user_id}" - ) - ]) - elif user_status == "blocked": - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_UNBLOCK", "✅ Разблокировать"), - callback_data=f"admin_user_unblock_{user_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_DELETE", "🗑️ Удалить"), - callback_data=f"admin_user_delete_{user_id}" - ) - ]) - elif user_status == "deleted": - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_USER_ALREADY_DELETED", "❌ Пользователь удален"), - callback_data="noop" - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)]) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_user_restrictions_keyboard( - user_id: int, - restriction_topup: bool, - restriction_subscription: bool, - language: str = "ru" + user_id: int, restriction_topup: bool, restriction_subscription: bool, language: str = 'ru' ) -> InlineKeyboardMarkup: """Клавиатура управления ограничениями пользователя.""" texts = get_texts(language) @@ -1190,58 +1181,43 @@ def get_user_restrictions_keyboard( keyboard = [] # Toggle для ограничения пополнения - topup_emoji = "🚫" if restriction_topup else "✅" - topup_text = f"{topup_emoji} Пополнение" - keyboard.append([ - InlineKeyboardButton( - text=topup_text, - callback_data=f"admin_user_restriction_toggle_topup_{user_id}" - ) - ]) + topup_emoji = '🚫' if restriction_topup else '✅' + topup_text = f'{topup_emoji} Пополнение' + keyboard.append( + [InlineKeyboardButton(text=topup_text, callback_data=f'admin_user_restriction_toggle_topup_{user_id}')] + ) # Toggle для ограничения подписки - sub_emoji = "🚫" if restriction_subscription else "✅" - sub_text = f"{sub_emoji} Продление/покупка" - keyboard.append([ - InlineKeyboardButton( - text=sub_text, - callback_data=f"admin_user_restriction_toggle_sub_{user_id}" - ) - ]) + sub_emoji = '🚫' if restriction_subscription else '✅' + sub_text = f'{sub_emoji} Продление/покупка' + keyboard.append([InlineKeyboardButton(text=sub_text, callback_data=f'admin_user_restriction_toggle_sub_{user_id}')]) # Кнопка изменения причины - keyboard.append([ - InlineKeyboardButton( - text="📝 Изменить причину", - callback_data=f"admin_user_restriction_reason_{user_id}" - ) - ]) + keyboard.append( + [InlineKeyboardButton(text='📝 Изменить причину', callback_data=f'admin_user_restriction_reason_{user_id}')] + ) # Кнопка снятия всех ограничений (если есть хотя бы одно) if restriction_topup or restriction_subscription: - keyboard.append([ - InlineKeyboardButton( - text="🔓 Снять все ограничения", - callback_data=f"admin_user_restriction_clear_{user_id}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text='🔓 Снять все ограничения', callback_data=f'admin_user_restriction_clear_{user_id}' + ) + ] + ) # Кнопка назад - keyboard.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data=f"admin_user_manage_{user_id}" - ) - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_user_manage_{user_id}')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_user_promo_group_keyboard( - promo_groups: List[Tuple[Any, int]], + promo_groups: list[tuple[Any, int]], user_id: int, current_group_ids, # Can be Optional[int] or List[int] - language: str = "ru" + language: str = 'ru', ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -1251,688 +1227,639 @@ def get_user_promo_group_keyboard( elif isinstance(current_group_ids, int): current_group_ids = [current_group_ids] - keyboard: List[List[InlineKeyboardButton]] = [] + keyboard: list[list[InlineKeyboardButton]] = [] for group, members_count in promo_groups: # Check if user has this group has_group = group.id in current_group_ids - prefix = "✅" if has_group else "👥" - count_text = f" ({members_count})" if members_count else "" - keyboard.append([ - InlineKeyboardButton( - text=f"{prefix} {group.name}{count_text}", - callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.ADMIN_USER_PROMO_GROUP_BACK, - callback_data=f"admin_user_manage_{user_id}" + prefix = '✅' if has_group else '👥' + count_text = f' ({members_count})' if members_count else '' + keyboard.append( + [ + InlineKeyboardButton( + text=f'{prefix} {group.name}{count_text}', + callback_data=f'admin_user_promo_group_toggle_{user_id}_{group.id}', + ) + ] ) - ]) + + keyboard.append( + [InlineKeyboardButton(text=texts.ADMIN_USER_PROMO_GROUP_BACK, callback_data=f'admin_user_manage_{user_id}')] + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_confirmation_keyboard( - confirm_action: str, - cancel_action: str = "admin_panel", - language: str = "ru" + confirm_action: str, cancel_action: str = 'admin_panel', language: str = 'ru' ) -> InlineKeyboardMarkup: texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.YES, callback_data=confirm_action), - InlineKeyboardButton(text=texts.NO, callback_data=cancel_action) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.YES, callback_data=confirm_action), + InlineKeyboardButton(text=texts.NO, callback_data=cancel_action), + ] ] - ]) + ) -def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_promocode_type_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TYPE_BALANCE", "💰 Баланс"), - callback_data="promo_type_balance" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TYPE_DAYS", "📅 Дни подписки"), - callback_data="promo_type_days" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"), - callback_data="promo_type_trial" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"), - callback_data="promo_type_group" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODE_TYPE_DISCOUNT", "💸 Одноразовая скидка"), - callback_data="promo_type_discount" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TYPE_BALANCE', '💰 Баланс'), callback_data='promo_type_balance' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TYPE_DAYS', '📅 Дни подписки'), callback_data='promo_type_days' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TYPE_TRIAL', '🎁 Триал'), callback_data='promo_type_trial' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TYPE_PROMO_GROUP', '🏷️ Промогруппа'), + callback_data='promo_type_group', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODE_TYPE_DISCOUNT', '💸 Одноразовая скидка'), + callback_data='promo_type_discount', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_promocodes')], ] - ]) + ) -def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_promocode_list_keyboard( + promocodes: list, page: int, total_pages: int, language: str = 'ru' +) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + for promo in promocodes: - status_emoji = "✅" if promo.is_active else "❌" - type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫") - - keyboard.append([ - InlineKeyboardButton( - text=f"{status_emoji} {type_emoji} {promo.code}", - callback_data=f"promo_manage_{promo.id}" - ) - ]) - + status_emoji = '✅' if promo.is_active else '❌' + type_emoji = {'balance': '💰', 'subscription_days': '📅', 'trial_subscription': '🎁'}.get(promo.type, '🎫') + + keyboard.append( + [ + InlineKeyboardButton( + text=f'{status_emoji} {type_emoji} {promo.code}', callback_data=f'promo_manage_{promo.id}' + ) + ] + ) + if total_pages > 1: pagination_row = [] - + if page > 1: - pagination_row.append( - InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}") - ) - - pagination_row.append( - InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page") - ) - + pagination_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'admin_promo_list_page_{page - 1}')) + + pagination_row.append(InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='current_page')) + if page < total_pages: - pagination_row.append( - InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}") - ) - + pagination_row.append(InlineKeyboardButton(text='➡️', callback_data=f'admin_promo_list_page_{page + 1}')) + keyboard.append(pagination_row) - - keyboard.extend([ + + keyboard.extend( [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PROMOCODES_CREATE", "➕ Создать"), - callback_data="admin_promo_create" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes")] - ]) + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PROMOCODES_CREATE', '➕ Создать'), callback_data='admin_promo_create' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_promocodes')], + ] + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_broadcast_target_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ALL", "👥 Всем"), - callback_data="broadcast_all" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE", "📱 С подпиской"), - callback_data="broadcast_active" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL", "🎁 Триал"), - callback_data="broadcast_trial" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_NO_SUB", "❌ Без подписки"), - callback_data="broadcast_no_sub" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRING", "⏰ Истекающие"), - callback_data="broadcast_expiring" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_EXPIRED", "🔚 Истекшие"), - callback_data="broadcast_expired" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_ACTIVE_ZERO", "🧊 Активна 0 ГБ"), - callback_data="broadcast_active_zero" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_TRIAL_ZERO", "🥶 Триал 0 ГБ"), - callback_data="broadcast_trial_zero" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_TARGET_BY_TARIFF", "📦 По тарифу"), - callback_data="broadcast_by_tariff" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_ALL', '👥 Всем'), callback_data='broadcast_all' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_ACTIVE', '📱 С подпиской'), callback_data='broadcast_active' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_TRIAL', '🎁 Триал'), callback_data='broadcast_trial' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_NO_SUB', '❌ Без подписки'), callback_data='broadcast_no_sub' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_EXPIRING', '⏰ Истекающие'), + callback_data='broadcast_expiring', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_EXPIRED', '🔚 Истекшие'), callback_data='broadcast_expired' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_ACTIVE_ZERO', '🧊 Активна 0 ГБ'), + callback_data='broadcast_active_zero', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_TRIAL_ZERO', '🥶 Триал 0 ГБ'), + callback_data='broadcast_trial_zero', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_TARGET_BY_TARIFF', '📦 По тарифу'), + callback_data='broadcast_by_tariff', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_messages')], + ] + ) -def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_custom_criteria_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_TODAY", "📅 Сегодня"), - callback_data="criteria_today" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_WEEK", "📅 За неделю"), - callback_data="criteria_week" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_MONTH", "📅 За месяц"), - callback_data="criteria_month" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_ACTIVE_TODAY", "⚡ Активные сегодня"), - callback_data="criteria_active_today" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_INACTIVE_WEEK", "💤 Неактивные 7+ дней"), - callback_data="criteria_inactive_week" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_INACTIVE_MONTH", "💤 Неактивные 30+ дней"), - callback_data="criteria_inactive_month" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_REFERRALS", "🤝 Через рефералов"), - callback_data="criteria_referrals" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_PROMOCODES", "🎫 Использовали промокоды"), - callback_data="criteria_promocodes" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CRITERIA_DIRECT", "🎯 Прямая регистрация"), - callback_data="criteria_direct" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_TODAY', '📅 Сегодня'), callback_data='criteria_today' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_WEEK', '📅 За неделю'), callback_data='criteria_week' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_MONTH', '📅 За месяц'), callback_data='criteria_month' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_ACTIVE_TODAY', '⚡ Активные сегодня'), + callback_data='criteria_active_today', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_INACTIVE_WEEK', '💤 Неактивные 7+ дней'), + callback_data='criteria_inactive_week', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_INACTIVE_MONTH', '💤 Неактивные 30+ дней'), + callback_data='criteria_inactive_month', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_REFERRALS', '🤝 Через рефералов'), callback_data='criteria_referrals' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_PROMOCODES', '🎫 Использовали промокоды'), + callback_data='criteria_promocodes', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_CRITERIA_DIRECT', '🎯 Прямая регистрация'), callback_data='criteria_direct' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_messages')], + ] + ) -def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup: +def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + if total_pages > 1: pagination_row = [] - + if page > 1: - pagination_row.append( - InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}") - ) - - pagination_row.append( - InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page") - ) - + pagination_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'admin_msg_history_page_{page - 1}')) + + pagination_row.append(InlineKeyboardButton(text=f'{page}/{total_pages}', callback_data='current_page')) + if page < total_pages: - pagination_row.append( - InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}") - ) - + pagination_row.append(InlineKeyboardButton(text='➡️', callback_data=f'admin_msg_history_page_{page + 1}')) + keyboard.append(pagination_row) - - keyboard.extend([ + + keyboard.extend( [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), - callback_data="admin_msg_history" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")] - ]) + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_HISTORY_REFRESH', '🔄 Обновить'), callback_data='admin_msg_history' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_messages')], + ] + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + +def get_sync_options_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), - callback_data="sync_all_users" + text=_t(texts, 'ADMIN_SYNC_FULL', '🔄 Полная синхронизация'), callback_data='sync_all_users' ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"), - callback_data="sync_to_panel" + text=_t(texts, 'ADMIN_SYNC_TO_PANEL', '⬆️ Синхронизация в панель'), callback_data='sync_to_panel' ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"), - callback_data="sync_new_users" + text=_t(texts, 'ADMIN_SYNC_ONLY_NEW', '🆕 Только новые'), callback_data='sync_new_users' ) ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_UPDATE", "📈 Обновить данные"), - callback_data="sync_update_data" + text=_t(texts, 'ADMIN_SYNC_UPDATE', '📈 Обновить данные'), callback_data='sync_update_data' ) ], [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"), - callback_data="sync_validate" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_CLEANUP", "🧹 Очистка"), - callback_data="sync_cleanup" - ) + InlineKeyboardButton(text=_t(texts, 'ADMIN_SYNC_VALIDATE', '🔍 Валидация'), callback_data='sync_validate'), + InlineKeyboardButton(text=_t(texts, 'ADMIN_SYNC_CLEANUP', '🧹 Очистка'), callback_data='sync_cleanup'), ], [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_RECOMMENDATIONS", "💡 Рекомендации"), - callback_data="sync_recommendations" + text=_t(texts, 'ADMIN_SYNC_RECOMMENDATIONS', '💡 Рекомендации'), callback_data='sync_recommendations' ) ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")] + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_remnawave')], ] return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup: + +def get_sync_confirmation_keyboard(sync_type: str, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [ [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_CONFIRM", "✅ Подтвердить"), - callback_data=f"confirm_{sync_type}" + text=_t(texts, 'ADMIN_SYNC_CONFIRM', '✅ Подтвердить'), callback_data=f'confirm_{sync_type}' ) ], - [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_rw_sync")] + [InlineKeyboardButton(text=_t(texts, 'ADMIN_CANCEL', '❌ Отмена'), callback_data='admin_rw_sync')], ] return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup: + +def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] if has_errors: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_RETRY", "🔄 Повторить"), - callback_data=f"sync_{sync_type}" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYNC_RETRY', '🔄 Повторить'), callback_data=f'sync_{sync_type}' + ) + ] + ) - if sync_type != "all_users": - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), - callback_data="sync_all_users" - ) - ]) + if sync_type != 'all_users': + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYNC_FULL', '🔄 Полная синхронизация'), callback_data='sync_all_users' + ) + ] + ) - keyboard.extend([ + keyboard.extend( [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_STATS_BUTTON", "📊 Статистика"), - callback_data="admin_rw_system" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_VALIDATE", "🔍 Валидация"), - callback_data="sync_validate" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_BACK", "⬅️ К синхронизации"), - callback_data="admin_rw_sync" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_MAIN", "🏠 В главное меню"), - callback_data="admin_remnawave" - ) + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_STATS_BUTTON', '📊 Статистика'), callback_data='admin_rw_system' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYNC_VALIDATE', '🔍 Валидация'), callback_data='sync_validate' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYNC_BACK', '⬅️ К синхронизации'), callback_data='admin_rw_sync' + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_MAIN', '🏠 В главное меню'), callback_data='admin_remnawave' + ) + ], ] - ]) - + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) - -def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_period_selection_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PERIOD_TODAY", "📅 Сегодня"), - callback_data="period_today" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PERIOD_YESTERDAY", "📅 Вчера"), - callback_data="period_yesterday" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PERIOD_WEEK", "📅 Неделя"), - callback_data="period_week" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_PERIOD_MONTH", "📅 Месяц"), - callback_data="period_month" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_PERIOD_ALL", "📅 Все время"), - callback_data="period_all" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_statistics")] - ]) - - -def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_NODE_ENABLE", "▶️ Включить"), - callback_data=f"node_enable_{node_uuid}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_NODE_DISABLE", "⏸️ Отключить"), - callback_data=f"node_disable_{node_uuid}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_NODE_RESTART", "🔄 Перезагрузить"), - callback_data=f"node_restart_{node_uuid}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_NODE_STATS", "📊 Статистика"), - callback_data=f"node_stats_{node_uuid}" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_nodes")] - ]) - -def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_ADD_ALL", "👥 Добавить всех пользователей"), - callback_data=f"squad_add_users_{squad_uuid}" - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_REMOVE_ALL", "❌ Удалить всех пользователей"), - callback_data=f"squad_remove_users_{squad_uuid}" - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_EDIT", "✏️ Редактировать"), - callback_data=f"squad_edit_{squad_uuid}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_DELETE", "🗑️ Удалить сквад"), - callback_data=f"squad_delete_{squad_uuid}" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_rw_squads")] - ]) - -def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_EDIT_INBOUNDS", "🔧 Изменить инбаунды"), - callback_data=f"squad_edit_inbounds_{squad_uuid}" - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SQUAD_RENAME", "✏️ Переименовать"), - callback_data=f"squad_rename_{squad_uuid}" - ), - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_SQUADS", "⬅️ Назад к сквадам"), - callback_data=f"admin_squad_manage_{squad_uuid}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=_t(texts, 'ADMIN_PERIOD_TODAY', '📅 Сегодня'), callback_data='period_today'), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_PERIOD_YESTERDAY', '📅 Вчера'), callback_data='period_yesterday' + ), + ], + [ + InlineKeyboardButton(text=_t(texts, 'ADMIN_PERIOD_WEEK', '📅 Неделя'), callback_data='period_week'), + InlineKeyboardButton(text=_t(texts, 'ADMIN_PERIOD_MONTH', '📅 Месяц'), callback_data='period_month'), + ], + [InlineKeyboardButton(text=_t(texts, 'ADMIN_PERIOD_ALL', '📅 Все время'), callback_data='period_all')], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_statistics')], ] - ]) + ) -def get_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + +def get_node_management_keyboard(node_uuid: str, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), - callback_data="admin_mon_start" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"), - callback_data="admin_mon_stop" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "🔄 Проверка подписок"), - callback_data="admin_mon_force_check" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_TRAFFIC_CHECK", "📊 Проверка трафика"), - callback_data="admin_mon_traffic_check" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), - callback_data="admin_mon_logs" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📈 Статистика"), - callback_data="admin_mon_statistics" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"), - callback_data="admin_mon_test_notifications" - ), - InlineKeyboardButton( - text="⚙️ Настройки трафика", - callback_data="admin_mon_traffic_settings" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_ADMIN", "⬅️ Назад в админку"), - callback_data="admin_panel" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_NODE_ENABLE', '▶️ Включить'), callback_data=f'node_enable_{node_uuid}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_NODE_DISABLE', '⏸️ Отключить'), callback_data=f'node_disable_{node_uuid}' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_NODE_RESTART', '🔄 Перезагрузить'), callback_data=f'node_restart_{node_uuid}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_NODE_STATS', '📊 Статистика'), callback_data=f'node_stats_{node_uuid}' + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_rw_nodes')], ] - ]) + ) -def get_monitoring_logs_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + +def get_squad_management_keyboard(squad_uuid: str, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), - callback_data="admin_mon_logs" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_CLEAR_OLD", "🗑️ Очистить старые"), - callback_data="admin_mon_clear_logs" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring")] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_ADD_ALL', '👥 Добавить всех пользователей'), + callback_data=f'squad_add_users_{squad_uuid}', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_REMOVE_ALL', '❌ Удалить всех пользователей'), + callback_data=f'squad_remove_users_{squad_uuid}', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_EDIT', '✏️ Редактировать'), callback_data=f'squad_edit_{squad_uuid}' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_DELETE', '🗑️ Удалить сквад'), callback_data=f'squad_delete_{squad_uuid}' + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_rw_squads')], + ] + ) + + +def get_squad_edit_keyboard(squad_uuid: str, language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_EDIT_INBOUNDS', '🔧 Изменить инбаунды'), + callback_data=f'squad_edit_inbounds_{squad_uuid}', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SQUAD_RENAME', '✏️ Переименовать'), callback_data=f'squad_rename_{squad_uuid}' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_SQUADS', '⬅️ Назад к сквадам'), + callback_data=f'admin_squad_manage_{squad_uuid}', + ) + ], + ] + ) + + +def get_monitoring_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_START', '▶️ Запустить'), callback_data='admin_mon_start' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_STOP_HARD', '⏹️ Остановить'), callback_data='admin_mon_stop' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_FORCE_CHECK', '🔄 Проверка подписок'), + callback_data='admin_mon_force_check', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_TRAFFIC_CHECK', '📊 Проверка трафика'), + callback_data='admin_mon_traffic_check', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_LOGS', '📋 Логи'), callback_data='admin_mon_logs' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_STATISTICS', '📈 Статистика'), callback_data='admin_mon_statistics' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_TEST_NOTIFICATIONS', '🧪 Тест уведомлений'), + callback_data='admin_mon_test_notifications', + ), + InlineKeyboardButton(text='⚙️ Настройки трафика', callback_data='admin_mon_traffic_settings'), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_ADMIN', '⬅️ Назад в админку'), callback_data='admin_panel' + ) + ], + ] + ) + + +def get_monitoring_logs_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_HISTORY_REFRESH', '🔄 Обновить'), callback_data='admin_mon_logs' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_CLEAR_OLD', '🗑️ Очистить старые'), + callback_data='admin_mon_clear_logs', + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_monitoring')], + ] + ) + def get_monitoring_logs_navigation_keyboard( - current_page: int, - total_pages: int, - has_logs: bool = True, - language: str = "ru" + current_page: int, total_pages: int, has_logs: bool = True, language: str = 'ru' ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + if total_pages > 1: nav_row = [] - + if current_page > 1: - nav_row.append(InlineKeyboardButton( - text="⬅️", - callback_data=f"admin_mon_logs_page_{current_page - 1}" - )) - - nav_row.append(InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page_info" - )) - + nav_row.append(InlineKeyboardButton(text='⬅️', callback_data=f'admin_mon_logs_page_{current_page - 1}')) + + nav_row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page_info')) + if current_page < total_pages: - nav_row.append(InlineKeyboardButton( - text="➡️", - callback_data=f"admin_mon_logs_page_{current_page + 1}" - )) - + nav_row.append(InlineKeyboardButton(text='➡️', callback_data=f'admin_mon_logs_page_{current_page + 1}')) + keyboard.append(nav_row) - + management_row = [] - + refresh_button = InlineKeyboardButton( - text=_t(texts, "ADMIN_HISTORY_REFRESH", "🔄 Обновить"), - callback_data="admin_mon_logs" + text=_t(texts, 'ADMIN_HISTORY_REFRESH', '🔄 Обновить'), callback_data='admin_mon_logs' ) if has_logs: - management_row.extend([ - refresh_button, - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_CLEAR", "🗑️ Очистить"), - callback_data="admin_mon_clear_logs" - ) - ]) + management_row.extend( + [ + refresh_button, + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_CLEAR', '🗑️ Очистить'), callback_data='admin_mon_clear_logs' + ), + ] + ) else: management_row.append(refresh_button) - + keyboard.append(management_row) - - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ Назад к мониторингу"), - callback_data="admin_monitoring" - ) - ]) - + + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_MONITORING', '⬅️ Назад к мониторингу'), callback_data='admin_monitoring' + ) + ] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_log_detail_keyboard(log_id: int, current_page: int = 1, language: str = "ru") -> InlineKeyboardMarkup: + +def get_log_detail_keyboard(log_id: int, current_page: int = 1, language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_DELETE_LOG", "🗑️ Удалить этот лог"), - callback_data=f"admin_mon_delete_log_{log_id}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_BACK_TO_LOGS", "⬅️ К списку логов"), - callback_data=f"admin_mon_logs_page_{current_page}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_DELETE_LOG', '🗑️ Удалить этот лог'), + callback_data=f'admin_mon_delete_log_{log_id}', + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_BACK_TO_LOGS', '⬅️ К списку логов'), + callback_data=f'admin_mon_logs_page_{current_page}', + ) + ], ] - ]) + ) -def get_monitoring_clear_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_monitoring_clear_confirm_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_CONFIRM_CLEAR", "✅ Да, очистить"), - callback_data="admin_mon_clear_logs_confirm" - ), - InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_mon_logs") - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_CLEAR_ALL", "🗑️ Очистить ВСЕ логи"), - callback_data="admin_mon_clear_all_logs" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_CONFIRM_CLEAR', '✅ Да, очистить'), + callback_data='admin_mon_clear_logs_confirm', + ), + InlineKeyboardButton(text=_t(texts, 'ADMIN_CANCEL', '❌ Отмена'), callback_data='admin_mon_logs'), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_CLEAR_ALL', '🗑️ Очистить ВСЕ логи'), + callback_data='admin_mon_clear_all_logs', + ) + ], ] - ]) + ) + def get_monitoring_status_keyboard( - is_running: bool, - last_check_ago_minutes: int = 0, - language: str = "ru" + is_running: bool, last_check_ago_minutes: int = 0, language: str = 'ru' ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] control_row = [] if is_running: - control_row.extend([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STOP_HARD", "⏹️ Остановить"), - callback_data="admin_mon_stop" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_RESTART", "🔄 Перезапустить"), - callback_data="admin_mon_restart" - ) - ]) + control_row.extend( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_STOP_HARD', '⏹️ Остановить'), callback_data='admin_mon_stop' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_RESTART', '🔄 Перезапустить'), callback_data='admin_mon_restart' + ), + ] + ) else: control_row.append( InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_START", "▶️ Запустить"), - callback_data="admin_mon_start" + text=_t(texts, 'ADMIN_MONITORING_START', '▶️ Запустить'), callback_data='admin_mon_start' ) ) @@ -1943,393 +1870,384 @@ def get_monitoring_status_keyboard( if not is_running or last_check_ago_minutes > 10: monitoring_row.append( InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FORCE_CHECK", "⚡ Срочная проверка"), - callback_data="admin_mon_force_check" + text=_t(texts, 'ADMIN_MONITORING_FORCE_CHECK', '⚡ Срочная проверка'), + callback_data='admin_mon_force_check', ) ) else: monitoring_row.append( InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_CHECK_NOW", "🔄 Проверить сейчас"), - callback_data="admin_mon_force_check" + text=_t(texts, 'ADMIN_MONITORING_CHECK_NOW', '🔄 Проверить сейчас'), + callback_data='admin_mon_force_check', ) ) keyboard.append(monitoring_row) info_row = [ - InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_LOGS", "📋 Логи"), callback_data="admin_mon_logs"), + InlineKeyboardButton(text=_t(texts, 'ADMIN_MONITORING_LOGS', '📋 Логи'), callback_data='admin_mon_logs'), InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_STATISTICS", "📊 Статистика"), - callback_data="admin_mon_statistics" - ) + text=_t(texts, 'ADMIN_MONITORING_STATISTICS', '📊 Статистика'), callback_data='admin_mon_statistics' + ), ] keyboard.append(info_row) test_row = [ InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_TEST_NOTIFICATIONS", "🧪 Тест уведомлений"), - callback_data="admin_mon_test_notifications" + text=_t(texts, 'ADMIN_MONITORING_TEST_NOTIFICATIONS', '🧪 Тест уведомлений'), + callback_data='admin_mon_test_notifications', ) ] keyboard.append(test_row) - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_settings") - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_monitoring_settings_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + +def get_monitoring_settings_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_SET_INTERVAL", "⏱️ Интервал проверки"), - callback_data="admin_mon_set_interval" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_NOTIFICATIONS", "🔔 Уведомления"), - callback_data="admin_mon_toggle_notifications" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_AUTOPAY_SETTINGS", "💳 Настройки автооплаты"), - callback_data="admin_mon_autopay_settings" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_AUTO_CLEANUP", "🧹 Автоочистка логов"), - callback_data="admin_mon_auto_cleanup" - ) - ], - [InlineKeyboardButton(text=_t(texts, "ADMIN_BACK_TO_MONITORING", "⬅️ К мониторингу"), callback_data="admin_monitoring")] - ]) - - -def get_log_type_filter_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FILTER_SUCCESS", "✅ Успешные"), - callback_data="admin_mon_logs_filter_success" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FILTER_ERRORS", "❌ Ошибки"), - callback_data="admin_mon_logs_filter_error" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FILTER_CYCLES", "🔄 Циклы мониторинга"), - callback_data="admin_mon_logs_filter_cycle" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MONITORING_FILTER_AUTOPAY", "💳 Автооплаты"), - callback_data="admin_mon_logs_filter_autopay" - ) - ], - [ - InlineKeyboardButton(text=_t(texts, "ADMIN_MONITORING_ALL_LOGS", "📋 Все логи"), callback_data="admin_mon_logs"), - InlineKeyboardButton(text=texts.BACK, callback_data="admin_monitoring") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_SET_INTERVAL', '⏱️ Интервал проверки'), + callback_data='admin_mon_set_interval', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_NOTIFICATIONS', '🔔 Уведомления'), + callback_data='admin_mon_toggle_notifications', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_AUTOPAY_SETTINGS', '💳 Настройки автооплаты'), + callback_data='admin_mon_autopay_settings', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_AUTO_CLEANUP', '🧹 Автоочистка логов'), + callback_data='admin_mon_auto_cleanup', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BACK_TO_MONITORING', '⬅️ К мониторингу'), callback_data='admin_monitoring' + ) + ], ] - ]) + ) -def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_log_type_filter_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVERS_LIST", "📋 Список серверов"), - callback_data="admin_servers_list" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVERS_SYNC", "🔄 Синхронизация"), - callback_data="admin_servers_sync" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVERS_ADD", "➕ Добавить сервер"), - callback_data="admin_servers_add" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVERS_STATS", "📊 Статистика"), - callback_data="admin_servers_stats" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_subscriptions")] - ]) - - -def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - - toggle_text = _t(texts, "ADMIN_SERVER_DISABLE", "❌ Отключить") if is_available else _t(texts, "ADMIN_SERVER_ENABLE", "✅ Включить") - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_EDIT_NAME", "✏️ Название"), - callback_data=f"admin_server_edit_name_{server_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_EDIT_PRICE", "💰 Цена"), - callback_data=f"admin_server_edit_price_{server_id}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_EDIT_COUNTRY", "🌍 Страна"), - callback_data=f"admin_server_edit_country_{server_id}" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_EDIT_LIMIT", "👥 Лимит"), - callback_data=f"admin_server_edit_limit_{server_id}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_EDIT_DESCRIPTION", "📝 Описание"), - callback_data=f"admin_server_edit_desc_{server_id}" - ) - ], - [ - InlineKeyboardButton( - text=toggle_text, - callback_data=f"admin_server_toggle_{server_id}" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_SERVER_DELETE", "🗑️ Удалить"), - callback_data=f"admin_server_delete_{server_id}" - ), - InlineKeyboardButton(text=texts.BACK, callback_data="admin_servers_list") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_FILTER_SUCCESS', '✅ Успешные'), + callback_data='admin_mon_logs_filter_success', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_FILTER_ERRORS', '❌ Ошибки'), + callback_data='admin_mon_logs_filter_error', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_FILTER_CYCLES', '🔄 Циклы мониторинга'), + callback_data='admin_mon_logs_filter_cycle', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_FILTER_AUTOPAY', '💳 Автооплаты'), + callback_data='admin_mon_logs_filter_autopay', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MONITORING_ALL_LOGS', '📋 Все логи'), callback_data='admin_mon_logs' + ), + InlineKeyboardButton(text=texts.BACK, callback_data='admin_monitoring'), + ], ] - ]) + ) + + +def get_admin_servers_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVERS_LIST', '📋 Список серверов'), callback_data='admin_servers_list' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVERS_SYNC', '🔄 Синхронизация'), callback_data='admin_servers_sync' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVERS_ADD', '➕ Добавить сервер'), callback_data='admin_servers_add' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVERS_STATS', '📊 Статистика'), callback_data='admin_servers_stats' + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_subscriptions')], + ] + ) + + +def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + + toggle_text = ( + _t(texts, 'ADMIN_SERVER_DISABLE', '❌ Отключить') + if is_available + else _t(texts, 'ADMIN_SERVER_ENABLE', '✅ Включить') + ) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_EDIT_NAME', '✏️ Название'), + callback_data=f'admin_server_edit_name_{server_id}', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_EDIT_PRICE', '💰 Цена'), + callback_data=f'admin_server_edit_price_{server_id}', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_EDIT_COUNTRY', '🌍 Страна'), + callback_data=f'admin_server_edit_country_{server_id}', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_EDIT_LIMIT', '👥 Лимит'), + callback_data=f'admin_server_edit_limit_{server_id}', + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_EDIT_DESCRIPTION', '📝 Описание'), + callback_data=f'admin_server_edit_desc_{server_id}', + ) + ], + [InlineKeyboardButton(text=toggle_text, callback_data=f'admin_server_toggle_{server_id}')], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SERVER_DELETE', '🗑️ Удалить'), callback_data=f'admin_server_delete_{server_id}' + ), + InlineKeyboardButton(text=texts.BACK, callback_data='admin_servers_list'), + ], + ] + ) def get_admin_pagination_keyboard( - current_page: int, - total_pages: int, - callback_prefix: str, - back_callback: str = "admin_panel", - language: str = "ru" + current_page: int, total_pages: int, callback_prefix: str, back_callback: str = 'admin_panel', language: str = 'ru' ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + if total_pages > 1: row = [] - + if current_page > 1: - row.append(InlineKeyboardButton( - text="⬅️", - callback_data=f"{callback_prefix}_page_{current_page - 1}" - )) - - row.append(InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page" - )) - + row.append(InlineKeyboardButton(text='⬅️', callback_data=f'{callback_prefix}_page_{current_page - 1}')) + + row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page')) + if current_page < total_pages: - row.append(InlineKeyboardButton( - text="➡️", - callback_data=f"{callback_prefix}_page_{current_page + 1}" - )) - + row.append(InlineKeyboardButton(text='➡️', callback_data=f'{callback_prefix}_page_{current_page + 1}')) + keyboard.append(row) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) - ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)]) return InlineKeyboardMarkup(inline_keyboard=keyboard) + def get_maintenance_keyboard( - language: str, - is_maintenance_active: bool, - is_monitoring_active: bool, - panel_has_issues: bool = False + language: str, is_maintenance_active: bool, is_monitoring_active: bool, panel_has_issues: bool = False ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] if is_maintenance_active: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_DISABLE", "🟢 Выключить техработы"), - callback_data="maintenance_toggle" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAINTENANCE_DISABLE', '🟢 Выключить техработы'), + callback_data='maintenance_toggle', + ) + ] + ) else: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_ENABLE", "🔧 Включить техработы"), - callback_data="maintenance_toggle" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAINTENANCE_ENABLE', '🔧 Включить техработы'), + callback_data='maintenance_toggle', + ) + ] + ) if is_monitoring_active: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_STOP_MONITORING", "⏹️ Остановить мониторинг"), - callback_data="maintenance_monitoring" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAINTENANCE_STOP_MONITORING', '⏹️ Остановить мониторинг'), + callback_data='maintenance_monitoring', + ) + ] + ) else: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_START_MONITORING", "▶️ Запустить мониторинг"), - callback_data="maintenance_monitoring" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_CHECK_API", "🔍 Проверить API"), - callback_data="maintenance_check_api" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_PANEL_STATUS", "🌐 Статус панели") + ("⚠️" if panel_has_issues else ""), - callback_data="maintenance_check_panel" + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAINTENANCE_START_MONITORING', '▶️ Запустить мониторинг'), + callback_data='maintenance_monitoring', + ) + ] ) - ]) - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_MAINTENANCE_SEND_NOTIFICATION", "📢 Отправить уведомление"), - callback_data="maintenance_manual_notify" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_REFRESH", "🔄 Обновить"), - callback_data="maintenance_panel" - ), - InlineKeyboardButton( - text=texts.BACK, - callback_data="admin_submenu_settings" - ) - ]) - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - -def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - keyboard = [ + keyboard.append( [ InlineKeyboardButton( - text=_t(texts, "ADMIN_SYNC_FULL", "🔄 Полная синхронизация"), - callback_data="sync_all_users" - ) - ], - [InlineKeyboardButton(text=texts.BACK, callback_data="admin_remnawave")] - ] - - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup: - - texts = get_texts(language) - toggle_text = _t(texts, "ADMIN_WELCOME_DISABLE", "🔴 Отключить") if is_enabled else _t(texts, "ADMIN_WELCOME_ENABLE", "🟢 Включить") - toggle_callback = "toggle_welcome_text" - - keyboard = [ - [ - InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_EDIT", "📝 Изменить текст"), - callback_data="edit_welcome_text" + text=_t(texts, 'ADMIN_MAINTENANCE_CHECK_API', '🔍 Проверить API'), callback_data='maintenance_check_api' ), InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_SHOW", "👁️ Показать текущий"), - callback_data="show_welcome_text" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_PREVIEW", "👁️ Предпросмотр"), - callback_data="preview_welcome_text" + text=_t(texts, 'ADMIN_MAINTENANCE_PANEL_STATUS', '🌐 Статус панели') + + ('⚠️' if panel_has_issues else ''), + callback_data='maintenance_check_panel', ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_RESET", "🔄 Сбросить"), - callback_data="reset_welcome_text" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_HTML", "🏷️ HTML форматирование"), - callback_data="show_formatting_help" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_WELCOME_PLACEHOLDERS", "💡 Плейсхолдеры"), - callback_data="show_placeholders_help" - ) - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications") ] - ] - + ) + + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_MAINTENANCE_SEND_NOTIFICATION', '📢 Отправить уведомление'), + callback_data='maintenance_manual_notify', + ) + ] + ) + + keyboard.append( + [ + InlineKeyboardButton(text=_t(texts, 'ADMIN_REFRESH', '🔄 Обновить'), callback_data='maintenance_panel'), + InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_settings'), + ] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) -DEFAULT_BROADCAST_BUTTONS = ("home",) + +def get_sync_simplified_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + keyboard = [ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_SYNC_FULL', '🔄 Полная синхронизация'), callback_data='sync_all_users' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_remnawave')], + ] + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def get_welcome_text_keyboard(language: str = 'ru', is_enabled: bool = True) -> InlineKeyboardMarkup: + texts = get_texts(language) + toggle_text = ( + _t(texts, 'ADMIN_WELCOME_DISABLE', '🔴 Отключить') + if is_enabled + else _t(texts, 'ADMIN_WELCOME_ENABLE', '🟢 Включить') + ) + toggle_callback = 'toggle_welcome_text' + + keyboard = [ + [InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_EDIT', '📝 Изменить текст'), callback_data='edit_welcome_text' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_SHOW', '👁️ Показать текущий'), callback_data='show_welcome_text' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_PREVIEW', '👁️ Предпросмотр'), callback_data='preview_welcome_text' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_RESET', '🔄 Сбросить'), callback_data='reset_welcome_text' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_HTML', '🏷️ HTML форматирование'), callback_data='show_formatting_help' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_WELCOME_PLACEHOLDERS', '💡 Плейсхолдеры'), callback_data='show_placeholders_help' + ), + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_communications')], + ] + + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +DEFAULT_BROADCAST_BUTTONS = ('home',) BROADCAST_BUTTONS = { - "balance": { - "default_text": "💰 Пополнить баланс", - "text_key": "ADMIN_BROADCAST_BUTTON_BALANCE", - "callback": "balance_topup", + 'balance': { + 'default_text': '💰 Пополнить баланс', + 'text_key': 'ADMIN_BROADCAST_BUTTON_BALANCE', + 'callback': 'balance_topup', }, - "referrals": { - "default_text": "🤝 Партнерка", - "text_key": "ADMIN_BROADCAST_BUTTON_REFERRALS", - "callback": "menu_referrals", + 'referrals': { + 'default_text': '🤝 Партнерка', + 'text_key': 'ADMIN_BROADCAST_BUTTON_REFERRALS', + 'callback': 'menu_referrals', }, - "promocode": { - "default_text": "🎫 Промокод", - "text_key": "ADMIN_BROADCAST_BUTTON_PROMOCODE", - "callback": "menu_promocode", + 'promocode': { + 'default_text': '🎫 Промокод', + 'text_key': 'ADMIN_BROADCAST_BUTTON_PROMOCODE', + 'callback': 'menu_promocode', }, - "connect": { - "default_text": "🔗 Подключиться", - "text_key": "ADMIN_BROADCAST_BUTTON_CONNECT", - "callback": "subscription_connect", + 'connect': { + 'default_text': '🔗 Подключиться', + 'text_key': 'ADMIN_BROADCAST_BUTTON_CONNECT', + 'callback': 'subscription_connect', }, - "subscription": { - "default_text": "📱 Подписка", - "text_key": "ADMIN_BROADCAST_BUTTON_SUBSCRIPTION", - "callback": "menu_subscription", + 'subscription': { + 'default_text': '📱 Подписка', + 'text_key': 'ADMIN_BROADCAST_BUTTON_SUBSCRIPTION', + 'callback': 'menu_subscription', }, - "support": { - "default_text": "🛠️ Техподдержка", - "text_key": "ADMIN_BROADCAST_BUTTON_SUPPORT", - "callback": "menu_support", + 'support': { + 'default_text': '🛠️ Техподдержка', + 'text_key': 'ADMIN_BROADCAST_BUTTON_SUPPORT', + 'callback': 'menu_support', }, - "home": { - "default_text": "🏠 На главную", - "text_key": "ADMIN_BROADCAST_BUTTON_HOME", - "callback": "back_to_menu", + 'home': { + 'default_text': '🏠 На главную', + 'text_key': 'ADMIN_BROADCAST_BUTTON_HOME', + 'callback': 'back_to_menu', }, } BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = ( - ("balance", "referrals"), - ("promocode", "connect"), - ("subscription", "support"), - ("home",), + ('balance', 'referrals'), + ('promocode', 'connect'), + ('subscription', 'support'), + ('home',), ) @@ -2337,69 +2255,73 @@ def get_broadcast_button_config(language: str) -> dict[str, dict[str, str]]: texts = get_texts(language) return { key: { - "text": texts.t(config["text_key"], config["default_text"]), - "callback": config["callback"], + 'text': texts.t(config['text_key'], config['default_text']), + 'callback': config['callback'], } for key, config in BROADCAST_BUTTONS.items() } def get_broadcast_button_labels(language: str) -> dict[str, str]: - return {key: value["text"] for key, value in get_broadcast_button_config(language).items()} + return {key: value['text'] for key, value in get_broadcast_button_config(language).items()} -def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_message_buttons_selector_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language) -def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup: - texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_ADD_PHOTO", "📷 Добавить фото"), - callback_data="add_media_photo" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_ADD_VIDEO", "🎥 Добавить видео"), - callback_data="add_media_video" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_ADD_DOCUMENT", "📄 Добавить документ"), - callback_data="add_media_document" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_SKIP_MEDIA", "⏭️ Пропустить медиа"), - callback_data="skip_media" - ) - ], - [InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages")] - ]) -def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup: +def get_broadcast_media_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_USE_MEDIA", "✅ Использовать это медиа"), - callback_data="confirm_media" - ), - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_REPLACE_MEDIA", "🔄 Заменить медиа"), - callback_data="replace_media" - ) - ], - [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_NO_MEDIA", "⏭️ Без медиа"), - callback_data="skip_media" - ), - InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_ADD_PHOTO', '📷 Добавить фото'), callback_data='add_media_photo' + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_ADD_VIDEO', '🎥 Добавить видео'), callback_data='add_media_video' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_ADD_DOCUMENT', '📄 Добавить документ'), + callback_data='add_media_document', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_SKIP_MEDIA', '⏭️ Пропустить медиа'), callback_data='skip_media' + ), + ], + [InlineKeyboardButton(text=_t(texts, 'ADMIN_CANCEL', '❌ Отмена'), callback_data='admin_messages')], ] - ]) + ) -def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup: + +def get_media_confirm_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: + texts = get_texts(language) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_USE_MEDIA', '✅ Использовать это медиа'), + callback_data='confirm_media', + ), + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_REPLACE_MEDIA', '🔄 Заменить медиа'), callback_data='replace_media' + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_NO_MEDIA', '⏭️ Без медиа'), callback_data='skip_media' + ), + InlineKeyboardButton(text=_t(texts, 'ADMIN_CANCEL', '❌ Отмена'), callback_data='admin_messages'), + ], + ] + ) + + +def get_updated_message_buttons_selector_keyboard_with_media( + selected_buttons: list, has_media: bool = False, language: str = 'ru' +) -> InlineKeyboardMarkup: selected_buttons = selected_buttons or [] texts = get_texts(language) @@ -2410,38 +2332,32 @@ def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: l row_buttons: list[InlineKeyboardButton] = [] for button_key in row: button_config = button_config_map[button_key] - base_text = button_config["text"] + base_text = button_config['text'] if button_key in selected_buttons: - if " " in base_text: - toggle_text = f"✅ {base_text.split(' ', 1)[1]}" + if ' ' in base_text: + toggle_text = f'✅ {base_text.split(" ", 1)[1]}' else: - toggle_text = f"✅ {base_text}" + toggle_text = f'✅ {base_text}' else: toggle_text = base_text - row_buttons.append( - InlineKeyboardButton(text=toggle_text, callback_data=f"btn_{button_key}") - ) + row_buttons.append(InlineKeyboardButton(text=toggle_text, callback_data=f'btn_{button_key}')) if row_buttons: keyboard.append(row_buttons) if has_media: - keyboard.append([ - InlineKeyboardButton( - text=_t(texts, "ADMIN_BROADCAST_CHANGE_MEDIA", "🖼️ Изменить медиа"), - callback_data="change_media" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=_t(texts, 'ADMIN_BROADCAST_CHANGE_MEDIA', '🖼️ Изменить медиа'), callback_data='change_media' + ) + ] + ) - keyboard.extend([ + keyboard.extend( [ - InlineKeyboardButton( - text=_t(texts, "ADMIN_CONTINUE", "✅ Продолжить"), - callback_data="buttons_confirm" - ) - ], - [ - InlineKeyboardButton(text=_t(texts, "ADMIN_CANCEL", "❌ Отмена"), callback_data="admin_messages") + [InlineKeyboardButton(text=_t(texts, 'ADMIN_CONTINUE', '✅ Продолжить'), callback_data='buttons_confirm')], + [InlineKeyboardButton(text=_t(texts, 'ADMIN_CANCEL', '❌ Отмена'), callback_data='admin_messages')], ] - ]) + ) return InlineKeyboardMarkup(inline_keyboard=keyboard) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index f791c722..5d8b7a19 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1,24 +1,25 @@ -from typing import List, Optional +import logging +from datetime import UTC, datetime + from aiogram import types -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from datetime import datetime, timezone -from app.database.models import User +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES +from app.config import PERIOD_PRICES, settings +from app.database.models import User from app.localization.loader import DEFAULT_LANGUAGE from app.localization.texts import get_texts from app.utils.miniapp_buttons import build_miniapp_or_callback_button -from app.utils.pricing_utils import ( - format_period_description, - apply_percentage_discount, -) from app.utils.price_display import PriceInfo, format_price_button +from app.utils.pricing_utils import ( + apply_percentage_discount, + format_period_description, +) from app.utils.subscription_utils import ( get_display_subscription_link, get_happ_cryptolink_redirect_link, ) -import logging + logger = logging.getLogger(__name__) @@ -36,7 +37,7 @@ async def get_main_menu_keyboard_async( has_saved_cart: bool = False, *, is_moderator: bool = False, - custom_buttons: Optional[list[InlineKeyboardButton]] = None, + custom_buttons: list[InlineKeyboardButton] | None = None, user=None, # Добавляем параметр пользователя для получения данных ) -> InlineKeyboardMarkup: """ @@ -46,9 +47,10 @@ async def get_main_menu_keyboard_async( Иначе делегирует в синхронную версию. """ if settings.MENU_LAYOUT_ENABLED: - from app.services.menu_layout_service import MenuLayoutService, MenuContext from datetime import datetime + from app.services.menu_layout_service import MenuContext, MenuLayoutService + # Получаем данные для плейсхолдеров subscription_days_left = 0 traffic_used_gb = 0.0 @@ -58,7 +60,7 @@ async def get_main_menu_keyboard_async( registration_days = 0 promo_group_id = None has_autopay = False - username = "" + username = '' # Заполняем данными из подписки if subscription: @@ -68,17 +70,17 @@ async def get_main_menu_keyboard_async( subscription_days_left = subscription.days_left elif hasattr(subscription, 'end_date') and subscription.end_date: # Fallback: вычисляем вручную, используя UTC - now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + now_utc = datetime.now(UTC).replace(tzinfo=None) days_left = (subscription.end_date - now_utc).days subscription_days_left = max(0, days_left) - + # Трафик if hasattr(subscription, 'traffic_used_gb'): traffic_used_gb = subscription.traffic_used_gb or 0.0 - + if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb: traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0)) - + # Автоплатеж if hasattr(subscription, 'autopay_enabled'): has_autopay = subscription.autopay_enabled @@ -90,12 +92,12 @@ async def get_main_menu_keyboard_async( username = user.username elif hasattr(user, 'first_name') and user.first_name: username = user.first_name - + # Дни с регистрации if hasattr(user, 'created_at') and user.created_at: - now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + now_utc = datetime.now(UTC).replace(tzinfo=None) registration_days = (now_utc - user.created_at).days - + # ID промо-группы if hasattr(user, 'promo_group_id'): promo_group_id = user.promo_group_id @@ -103,13 +105,14 @@ async def get_main_menu_keyboard_async( # Получаем данные о рефералах из БД (если нужно) try: from app.database.crud.referral import get_user_referral_stats + if user and hasattr(user, 'id'): referral_data = await get_user_referral_stats(db, user.id) if referral_data: referral_count = referral_data.get('invited_count', 0) referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0) except Exception as e: - logger.error(f"Error getting referral data: {e}") + logger.error(f'Error getting referral data: {e}') context = MenuContext( language=language, @@ -153,19 +156,19 @@ async def get_main_menu_keyboard_async( ) -def _get_localized_value(values, language: str, default_language: str = "en") -> str: +def _get_localized_value(values, language: str, default_language: str = 'en') -> str: if not isinstance(values, dict): - return "" + return '' candidates = [] - normalized_language = (language or "").strip().lower() + normalized_language = (language or '').strip().lower() if normalized_language: candidates.append(normalized_language) - if "-" in normalized_language: - candidates.append(normalized_language.split("-")[0]) + if '-' in normalized_language: + candidates.append(normalized_language.split('-')[0]) - default_language = (default_language or "").strip().lower() + default_language = (default_language or '').strip().lower() if default_language and default_language not in candidates: candidates.append(default_language) @@ -180,111 +183,110 @@ def _get_localized_value(values, language: str, default_language: str = "en") -> if isinstance(value, str) and value.strip(): return value - return "" + return '' -def _build_additional_buttons(additional_section, language: str) -> List[InlineKeyboardButton]: +def _build_additional_buttons(additional_section, language: str) -> list[InlineKeyboardButton]: if not isinstance(additional_section, dict): return [] - buttons = additional_section.get("buttons") + buttons = additional_section.get('buttons') if not isinstance(buttons, list): return [] - localized_buttons: List[InlineKeyboardButton] = [] + localized_buttons: list[InlineKeyboardButton] = [] for button in buttons: if not isinstance(button, dict): continue - button_text = _get_localized_value(button.get("buttonText"), language) - button_link = button.get("buttonLink") + button_text = _get_localized_value(button.get('buttonText'), language) + button_link = button.get('buttonLink') if not button_text or not button_link: continue - localized_buttons.append( - InlineKeyboardButton(text=button_text, url=button_link) - ) + localized_buttons.append(InlineKeyboardButton(text=button_text, url=button_link)) return localized_buttons _LANGUAGE_DISPLAY_NAMES = { - "ru": "🇷🇺 Русский", - "ru-ru": "🇷🇺 Русский", - "en": "🇬🇧 English", - "en-us": "🇺🇸 English", - "en-gb": "🇬🇧 English", - "ua": "🇺🇦 Українська", - "uk": "🇺🇦 Українська", - "uk-ua": "🇺🇦 Українська", - "kk": "🇰🇿 Қазақша", - "kk-kz": "🇰🇿 Қазақша", - "kz": "🇰🇿 Қазақша", - "uz": "🇺🇿 Oʻzbekcha", - "uz-uz": "🇺🇿 Oʻzbekcha", - "tr": "🇹🇷 Türkçe", - "tr-tr": "🇹🇷 Türkçe", - "pl": "🇵🇱 Polski", - "pl-pl": "🇵🇱 Polski", - "de": "🇩🇪 Deutsch", - "de-de": "🇩🇪 Deutsch", - "fr": "🇫🇷 Français", - "fr-fr": "🇫🇷 Français", - "es": "🇪🇸 Español", - "es-es": "🇪🇸 Español", - "it": "🇮🇹 Italiano", - "it-it": "🇮🇹 Italiano", - "pt": "🇵🇹 Português", - "pt-pt": "🇵🇹 Português", - "pt-br": "🇧🇷 Português", - "zh": "🇨🇳 中文", - "zh-cn": "🇨🇳 中文 (简体)", - "zh-hans": "🇨🇳 中文 (简体)", - "zh-tw": "🇹🇼 中文 (繁體)", - "zh-hant": "🇹🇼 中文 (繁體)", - "vi": "🇻🇳 Tiếng Việt", - "vi-vn": "🇻🇳 Tiếng Việt", + 'ru': '🇷🇺 Русский', + 'ru-ru': '🇷🇺 Русский', + 'en': '🇬🇧 English', + 'en-us': '🇺🇸 English', + 'en-gb': '🇬🇧 English', + 'ua': '🇺🇦 Українська', + 'uk': '🇺🇦 Українська', + 'uk-ua': '🇺🇦 Українська', + 'kk': '🇰🇿 Қазақша', + 'kk-kz': '🇰🇿 Қазақша', + 'kz': '🇰🇿 Қазақша', + 'uz': '🇺🇿 Oʻzbekcha', + 'uz-uz': '🇺🇿 Oʻzbekcha', + 'tr': '🇹🇷 Türkçe', + 'tr-tr': '🇹🇷 Türkçe', + 'pl': '🇵🇱 Polski', + 'pl-pl': '🇵🇱 Polski', + 'de': '🇩🇪 Deutsch', + 'de-de': '🇩🇪 Deutsch', + 'fr': '🇫🇷 Français', + 'fr-fr': '🇫🇷 Français', + 'es': '🇪🇸 Español', + 'es-es': '🇪🇸 Español', + 'it': '🇮🇹 Italiano', + 'it-it': '🇮🇹 Italiano', + 'pt': '🇵🇹 Português', + 'pt-pt': '🇵🇹 Português', + 'pt-br': '🇧🇷 Português', + 'zh': '🇨🇳 中文', + 'zh-cn': '🇨🇳 中文 (简体)', + 'zh-hans': '🇨🇳 中文 (简体)', + 'zh-tw': '🇹🇼 中文 (繁體)', + 'zh-hant': '🇹🇼 中文 (繁體)', + 'vi': '🇻🇳 Tiếng Việt', + 'vi-vn': '🇻🇳 Tiếng Việt', } + def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.RULES_ACCEPT, callback_data="rules_accept"), - InlineKeyboardButton(text=texts.RULES_DECLINE, callback_data="rules_decline") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.RULES_ACCEPT, callback_data='rules_accept'), + InlineKeyboardButton(text=texts.RULES_DECLINE, callback_data='rules_decline'), + ] ] - ]) + ) + def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.PRIVACY_POLICY_ACCEPT, - callback_data="privacy_policy_accept" - ), - InlineKeyboardButton( - text=texts.PRIVACY_POLICY_DECLINE, - callback_data="privacy_policy_decline" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.PRIVACY_POLICY_ACCEPT, callback_data='privacy_policy_accept'), + InlineKeyboardButton(text=texts.PRIVACY_POLICY_DECLINE, callback_data='privacy_policy_decline'), + ] ] - ]) + ) + def get_channel_sub_keyboard( - channel_link: Optional[str], + channel_link: str | None, language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: List[List[InlineKeyboardButton]] = [] + buttons: list[list[InlineKeyboardButton]] = [] if channel_link: buttons.append( [ InlineKeyboardButton( - text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), + text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'), url=channel_link, ) ] @@ -293,8 +295,8 @@ def get_channel_sub_keyboard( buttons.append( [ InlineKeyboardButton( - text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"), - callback_data="sub_channel_check", + text=texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался'), + callback_data='sub_channel_check', ) ] ) @@ -304,29 +306,31 @@ def get_channel_sub_keyboard( def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("POST_REGISTRATION_TRIAL_BUTTON", "🚀 Подключиться бесплатно 🚀"), - callback_data="trial_activate" - ) - ], - [InlineKeyboardButton(text=texts.t("SKIP_BUTTON", "Пропустить ➡️"), callback_data="back_to_menu")], - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('POST_REGISTRATION_TRIAL_BUTTON', '🚀 Подключиться бесплатно 🚀'), + callback_data='trial_activate', + ) + ], + [InlineKeyboardButton(text=texts.t('SKIP_BUTTON', 'Пропустить ➡️'), callback_data='back_to_menu')], + ] + ) def get_language_selection_keyboard( - current_language: Optional[str] = None, + current_language: str | None = None, *, include_back: bool = False, language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: available_languages = settings.get_available_languages() - buttons: List[List[InlineKeyboardButton]] = [] - row: List[InlineKeyboardButton] = [] + buttons: list[list[InlineKeyboardButton]] = [] + row: list[InlineKeyboardButton] = [] - normalized_current = (current_language or "").lower() + normalized_current = (current_language or '').lower() for index, lang_code in enumerate(available_languages, start=1): normalized_code = lang_code.lower() @@ -335,12 +339,12 @@ def get_language_selection_keyboard( normalized_code.upper(), ) - prefix = "✅ " if normalized_code == normalized_current and normalized_current else "" + 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}", + text=f'{prefix}{display_name}', + callback_data=f'language_select:{normalized_code}', ) ) @@ -353,7 +357,7 @@ def get_language_selection_keyboard( if include_back: texts = get_texts(language) - buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -365,7 +369,7 @@ def _build_text_main_menu_keyboard( is_admin: bool, is_moderator: bool, ) -> InlineKeyboardMarkup: - profile_text = texts.t("MENU_PROFILE", "👤 Личный кабинет") + profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет') miniapp_url = settings.get_main_menu_miniapp_url() if miniapp_url: @@ -376,15 +380,13 @@ def _build_text_main_menu_keyboard( else: profile_button = InlineKeyboardButton( text=profile_text, - callback_data="menu_profile_unavailable", + callback_data='menu_profile_unavailable', ) - keyboard_rows: List[List[InlineKeyboardButton]] = [[profile_button]] + keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]] if settings.is_language_selection_enabled(): - keyboard_rows.append([ - InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language") - ]) + keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')]) support_enabled = False try: @@ -395,18 +397,12 @@ def _build_text_main_menu_keyboard( support_enabled = settings.SUPPORT_MENU_ENABLED if support_enabled: - keyboard_rows.append([ - InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support") - ]) + keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')]) if is_admin: - keyboard_rows.append([ - InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ]) + keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]) elif is_moderator: - keyboard_rows.append([ - InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") - ]) + keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')]) return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) @@ -423,7 +419,7 @@ def get_main_menu_keyboard( has_saved_cart: bool = False, # Новый параметр для отображения уведомления о сохраненной корзине *, is_moderator: bool = False, - custom_buttons: Optional[list[InlineKeyboardButton]] = None, + custom_buttons: list[InlineKeyboardButton] | None = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -434,19 +430,21 @@ def get_main_menu_keyboard( is_admin=is_admin, is_moderator=is_moderator, ) - + if settings.DEBUG: - print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}") - + print( + f'DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}' + ) + safe_balance = balance_kopeks or 0 if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0: balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance)) else: balance_button_text = texts.t( - "BALANCE_BUTTON_DEFAULT", - "💰 Баланс: {balance}", + 'BALANCE_BUTTON_DEFAULT', + '💰 Баланс: {balance}', ).format(balance=texts.format_price(safe_balance)) - + keyboard: list[list[InlineKeyboardButton]] = [] paired_buttons: list[InlineKeyboardButton] = [] @@ -456,45 +454,48 @@ def get_main_menu_keyboard( def _fallback_connect_button() -> InlineKeyboardButton: return InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', ) - if connect_mode == "miniapp_subscription": + if connect_mode == 'miniapp_subscription': if subscription_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=subscription_link) - ) - ]) - else: - keyboard.append([_fallback_connect_button()]) - elif connect_mode == "miniapp_custom": - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), + ) + ] ) - ]) - elif connect_mode == "link": - if subscription_link: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=subscription_link - ) - ]) else: keyboard.append([_fallback_connect_button()]) - elif connect_mode == "happ_cryptolink": - if subscription_link: - keyboard.append([ + elif connect_mode == 'miniapp_custom': + keyboard.append( + [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL), ) - ]) + ] + ) + elif connect_mode == 'link': + if subscription_link: + keyboard.append( + [InlineKeyboardButton(text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), 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: keyboard.append([_fallback_connect_button()]) else: @@ -503,9 +504,7 @@ def get_main_menu_keyboard( happ_row = get_happ_download_button_row(texts) if happ_row: keyboard.append(happ_row) - paired_buttons.append( - InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription") - ) + paired_buttons.append(InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data='menu_subscription')) # Добавляем кнопку докупки трафика для лимитированных подписок # В режиме тарифов проверяем tariff_id (детальная проверка в хендлере) @@ -521,48 +520,44 @@ def get_main_menu_keyboard( if show_traffic_topup: paired_buttons.append( - InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic") + InlineKeyboardButton( + text=texts.t('BUY_TRAFFIC_BUTTON', '📈 Докупить трафик'), callback_data='buy_traffic' + ) ) - keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")]) - + keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data='menu_balance')]) + show_trial = not has_had_paid_subscription and not has_active_subscription show_buy = not has_active_subscription or not subscription_is_active current_subscription = subscription - has_active_paid_subscription = bool( + bool( current_subscription - and not getattr(current_subscription, "is_trial", False) - and getattr(current_subscription, "is_active", False) + and not getattr(current_subscription, 'is_trial', False) + and getattr(current_subscription, 'is_active', False) ) simple_purchase_button = None if settings.SIMPLE_SUBSCRIPTION_ENABLED: simple_purchase_button = InlineKeyboardButton( text=texts.MENU_SIMPLE_SUBSCRIPTION, - callback_data="simple_subscription_purchase", + callback_data='simple_subscription_purchase', ) subscription_buttons: list[InlineKeyboardButton] = [] if show_trial: - subscription_buttons.append( - InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial") - ) - + subscription_buttons.append(InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data='menu_trial')) + if show_buy: - subscription_buttons.append( - InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy") - ) - + subscription_buttons.append(InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='menu_buy')) + if subscription_buttons: paired_buttons.extend(subscription_buttons) if simple_purchase_button: paired_buttons.append(simple_purchase_button) if show_resume_checkout or has_saved_cart: - resume_callback = ( - "return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout" - ) + resume_callback = 'return_to_saved_cart' if has_saved_cart else 'subscription_resume_checkout' paired_buttons.append( InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, @@ -576,73 +571,59 @@ def get_main_menu_keyboard( paired_buttons.append(button) # Добавляем кнопки промокода и рефералов, учитывая настройки - paired_buttons.append( - InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode") - ) - + paired_buttons.append(InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data='menu_promocode')) + # Добавляем кнопку рефералов, только если программа включена if settings.is_referral_program_enabled(): - paired_buttons.append( - InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals") - ) + paired_buttons.append(InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data='menu_referrals')) # Добавляем кнопку конкурсов if settings.CONTESTS_ENABLED and settings.CONTESTS_BUTTON_VISIBLE: paired_buttons.append( - InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu") + InlineKeyboardButton(text=texts.t('CONTESTS_BUTTON', '🎲 Конкурсы'), callback_data='contests_menu') ) try: from app.services.support_settings_service import SupportSettingsService + support_enabled = SupportSettingsService.is_support_menu_enabled() except Exception: support_enabled = settings.SUPPORT_MENU_ENABLED if support_enabled: - paired_buttons.append( - InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support") - ) + paired_buttons.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')) # Добавляем кнопку активации if settings.ACTIVATE_BUTTON_VISIBLE: - paired_buttons.append( - InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data="activate_button") - ) + paired_buttons.append(InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data='activate_button')) paired_buttons.append( InlineKeyboardButton( - text=texts.t("MENU_INFO", "ℹ️ Инфо"), - callback_data="menu_info", + text=texts.t('MENU_INFO', 'ℹ️ Инфо'), + callback_data='menu_info', ) ) if settings.is_language_selection_enabled(): - paired_buttons.append( - InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language") - ) + paired_buttons.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')) for i in range(0, len(paired_buttons), 2): row = paired_buttons[i : i + 2] keyboard.append(row) if settings.DEBUG: - print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}") + print(f'DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}') if is_admin: if settings.DEBUG: - print("DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!") - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data="admin_panel") - ]) - else: - if settings.DEBUG: - print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена") + print('DEBUG KEYBOARD: Админ кнопка ДОБАВЛЕНА!') + keyboard.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]) + elif settings.DEBUG: + print('DEBUG KEYBOARD: Админ кнопка НЕ добавлена') # Moderator access (limited support panel) if (not is_admin) and is_moderator: - keyboard.append([ - InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel") - ]) - + keyboard.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -655,81 +636,90 @@ def get_info_menu_keyboard( ) -> InlineKeyboardMarkup: texts = get_texts(language) - buttons: List[List[InlineKeyboardButton]] = [] + buttons: list[list[InlineKeyboardButton]] = [] if show_faq: - buttons.append([ - InlineKeyboardButton( - text=texts.t("MENU_FAQ", "❓ FAQ"), - callback_data="menu_faq", - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('MENU_FAQ', '❓ FAQ'), + callback_data='menu_faq', + ) + ] + ) if show_promo_groups: - buttons.append([ - InlineKeyboardButton( - text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"), - callback_data="menu_info_promo_groups", - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('MENU_PROMO_GROUPS_INFO', '🎯 Промогруппы'), + callback_data='menu_info_promo_groups', + ) + ] + ) if show_privacy_policy: - buttons.append([ - InlineKeyboardButton( - text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."), - callback_data="menu_privacy_policy", - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('MENU_PRIVACY_POLICY', '🛡️ Политика конф.'), + callback_data='menu_privacy_policy', + ) + ] + ) if show_public_offer: - buttons.append([ - InlineKeyboardButton( - text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"), - callback_data="menu_public_offer", - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('MENU_PUBLIC_OFFER', '📄 Оферта'), + callback_data='menu_public_offer', + ) + ] + ) - buttons.append([ - InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules") - ]) + buttons.append([InlineKeyboardButton(text=texts.MENU_RULES, callback_data='menu_rules')]) server_status_mode = settings.get_server_status_mode() - server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов") + server_status_text = texts.t('MENU_SERVER_STATUS', '📊 Статус серверов') - if server_status_mode == "external_link": + if server_status_mode == 'external_link': status_url = settings.get_server_status_external_url() if status_url: buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)]) - elif server_status_mode == "external_link_miniapp": + elif server_status_mode == 'external_link_miniapp': status_url = settings.get_server_status_external_url() if status_url: - buttons.append([ + buttons.append( + [ + InlineKeyboardButton( + text=server_status_text, + web_app=types.WebAppInfo(url=status_url), + ) + ] + ) + elif server_status_mode == 'xray': + buttons.append( + [ InlineKeyboardButton( text=server_status_text, - web_app=types.WebAppInfo(url=status_url), + callback_data='menu_server_status', ) - ]) - elif server_status_mode == "xray": - buttons.append([ - InlineKeyboardButton( - text=server_status_text, - callback_data="menu_server_status", - ) - ]) + ] + ) - buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: +def get_happ_download_button_row(texts) -> list[InlineKeyboardButton] | None: if not settings.is_happ_download_button_enabled(): return None return [ InlineKeyboardButton( - text=texts.t("HAPP_DOWNLOAD_BUTTON", "⬇️ Скачать Happ"), - callback_data="subscription_happ_download" + text=texts.t('HAPP_DOWNLOAD_BUTTON', '⬇️ Скачать Happ'), callback_data='subscription_happ_download' ) ] @@ -737,53 +727,57 @@ def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]: def get_happ_cryptolink_keyboard( subscription_link: str, language: str = DEFAULT_LANGUAGE, - redirect_link: Optional[str] = None, + redirect_link: str | None = None, ) -> InlineKeyboardMarkup: texts = get_texts(language) final_redirect_link = redirect_link or get_happ_cryptolink_redirect_link(subscription_link) - buttons: List[List[InlineKeyboardButton]] = [] + buttons: list[list[InlineKeyboardButton]] = [] if final_redirect_link: - buttons.append([ - InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - url=final_redirect_link, - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + url=final_redirect_link, + ) + ] + ) - buttons.extend([ + 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", - ) - ], - ]) + [ + 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) @@ -791,11 +785,19 @@ def get_happ_cryptolink_keyboard( 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")], + [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) @@ -804,18 +806,16 @@ def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> Inl 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")], + [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, callback_data: str = "back_to_menu") -> InlineKeyboardMarkup: +def get_back_keyboard(language: str = DEFAULT_LANGUAGE, callback_data: str = 'back_to_menu') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text=texts.BACK, callback_data=callback_data)] - ]) + return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=texts.BACK, callback_data=callback_data)]]) def get_server_status_keyboard( @@ -827,8 +827,8 @@ def get_server_status_keyboard( keyboard: list[list[InlineKeyboardButton]] = [ [ InlineKeyboardButton( - text=texts.t("SERVER_STATUS_REFRESH", "🔄 Обновить"), - callback_data=f"server_status_page:{current_page}", + text=texts.t('SERVER_STATUS_REFRESH', '🔄 Обновить'), + callback_data=f'server_status_page:{current_page}', ) ] ] @@ -839,23 +839,23 @@ def get_server_status_keyboard( if current_page > 1: nav_row.append( InlineKeyboardButton( - text=texts.t("SERVER_STATUS_PREV_PAGE", "⬅️ Назад"), - callback_data=f"server_status_page:{current_page - 1}", + text=texts.t('SERVER_STATUS_PREV_PAGE', '⬅️ Назад'), + callback_data=f'server_status_page:{current_page - 1}', ) ) if current_page < total_pages: nav_row.append( InlineKeyboardButton( - text=texts.t("SERVER_STATUS_NEXT_PAGE", "Вперед ➡️"), - callback_data=f"server_status_page:{current_page + 1}", + text=texts.t('SERVER_STATUS_NEXT_PAGE', 'Вперед ➡️'), + callback_data=f'server_status_page:{current_page + 1}', ) ) if nav_row: keyboard.append(nav_row) - keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -866,7 +866,6 @@ def get_insufficient_balance_keyboard( amount_kopeks: int | None = None, has_saved_cart: bool = False, # Новый параметр для указания наличия сохраненной корзины ) -> InlineKeyboardMarkup: - texts = get_texts(language) keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language) @@ -877,11 +876,11 @@ def get_insufficient_balance_keyboard( if ( len(last_row) == 1 and isinstance(last_row[0], InlineKeyboardButton) - and last_row[0].callback_data in {"menu_balance", "back_to_menu"} + and last_row[0].callback_data in {'menu_balance', 'back_to_menu'} ): keyboard.inline_keyboard[-1][0] = InlineKeyboardButton( - text=texts.t("PAYMENT_RETURN_HOME_BUTTON", "🏠 На главную"), - callback_data="back_to_menu", + text=texts.t('PAYMENT_RETURN_HOME_BUTTON', '🏠 На главную'), + callback_data='back_to_menu', ) back_row_index = len(keyboard.inline_keyboard) - 1 @@ -890,7 +889,7 @@ def get_insufficient_balance_keyboard( return_row = [ InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ] insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard) @@ -909,76 +908,92 @@ def get_insufficient_balance_keyboard( def get_subscription_keyboard( - language: str = DEFAULT_LANGUAGE, - has_subscription: bool = False, - is_trial: bool = False, - subscription=None + language: str = DEFAULT_LANGUAGE, has_subscription: bool = False, is_trial: bool = False, subscription=None ) -> InlineKeyboardMarkup: - from app.config import settings - + from app.config import settings + texts = get_texts(language) keyboard = [] - has_direct_payment_methods = False if has_subscription: 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_link) - ) - ]) - elif connect_mode == "miniapp_custom": - if settings.MINIAPP_CUSTOM_URL: - keyboard.append([ + if connect_mode == 'miniapp_subscription': + keyboard.append( + [ InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL) + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + web_app=types.WebAppInfo(url=subscription_link), ) - ]) - else: - keyboard.append([ - InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect") - ]) - elif connect_mode == "link": - keyboard.append([ - 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) + ] ) - ]) + elif connect_mode == 'miniapp_custom': + if settings.MINIAPP_CUSTOM_URL: + 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' + ) + ] + ) + elif connect_mode == 'link': + keyboard.append( + [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") - ]) + 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 is_trial: - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade") - ]) + keyboard.append( + [InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data='subscription_upgrade')] + ) else: # Проверяем, является ли тариф суточным tariff = getattr(subscription, 'tariff', None) if subscription else None @@ -988,36 +1003,37 @@ def get_subscription_keyboard( # Для суточного тарифа показываем кнопку паузы/возобновления is_paused = getattr(subscription, 'is_daily_paused', False) if is_paused: - pause_text = texts.t("RESUME_DAILY_BUTTON", "▶️ Возобновить подписку") + pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку') else: - pause_text = texts.t("PAUSE_DAILY_BUTTON", "⏸️ Приостановить подписку") - keyboard.append([ - InlineKeyboardButton(text=pause_text, callback_data="toggle_daily_subscription_pause") - ]) + pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку') + keyboard.append( + [InlineKeyboardButton(text=pause_text, callback_data='toggle_daily_subscription_pause')] + ) else: # Для обычного тарифа: [Продлить] [Автоплатеж] - keyboard.append([ - InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend"), - InlineKeyboardButton( - text=texts.t("AUTOPAY_BUTTON", "💳 Автоплатеж"), - callback_data="subscription_autopay", - ) - ]) + keyboard.append( + [ + InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data='subscription_extend'), + InlineKeyboardButton( + text=texts.t('AUTOPAY_BUTTON', '💳 Автоплатеж'), + callback_data='subscription_autopay', + ), + ] + ) # Ряд: [Настройки] [Тариф] (если режим тарифов) settings_row = [ InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_SETTINGS_BUTTON", "⚙️ Настройки"), - callback_data="subscription_settings", + text=texts.t('SUBSCRIPTION_SETTINGS_BUTTON', '⚙️ Настройки'), + callback_data='subscription_settings', ) ] if settings.is_tariffs_mode() and subscription: # Для суточных тарифов переходим на список тарифов, для обычных - мгновенное переключение - tariff_callback = "tariff_switch" if is_daily_tariff else "instant_switch" + tariff_callback = 'tariff_switch' if is_daily_tariff else 'instant_switch' settings_row.append( InlineKeyboardButton( - text=texts.t("CHANGE_TARIFF_BUTTON", "📦 Тариф"), - callback_data=tariff_callback + text=texts.t('CHANGE_TARIFF_BUTTON', '📦 Тариф'), callback_data=tariff_callback ) ) keyboard.append(settings_row) @@ -1026,69 +1042,66 @@ def get_subscription_keyboard( # В режиме тарифов проверяем tariff_id, в классическом - глобальные настройки show_traffic_topup = False if subscription and (subscription.traffic_limit_gb or 0) > 0: - if settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None): - show_traffic_topup = True - elif settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked(): + if (settings.is_tariffs_mode() and getattr(subscription, 'tariff_id', None)) or ( + settings.is_traffic_topup_enabled() and not settings.is_traffic_topup_blocked() + ): show_traffic_topup = True if show_traffic_topup: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), - callback_data="buy_traffic" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('BUY_TRAFFIC_BUTTON', '📈 Докупить трафик'), callback_data='buy_traffic' + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ]) - return InlineKeyboardMarkup(inline_keyboard=keyboard) + def get_payment_methods_keyboard_with_cart( - language: str = "ru", + language: str = 'ru', amount_kopeks: int = 0, ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = get_payment_methods_keyboard(amount_kopeks, language) - + # Добавляем кнопку "Очистить корзину" - keyboard.inline_keyboard.append([ - InlineKeyboardButton( - text="🗑️ Очистить корзину и вернуться", - callback_data="clear_saved_cart" - ) - ]) - + keyboard.inline_keyboard.append( + [InlineKeyboardButton(text='🗑️ Очистить корзину и вернуться', callback_data='clear_saved_cart')] + ) + # Добавляем кнопку возврата к оформлению подписки - keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад" - InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart" - ) - ]) - + keyboard.inline_keyboard.insert( + -1, + [ # Вставляем перед кнопкой "назад" + InlineKeyboardButton(text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data='return_to_saved_cart') + ], + ) + return keyboard -def get_subscription_confirm_keyboard_with_cart(language: str = "ru") -> InlineKeyboardMarkup: + +def get_subscription_confirm_keyboard_with_cart(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text="✅ Подтвердить покупку", - callback_data="subscription_confirm" - )], - [InlineKeyboardButton( - text="🗑️ Очистить корзину", - callback_data="clear_saved_cart" - )], - [InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_config_back" # Изменили на возврат к настройке - )] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='✅ Подтвердить покупку', callback_data='subscription_confirm')], + [InlineKeyboardButton(text='🗑️ Очистить корзину', callback_data='clear_saved_cart')], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data='subscription_config_back', # Изменили на возврат к настройке + ) + ], + ] + ) + def get_insufficient_balance_keyboard_with_cart( - language: str = "ru", + language: str = 'ru', amount_kopeks: int = 0, ) -> InlineKeyboardMarkup: # Используем обновленную версию с флагом has_saved_cart=True @@ -1103,27 +1116,31 @@ def get_insufficient_balance_keyboard_with_cart( 0, [ InlineKeyboardButton( - text="🗑️ Очистить корзину и вернуться", - callback_data="clear_saved_cart", + text='🗑️ Очистить корзину и вернуться', + callback_data='clear_saved_cart', ) ], ) return keyboard -def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + +def get_trial_keyboard(language: str = 'ru') -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.t("TRIAL_ACTIVATE_BUTTON", "🎁 Активировать"), callback_data="trial_activate"), - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('TRIAL_ACTIVATE_BUTTON', '🎁 Активировать'), callback_data='trial_activate' + ), + InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu'), + ] ] - ]) + ) def get_subscription_period_keyboard( - language: str = DEFAULT_LANGUAGE, - user: Optional[User] = None + language: str = DEFAULT_LANGUAGE, user: User | None = None ) -> InlineKeyboardMarkup: """ Generate subscription period selection keyboard with personalized pricing. @@ -1147,7 +1164,7 @@ def get_subscription_period_keyboard( base_price = PERIOD_PRICES.get(days, 0) # Calculate personalized price with user's discounts - price_info = calculate_user_price(user, base_price, days, "period") + price_info = calculate_user_price(user, base_price, days, 'period') # Format period description period_display = format_period_description(days, language) @@ -1158,190 +1175,191 @@ def get_subscription_period_keyboard( price_info=price_info, format_price_func=texts.format_price, emphasize=False, - add_exclamation=False + add_exclamation=False, ) - keyboard.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"period_{days}" - ) - ]) + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f'period_{days}')]) # Кнопка "Простая покупка" была убрана из выбора периода подписки - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: import logging + logger = logging.getLogger(__name__) from app.config import settings if settings.is_traffic_topup_blocked(): return get_back_keyboard(language) - + logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'") - + all_packages = settings.get_traffic_packages() - logger.info(f"🔍 ALL PACKAGES: {all_packages}") - + logger.info(f'🔍 ALL PACKAGES: {all_packages}') + enabled_packages = [pkg for pkg in all_packages if pkg['enabled']] disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']] - - logger.info(f"🔍 ENABLED: {len(enabled_packages)} packages") - logger.info(f"🔍 DISABLED: {len(disabled_packages)} packages") - + + logger.info(f'🔍 ENABLED: {len(enabled_packages)} packages') + logger.info(f'🔍 DISABLED: {len(disabled_packages)} packages') + for pkg in disabled_packages: - logger.info(f"🔍 DISABLED PACKAGE: {pkg['gb']}GB = {pkg['price']} kopeks, enabled={pkg['enabled']}") - + logger.info(f'🔍 DISABLED PACKAGE: {pkg["gb"]}GB = {pkg["price"]} kopeks, enabled={pkg["enabled"]}') + texts = get_texts(language) keyboard = [] - + traffic_packages = settings.get_traffic_packages() - + for package in traffic_packages: - gb = package["gb"] - price = package["price"] - enabled = package["enabled"] - + gb = package['gb'] + package['price'] + enabled = package['enabled'] + if not enabled: continue - + if gb == 0: - text = f"♾️ Безлимит - {settings.format_price(package['price'])}" + text = f'♾️ Безлимит - {settings.format_price(package["price"])}' else: - text = f"📊 {gb} ГБ - {settings.format_price(package['price'])}" - - keyboard.append([ - InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}") - ]) + text = f'📊 {gb} ГБ - {settings.format_price(package["price"])}' + + keyboard.append([InlineKeyboardButton(text=text, callback_data=f'traffic_{gb}')]) if not keyboard: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("TRAFFIC_PACKAGES_NOT_CONFIGURED", "⚠️ Пакеты трафика не настроены"), - callback_data="no_traffic_packages" - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back") - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('TRAFFIC_PACKAGES_NOT_CONFIGURED', '⚠️ Пакеты трафика не настроены'), + callback_data='no_traffic_packages', + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='subscription_config_back')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + +def get_countries_keyboard( + countries: list[dict], selected: list[str], language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + for country in countries: if not country.get('is_available', True): - continue - - emoji = "✅" if country['uuid'] in selected else "⚪" - + continue + + emoji = '✅' if country['uuid'] in selected else '⚪' + if country['price_kopeks'] > 0: - price_text = f" (+{texts.format_price(country['price_kopeks'])})" + price_text = f' (+{texts.format_price(country["price_kopeks"])})' else: - price_text = " (Бесплатно)" - - keyboard.append([ - InlineKeyboardButton( - text=f"{emoji} {country['name']}{price_text}", - callback_data=f"country_{country['uuid']}" - ) - ]) - + price_text = ' (Бесплатно)' + + keyboard.append( + [ + InlineKeyboardButton( + text=f'{emoji} {country["name"]}{price_text}', callback_data=f'country_{country["uuid"]}' + ) + ] + ) + if not keyboard: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("NO_SERVERS_AVAILABLE", "❌ Нет доступных серверов"), - callback_data="no_servers" - ) - ]) - - keyboard.extend([ - [InlineKeyboardButton(text=texts.t("CONTINUE_BUTTON", "✅ Продолжить"), callback_data="countries_continue")], - [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")] - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('NO_SERVERS_AVAILABLE', '❌ Нет доступных серверов'), callback_data='no_servers' + ) + ] + ) + + keyboard.extend( + [ + [ + InlineKeyboardButton( + text=texts.t('CONTINUE_BUTTON', '✅ Продолжить'), callback_data='countries_continue' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='subscription_config_back')], + ] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_devices_keyboard(current: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + start_devices = settings.DEFAULT_DEVICE_LIMIT max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 50 end_devices = min(max_devices + 1, start_devices + 10) - + buttons = [] - - for devices in range(start_devices, end_devices): + + for devices in range(start_devices, end_devices): price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE - price_text = f" (+{texts.format_price(price)})" if price > 0 else " (вкл.)" - emoji = "✅" if devices == current else "⚪" - - button_text = f"{emoji} {devices}{price_text}" - - buttons.append( - InlineKeyboardButton( - text=button_text, - callback_data=f"devices_{devices}" - ) - ) - + price_text = f' (+{texts.format_price(price)})' if price > 0 else ' (вкл.)' + emoji = '✅' if devices == current else '⚪' + + button_text = f'{emoji} {devices}{price_text}' + + buttons.append(InlineKeyboardButton(text=button_text, callback_data=f'devices_{devices}')) + for i in range(0, len(buttons), 2): if i + 1 < len(buttons): keyboard.append([buttons[i], buttons[i + 1]]) else: keyboard.append([buttons[i]]) - - keyboard.extend([ - [InlineKeyboardButton(text=texts.t("CONTINUE_BUTTON", "✅ Продолжить"), callback_data="devices_continue")], - [InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")] - ]) - + + keyboard.extend( + [ + [InlineKeyboardButton(text=texts.t('CONTINUE_BUTTON', '✅ Продолжить'), callback_data='devices_continue')], + [InlineKeyboardButton(text=texts.BACK, callback_data='subscription_config_back')], + ] + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + def _get_device_declension(count: int) -> str: if count % 10 == 1 and count % 100 != 11: - return "устройство" - elif count % 10 in [2, 3, 4] and count % 100 not in [12, 13, 14]: - return "устройства" - else: - return "устройств" + return 'устройство' + if count % 10 in [2, 3, 4] and count % 100 not in [12, 13, 14]: + return 'устройства' + return 'устройств' + def get_subscription_confirm_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.CONFIRM, callback_data="subscription_confirm"), - InlineKeyboardButton(text=texts.CANCEL, callback_data="subscription_cancel") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.CONFIRM, callback_data='subscription_confirm'), + InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_cancel'), + ] ] - ]) + ) def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - + keyboard = [ [ - InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data="balance_history"), - InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data="balance_topup") + InlineKeyboardButton(text=texts.BALANCE_HISTORY, callback_data='balance_history'), + InlineKeyboardButton(text=texts.BALANCE_TOP_UP, callback_data='balance_topup'), ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu") - ] + [InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')], ] - + return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1354,231 +1372,250 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN def _build_callback(method: str) -> str: if amount_kopeks > 0: - return f"topup_amount|{method}|{amount_kopeks}" - return f"topup_{method}" + return f'topup_amount|{method}|{amount_kopeks}' + return f'topup_{method}' if settings.TELEGRAM_STARS_ENABLED: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_TELEGRAM_STARS", "⭐ Telegram Stars"), - callback_data=_build_callback("stars") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_TELEGRAM_STARS', '⭐ Telegram Stars'), callback_data=_build_callback('stars') + ) + ] + ) has_direct_payment_methods = True if settings.is_yookassa_enabled(): if settings.YOOKASSA_SBP_ENABLED: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_SBP_YOOKASSA", "🏦 Оплатить по СБП (YooKassa)"), - callback_data=_build_callback("yookassa_sbp"), - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_SBP_YOOKASSA', '🏦 Оплатить по СБП (YooKassa)'), + callback_data=_build_callback('yookassa_sbp'), + ) + ] + ) has_direct_payment_methods = True - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_YOOKASSA", "💳 Банковская карта (YooKassa)"), - callback_data=_build_callback("yookassa"), - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CARD_YOOKASSA', '💳 Банковская карта (YooKassa)'), + callback_data=_build_callback('yookassa'), + ) + ] + ) has_direct_payment_methods = True if settings.TRIBUTE_ENABLED: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_TRIBUTE", "💳 Банковская карта (Tribute)"), - callback_data=_build_callback("tribute") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CARD_TRIBUTE', '💳 Банковская карта (Tribute)'), + callback_data=_build_callback('tribute'), + ) + ] + ) has_direct_payment_methods = True if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() - keyboard.append([ - InlineKeyboardButton( - text=texts.t( - "PAYMENT_CARD_MULENPAY", - "💳 Банковская карта ({mulenpay_name})", - ).format(mulenpay_name=mulenpay_name), - callback_data=_build_callback("mulenpay") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t( + 'PAYMENT_CARD_MULENPAY', + '💳 Банковская карта ({mulenpay_name})', + ).format(mulenpay_name=mulenpay_name), + callback_data=_build_callback('mulenpay'), + ) + ] + ) has_direct_payment_methods = True if settings.is_wata_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_WATA", "💳 Банковская карта (WATA)"), - callback_data=_build_callback("wata") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CARD_WATA', '💳 Банковская карта (WATA)'), + callback_data=_build_callback('wata'), + ) + ] + ) has_direct_payment_methods = True if settings.is_pal24_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CARD_PAL24", "🏦 СБП (PayPalych)"), - callback_data=_build_callback("pal24") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CARD_PAL24', '🏦 СБП (PayPalych)'), callback_data=_build_callback('pal24') + ) + ] + ) has_direct_payment_methods = True if settings.is_platega_enabled() and settings.get_platega_active_methods(): platega_name = settings.get_platega_display_name() - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_PLATEGA", f"💳 {platega_name}"), - callback_data=_build_callback("platega"), - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'), + callback_data=_build_callback('platega'), + ) + ] + ) has_direct_payment_methods = True if settings.is_cryptobot_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)"), - callback_data=_build_callback("cryptobot") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CRYPTOBOT', '🪙 Криптовалюта (CryptoBot)'), + callback_data=_build_callback('cryptobot'), + ) + ] + ) has_direct_payment_methods = True if settings.is_heleket_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"), - callback_data=_build_callback("heleket") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_HELEKET', '🪙 Криптовалюта (Heleket)'), + callback_data=_build_callback('heleket'), + ) + ] + ) has_direct_payment_methods = True if settings.is_cloudpayments_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_CLOUDPAYMENTS", "💳 Банковская карта (CloudPayments)"), - callback_data=_build_callback("cloudpayments") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_CLOUDPAYMENTS', '💳 Банковская карта (CloudPayments)'), + callback_data=_build_callback('cloudpayments'), + ) + ] + ) has_direct_payment_methods = True if settings.is_freekassa_enabled(): freekassa_name = settings.get_freekassa_display_name() - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_FREEKASSA", f"💳 {freekassa_name}"), - callback_data=_build_callback("freekassa") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_FREEKASSA', f'💳 {freekassa_name}'), + callback_data=_build_callback('freekassa'), + ) + ] + ) has_direct_payment_methods = True if settings.is_kassa_ai_enabled(): kassa_ai_name = settings.get_kassa_ai_display_name() - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_KASSA_AI", f"💳 {kassa_ai_name}"), - callback_data=_build_callback("kassa_ai") - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_KASSA_AI', f'💳 {kassa_ai_name}'), callback_data=_build_callback('kassa_ai') + ) + ] + ) has_direct_payment_methods = True if settings.is_support_topup_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"), - callback_data="topup_support" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_VIA_SUPPORT', '🛠️ Через поддержку'), callback_data='topup_support' + ) + ] + ) if not keyboard: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"), - callback_data="payment_methods_unavailable" - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENTS_TEMPORARILY_UNAVAILABLE', '⚠️ Способы оплаты временно недоступны'), + callback_data='payment_methods_unavailable', + ) + ] + ) elif not has_direct_payment_methods and settings.is_support_topup_enabled(): - keyboard.insert(0, [ - InlineKeyboardButton( - text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"), - callback_data="payment_methods_unavailable" - ) - ]) + keyboard.insert( + 0, + [ + InlineKeyboardButton( + text=texts.t('PAYMENTS_TEMPORARILY_UNAVAILABLE', '⚠️ Способы оплаты временно недоступны'), + callback_data='payment_methods_unavailable', + ) + ], + ) - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance") - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) + def get_yookassa_payment_keyboard( - payment_id: str, - amount_kopeks: int, - confirmation_url: str, - language: str = DEFAULT_LANGUAGE + payment_id: str, amount_kopeks: int, confirmation_url: str, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("PAY_NOW_BUTTON", "💳 Оплатить"), - url=confirmation_url - ) - ], - [ - InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_yookassa_status_{payment_id}" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("MY_BALANCE_BUTTON", "💰 Мой баланс"), - callback_data="menu_balance" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('PAY_NOW_BUTTON', '💳 Оплатить'), url=confirmation_url)], + [ + InlineKeyboardButton( + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_yookassa_status_{payment_id}', + ) + ], + [InlineKeyboardButton(text=texts.t('MY_BALANCE_BUTTON', '💰 Мой баланс'), callback_data='menu_balance')], ] - ]) + ) + def get_autopay_notification_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - build_miniapp_or_callback_button( - text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"), - callback_data="balance_topup" - ) - ], - [ - build_miniapp_or_callback_button( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'), callback_data='balance_topup' + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), callback_data='menu_subscription' + ) + ], ] - ]) + ) + def get_subscription_expiring_keyboard(subscription_id: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - build_miniapp_or_callback_button( - text=texts.MENU_EXTEND_SUBSCRIPTION, - callback_data="subscription_extend" - ) - ], - [ - build_miniapp_or_callback_button( - text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"), - callback_data="balance_topup" - ) - ], - [ - build_miniapp_or_callback_button( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data='subscription_extend' + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'), callback_data='balance_topup' + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), callback_data='menu_subscription' + ) + ], ] - ]) + ) + def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) @@ -1586,45 +1623,34 @@ def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMar keyboard = [ [ InlineKeyboardButton( - text=texts.t("CREATE_INVITE_BUTTON", "📝 Создать приглашение"), - callback_data="referral_create_invite" + text=texts.t('CREATE_INVITE_BUTTON', '📝 Создать приглашение'), callback_data='referral_create_invite' + ) + ], + [InlineKeyboardButton(text=texts.t('SHOW_QR_BUTTON', '📱 Показать QR код'), callback_data='referral_show_qr')], + [ + InlineKeyboardButton( + text=texts.t('REFERRAL_LIST_BUTTON', '👥 Список рефералов'), callback_data='referral_list' ) ], [ InlineKeyboardButton( - text=texts.t("SHOW_QR_BUTTON", "📱 Показать QR код"), - callback_data="referral_show_qr" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("REFERRAL_LIST_BUTTON", "👥 Список рефералов"), - callback_data="referral_list" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("REFERRAL_ANALYTICS_BUTTON", "📊 Аналитика"), - callback_data="referral_analytics" + text=texts.t('REFERRAL_ANALYTICS_BUTTON', '📊 Аналитика'), callback_data='referral_analytics' ) ], ] # Добавляем кнопку вывода, если включена if settings.is_referral_withdrawal_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("REFERRAL_WITHDRAWAL_BUTTON", "💸 Запросить вывод"), - callback_data="referral_withdrawal" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="back_to_menu" + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('REFERRAL_WITHDRAWAL_BUTTON', '💸 Запросить вывод'), + callback_data='referral_withdrawal', + ) + ] ) - ]) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -1633,6 +1659,7 @@ def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark texts = get_texts(language) try: from app.services.support_settings_service import SupportSettingsService + tickets_enabled = SupportSettingsService.is_tickets_enabled() contact_enabled = SupportSettingsService.is_contact_enabled() except Exception: @@ -1641,119 +1668,112 @@ def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark rows: list[list[InlineKeyboardButton]] = [] # Tickets if tickets_enabled: - rows.append([ - InlineKeyboardButton( - text=texts.t("CREATE_TICKET_BUTTON", "🎫 Создать тикет"), - callback_data="create_ticket" - ) - ]) - rows.append([ - InlineKeyboardButton( - text=texts.t("MY_TICKETS_BUTTON", "📋 Мои тикеты"), - callback_data="my_tickets" - ) - ]) + rows.append( + [ + InlineKeyboardButton( + text=texts.t('CREATE_TICKET_BUTTON', '🎫 Создать тикет'), callback_data='create_ticket' + ) + ] + ) + rows.append( + [InlineKeyboardButton(text=texts.t('MY_TICKETS_BUTTON', '📋 Мои тикеты'), callback_data='my_tickets')] + ) # Direct contact if contact_enabled and settings.get_support_contact_url(): - rows.append([ - InlineKeyboardButton( - text=texts.t("CONTACT_SUPPORT_BUTTON", "💬 Связаться с поддержкой"), - url=settings.get_support_contact_url() or "https://t.me/" - ) - ]) - rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + rows.append( + [ + InlineKeyboardButton( + text=texts.t('CONTACT_SUPPORT_BUTTON', '💬 Связаться с поддержкой'), + url=settings.get_support_contact_url() or 'https://t.me/', + ) + ] + ) + rows.append([InlineKeyboardButton(text=texts.BACK, callback_data='back_to_menu')]) return InlineKeyboardMarkup(inline_keyboard=rows) def get_pagination_keyboard( - current_page: int, - total_pages: int, - callback_prefix: str, - language: str = DEFAULT_LANGUAGE -) -> List[List[InlineKeyboardButton]]: + current_page: int, total_pages: int, callback_prefix: str, language: str = DEFAULT_LANGUAGE +) -> list[list[InlineKeyboardButton]]: texts = get_texts(language) keyboard = [] - + if total_pages > 1: row = [] - + if current_page > 1: - row.append(InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"{callback_prefix}_page_{current_page - 1}" - )) - - row.append(InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page" - )) - + row.append( + InlineKeyboardButton( + text=texts.t('PAGINATION_PREV', '⬅️'), callback_data=f'{callback_prefix}_page_{current_page - 1}' + ) + ) + + row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page')) + if current_page < total_pages: - row.append(InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"{callback_prefix}_page_{current_page + 1}" - )) - + row.append( + InlineKeyboardButton( + text=texts.t('PAGINATION_NEXT', '➡️'), callback_data=f'{callback_prefix}_page_{current_page + 1}' + ) + ) + keyboard.append(row) - + return keyboard + def get_confirmation_keyboard( - confirm_data: str, - cancel_data: str = "cancel", - language: str = DEFAULT_LANGUAGE + confirm_data: str, cancel_data: str = 'cancel', language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.YES, callback_data=confirm_data), - InlineKeyboardButton(text=texts.NO, callback_data=cancel_data) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.YES, callback_data=confirm_data), + InlineKeyboardButton(text=texts.NO, callback_data=cancel_data), + ] ] - ]) + ) def get_autopay_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton(text=texts.t("ENABLE_BUTTON", "✅ Включить"), callback_data="autopay_enable"), - InlineKeyboardButton(text=texts.t("DISABLE_BUTTON", "❌ Выключить"), callback_data="autopay_disable") - ], - [ - InlineKeyboardButton(text=texts.t("AUTOPAY_SET_DAYS_BUTTON", "⚙️ Настроить дни"), callback_data="autopay_set_days") - ], - [ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=texts.t('ENABLE_BUTTON', '✅ Включить'), callback_data='autopay_enable'), + InlineKeyboardButton(text=texts.t('DISABLE_BUTTON', '❌ Выключить'), callback_data='autopay_disable'), + ], + [ + InlineKeyboardButton( + text=texts.t('AUTOPAY_SET_DAYS_BUTTON', '⚙️ Настроить дни'), callback_data='autopay_set_days' + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], ] - ]) + ) def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + for days in [1, 3, 7, 14]: - keyboard.append([ - InlineKeyboardButton( - text=f"{days} {_get_days_word(days)}", - callback_data=f"autopay_days_{days}" - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="subscription_autopay") - ]) - + keyboard.append( + [InlineKeyboardButton(text=f'{days} {_get_days_word(days)}', callback_data=f'autopay_days_{days}')] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='subscription_autopay')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def _get_days_word(days: int) -> str: if days % 10 == 1 and days % 100 != 11: - return "день" + return 'день' if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14): - return "дня" - return "дней" - + return 'дня' + return 'дней' # Deprecated: get_extend_subscription_keyboard() was removed. @@ -1765,34 +1785,36 @@ def get_add_traffic_keyboard( subscription_end_date: datetime = None, discount_percent: int = 0, ) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months from app.config import settings + from app.utils.pricing_utils import get_remaining_months + texts = get_texts(language) - + months_multiplier = 1 - period_text = "" + period_text = '' if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = f" (за {months_multiplier} мес)" - + period_text = f' (за {months_multiplier} мес)' + packages = settings.get_traffic_topup_packages() enabled_packages = [pkg for pkg in packages if pkg['enabled']] - + if not enabled_packages: - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"), - callback_data="no_traffic_packages" - )], - [InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - )] - ]) - + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('NO_TRAFFIC_PACKAGES', '❌ Нет доступных пакетов'), + callback_data='no_traffic_packages', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], + ] + ) + buttons = [] - + for package in enabled_packages: gb = package['gb'] price_per_month = package['price'] @@ -1804,30 +1826,22 @@ def get_add_traffic_keyboard( total_discount = discount_per_month * months_multiplier if gb == 0: - if language == "ru": - text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}" + if language == 'ru': + text = f'♾️ Безлимитный трафик - {total_price // 100} ₽{period_text}' else: - text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}" + text = f'♾️ Unlimited traffic - {total_price // 100} ₽{period_text}' + elif language == 'ru': + text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}' else: - if language == "ru": - text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}" - else: - text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}" + 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}₽)" + text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)' + + buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')]) + + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) - buttons.append([ - InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - ) - ]) - return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -1851,23 +1865,24 @@ def get_add_traffic_keyboard_from_tariff( texts = get_texts(language) months_multiplier = 1 - period_text = "" + period_text = '' if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = f" (за {months_multiplier} мес)" + period_text = f' (за {months_multiplier} мес)' if not packages: - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("NO_TRAFFIC_PACKAGES", "❌ Нет доступных пакетов"), - callback_data="no_traffic_packages" - )], - [InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - )] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('NO_TRAFFIC_PACKAGES', '❌ Нет доступных пакетов'), + callback_data='no_traffic_packages', + ) + ], + [InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')], + ] + ) buttons = [] @@ -1882,24 +1897,17 @@ def get_add_traffic_keyboard_from_tariff( total_price = discounted_per_month * months_multiplier total_discount = discount_per_month * months_multiplier - if language == "ru": - text = f"📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}" + if language == 'ru': + text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}' else: - text = f"📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}" + 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}₽)" + text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)' - buttons.append([ - InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}") - ]) + buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')]) - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - ) - ]) + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=buttons) @@ -1911,16 +1919,17 @@ def get_change_devices_keyboard( discount_percent: int = 0, tariff=None, # Тариф для цены за устройство ) -> InlineKeyboardMarkup: - from app.utils.pricing_utils import get_remaining_months from app.config import settings + from app.utils.pricing_utils import get_remaining_months + texts = get_texts(language) months_multiplier = 1 - period_text = "" + period_text = '' if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) if months_multiplier > 1: - period_text = f" (за {months_multiplier} мес)" + period_text = f' (за {months_multiplier} мес)' # Используем цену из тарифа если есть, иначе глобальную настройку tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None @@ -1934,7 +1943,6 @@ def get_change_devices_keyboard( buttons = [] - min_devices = 1 max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20 start_range = max(1, min(current_devices - 3, max_devices - 6)) @@ -1942,12 +1950,12 @@ def get_change_devices_keyboard( for devices_count in range(start_range, end_range): if devices_count == current_devices: - emoji = "✅" - action_text = " (текущее)" - price_text = "" + emoji = '✅' + action_text = ' (текущее)' + price_text = '' elif devices_count > current_devices: - emoji = "➕" - additional_devices = devices_count - current_devices + emoji = '➕' + devices_count - current_devices current_chargeable = max(0, current_devices - default_device_limit) new_chargeable = max(0, devices_count - default_device_limit) @@ -1960,63 +1968,53 @@ def get_change_devices_keyboard( discount_percent, ) total_price = discounted_per_month * months_multiplier - price_text = f" (+{total_price//100}₽{period_text})" + 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 = "" + price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)' + action_text = '' else: - price_text = " (бесплатно)" - action_text = "" + price_text = ' (бесплатно)' + action_text = '' else: - emoji = "➖" - action_text = "" - price_text = " (без возврата)" + emoji = '➖' + action_text = '' + price_text = ' (без возврата)' - button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}" + button_text = f'{emoji} {devices_count} устр.{action_text}{price_text}' - buttons.append([ - InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}") - ]) + buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'change_devices_{devices_count}')]) if current_devices < start_range or current_devices >= end_range: - current_button = f"✅ {current_devices} устр. (текущее)" - buttons.insert(0, [ - InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}") - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_settings" + current_button = f'✅ {current_devices} устр. (текущее)' + buttons.insert( + 0, [InlineKeyboardButton(text=current_button, callback_data=f'change_devices_{current_devices}')] ) - ]) - + + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')]) + return InlineKeyboardMarkup(inline_keyboard=buttons) -def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: + +def get_confirm_change_devices_keyboard( + new_devices_count: int, price: int, language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: texts = get_texts(language) - - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CONFIRM_CHANGE_BUTTON", "✅ Подтвердить изменение"), - callback_data=f"confirm_change_devices_{new_devices_count}_{price}" - ) - ], - [ - InlineKeyboardButton( - text=texts.CANCEL, - callback_data="subscription_settings" - ) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CONFIRM_CHANGE_BUTTON', '✅ Подтвердить изменение'), + callback_data=f'confirm_change_devices_{new_devices_count}_{price}', + ) + ], + [InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_settings')], ] - ]) + ) def get_reset_traffic_confirm_keyboard( - price_kopeks: int, + price_kopeks: int, language: str = DEFAULT_LANGUAGE, has_enough_balance: bool = True, missing_kopeks: int = 0, @@ -2025,40 +2023,46 @@ def get_reset_traffic_confirm_keyboard( if settings.is_traffic_topup_blocked(): return get_back_keyboard(language) - + texts = get_texts(language) buttons = [] - + if has_enough_balance: # Достаточно средств - показываем кнопку сброса - buttons.append([ - InlineKeyboardButton( - text=f"✅ Сбросить за {settings.format_price(price_kopeks)}", - callback_data="confirm_reset_traffic" - ) - ]) + buttons.append( + [ + InlineKeyboardButton( + text=f'✅ Сбросить за {settings.format_price(price_kopeks)}', callback_data='confirm_reset_traffic' + ) + ] + ) else: # Не хватает средств - показываем кнопку пополнения - buttons.append([ - InlineKeyboardButton( - text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"), - callback_data=f"topup_amount_{missing_kopeks}" - ) - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_settings", + buttons.append( + [ + InlineKeyboardButton( + text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'), + callback_data=f'topup_amount_{missing_kopeks}', + ) + ] ) - ]) - + + buttons.append( + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data='subscription_settings', + ) + ] + ) + return InlineKeyboardMarkup(inline_keyboard=buttons) + def get_manage_countries_keyboard( - countries: List[dict], - selected: List[str], - current_subscription_countries: List[str], + countries: list[dict], + selected: list[str], + current_subscription_countries: list[str], language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None, discount_percent: int = 0, @@ -2070,11 +2074,13 @@ def get_manage_countries_keyboard( months_multiplier = 1 if subscription_end_date: months_multiplier = get_remaining_months(subscription_end_date) - logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}") - + logger.info( + f'🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}' + ) + buttons = [] total_cost = 0 - + for country in countries: if not country.get('is_available', True): continue @@ -2090,24 +2096,21 @@ def get_manage_countries_keyboard( if uuid in current_subscription_countries: if uuid in selected: - icon = "✅" + icon = '✅' else: - icon = "➖" + icon = '➖' + elif uuid in selected: + icon = '➕' + total_cost += discounted_per_month * months_multiplier else: - if uuid in selected: - icon = "➕" - total_cost += discounted_per_month * months_multiplier - else: - icon = "⚪" + icon = '⚪' if uuid not in current_subscription_countries and uuid in selected: total_price = discounted_per_month * months_multiplier if months_multiplier > 1: - price_text = ( - f" ({discounted_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₽)", + '🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)', name, discounted_per_month / 100, months_multiplier, @@ -2115,70 +2118,70 @@ def get_manage_countries_keyboard( (discount_per_month * months_multiplier) / 100, ) else: - price_text = f" ({total_price//100}₽)" + 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}" + price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)' + display_name = f'{icon} {name}{price_text}' else: - display_name = f"{icon} {name}" - - buttons.append([ - InlineKeyboardButton( - text=display_name, - callback_data=f"country_manage_{uuid}" - ) - ]) - + display_name = f'{icon} {name}' + + buttons.append([InlineKeyboardButton(text=display_name, callback_data=f'country_manage_{uuid}')]) + if total_cost > 0: - apply_text = f"✅ Применить изменения ({total_cost//100} ₽)" - logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽") + apply_text = f'✅ Применить изменения ({total_cost // 100} ₽)' + logger.info(f'🔍 Общая стоимость новых серверов: {total_cost / 100}₽') else: - apply_text = "✅ Применить изменения" - - buttons.append([ - InlineKeyboardButton(text=apply_text, callback_data="countries_apply") - ]) - - buttons.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="menu_subscription" - ) - ]) - + apply_text = '✅ Применить изменения' + + buttons.append([InlineKeyboardButton(text=apply_text, callback_data='countries_apply')]) + + buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) + return InlineKeyboardMarkup(inline_keyboard=buttons) + def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: from app.config import settings + texts = get_texts(language) - + keyboard = [ [ - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_IOS", "📱 iOS (iPhone/iPad)"), callback_data="device_guide_ios"), - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID", "🤖 Android"), callback_data="device_guide_android") + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios' + ), + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android' + ), ], [ - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_WINDOWS", "💻 Windows"), callback_data="device_guide_windows"), - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_MAC", "🎯 macOS"), callback_data="device_guide_mac") + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows' + ), + InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'), ], [ - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_ANDROID_TV", "📺 Android TV"), callback_data="device_guide_tv"), - InlineKeyboardButton(text=texts.t("DEVICE_GUIDE_APPLE_TV", "📺 Apple TV"), callback_data="device_guide_appletv") - ] + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv' + ), + InlineKeyboardButton( + text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv' + ), + ], ] - - if settings.CONNECT_BUTTON_MODE == "guide": - keyboard.append([ - InlineKeyboardButton(text=texts.t("SHOW_SUBSCRIPTION_LINK", "📋 Показать ссылку подписки"), callback_data="open_subscription_link") - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) - + + if settings.CONNECT_BUTTON_MODE == 'guide': + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('SHOW_SUBSCRIPTION_LINK', '📋 Показать ссылку подписки'), + callback_data='open_subscription_link', + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -2190,6 +2193,7 @@ def get_connection_guide_keyboard( has_other_apps: bool = False, ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link + texts = get_texts(language) keyboard = [] @@ -2203,9 +2207,7 @@ def get_connection_guide_keyboard( if not button_text or not button_link: continue - app_buttons.append( - InlineKeyboardButton(text=f"📥 {button_text}", url=button_link) - ) + app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link)) if len(app_buttons) == 2: keyboard.append(app_buttons) app_buttons = [] @@ -2225,17 +2227,17 @@ def get_connection_guide_keyboard( if connect_link: connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=connect_link, ) elif settings.is_happ_cryptolink_mode(): connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) else: connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_url, ) @@ -2250,64 +2252,69 @@ def get_connection_guide_keyboard( keyboard.append([button]) if has_other_apps: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"), - callback_data=f"app_list_{device_type}", - ) - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), + callback_data=f'app_list_{device_type}', + ) + ] + ) - keyboard.extend([ + keyboard.extend( [ - InlineKeyboardButton(text=texts.t("CHOOSE_ANOTHER_DEVICE", "📱 Выбрать другое устройство"), callback_data="subscription_connect") - ], - [ - InlineKeyboardButton(text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), callback_data="menu_subscription") + [ + InlineKeyboardButton( + text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'), + callback_data='subscription_connect', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription' + ) + ], ] - ]) - + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_app_selection_keyboard( - device_type: str, - apps: list, - language: str = DEFAULT_LANGUAGE -) -> InlineKeyboardMarkup: +def get_app_selection_keyboard(device_type: str, apps: list, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + for app in apps: app_name = app['name'] if app.get('isFeatured', False): - app_name = f"⭐ {app_name}" - - keyboard.append([ - InlineKeyboardButton( - text=app_name, - callback_data=f"app_{device_type}_{app['id']}" - ) - ]) - - keyboard.extend([ + app_name = f'⭐ {app_name}' + + keyboard.append([InlineKeyboardButton(text=app_name, callback_data=f'app_{device_type}_{app["id"]}')]) + + keyboard.extend( [ - InlineKeyboardButton(text=texts.t("CHOOSE_ANOTHER_DEVICE", "📱 Выбрать другое устройство"), callback_data="subscription_connect") - ], - [ - InlineKeyboardButton(text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), callback_data="menu_subscription") + [ + InlineKeyboardButton( + text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'), + callback_data='subscription_connect', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription' + ) + ], ] - ]) - + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_specific_app_keyboard( - subscription_url: str, - app: dict, - device_type: str, - language: str = DEFAULT_LANGUAGE + subscription_url: str, app: dict, device_type: str, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: from app.handlers.subscription import create_deep_link + texts = get_texts(language) keyboard = [] @@ -2321,9 +2328,7 @@ def get_specific_app_keyboard( if not button_text or not button_link: continue - app_buttons.append( - InlineKeyboardButton(text=f"📥 {button_text}", url=button_link) - ) + app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link)) if len(app_buttons) == 2: keyboard.append(app_buttons) app_buttons = [] @@ -2343,17 +2348,17 @@ def get_specific_app_keyboard( if connect_link: connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=connect_link, ) elif settings.is_happ_cryptolink_mode(): connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="open_subscription_link", + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='open_subscription_link', ) else: connect_button = InlineKeyboardButton( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), url=subscription_url, ) @@ -2367,20 +2372,30 @@ def get_specific_app_keyboard( for button in additional_after_buttons: keyboard.append([button]) - keyboard.extend([ + keyboard.extend( [ - InlineKeyboardButton(text=texts.t("OTHER_APPS_BUTTON", "📋 Другие приложения"), callback_data=f"app_list_{device_type}") - ], - [ - InlineKeyboardButton(text=texts.t("CHOOSE_ANOTHER_DEVICE", "📱 Выбрать другое устройство"), callback_data="subscription_connect") - ], - [ - InlineKeyboardButton(text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), callback_data="menu_subscription") + [ + InlineKeyboardButton( + text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), callback_data=f'app_list_{device_type}' + ) + ], + [ + InlineKeyboardButton( + text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'), + callback_data='subscription_connect', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription' + ) + ], ] - ]) - + ) + return InlineKeyboardMarkup(inline_keyboard=keyboard) + def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] @@ -2394,10 +2409,10 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> price_info = prices[days] if isinstance(price_info, dict): - final_price = price_info.get("final") - original_price = price_info.get("original", 0) + final_price = price_info.get('final') + original_price = price_info.get('original', 0) if final_price is None: - final_price = price_info.get("original", 0) + final_price = price_info.get('original', 0) else: final_price = price_info original_price = price_info @@ -2411,9 +2426,7 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> discount_percent = ((original_price - final_price) * 100) // original_price price_info_obj = PriceInfo( - base_price=original_price, - final_price=final_price, - discount_percent=discount_percent + base_price=original_price, final_price=final_price, discount_percent=discount_percent ) # Format button using unified system @@ -2422,118 +2435,92 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> price_info=price_info_obj, format_price_func=texts.format_price, emphasize=False, - add_exclamation=False + add_exclamation=False, ) - keyboard.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"extend_period_{days}" - ) - ]) + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f'extend_period_{days}')]) - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) + def get_cryptobot_payment_keyboard( payment_id: str, local_payment_id: int, amount_usd: float, asset: str, bot_invoice_url: str, - language: str = DEFAULT_LANGUAGE + language: str = DEFAULT_LANGUAGE, ) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), - url=bot_invoice_url - ) - ], - [ - InlineKeyboardButton( - text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), - callback_data=f"check_cryptobot_{local_payment_id}" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("MY_BALANCE_BUTTON", "💰 Мой баланс"), - callback_data="menu_balance" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text=texts.t('PAY_WITH_COINS_BUTTON', '🪙 Оплатить'), url=bot_invoice_url)], + [ + InlineKeyboardButton( + text=texts.t('CHECK_STATUS_BUTTON', '📊 Проверить статус'), + callback_data=f'check_cryptobot_{local_payment_id}', + ) + ], + [InlineKeyboardButton(text=texts.t('MY_BALANCE_BUTTON', '💰 Мой баланс'), callback_data='menu_balance')], ] - ]) + ) + def get_devices_management_keyboard( - devices: List[dict], - pagination, - language: str = DEFAULT_LANGUAGE + devices: list[dict], pagination, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) - + keyboard = [] - + for i, device in enumerate(devices): platform = device.get('platform', 'Unknown') device_model = device.get('deviceModel', 'Unknown') - device_info = f"{platform} - {device_model}" - + device_info = f'{platform} - {device_model}' + if len(device_info) > 25: - device_info = device_info[:22] + "..." - - keyboard.append([ - InlineKeyboardButton( - text=f"🔄 {device_info}", - callback_data=f"reset_device_{i}_{pagination.page}" - ) - ]) - + device_info = device_info[:22] + '...' + + keyboard.append( + [InlineKeyboardButton(text=f'🔄 {device_info}', callback_data=f'reset_device_{i}_{pagination.page}')] + ) + if pagination.total_pages > 1: nav_row = [] - + if pagination.has_prev: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"devices_page_{pagination.prev_page}" + text=texts.t('PAGINATION_PREV', '⬅️'), callback_data=f'devices_page_{pagination.prev_page}' ) ) nav_row.append( - InlineKeyboardButton( - text=f"{pagination.page}/{pagination.total_pages}", - callback_data="current_page" - ) + InlineKeyboardButton(text=f'{pagination.page}/{pagination.total_pages}', callback_data='current_page') ) - + if pagination.has_next: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"devices_page_{pagination.next_page}" + text=texts.t('PAGINATION_NEXT', '➡️'), callback_data=f'devices_page_{pagination.next_page}' ) ) - + keyboard.append(nav_row) - - keyboard.append([ - InlineKeyboardButton( - text=texts.t("RESET_ALL_DEVICES_BUTTON", "🔄 Сбросить все устройства"), - callback_data="reset_all_devices" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.BACK, - callback_data="subscription_settings" - ) - ]) - + + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('RESET_ALL_DEVICES_BUTTON', '🔄 Сбросить все устройства'), + callback_data='reset_all_devices', + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -2554,234 +2541,239 @@ def get_updated_subscription_settings_keyboard( # Для суточных тарифов кнопка паузы теперь в главном меню подписки if show_countries_management and not has_tariff: - keyboard.append([ - InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries") - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('ADD_COUNTRIES_BUTTON', '🌐 Добавить страны'), + callback_data='subscription_add_countries', + ) + ] + ) if settings.is_traffic_selectable() and not has_tariff: - keyboard.append([ - InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic") - ]) - keyboard.append([ - InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic") - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('RESET_TRAFFIC_BUTTON', '🔄 Сбросить трафик'), + callback_data='subscription_reset_traffic', + ) + ] + ) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('SWITCH_TRAFFIC_BUTTON', '🔄 Переключить трафик'), + callback_data='subscription_switch_traffic', + ) + ] + ) # Устройства: для тарифов - только если указана цена за устройство if has_tariff: tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff_device_price is not None and tariff_device_price > 0: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), - callback_data="subscription_change_devices" - ) - ]) - elif settings.is_devices_selection_enabled(): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), - callback_data="subscription_change_devices" + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CHANGE_DEVICES_BUTTON', '📱 Изменить устройства'), + callback_data='subscription_change_devices', + ) + ] ) - ]) + elif settings.is_devices_selection_enabled(): + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CHANGE_DEVICES_BUTTON', '📱 Изменить устройства'), + callback_data='subscription_change_devices', + ) + ] + ) if settings.is_modem_enabled() and not has_tariff: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("MODEM_BUTTON", "📡 Модем"), - callback_data="subscription_modem" - ) - ]) - - keyboard.append([ - InlineKeyboardButton( - text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), - callback_data="subscription_manage_devices" + keyboard.append( + [InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')] ) - ]) - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription") - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('MANAGE_DEVICES_BUTTON', '🔧 Управление устройствами'), + callback_data='subscription_manage_devices', + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')]) return InlineKeyboardMarkup(inline_keyboard=keyboard) -def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: +def get_device_reset_confirm_keyboard( + device_info: str, device_index: int, page: int, language: str = DEFAULT_LANGUAGE +) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("RESET_DEVICE_CONFIRM_BUTTON", "✅ Да, сбросить это устройство"), - callback_data=f"confirm_reset_device_{device_index}_{page}" - ) - ], - [ - InlineKeyboardButton( - text=texts.CANCEL, - callback_data=f"devices_page_{page}" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('RESET_DEVICE_CONFIRM_BUTTON', '✅ Да, сбросить это устройство'), + callback_data=f'confirm_reset_device_{device_index}_{page}', + ) + ], + [InlineKeyboardButton(text=texts.CANCEL, callback_data=f'devices_page_{page}')], ] - ]) + ) def get_device_management_help_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("DEVICE_CONNECTION_HELP", "❓ Как подключить устройство заново?"), - callback_data="device_connection_help" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), - callback_data="subscription_manage_devices" - ) - ], - [ - InlineKeyboardButton( - text=texts.t("BACK_TO_SUBSCRIPTION", "⬅️ К подписке"), - callback_data="menu_subscription" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('DEVICE_CONNECTION_HELP', '❓ Как подключить устройство заново?'), + callback_data='device_connection_help', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('MANAGE_DEVICES_BUTTON', '🔧 Управление устройствами'), + callback_data='subscription_manage_devices', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription' + ) + ], ] - ]) + ) # ==================== TICKET KEYBOARDS ==================== + def get_ticket_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CANCEL_TICKET_CREATION", "❌ Отменить создание тикета"), - callback_data="cancel_ticket_creation" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CANCEL_TICKET_CREATION', '❌ Отменить создание тикета'), + callback_data='cancel_ticket_creation', + ) + ] ] - ]) + ) def get_my_tickets_keyboard( - tickets: List[dict], + tickets: list[dict], current_page: int = 1, total_pages: int = 1, language: str = DEFAULT_LANGUAGE, - page_prefix: str = "my_tickets_page_" + page_prefix: str = 'my_tickets_page_', ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + for ticket in tickets: status_emoji = ticket.get('status_emoji', '❓') # Override status emoji for closed tickets in admin list if ticket.get('is_closed', False): status_emoji = '✅' title = ticket.get('title', 'Без названия')[:25] - button_text = f"{status_emoji} #{ticket['id']} {title}" - - keyboard.append([ - InlineKeyboardButton( - text=button_text, - callback_data=f"view_ticket_{ticket['id']}" - ) - ]) - + button_text = f'{status_emoji} #{ticket["id"]} {title}' + + keyboard.append([InlineKeyboardButton(text=button_text, callback_data=f'view_ticket_{ticket["id"]}')]) + # Пагинация if total_pages > 1: nav_row = [] - + if current_page > 1: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"{page_prefix}{current_page - 1}" + text=texts.t('PAGINATION_PREV', '⬅️'), callback_data=f'{page_prefix}{current_page - 1}' ) ) - - nav_row.append( - InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page" - ) - ) - + + nav_row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page')) + if current_page < total_pages: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"{page_prefix}{current_page + 1}" + text=texts.t('PAGINATION_NEXT', '➡️'), callback_data=f'{page_prefix}{current_page + 1}' ) ) - + keyboard.append(nav_row) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="menu_support") - ]) - + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_support')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_ticket_view_keyboard( - ticket_id: int, - is_closed: bool = False, - language: str = DEFAULT_LANGUAGE + ticket_id: int, is_closed: bool = False, language: str = DEFAULT_LANGUAGE ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + if not is_closed: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("REPLY_TO_TICKET", "💬 Ответить"), - callback_data=f"reply_ticket_{ticket_id}" - ) - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('REPLY_TO_TICKET', '💬 Ответить'), callback_data=f'reply_ticket_{ticket_id}' + ) + ] + ) + if not is_closed: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"), - callback_data=f"close_ticket_{ticket_id}" - ) - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="my_tickets") - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CLOSE_TICKET', '🔒 Закрыть тикет'), callback_data=f'close_ticket_{ticket_id}' + ) + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='my_tickets')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), - callback_data="cancel_ticket_reply" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CANCEL_REPLY', '❌ Отменить ответ'), callback_data='cancel_ticket_reply' + ) + ] ] - ]) + ) # ==================== ADMIN TICKET KEYBOARDS ==================== + def get_admin_tickets_keyboard( - tickets: List[dict], + tickets: list[dict], current_page: int = 1, total_pages: int = 1, language: str = DEFAULT_LANGUAGE, - scope: str = "all", + scope: str = 'all', *, - back_callback: str = "admin_submenu_support" + back_callback: str = 'admin_submenu_support', ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + # Разделяем открытые/закрытые open_rows = [] closed_rows = [] @@ -2796,16 +2788,16 @@ def get_admin_tickets_keyboard( name_parts = [user_name[:15]] contact_parts = [] if username: - contact_parts.append(f"@{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_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} {name_display}: {title}".replace(" ", " ") - row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")] + 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) else: @@ -2813,110 +2805,120 @@ def get_admin_tickets_keyboard( # Scope switcher switch_row = [] - switch_row.append(InlineKeyboardButton(text=texts.t("OPEN_TICKETS", "🔴 Открытые"), callback_data="admin_tickets_scope_open")) - switch_row.append(InlineKeyboardButton(text=texts.t("CLOSED_TICKETS", "🟢 Закрытые"), callback_data="admin_tickets_scope_closed")) + switch_row.append( + InlineKeyboardButton(text=texts.t('OPEN_TICKETS', '🔴 Открытые'), callback_data='admin_tickets_scope_open') + ) + switch_row.append( + InlineKeyboardButton(text=texts.t('CLOSED_TICKETS', '🟢 Закрытые'), callback_data='admin_tickets_scope_closed') + ) keyboard.append(switch_row) - if open_rows and scope in ("all", "open"): - keyboard.append([ - InlineKeyboardButton( - text=texts.t("ADMIN_CLOSE_ALL_OPEN_TICKETS", "🔒 Закрыть все открытые"), - callback_data="admin_tickets_close_all_open" - ) - ]) - keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")]) + if open_rows and scope in ('all', 'open'): + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('ADMIN_CLOSE_ALL_OPEN_TICKETS', '🔒 Закрыть все открытые'), + callback_data='admin_tickets_close_all_open', + ) + ] + ) + keyboard.append( + [InlineKeyboardButton(text=texts.t('OPEN_TICKETS_HEADER', 'Открытые тикеты'), callback_data='noop')] + ) keyboard.extend(open_rows) - if closed_rows and scope in ("all", "closed"): - keyboard.append([InlineKeyboardButton(text=texts.t("CLOSED_TICKETS_HEADER", "Закрытые тикеты"), callback_data="noop")]) + if closed_rows and scope in ('all', 'closed'): + keyboard.append( + [InlineKeyboardButton(text=texts.t('CLOSED_TICKETS_HEADER', 'Закрытые тикеты'), callback_data='noop')] + ) keyboard.extend(closed_rows) - + # Пагинация if total_pages > 1: nav_row = [] - + if current_page > 1: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_PREV", "⬅️"), - callback_data=f"admin_tickets_page_{scope}_{current_page - 1}" + text=texts.t('PAGINATION_PREV', '⬅️'), callback_data=f'admin_tickets_page_{scope}_{current_page - 1}' ) ) - - nav_row.append( - InlineKeyboardButton( - text=f"{current_page}/{total_pages}", - callback_data="current_page" - ) - ) - + + nav_row.append(InlineKeyboardButton(text=f'{current_page}/{total_pages}', callback_data='current_page')) + if current_page < total_pages: nav_row.append( InlineKeyboardButton( - text=texts.t("PAGINATION_NEXT", "➡️"), - callback_data=f"admin_tickets_page_{scope}_{current_page + 1}" + text=texts.t('PAGINATION_NEXT', '➡️'), callback_data=f'admin_tickets_page_{scope}_{current_page + 1}' ) ) - + keyboard.append(nav_row) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data=back_callback) - ]) - + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_admin_ticket_view_keyboard( - ticket_id: int, - is_closed: bool = False, - language: str = DEFAULT_LANGUAGE, - *, - is_user_blocked: bool = False + ticket_id: int, is_closed: bool = False, language: str = DEFAULT_LANGUAGE, *, is_user_blocked: bool = False ) -> InlineKeyboardMarkup: texts = get_texts(language) keyboard = [] - + if not is_closed: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("REPLY_TO_TICKET", "💬 Ответить"), - callback_data=f"admin_reply_ticket_{ticket_id}" - ) - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('REPLY_TO_TICKET', '💬 Ответить'), callback_data=f'admin_reply_ticket_{ticket_id}' + ) + ] + ) + if not is_closed: - keyboard.append([ - InlineKeyboardButton( - text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"), - callback_data=f"admin_close_ticket_{ticket_id}" - ) - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('CLOSE_TICKET', '🔒 Закрыть тикет'), callback_data=f'admin_close_ticket_{ticket_id}' + ) + ] + ) + # Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать" if is_user_blocked: - keyboard.append([ - InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}") - ]) + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('UNBLOCK', '✅ Разблокировать'), callback_data=f'admin_unblock_user_ticket_{ticket_id}' + ) + ] + ) else: - keyboard.append([ - InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"), - InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блок по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}") - ]) - - keyboard.append([ - InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets") - ]) - + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('BLOCK_FOREVER', '🚫 Заблокировать'), + callback_data=f'admin_block_user_perm_ticket_{ticket_id}', + ), + InlineKeyboardButton( + text=texts.t('BLOCK_BY_TIME', '⏳ Блок по времени'), + callback_data=f'admin_block_user_ticket_{ticket_id}', + ), + ] + ) + + keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='admin_tickets')]) + return InlineKeyboardMarkup(inline_keyboard=keyboard) def get_admin_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup: texts = get_texts(language) - return InlineKeyboardMarkup(inline_keyboard=[ - [ - InlineKeyboardButton( - text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"), - callback_data="cancel_admin_ticket_reply" - ) + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=texts.t('CANCEL_REPLY', '❌ Отменить ответ'), callback_data='cancel_admin_ticket_reply' + ) + ] ] - ]) + ) diff --git a/app/keyboards/reply.py b/app/keyboards/reply.py index e7ae263c..d82bb939 100644 --- a/app/keyboards/reply.py +++ b/app/keyboards/reply.py @@ -1,102 +1,65 @@ -from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove -from typing import List +from aiogram.types import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove -from app.localization.texts import get_texts from app.config import settings +from app.localization.texts import get_texts -def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_main_reply_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) - - keyboard = [ - [ - KeyboardButton(text=texts.MENU_BALANCE), - KeyboardButton(text=texts.MENU_SUBSCRIPTION) - ] - ] - + + keyboard = [[KeyboardButton(text=texts.MENU_BALANCE), KeyboardButton(text=texts.MENU_SUBSCRIPTION)]] + # Добавляем кнопки промокода и рефералов, учитывая настройки second_row = [KeyboardButton(text=texts.MENU_PROMOCODE)] - + # Добавляем кнопку рефералов только если программа включена if settings.is_referral_program_enabled(): second_row.append(KeyboardButton(text=texts.MENU_REFERRALS)) - + keyboard.append(second_row) - - keyboard.append([ - KeyboardButton(text=texts.MENU_SUPPORT), - KeyboardButton(text=texts.MENU_RULES) - ]) - + + keyboard.append([KeyboardButton(text=texts.MENU_SUPPORT), KeyboardButton(text=texts.MENU_RULES)]) + + return ReplyKeyboardMarkup(keyboard=keyboard, resize_keyboard=True, one_time_keyboard=False) + + +def get_admin_reply_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: + texts = get_texts(language) + return ReplyKeyboardMarkup( - keyboard=keyboard, + keyboard=[ + [KeyboardButton(text=texts.ADMIN_USERS), KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)], + [KeyboardButton(text=texts.ADMIN_PROMOCODES), KeyboardButton(text=texts.ADMIN_MESSAGES)], + [KeyboardButton(text=texts.ADMIN_STATISTICS), KeyboardButton(text=texts.ADMIN_MONITORING)], + [KeyboardButton(text=texts.t('ADMIN_MAIN_MENU', '🏠 Главное меню'))], + ], resize_keyboard=True, - one_time_keyboard=False + one_time_keyboard=False, ) -def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_cancel_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) - + return ReplyKeyboardMarkup( - keyboard=[ - [ - KeyboardButton(text=texts.ADMIN_USERS), - KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS) - ], - [ - KeyboardButton(text=texts.ADMIN_PROMOCODES), - KeyboardButton(text=texts.ADMIN_MESSAGES) - ], - [ - KeyboardButton(text=texts.ADMIN_STATISTICS), - KeyboardButton(text=texts.ADMIN_MONITORING) - ], - [ - KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню")) - ] - ], - resize_keyboard=True, - one_time_keyboard=False + keyboard=[[KeyboardButton(text=texts.CANCEL)]], resize_keyboard=True, one_time_keyboard=True ) -def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_confirmation_reply_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) - + return ReplyKeyboardMarkup( - keyboard=[ - [KeyboardButton(text=texts.CANCEL)] - ], + keyboard=[[KeyboardButton(text=texts.YES), KeyboardButton(text=texts.NO)]], resize_keyboard=True, - one_time_keyboard=True + one_time_keyboard=True, ) -def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: - texts = get_texts(language) - - return ReplyKeyboardMarkup( - keyboard=[ - [ - KeyboardButton(text=texts.YES), - KeyboardButton(text=texts.NO) - ] - ], - resize_keyboard=True, - one_time_keyboard=True - ) - - -def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_skip_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) return ReplyKeyboardMarkup( - keyboard=[ - [KeyboardButton(text=texts.REFERRAL_CODE_SKIP)] - ], - resize_keyboard=True, - one_time_keyboard=True + keyboard=[[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]], resize_keyboard=True, one_time_keyboard=True ) @@ -104,25 +67,25 @@ def remove_keyboard() -> ReplyKeyboardRemove: return ReplyKeyboardRemove() -def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_contact_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) return ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)], - [KeyboardButton(text=texts.CANCEL)] + [KeyboardButton(text=texts.t('SEND_CONTACT_BUTTON', '📱 Отправить контакт'), request_contact=True)], + [KeyboardButton(text=texts.CANCEL)], ], resize_keyboard=True, - one_time_keyboard=True + one_time_keyboard=True, ) -def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup: +def get_location_keyboard(language: str = 'ru') -> ReplyKeyboardMarkup: texts = get_texts(language) return ReplyKeyboardMarkup( keyboard=[ - [KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)], - [KeyboardButton(text=texts.CANCEL)] + [KeyboardButton(text=texts.t('SEND_LOCATION_BUTTON', '📍 Отправить геолокацию'), request_location=True)], + [KeyboardButton(text=texts.CANCEL)], ], resize_keyboard=True, - one_time_keyboard=True + one_time_keyboard=True, ) diff --git a/app/lib/nalogo/__init__.py b/app/lib/nalogo/__init__.py index cfb8a954..454bc277 100644 --- a/app/lib/nalogo/__init__.py +++ b/app/lib/nalogo/__init__.py @@ -22,16 +22,16 @@ from .exceptions import ( ValidationException, ) -__version__ = "1.0.0" +__version__ = '1.0.0' __all__ = [ - "Client", - "ClientException", - "DomainException", - "ForbiddenException", - "NotFoundException", - "PhoneException", - "ServerException", - "UnauthorizedException", - "UnknownErrorException", - "ValidationException", + 'Client', + 'ClientException', + 'DomainException', + 'ForbiddenException', + 'NotFoundException', + 'PhoneException', + 'ServerException', + 'UnauthorizedException', + 'UnknownErrorException', + 'ValidationException', ] diff --git a/app/lib/nalogo/_http.py b/app/lib/nalogo/_http.py index 9f233365..34062fcf 100644 --- a/app/lib/nalogo/_http.py +++ b/app/lib/nalogo/_http.py @@ -52,14 +52,12 @@ class AsyncHTTPClient: async def _get_auth_headers(self) -> dict[str, str]: """Get authorization headers from current token.""" token_data = await self.auth_provider.get_token() - if not token_data or "token" not in token_data: + if not token_data or 'token' not in token_data: return {} - return {"Authorization": f"Bearer {token_data['token']}"} + return {'Authorization': f'Bearer {token_data["token"]}'} - async def _handle_401_response( - self, client: httpx.AsyncClient, request: httpx.Request - ) -> httpx.Response | None: + async def _handle_401_response(self, client: httpx.AsyncClient, request: httpx.Request) -> httpx.Response | None: """ Handle 401 response by refreshing token and retrying request. @@ -68,18 +66,16 @@ class AsyncHTTPClient: """ async with self._refresh_lock: token_data = await self.auth_provider.get_token() - if not token_data or "refreshToken" not in token_data: + if not token_data or 'refreshToken' not in token_data: return None # Attempt token refresh - new_token_data = await self.auth_provider.refresh( - token_data["refreshToken"] - ) - if not new_token_data or "token" not in new_token_data: + new_token_data = await self.auth_provider.refresh(token_data['refreshToken']) + if not new_token_data or 'token' not in new_token_data: return None # Update request with new authorization header - new_auth_headers = {"Authorization": f"Bearer {new_token_data['token']}"} + new_auth_headers = {'Authorization': f'Bearer {new_token_data["token"]}'} request.headers.update(new_auth_headers) # Retry request with new token @@ -118,15 +114,15 @@ class AsyncHTTPClient: # Prepare request parameters request_kwargs = { - "method": method, - "url": self.base_url + path, - "headers": request_headers, - "timeout": self.timeout, + 'method': method, + 'url': self.base_url + path, + 'headers': request_headers, + 'timeout': self.timeout, **kwargs, } if json_data is not None: - request_kwargs["json"] = json_data + request_kwargs['json'] = json_data async with httpx.AsyncClient() as client: # Initial request @@ -152,7 +148,7 @@ class AsyncHTTPClient: **kwargs: Any, ) -> httpx.Response: """GET request.""" - return await self.request("GET", path, headers=headers, **kwargs) + return await self.request('GET', path, headers=headers, **kwargs) async def post( self, @@ -162,9 +158,7 @@ class AsyncHTTPClient: **kwargs: Any, ) -> httpx.Response: """POST request with JSON data.""" - return await self.request( - "POST", path, headers=headers, json_data=json_data, **kwargs - ) + return await self.request('POST', path, headers=headers, json_data=json_data, **kwargs) async def put( self, @@ -174,9 +168,7 @@ class AsyncHTTPClient: **kwargs: Any, ) -> httpx.Response: """PUT request with JSON data.""" - return await self.request( - "PUT", path, headers=headers, json_data=json_data, **kwargs - ) + return await self.request('PUT', path, headers=headers, json_data=json_data, **kwargs) async def delete( self, @@ -185,4 +177,4 @@ class AsyncHTTPClient: **kwargs: Any, ) -> httpx.Response: """DELETE request.""" - return await self.request("DELETE", path, headers=headers, **kwargs) + return await self.request('DELETE', path, headers=headers, **kwargs) diff --git a/app/lib/nalogo/auth.py b/app/lib/nalogo/auth.py index c5150a92..ad6311ac 100644 --- a/app/lib/nalogo/auth.py +++ b/app/lib/nalogo/auth.py @@ -18,7 +18,7 @@ from .exceptions import raise_for_status def generate_device_id() -> str: """Generate device ID similar to PHP's DeviceIdGenerator.""" - return str(uuid.uuid4()).replace("-", "")[:21].lower() + return str(uuid.uuid4()).replace('-', '')[:21].lower() # DeviceInfo is now imported from dto.device @@ -37,12 +37,12 @@ class AuthProviderImpl(AuthProvider): def __init__( self, - base_url: str = "https://lknpd.nalog.ru/api", + base_url: str = 'https://lknpd.nalog.ru/api', storage_path: str | None = None, device_id: str | None = None, ): - self.base_url_v1 = f"{base_url}/v1" - self.base_url_v2 = f"{base_url}/v2" + self.base_url_v1 = f'{base_url}/v1' + self.base_url_v2 = f'{base_url}/v2' self.storage_path = storage_path self.device_id = device_id or generate_device_id() self.device_info = DeviceInfo(sourceDeviceId=self.device_id) @@ -50,10 +50,10 @@ class AuthProviderImpl(AuthProvider): # Default headers similar to PHP Authenticator self.default_headers = { - "Referrer": "https://lknpd.nalog.ru/auth/login", - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + 'Referrer': 'https://lknpd.nalog.ru/auth/login', + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', } # Load token from storage if available @@ -69,7 +69,7 @@ class AuthProviderImpl(AuthProvider): return try: - with storage_path.open(encoding="utf-8") as f: + with storage_path.open(encoding='utf-8') as f: self._token_data = json.load(f) except (json.JSONDecodeError, OSError): # Ignore errors, token will be None @@ -85,7 +85,7 @@ class AuthProviderImpl(AuthProvider): # Ensure directory exists storage_path.parent.mkdir(parents=True, exist_ok=True) - with storage_path.open("w", encoding="utf-8") as f: + with storage_path.open('w', encoding='utf-8') as f: json.dump(self._token_data, f, ensure_ascii=False, indent=2) except OSError: # Ignore storage errors @@ -106,7 +106,7 @@ class AuthProviderImpl(AuthProvider): self._token_data = json.loads(token_json) self._save_token_to_storage() except json.JSONDecodeError as e: - raise ValueError(f"Invalid token JSON: {e}") from e + raise ValueError(f'Invalid token JSON: {e}') from e async def create_new_access_token(self, username: str, password: str) -> str: """ @@ -125,14 +125,14 @@ class AuthProviderImpl(AuthProvider): Domain exceptions for authentication errors """ request_data = { - "username": username, - "password": password, - "deviceInfo": self.device_info.model_dump(), + 'username': username, + 'password': password, + 'deviceInfo': self.device_info.model_dump(), } async with httpx.AsyncClient() as client: response = await client.post( - f"{self.base_url_v1}/auth/lkfl", + f'{self.base_url_v1}/auth/lkfl', json=request_data, headers=self.default_headers, timeout=10.0, @@ -161,13 +161,13 @@ class AuthProviderImpl(AuthProvider): Domain exceptions for API errors """ request_data = { - "phone": phone, - "requireTpToBeActive": True, + 'phone': phone, + 'requireTpToBeActive': True, } async with httpx.AsyncClient() as client: response = await client.post( - f"{self.base_url_v2}/auth/challenge/sms/start", + f'{self.base_url_v2}/auth/challenge/sms/start', json=request_data, headers=self.default_headers, timeout=10.0, @@ -176,9 +176,7 @@ class AuthProviderImpl(AuthProvider): raise_for_status(response) return response.json() # type: ignore[no-any-return] - async def create_new_access_token_by_phone( - self, phone: str, challenge_token: str, verification_code: str - ) -> str: + async def create_new_access_token_by_phone(self, phone: str, challenge_token: str, verification_code: str) -> str: """ Complete phone-based authentication with SMS code. @@ -196,15 +194,15 @@ class AuthProviderImpl(AuthProvider): Domain exceptions for authentication errors """ request_data = { - "phone": phone, - "code": verification_code, - "challengeToken": challenge_token, - "deviceInfo": self.device_info.model_dump(), + 'phone': phone, + 'code': verification_code, + 'challengeToken': challenge_token, + 'deviceInfo': self.device_info.model_dump(), } async with httpx.AsyncClient() as client: response = await client.post( - f"{self.base_url_v1}/auth/challenge/sms/verify", + f'{self.base_url_v1}/auth/challenge/sms/verify', json=request_data, headers=self.default_headers, timeout=10.0, @@ -230,14 +228,14 @@ class AuthProviderImpl(AuthProvider): New token data dictionary or None if refresh failed """ request_data = { - "deviceInfo": self.device_info.model_dump(), - "refreshToken": refresh_token, + 'deviceInfo': self.device_info.model_dump(), + 'refreshToken': refresh_token, } try: async with httpx.AsyncClient() as client: response = await client.post( - f"{self.base_url_v1}/auth/token", + f'{self.base_url_v1}/auth/token', json=request_data, headers=self.default_headers, timeout=10.0, diff --git a/app/lib/nalogo/client.py b/app/lib/nalogo/client.py index dc9d8b15..648e2492 100644 --- a/app/lib/nalogo/client.py +++ b/app/lib/nalogo/client.py @@ -24,15 +24,15 @@ class Client: Example: >>> client = Client() - >>> token = await client.create_new_access_token("inn", "password") + >>> token = await client.create_new_access_token('inn', 'password') >>> await client.authenticate(token) >>> income_api = client.income() - >>> result = await income_api.create("Service", 100, 1) + >>> result = await income_api.create('Service', 100, 1) """ def __init__( self, - base_url: str = "https://lknpd.nalog.ru/api", + base_url: str = 'https://lknpd.nalog.ru/api', storage_path: str | None = None, device_id: str | None = None, timeout: float = 10.0, @@ -58,13 +58,13 @@ class Client: # Initialize HTTP client with auth middleware self.http_client = AsyncHTTPClient( - base_url=f"{base_url}/v1", + base_url=f'{base_url}/v1', auth_provider=self.auth_provider, default_headers={ - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", - "Referrer": "https://lknpd.nalog.ru/auth/login", + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7', + 'Referrer': 'https://lknpd.nalog.ru/auth/login', }, timeout=timeout, ) @@ -108,9 +108,7 @@ class Client: """ return await self.auth_provider.create_phone_challenge(phone) - async def create_new_access_token_by_phone( - self, phone: str, challenge_token: str, verification_code: str - ) -> str: + async def create_new_access_token_by_phone(self, phone: str, challenge_token: str, verification_code: str) -> str: """ Complete phone-based authentication with SMS code. @@ -128,9 +126,7 @@ class Client: UnauthorizedException: For invalid verification DomainException: For other API errors """ - return await self.auth_provider.create_new_access_token_by_phone( - phone, challenge_token, verification_code - ) + return await self.auth_provider.create_new_access_token_by_phone(phone, challenge_token, verification_code) async def authenticate(self, access_token: str) -> None: """ @@ -149,8 +145,8 @@ class Client: # Parse token to extract user profile (like PHP version) try: token_data = json.loads(access_token) - if "profile" in token_data: - self._user_profile = token_data["profile"] + if 'profile' in token_data: + self._user_profile = token_data['profile'] except json.JSONDecodeError: # If token parsing fails, profile will remain None pass @@ -192,13 +188,13 @@ class Client: Raises: ValueError: If user is not authenticated (no profile data) """ - if not self._user_profile or "inn" not in self._user_profile: - raise ValueError("User profile not available. Please authenticate first.") + if not self._user_profile or 'inn' not in self._user_profile: + raise ValueError('User profile not available. Please authenticate first.') return ReceiptAPI( http_client=self.http_client, base_endpoint=self.base_url, - user_inn=self._user_profile["inn"], + user_inn=self._user_profile['inn'], ) def payment_type(self) -> PaymentTypeAPI: diff --git a/app/lib/nalogo/dto/__init__.py b/app/lib/nalogo/dto/__init__.py index 1cbed53e..1d84973f 100644 --- a/app/lib/nalogo/dto/__init__.py +++ b/app/lib/nalogo/dto/__init__.py @@ -18,29 +18,29 @@ from .tax import History, HistoryRecords, Payment, PaymentRecords, Tax from .user import UserType __all__ = [ - "AtomDateTime", - "CancelCommentType", - "CancelRequest", + 'AtomDateTime', + 'CancelCommentType', + 'CancelRequest', # Device DTOs - "DeviceInfo", - "History", - "HistoryRecords", - "IncomeClient", - "IncomeRequest", - "IncomeServiceItem", + 'DeviceInfo', + 'History', + 'HistoryRecords', + 'IncomeClient', + 'IncomeRequest', + 'IncomeServiceItem', # Income DTOs - "IncomeType", - "InvoiceClient", + 'IncomeType', + 'InvoiceClient', # Invoice DTOs - "InvoiceServiceItem", - "Payment", - "PaymentRecords", - "PaymentType", - "PaymentTypeCollection", + 'InvoiceServiceItem', + 'Payment', + 'PaymentRecords', + 'PaymentType', + 'PaymentTypeCollection', # Payment Type DTOs - "PaymentTypeModel", + 'PaymentTypeModel', # Tax DTOs - "Tax", + 'Tax', # User DTOs - "UserType", + 'UserType', ] diff --git a/app/lib/nalogo/dto/device.py b/app/lib/nalogo/dto/device.py index da462d5c..7ae34f96 100644 --- a/app/lib/nalogo/dto/device.py +++ b/app/lib/nalogo/dto/device.py @@ -15,30 +15,28 @@ class DeviceInfo(BaseModel): """ # Constants from PHP class - SOURCE_TYPE_WEB: ClassVar[str] = "WEB" - APP_VERSION: ClassVar[str] = "1.0.0" + SOURCE_TYPE_WEB: ClassVar[str] = 'WEB' + APP_VERSION: ClassVar[str] = '1.0.0' USER_AGENT: ClassVar[str] = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36" + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36' ) source_type: str = Field( default=SOURCE_TYPE_WEB, - alias="sourceType", - description="Source type (usually WEB)", + alias='sourceType', + description='Source type (usually WEB)', ) - source_device_id: str = Field(..., alias="sourceDeviceId", description="Device ID") - app_version: str = Field( - default=APP_VERSION, alias="appVersion", description="Application version" - ) - user_agent: str = Field(default=USER_AGENT, description="User agent string") + source_device_id: str = Field(..., alias='sourceDeviceId', description='Device ID') + app_version: str = Field(default=APP_VERSION, alias='appVersion', description='Application version') + user_agent: str = Field(default=USER_AGENT, description='User agent string') def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Custom serialization to match PHP jsonSerialize format.""" _ = kwargs return { - "sourceType": self.source_type, - "sourceDeviceId": self.source_device_id, - "appVersion": self.app_version, - "metaDetails": {"userAgent": self.user_agent}, + 'sourceType': self.source_type, + 'sourceDeviceId': self.source_device_id, + 'appVersion': self.app_version, + 'metaDetails': {'userAgent': self.user_agent}, } diff --git a/app/lib/nalogo/dto/income.py b/app/lib/nalogo/dto/income.py index e0a29cfe..3b86092d 100644 --- a/app/lib/nalogo/dto/income.py +++ b/app/lib/nalogo/dto/income.py @@ -16,23 +16,23 @@ from pydantic import BaseModel, Field, field_serializer, field_validator class IncomeType(str, Enum): """Income type enumeration. Maps to PHP Enum\\IncomeType.""" - FROM_INDIVIDUAL = "FROM_INDIVIDUAL" - FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY" - FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY" + FROM_INDIVIDUAL = 'FROM_INDIVIDUAL' + FROM_LEGAL_ENTITY = 'FROM_LEGAL_ENTITY' + FROM_FOREIGN_AGENCY = 'FROM_FOREIGN_AGENCY' class PaymentType(str, Enum): """Payment type enumeration. Maps to PHP Enum\\PaymentType.""" - CASH = "CASH" - ACCOUNT = "ACCOUNT" + CASH = 'CASH' + ACCOUNT = 'ACCOUNT' class CancelCommentType(str, Enum): """Cancel comment type enumeration. Maps to PHP Enum\\CancelCommentType.""" - CANCEL = "Чек сформирован ошибочно" - REFUND = "Возврат средств" + CANCEL = 'Чек сформирован ошибочно' + REFUND = 'Возврат средств' # Московская таймзона UTC+3 @@ -49,7 +49,7 @@ class AtomDateTime(BaseModel): value: datetime = Field(default_factory=lambda: datetime.now(MOSCOW_TZ)) - @field_serializer("value") + @field_serializer('value') def serialize_datetime(self, dt: datetime) -> str: """Serialize datetime with timezone offset (NOT Z suffix).""" # Убеждаемся что есть московская таймзона @@ -62,12 +62,12 @@ class AtomDateTime(BaseModel): return dt.isoformat() @classmethod - def now(cls) -> "AtomDateTime": + def now(cls) -> 'AtomDateTime': """Create AtomDateTime with current Moscow time.""" return cls(value=datetime.now(MOSCOW_TZ)) @classmethod - def from_datetime(cls, dt: datetime) -> "AtomDateTime": + def from_datetime(cls, dt: datetime) -> 'AtomDateTime': """Create AtomDateTime from datetime object.""" return cls(value=dt) @@ -78,27 +78,27 @@ class IncomeServiceItem(BaseModel): Maps to PHP DTO\\IncomeServiceItem. """ - name: str = Field(..., description="Service name/description") - amount: Decimal = Field(..., description="Service amount", gt=0) - quantity: Decimal = Field(..., description="Service quantity", gt=0) + name: str = Field(..., description='Service name/description') + amount: Decimal = Field(..., description='Service amount', gt=0) + quantity: Decimal = Field(..., description='Service quantity', gt=0) - @field_validator("name") + @field_validator('name') @classmethod def validate_name(cls, v: str) -> str: """Validate name is not empty.""" if not v.strip(): - raise ValueError("Name cannot be empty") + raise ValueError('Name cannot be empty') return v.strip() - @field_validator("amount", "quantity") + @field_validator('amount', 'quantity') @classmethod def validate_positive_decimal(cls, v: Decimal) -> Decimal: """Validate decimal values are positive.""" if v <= 0: - raise ValueError("Amount and quantity must be greater than 0") + raise ValueError('Amount and quantity must be greater than 0') return v - @field_serializer("amount", "quantity") + @field_serializer('amount', 'quantity') def serialize_decimal(self, value: Decimal) -> str: """Serialize Decimal as string (like PHP BigDecimal).""" return str(value) @@ -111,9 +111,9 @@ class IncomeServiceItem(BaseModel): """Custom serialization to match PHP jsonSerialize format.""" _ = kwargs return { - "name": self.name, - "amount": str(self.amount), - "quantity": str(self.quantity), + 'name': self.name, + 'amount': str(self.amount), + 'quantity': str(self.quantity), } @@ -123,14 +123,12 @@ class IncomeClient(BaseModel): Maps to PHP DTO\\IncomeClient. """ - contact_phone: str | None = Field(default=None, description="Client contact phone") - display_name: str | None = Field(default=None, description="Client display name") - income_type: IncomeType = Field( - default=IncomeType.FROM_INDIVIDUAL, description="Income type" - ) - inn: str | None = Field(default=None, description="Client INN (tax ID)") + contact_phone: str | None = Field(default=None, description='Client contact phone') + display_name: str | None = Field(default=None, description='Client display name') + income_type: IncomeType = Field(default=IncomeType.FROM_INDIVIDUAL, description='Income type') + inn: str | None = Field(default=None, description='Client INN (tax ID)') - @field_validator("inn") + @field_validator('inn') @classmethod def validate_inn(cls, v: str | None, info: Any) -> str | None: """Validate INN format for legal entities.""" @@ -145,19 +143,17 @@ class IncomeClient(BaseModel): # Check if it's numeric if not v.isdigit(): - raise ValueError("INN must contain only numbers") + raise ValueError('INN must contain only numbers') # Check length (10 for legal entities, 12 for individuals) if len(v) not in [10, 12]: - raise ValueError("INN length must be 10 or 12 digits") + raise ValueError('INN length must be 10 or 12 digits') return v - @field_validator("display_name") + @field_validator('display_name') @classmethod - def validate_display_name_for_legal_entity( - cls, v: str | None, info: Any - ) -> str | None: + def validate_display_name_for_legal_entity(cls, v: str | None, info: Any) -> str | None: """Validate display name is provided for legal entities.""" _ = info # Note: This validation is applied in the API layer in PHP, @@ -172,10 +168,10 @@ class IncomeClient(BaseModel): """Custom serialization to match PHP jsonSerialize format.""" _ = kwargs return { - "contactPhone": self.contact_phone, - "displayName": self.display_name, - "incomeType": self.income_type.value, - "inn": self.inn, + 'contactPhone': self.contact_phone, + 'displayName': self.display_name, + 'incomeType': self.income_type.value, + 'inn': self.inn, } @@ -188,34 +184,30 @@ class IncomeRequest(BaseModel): operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now) request_time: AtomDateTime = Field(default_factory=AtomDateTime.now) services: list[IncomeServiceItem] = Field(..., min_length=1) - total_amount: str = Field(..., description="Total amount as string") + total_amount: str = Field(..., description='Total amount as string') client: IncomeClient = Field(default_factory=IncomeClient) payment_type: PaymentType = Field(default=PaymentType.CASH) ignore_max_total_income_restriction: bool = Field(default=False) - @field_validator("services") + @field_validator('services') @classmethod def validate_services(cls, v: list[IncomeServiceItem]) -> list[IncomeServiceItem]: """Validate services list is not empty.""" if not v: - raise ValueError("Services cannot be empty") + raise ValueError('Services cannot be empty') return v def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Custom serialization to match PHP request format.""" _ = kwargs return { - "operationTime": self.operation_time.serialize_datetime( - self.operation_time.value - ), - "requestTime": self.request_time.serialize_datetime( - self.request_time.value - ), - "services": [service.model_dump() for service in self.services], - "totalAmount": self.total_amount, - "client": self.client.model_dump(), - "paymentType": self.payment_type.value, - "ignoreMaxTotalIncomeRestriction": self.ignore_max_total_income_restriction, + 'operationTime': self.operation_time.serialize_datetime(self.operation_time.value), + 'requestTime': self.request_time.serialize_datetime(self.request_time.value), + 'services': [service.model_dump() for service in self.services], + 'totalAmount': self.total_amount, + 'client': self.client.model_dump(), + 'paymentType': self.payment_type.value, + 'ignoreMaxTotalIncomeRestriction': self.ignore_max_total_income_restriction, } @@ -227,29 +219,25 @@ class CancelRequest(BaseModel): operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now) request_time: AtomDateTime = Field(default_factory=AtomDateTime.now) - comment: CancelCommentType = Field(..., description="Cancellation reason") - receipt_uuid: str = Field(..., description="Receipt UUID to cancel") - partner_code: str | None = Field(default=None, description="Partner code") + comment: CancelCommentType = Field(..., description='Cancellation reason') + receipt_uuid: str = Field(..., description='Receipt UUID to cancel') + partner_code: str | None = Field(default=None, description='Partner code') - @field_validator("receipt_uuid") + @field_validator('receipt_uuid') @classmethod def validate_receipt_uuid(cls, v: str) -> str: """Validate receipt UUID is not empty.""" if not v.strip(): - raise ValueError("Receipt UUID cannot be empty") + raise ValueError('Receipt UUID cannot be empty') return v.strip() def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Custom serialization to match PHP request format.""" _ = kwargs return { - "operationTime": self.operation_time.serialize_datetime( - self.operation_time.value - ), - "requestTime": self.request_time.serialize_datetime( - self.request_time.value - ), - "comment": self.comment.value, - "receiptUuid": self.receipt_uuid, - "partnerCode": self.partner_code, + 'operationTime': self.operation_time.serialize_datetime(self.operation_time.value), + 'requestTime': self.request_time.serialize_datetime(self.request_time.value), + 'comment': self.comment.value, + 'receiptUuid': self.receipt_uuid, + 'partnerCode': self.partner_code, } diff --git a/app/lib/nalogo/dto/invoice.py b/app/lib/nalogo/dto/invoice.py index 16ce7989..1bc45c4c 100644 --- a/app/lib/nalogo/dto/invoice.py +++ b/app/lib/nalogo/dto/invoice.py @@ -20,11 +20,11 @@ class InvoiceServiceItem(BaseModel): Similar to IncomeServiceItem but for invoices. """ - name: str = Field(..., description="Service name/description") - amount: Decimal = Field(..., description="Service amount", gt=0) - quantity: Decimal = Field(..., description="Service quantity", gt=0) + name: str = Field(..., description='Service name/description') + amount: Decimal = Field(..., description='Service amount', gt=0) + quantity: Decimal = Field(..., description='Service quantity', gt=0) - @field_serializer("amount", "quantity") + @field_serializer('amount', 'quantity') def serialize_decimal(self, value: Decimal) -> str: """Serialize Decimal as string.""" return str(value) @@ -37,9 +37,9 @@ class InvoiceServiceItem(BaseModel): """Custom serialization to match API format.""" _ = kwargs return { - "name": self.name, - "amount": str(self.amount), - "quantity": str(self.quantity), + 'name': self.name, + 'amount': str(self.amount), + 'quantity': str(self.quantity), } @@ -51,19 +51,15 @@ class InvoiceClient(BaseModel): Similar to IncomeClient but for invoices. """ - contact_phone: str | None = Field( - None, alias="contactPhone", description="Client contact phone" - ) - display_name: str | None = Field( - None, alias="displayName", description="Client display name" - ) - inn: str | None = Field(None, description="Client INN") + contact_phone: str | None = Field(None, alias='contactPhone', description='Client contact phone') + display_name: str | None = Field(None, alias='displayName', description='Client display name') + inn: str | None = Field(None, description='Client INN') def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Custom serialization to match API format.""" _ = kwargs return { - "contactPhone": self.contact_phone, - "displayName": self.display_name, - "inn": self.inn, + 'contactPhone': self.contact_phone, + 'displayName': self.display_name, + 'inn': self.inn, } diff --git a/app/lib/nalogo/dto/payment_type.py b/app/lib/nalogo/dto/payment_type.py index a63cb2b2..29635e4f 100644 --- a/app/lib/nalogo/dto/payment_type.py +++ b/app/lib/nalogo/dto/payment_type.py @@ -14,22 +14,16 @@ class PaymentType(BaseModel): Maps to PHP Model\\PaymentType\\PaymentType. """ - id: int = Field(..., description="Payment type ID") - type: str = Field(..., description="Payment type") - bank_name: str = Field(..., alias="bankName", description="Bank name") - bank_bik: str = Field(..., alias="bankBik", description="Bank BIK") - corr_account: str = Field( - ..., alias="corrAccount", description="Correspondent account" - ) - favorite: bool = Field(..., description="Is favorite payment type") - phone: str | None = Field(None, description="Phone number") - bank_id: str | None = Field(None, alias="bankId", description="Bank ID") - current_account: str = Field( - ..., alias="currentAccount", description="Current account" - ) - available_for_pa: bool = Field( - ..., alias="availableForPa", description="Available for PA" - ) + id: int = Field(..., description='Payment type ID') + type: str = Field(..., description='Payment type') + bank_name: str = Field(..., alias='bankName', description='Bank name') + bank_bik: str = Field(..., alias='bankBik', description='Bank BIK') + corr_account: str = Field(..., alias='corrAccount', description='Correspondent account') + favorite: bool = Field(..., description='Is favorite payment type') + phone: str | None = Field(None, description='Phone number') + bank_id: str | None = Field(None, alias='bankId', description='Bank ID') + current_account: str = Field(..., alias='currentAccount', description='Current account') + available_for_pa: bool = Field(..., alias='availableForPa', description='Available for PA') def is_favorite(self) -> bool: """Check if this payment type is marked as favorite.""" @@ -39,16 +33,16 @@ class PaymentType(BaseModel): """Custom serialization to match API format.""" _ = kwargs return { - "id": self.id, - "type": self.type, - "bankName": self.bank_name, - "bankBik": self.bank_bik, - "corrAccount": self.corr_account, - "favorite": self.favorite, - "phone": self.phone, - "bankId": self.bank_id, - "currentAccount": self.current_account, - "availableForPa": self.available_for_pa, + 'id': self.id, + 'type': self.type, + 'bankName': self.bank_name, + 'bankBik': self.bank_bik, + 'corrAccount': self.corr_account, + 'favorite': self.favorite, + 'phone': self.phone, + 'bankId': self.bank_id, + 'currentAccount': self.current_account, + 'availableForPa': self.available_for_pa, } diff --git a/app/lib/nalogo/dto/tax.py b/app/lib/nalogo/dto/tax.py index 090c1c82..8c3f0701 100644 --- a/app/lib/nalogo/dto/tax.py +++ b/app/lib/nalogo/dto/tax.py @@ -16,7 +16,7 @@ class Tax(BaseModel): # Tax model fields would be defined based on API response structure # Since we don't have the exact PHP model structure, we'll use flexible Dict - data: dict[str, Any] = Field(default_factory=dict, description="Tax data") + data: dict[str, Any] = Field(default_factory=dict, description='Tax data') def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Return the raw data dictionary.""" @@ -31,7 +31,7 @@ class History(BaseModel): """ # History fields would be defined based on API response - data: dict[str, Any] = Field(default_factory=dict, description="History entry data") + data: dict[str, Any] = Field(default_factory=dict, description='History entry data') def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Return the raw data dictionary.""" @@ -45,7 +45,7 @@ class HistoryRecords(BaseModel): Maps to PHP Model\\Tax\\HistoryRecords. """ - records: list[History] = Field(default_factory=list, description="History records") + records: list[History] = Field(default_factory=list, description='History records') def __iter__(self) -> Any: """Make collection iterable.""" @@ -67,7 +67,7 @@ class Payment(BaseModel): """ # Payment fields would be defined based on API response - data: dict[str, Any] = Field(default_factory=dict, description="Payment data") + data: dict[str, Any] = Field(default_factory=dict, description='Payment data') def model_dump(self, **kwargs: Any) -> dict[str, Any]: """Return the raw data dictionary.""" @@ -81,7 +81,7 @@ class PaymentRecords(BaseModel): Maps to PHP Model\\Tax\\PaymentRecords. """ - records: list[Payment] = Field(default_factory=list, description="Payment records") + records: list[Payment] = Field(default_factory=list, description='Payment records') def __iter__(self) -> Any: """Make collection iterable.""" diff --git a/app/lib/nalogo/dto/user.py b/app/lib/nalogo/dto/user.py index 00856100..41c69b63 100644 --- a/app/lib/nalogo/dto/user.py +++ b/app/lib/nalogo/dto/user.py @@ -15,59 +15,53 @@ class UserType(BaseModel): Maps to PHP Model\\User\\UserType. """ - id: int = Field(..., description="User ID") - last_name: str | None = Field(None, alias="lastName", description="Last name") - display_name: str = Field(..., alias="displayName", description="Display name") - middle_name: str | None = Field(None, alias="middleName", description="Middle name") - email: str | None = Field(None, description="Email address") - phone: str = Field(..., description="Phone number") - inn: str = Field(..., description="INN (tax identification number)") - snils: str | None = Field(None, description="SNILS") - avatar_exists: bool = Field(..., alias="avatarExists", description="Avatar exists") + id: int = Field(..., description='User ID') + last_name: str | None = Field(None, alias='lastName', description='Last name') + display_name: str = Field(..., alias='displayName', description='Display name') + middle_name: str | None = Field(None, alias='middleName', description='Middle name') + email: str | None = Field(None, description='Email address') + phone: str = Field(..., description='Phone number') + inn: str = Field(..., description='INN (tax identification number)') + snils: str | None = Field(None, description='SNILS') + avatar_exists: bool = Field(..., alias='avatarExists', description='Avatar exists') initial_registration_date: datetime | None = Field( - None, alias="initialRegistrationDate", description="Initial registration date" - ) - registration_date: datetime | None = Field( - None, alias="registrationDate", description="Registration date" + None, alias='initialRegistrationDate', description='Initial registration date' ) + registration_date: datetime | None = Field(None, alias='registrationDate', description='Registration date') first_receipt_register_time: datetime | None = Field( None, - alias="firstReceiptRegisterTime", - description="First receipt registration time", + alias='firstReceiptRegisterTime', + description='First receipt registration time', ) first_receipt_cancel_time: datetime | None = Field( None, - alias="firstReceiptCancelTime", - description="First receipt cancellation time", - ) - hide_cancelled_receipt: bool = Field( - ..., alias="hideCancelledReceipt", description="Hide cancelled receipts" + alias='firstReceiptCancelTime', + description='First receipt cancellation time', ) + hide_cancelled_receipt: bool = Field(..., alias='hideCancelledReceipt', description='Hide cancelled receipts') register_available: bool | str | None = Field( - None, alias="registerAvailable", description="Register available (mixed type)" + None, alias='registerAvailable', description='Register available (mixed type)' ) - status: str | None = Field(None, description="User status") - restricted_mode: bool = Field( - ..., alias="restrictedMode", description="Restricted mode" - ) - pfr_url: str | None = Field(None, alias="pfrUrl", description="PFR URL") - login: str | None = Field(None, description="Login") + status: str | None = Field(None, description='User status') + restricted_mode: bool = Field(..., alias='restrictedMode', description='Restricted mode') + pfr_url: str | None = Field(None, alias='pfrUrl', description='PFR URL') + login: str | None = Field(None, description='Login') @field_validator( - "initial_registration_date", - "registration_date", - "first_receipt_register_time", - "first_receipt_cancel_time", - mode="before", + 'initial_registration_date', + 'registration_date', + 'first_receipt_register_time', + 'first_receipt_cancel_time', + mode='before', ) @classmethod def parse_datetime(cls, v: Any) -> datetime | None: """Parse datetime strings from API.""" - if v is None or v == "": + if v is None or v == '': return None if isinstance(v, str): try: - return datetime.fromisoformat(v.replace("Z", "+00:00")) + return datetime.fromisoformat(v.replace('Z', '+00:00')) except ValueError: return None return v # type: ignore[no-any-return] @@ -94,29 +88,23 @@ class UserType(BaseModel): return dt.isoformat() return { - "id": self.id, - "lastName": self.last_name, - "displayName": self.display_name, - "middleName": self.middle_name, - "email": self.email, - "phone": self.phone, - "inn": self.inn, - "snils": self.snils, - "avatarExists": self.avatar_exists, - "initialRegistrationDate": serialize_datetime( - self.initial_registration_date - ), - "registrationDate": serialize_datetime(self.registration_date), - "firstReceiptRegisterTime": serialize_datetime( - self.first_receipt_register_time - ), - "firstReceiptCancelTime": serialize_datetime( - self.first_receipt_cancel_time - ), - "hideCancelledReceipt": self.hide_cancelled_receipt, - "registerAvailable": self.register_available, - "status": self.status, - "restrictedMode": self.restricted_mode, - "pfrUrl": self.pfr_url, - "login": self.login, + 'id': self.id, + 'lastName': self.last_name, + 'displayName': self.display_name, + 'middleName': self.middle_name, + 'email': self.email, + 'phone': self.phone, + 'inn': self.inn, + 'snils': self.snils, + 'avatarExists': self.avatar_exists, + 'initialRegistrationDate': serialize_datetime(self.initial_registration_date), + 'registrationDate': serialize_datetime(self.registration_date), + 'firstReceiptRegisterTime': serialize_datetime(self.first_receipt_register_time), + 'firstReceiptCancelTime': serialize_datetime(self.first_receipt_cancel_time), + 'hideCancelledReceipt': self.hide_cancelled_receipt, + 'registerAvailable': self.register_available, + 'status': self.status, + 'restrictedMode': self.restricted_mode, + 'pfrUrl': self.pfr_url, + 'login': self.login, } diff --git a/app/lib/nalogo/exceptions.py b/app/lib/nalogo/exceptions.py index 498febe4..8b2546fa 100644 --- a/app/lib/nalogo/exceptions.py +++ b/app/lib/nalogo/exceptions.py @@ -30,7 +30,7 @@ class DomainException(Exception): # noqa: N818 для совместимост safe_headers = self._mask_sensitive_headers(dict(response.headers)) logger.error( - "API Error: %s | Status: %d | URL: %s | Headers: %s | Body: %s", + 'API Error: %s | Status: %d | URL: %s | Headers: %s | Body: %s', message, response.status_code, safe_url, @@ -42,9 +42,9 @@ class DomainException(Exception): # noqa: N818 для совместимост """Mask potential sensitive data in URL.""" # Replace tokens/keys with asterisks patterns = [ - (r"(token=)[^&]*", r"\1***"), - (r"(key=)[^&]*", r"\1***"), - (r"(secret=)[^&]*", r"\1***"), + (r'(token=)[^&]*', r'\1***'), + (r'(key=)[^&]*', r'\1***'), + (r'(secret=)[^&]*', r'\1***'), ] for pattern, replacement in patterns: @@ -54,13 +54,13 @@ class DomainException(Exception): # noqa: N818 для совместимост def _mask_sensitive_headers(self, headers: dict[str, str]) -> dict[str, str]: """Mask sensitive headers.""" safe_headers = headers.copy() - sensitive_keys = ["authorization", "x-api-key", "cookie", "set-cookie"] + sensitive_keys = ['authorization', 'x-api-key', 'cookie', 'set-cookie'] for key in sensitive_keys: if key.lower() in [h.lower() for h in safe_headers]: # Find the actual key (case-insensitive) actual_key = next(k for k in safe_headers if k.lower() == key.lower()) - safe_headers[actual_key] = "***" + safe_headers[actual_key] = '***' return safe_headers @@ -70,10 +70,10 @@ class DomainException(Exception): # noqa: N818 для совместимост body = response.text[:1000] # Limit body size for logging # Mask potential tokens in JSON responses patterns = [ - (r'("token":\s*")[^"]*(")', r"\1***\2"), - (r'("refreshToken":\s*")[^"]*(")', r"\1***\2"), - (r'("password":\s*")[^"]*(")', r"\1***\2"), - (r'("secret":\s*")[^"]*(")', r"\1***\2"), + (r'("token":\s*")[^"]*(")', r'\1***\2'), + (r'("refreshToken":\s*")[^"]*(")', r'\1***\2'), + (r'("password":\s*")[^"]*(")', r'\1***\2'), + (r'("secret":\s*")[^"]*(")', r'\1***\2'), ] for pattern, replacement in patterns: @@ -81,7 +81,7 @@ class DomainException(Exception): # noqa: N818 для совместимост return body except Exception: - return "[Failed to read response body]" + return '[Failed to read response body]' class ValidationException(DomainException): @@ -150,7 +150,7 @@ def raise_for_status(response: httpx.Response) -> None: if response.status_code == HTTPStatus.NOT_FOUND: raise NotFoundException(body, response) if response.status_code == HTTPStatus.NOT_ACCEPTABLE: - raise ClientException("Wrong Accept headers", response) + raise ClientException('Wrong Accept headers', response) if response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY: raise PhoneException(body, response) if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR: diff --git a/app/lib/nalogo/income.py b/app/lib/nalogo/income.py index 48edcfbb..38b74c85 100644 --- a/app/lib/nalogo/income.py +++ b/app/lib/nalogo/income.py @@ -94,25 +94,21 @@ class IncomeAPI: DomainException: For other API errors """ if not services: - raise ValueError("Services cannot be empty") + raise ValueError('Services cannot be empty') # Validate client for legal entity (mirrors PHP validation) if client and client.income_type == IncomeType.FROM_LEGAL_ENTITY: if not client.inn: - raise ValueError("Client INN cannot be empty for legal entity") + raise ValueError('Client INN cannot be empty for legal entity') if not client.display_name: - raise ValueError("Client DisplayName cannot be empty for legal entity") + raise ValueError('Client DisplayName cannot be empty for legal entity') # Calculate total amount (mirrors PHP BigDecimal logic) total_amount = sum(item.get_total_amount() for item in services) # Create request object request = IncomeRequest( - operation_time=( - AtomDateTime.from_datetime(operation_time) - if operation_time - else AtomDateTime.now() - ), + operation_time=(AtomDateTime.from_datetime(operation_time) if operation_time else AtomDateTime.now()), request_time=AtomDateTime.now(), services=services, total_amount=str(total_amount), @@ -122,7 +118,7 @@ class IncomeAPI: ) # Make API request - response = await self.http.post("/income", json_data=request.model_dump()) + response = await self.http.post('/income', json_data=request.model_dump()) return response.json() # type: ignore[no-any-return] async def cancel( @@ -154,7 +150,7 @@ class IncomeAPI: """ # Validate receipt UUID if not receipt_uuid.strip(): - raise ValueError("Receipt UUID cannot be empty") + raise ValueError('Receipt UUID cannot be empty') # Convert comment to enum if string if isinstance(comment, str): @@ -167,31 +163,21 @@ class IncomeAPI: if comment_enum is None: valid_comments = [e.value for e in CancelCommentType] - raise ValueError( - f"Comment is invalid. Must be one of: {valid_comments}" - ) + raise ValueError(f'Comment is invalid. Must be one of: {valid_comments}') comment = comment_enum # Create request object request = CancelRequest( - operation_time=( - AtomDateTime.from_datetime(operation_time) - if operation_time - else AtomDateTime.now() - ), - request_time=( - AtomDateTime.from_datetime(request_time) - if request_time - else AtomDateTime.now() - ), + operation_time=(AtomDateTime.from_datetime(operation_time) if operation_time else AtomDateTime.now()), + request_time=(AtomDateTime.from_datetime(request_time) if request_time else AtomDateTime.now()), comment=comment, receipt_uuid=receipt_uuid.strip(), partner_code=partner_code, ) # Make API request - response = await self.http.post("/cancel", json_data=request.model_dump()) + response = await self.http.post('/cancel', json_data=request.model_dump()) return response.json() # type: ignore[no-any-return] async def get_list( @@ -225,15 +211,15 @@ class IncomeAPI: # API использует GET с query параметрами params = { - "from": from_date.isoformat(), - "to": to_date.isoformat(), - "limit": str(limit), - "offset": str(offset), - "sortBy": "OPERATION_TIME", - "sortOrder": "DESC", + 'from': from_date.isoformat(), + 'to': to_date.isoformat(), + 'limit': str(limit), + 'offset': str(offset), + 'sortBy': 'OPERATION_TIME', + 'sortOrder': 'DESC', } # Формируем query string - query = "&".join(f"{k}={v}" for k, v in params.items()) - response = await self.http.get(f"/incomes?{query}") + query = '&'.join(f'{k}={v}' for k, v in params.items()) + response = await self.http.get(f'/incomes?{query}') return response.json() # type: ignore[no-any-return] diff --git a/app/lib/nalogo/payment_type.py b/app/lib/nalogo/payment_type.py index 714efff9..f3e877b4 100644 --- a/app/lib/nalogo/payment_type.py +++ b/app/lib/nalogo/payment_type.py @@ -34,7 +34,7 @@ class PaymentTypeAPI: Raises: DomainException: For API errors """ - response = await self.http.get("/payment-type/table") + response = await self.http.get('/payment-type/table') return response.json() # type: ignore[no-any-return] async def favorite(self) -> dict[str, Any] | None: @@ -54,7 +54,7 @@ class PaymentTypeAPI: # Find first payment type with favorite=True for payment_type in payment_types: - if payment_type.get("favorite", False): + if payment_type.get('favorite', False): return payment_type return None diff --git a/app/lib/nalogo/receipt.py b/app/lib/nalogo/receipt.py index f8664b91..501fc10a 100644 --- a/app/lib/nalogo/receipt.py +++ b/app/lib/nalogo/receipt.py @@ -41,11 +41,11 @@ class ReceiptAPI: ValueError: If receipt_uuid is empty """ if not receipt_uuid.strip(): - raise ValueError("Receipt UUID cannot be empty") + raise ValueError('Receipt UUID cannot be empty') # Compose URL like PHP: sprintf('/receipt/%s/%s/print', $this->profile->getInn(), $receiptUuid) - path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/print" - return f"{self.base_endpoint}{path}" + path = f'/receipt/{self.user_inn}/{receipt_uuid.strip()}/print' + return f'{self.base_endpoint}{path}' async def json(self, receipt_uuid: str) -> dict[str, Any]: """ @@ -64,10 +64,10 @@ class ReceiptAPI: DomainException: For API errors """ if not receipt_uuid.strip(): - raise ValueError("Receipt UUID cannot be empty") + raise ValueError('Receipt UUID cannot be empty') # Make GET request like PHP: sprintf('/receipt/%s/%s/json', $this->profile->getInn(), $receiptUuid) - path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/json" + path = f'/receipt/{self.user_inn}/{receipt_uuid.strip()}/json' response = await self.http.get(path) return response.json() # type: ignore[no-any-return] diff --git a/app/lib/nalogo/tax.py b/app/lib/nalogo/tax.py index 5c845a5d..dd5317d8 100644 --- a/app/lib/nalogo/tax.py +++ b/app/lib/nalogo/tax.py @@ -35,7 +35,7 @@ class TaxAPI: Raises: DomainException: For API errors """ - response = await self.http.get("/taxes") + response = await self.http.get('/taxes') return response.json() # type: ignore[no-any-return] async def history(self, oktmo: str | None = None) -> dict[str, Any]: @@ -53,13 +53,11 @@ class TaxAPI: Raises: DomainException: For API errors """ - request_data = {"oktmo": oktmo} - response = await self.http.post("/taxes/history", json_data=request_data) + request_data = {'oktmo': oktmo} + response = await self.http.post('/taxes/history', json_data=request_data) return response.json() # type: ignore[no-any-return] - async def payments( - self, oktmo: str | None = None, only_paid: bool = False - ) -> dict[str, Any]: + async def payments(self, oktmo: str | None = None, only_paid: bool = False) -> dict[str, Any]: """ Get tax payment records. @@ -76,8 +74,8 @@ class TaxAPI: DomainException: For API errors """ request_data = { - "oktmo": oktmo, - "onlyPaid": only_paid, + 'oktmo': oktmo, + 'onlyPaid': only_paid, } - response = await self.http.post("/taxes/payments", json_data=request_data) + response = await self.http.post('/taxes/payments', json_data=request_data) return response.json() # type: ignore[no-any-return] diff --git a/app/lib/nalogo/user.py b/app/lib/nalogo/user.py index 4cdf7cac..4f12952f 100644 --- a/app/lib/nalogo/user.py +++ b/app/lib/nalogo/user.py @@ -36,5 +36,5 @@ class UserAPI: Raises: DomainException: For API errors """ - response = await self.http.get("/user") + response = await self.http.get('/user') return response.json() # type: ignore[no-any-return] diff --git a/app/localization/loader.py b/app/localization/loader.py index f5941fa0..2e490ad4 100644 --- a/app/localization/loader.py +++ b/app/localization/loader.py @@ -1,29 +1,30 @@ from __future__ import annotations import json -import os import logging +import os import shutil import tempfile -from functools import lru_cache +from functools import cache from pathlib import Path -from typing import Any, Dict +from typing import Any from app.config import settings + _logger = logging.getLogger(__name__) -_FALLBACK_LANGUAGE = "ru" +_FALLBACK_LANGUAGE = 'ru' _BASE_DIR = Path(__file__).resolve().parent -_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales" +_DEFAULT_LOCALES_DIR = _BASE_DIR / 'locales' def _normalize_language_code(value: Any) -> str: if isinstance(value, str): return value.strip().lower() if value is None: - return "" + return '' return str(value).strip().lower() @@ -39,18 +40,15 @@ def _locale_file_exists(language: str) -> bool: if not code: return False - default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json" + default_candidate = _DEFAULT_LOCALES_DIR / f'{code}.json' if default_candidate.exists(): return True user_dir = _resolve_user_locales_dir() - for extension in (".json", ".yml", ".yaml"): - if (user_dir / f"{code}{extension}").exists(): - return True - return False + return any((user_dir / f'{code}{extension}').exists() for extension in ('.json', '.yml', '.yaml')) -def _select_fallback_language(available_map: Dict[str, str]) -> str: +def _select_fallback_language(available_map: dict[str, str]) -> str: candidates = [] if _FALLBACK_LANGUAGE: candidates.append(_FALLBACK_LANGUAGE) @@ -72,7 +70,7 @@ def _select_fallback_language(available_map: Dict[str, str]) -> str: if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE): return _FALLBACK_LANGUAGE - return _FALLBACK_LANGUAGE or "ru" + return _FALLBACK_LANGUAGE or 'ru' def _determine_default_language() -> str: @@ -81,12 +79,12 @@ def _determine_default_language() -> str: except AttributeError: raw_default = None - configured = raw_default.strip() if isinstance(raw_default, str) else "" + configured = raw_default.strip() if isinstance(raw_default, str) else '' try: available_languages = settings.get_available_languages() except Exception as error: # pragma: no cover - defensive logging - _logger.warning("Failed to load available languages from settings: %s", error) + _logger.warning('Failed to load available languages from settings: %s', error) available_languages = [] available_map = { @@ -135,16 +133,16 @@ DEFAULT_LANGUAGE = _determine_default_language() def _normalize_key(raw_key: Any) -> str: - key = str(raw_key).strip().replace(" ", "_") + key = str(raw_key).strip().replace(' ', '_') return key.upper() -def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]: - flattened: Dict[str, Any] = {} +def _flatten_locale_dict(data: dict[str, Any], parent_key: str = '') -> dict[str, Any]: + flattened: dict[str, Any] = {} for key, value in (data or {}).items(): composite_key = _normalize_key(key) if parent_key: - composite_key = f"{parent_key}_{composite_key}" + composite_key = f'{parent_key}_{composite_key}' if isinstance(value, dict): flattened.update(_flatten_locale_dict(value, composite_key)) @@ -153,8 +151,8 @@ def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str return flattened -def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]: - normalized: Dict[str, Any] = {} +def _normalize_locale_dict(data: dict[str, Any]) -> dict[str, Any]: + normalized: dict[str, Any] = {} for key, value in (data or {}).items(): if isinstance(value, dict): normalized.update(_flatten_locale_dict(value, _normalize_key(key))) @@ -165,32 +163,32 @@ def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]: def _directory_is_writable(directory: Path) -> bool: try: - current_user = f"{os.geteuid()}:{os.getegid()}" - user_hint = f" (running as UID:GID {current_user})" + current_user = f'{os.geteuid()}:{os.getegid()}' + user_hint = f' (running as UID:GID {current_user})' except Exception: # pragma: no cover - best effort only - user_hint = "" + user_hint = '' try: - with tempfile.NamedTemporaryFile(dir=directory, prefix=".locale_write_test_", delete=True): + with tempfile.NamedTemporaryFile(dir=directory, prefix='.locale_write_test_', delete=True): pass return True except PermissionError as error: _logger.warning( - "Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)", + 'Locale directory %s is not writable%s. Ensure the mounted directory allows writes for the container user or configure LOCALES_PATH to a writable path. (%s)', directory, user_hint, error, ) except OSError as error: _logger.warning( - "Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.", + 'Unable to prepare locale directory %s for writing%s: %s. Configure LOCALES_PATH to a writable path.', directory, user_hint, error, ) except Exception as error: # pragma: no cover - defensive logging _logger.warning( - "Unexpected error while checking locale directory %s%s: %s", + 'Unexpected error while checking locale directory %s%s: %s', directory, user_hint, error, @@ -203,24 +201,24 @@ def ensure_locale_templates() -> None: try: destination.mkdir(parents=True, exist_ok=True) except Exception as error: - _logger.warning("Unable to create locales directory %s: %s", destination, error) + _logger.warning('Unable to create locales directory %s: %s', destination, error) return if not _DEFAULT_LOCALES_DIR.exists(): - _logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR) + _logger.debug('Default locales directory %s is missing', _DEFAULT_LOCALES_DIR) return if not _directory_is_writable(destination): return - destination_has_files = any(destination.glob("*")) + destination_has_files = any(destination.glob('*')) def _copy_locale(source: Path, target: Path) -> None: try: shutil.copyfile(source, target) except Exception as error: _logger.warning( - "Failed to copy default locale %s to %s: %s", + 'Failed to copy default locale %s to %s: %s', source, target, error, @@ -233,75 +231,69 @@ def ensure_locale_templates() -> None: _copy_locale(template, destination / template.name) return - for locale_code in ("ru", "en"): - source_path = _DEFAULT_LOCALES_DIR / f"{locale_code}.json" - target_path = destination / f"{locale_code}.json" + for locale_code in ('ru', 'en'): + source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json' + target_path = destination / f'{locale_code}.json' if target_path.exists(): continue if not source_path.exists(): - _logger.debug( - "Default locale template %s is missing at %s", locale_code, source_path - ) + _logger.debug('Default locale template %s is missing at %s', locale_code, source_path) continue _copy_locale(source_path, target_path) -def _load_default_locale(language: str) -> Dict[str, Any]: - default_path = _DEFAULT_LOCALES_DIR / f"{language}.json" +def _load_default_locale(language: str) -> dict[str, Any]: + default_path = _DEFAULT_LOCALES_DIR / f'{language}.json' if not default_path.exists(): return {} return _normalize_locale_dict(_load_locale_file(default_path)) -def _load_user_locale(language: str) -> Dict[str, Any]: +def _load_user_locale(language: str) -> dict[str, Any]: user_dir = _resolve_user_locales_dir() - for extension in (".json", ".yml", ".yaml"): - candidate = user_dir / f"{language}{extension}" + for extension in ('.json', '.yml', '.yaml'): + candidate = user_dir / f'{language}{extension}' if candidate.exists(): return _normalize_locale_dict(_load_locale_file(candidate)) return {} -def _load_locale_file(path: Path) -> Dict[str, Any]: +def _load_locale_file(path: Path) -> dict[str, Any]: suffix = path.suffix.lower() try: - if suffix == ".json": - return json.loads(path.read_text(encoding="utf-8")) - if suffix in {".yml", ".yaml"}: + if suffix == '.json': + return json.loads(path.read_text(encoding='utf-8')) + if suffix in {'.yml', '.yaml'}: try: import yaml # type: ignore except ModuleNotFoundError as import_error: raise RuntimeError( - "PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files." + 'PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files.' ) from import_error - return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + return yaml.safe_load(path.read_text(encoding='utf-8')) or {} except Exception as error: - _logger.warning("Failed to parse locale file %s: %s", path, error) + _logger.warning('Failed to parse locale file %s: %s', path, error) return {} - _logger.warning("Unsupported locale format for %s", path) + _logger.warning('Unsupported locale format for %s', path) return {} -def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: +def _merge_dicts(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: result = dict(base) for key, value in overrides.items(): - if ( - key in result - and isinstance(result[key], dict) - and isinstance(value, dict) - ): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = _merge_dicts(result[key], value) else: result[key] = value return result -@lru_cache(maxsize=None) -def load_locale(language: str) -> Dict[str, Any]: +@cache +def load_locale(language: str) -> dict[str, Any]: language = language or DEFAULT_LANGUAGE defaults = _load_default_locale(language) overrides = _load_user_locale(language) @@ -309,7 +301,7 @@ def load_locale(language: str) -> Dict[str, Any]: if not merged and language != DEFAULT_LANGUAGE: _logger.warning( - "Locale %s not found. Falling back to default language %s.", + 'Locale %s not found. Falling back to default language %s.', language, DEFAULT_LANGUAGE, ) diff --git a/app/localization/texts.py b/app/localization/texts.py index 768dce3a..b8d6db0e 100644 --- a/app/localization/texts.py +++ b/app/localization/texts.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any, Dict +from typing import Any from app.config import settings from app.localization.loader import ( @@ -11,81 +11,82 @@ from app.localization.loader import ( load_locale, ) + _logger = logging.getLogger(__name__) -_cached_rules: Dict[str, str] = {} +_cached_rules: dict[str, str] = {} _LANGUAGE_ALIASES = { - "uk": "ua", + 'uk': 'ua', } _DYNAMIC_LANGUAGE_CONFIGS = { - "ru": { - "traffic_pattern": "📊 {size} ГБ - {price}", - "unlimited_pattern": "📊 Безлимит - {price}", - "support_info": ( - "\n🛟 Поддержка\n\n" - "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n" - "• 🎫 Создать тикет — опишите проблему или вопрос\n" - "• 📋 Мои тикеты — статус и переписка\n" - "• 💬 Связаться — написать напрямую (если нужно)\n\n" - "Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n" + 'ru': { + 'traffic_pattern': '📊 {size} ГБ - {price}', + 'unlimited_pattern': '📊 Безлимит - {price}', + 'support_info': ( + '\n🛟 Поддержка\n\n' + 'Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n' + '• 🎫 Создать тикет — опишите проблему или вопрос\n' + '• 📋 Мои тикеты — статус и переписка\n' + '• 💬 Связаться — написать напрямую (если нужно)\n\n' + 'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n' ), }, - "en": { - "traffic_pattern": "📊 {size} GB - {price}", - "unlimited_pattern": "📊 Unlimited - {price}", - "support_info": ( - "\n🛟 RemnaWave Support\n\n" - "This is the ticket center: create requests, view replies and history.\n\n" - "• 🎫 Create ticket — describe your issue or question\n" - "• 📋 My tickets — status and conversation\n" - "• 💬 Contact — message directly if needed\n\n" - "Prefer tickets — it helps us respond faster and keep context.\n" + 'en': { + 'traffic_pattern': '📊 {size} GB - {price}', + 'unlimited_pattern': '📊 Unlimited - {price}', + 'support_info': ( + '\n🛟 RemnaWave Support\n\n' + 'This is the ticket center: create requests, view replies and history.\n\n' + '• 🎫 Create ticket — describe your issue or question\n' + '• 📋 My tickets — status and conversation\n' + '• 💬 Contact — message directly if needed\n\n' + 'Prefer tickets — it helps us respond faster and keep context.\n' ), }, - "ua": { - "traffic_pattern": "📊 {size} ГБ - {price}", - "unlimited_pattern": "📊 Безліміт - {price}", - "support_info": ( - "\n🛠️ Технічна підтримка\n\n" - "З усіх питань звертайтеся до нашої підтримки:\n\n" - "👤 {support_username}\n\n" - "Ми допоможемо з:\n" - "• Налаштуванням підключення\n" - "• Вирішенням технічних проблем\n" - "• Питаннями щодо оплати\n" - "• Іншими питаннями\n\n" - "⏰ Час відповіді: зазвичай протягом 1-2 годин\n" + 'ua': { + 'traffic_pattern': '📊 {size} ГБ - {price}', + 'unlimited_pattern': '📊 Безліміт - {price}', + 'support_info': ( + '\n🛠️ Технічна підтримка\n\n' + 'З усіх питань звертайтеся до нашої підтримки:\n\n' + '👤 {support_username}\n\n' + 'Ми допоможемо з:\n' + '• Налаштуванням підключення\n' + '• Вирішенням технічних проблем\n' + '• Питаннями щодо оплати\n' + '• Іншими питаннями\n\n' + '⏰ Час відповіді: зазвичай протягом 1-2 годин\n' ), }, - "zh": { - "traffic_pattern": "📊{size}GB-{price}", - "unlimited_pattern": "📊无限-{price}", - "support_info": ( - "\n🛠️ 技术支持\n\n" - "如有任何问题,请联系我们的支持团队:\n\n" - "👤 {support_username}\n\n" - "我们将帮助您:\n" - "• 设置连接\n" - "• 解决技术问题\n" - "• 付款问题\n" - "• 其他问题\n\n" - "⏰ 响应时间:通常在 1-2 小时内\n" + 'zh': { + 'traffic_pattern': '📊{size}GB-{price}', + 'unlimited_pattern': '📊无限-{price}', + 'support_info': ( + '\n🛠️ 技术支持\n\n' + '如有任何问题,请联系我们的支持团队:\n\n' + '👤 {support_username}\n\n' + '我们将帮助您:\n' + '• 设置连接\n' + '• 解决技术问题\n' + '• 付款问题\n' + '• 其他问题\n\n' + '⏰ 响应时间:通常在 1-2 小时内\n' ), }, } _TRAFFIC_TIERS = ( - ("TRAFFIC_5GB", "5", "PRICE_TRAFFIC_5GB"), - ("TRAFFIC_10GB", "10", "PRICE_TRAFFIC_10GB"), - ("TRAFFIC_25GB", "25", "PRICE_TRAFFIC_25GB"), - ("TRAFFIC_50GB", "50", "PRICE_TRAFFIC_50GB"), - ("TRAFFIC_100GB", "100", "PRICE_TRAFFIC_100GB"), - ("TRAFFIC_250GB", "250", "PRICE_TRAFFIC_250GB"), + ('TRAFFIC_5GB', '5', 'PRICE_TRAFFIC_5GB'), + ('TRAFFIC_10GB', '10', 'PRICE_TRAFFIC_10GB'), + ('TRAFFIC_25GB', '25', 'PRICE_TRAFFIC_25GB'), + ('TRAFFIC_50GB', '50', 'PRICE_TRAFFIC_50GB'), + ('TRAFFIC_100GB', '100', 'PRICE_TRAFFIC_100GB'), + ('TRAFFIC_250GB', '250', 'PRICE_TRAFFIC_250GB'), ) @@ -98,8 +99,8 @@ def _get_cached_rules_value(language: str) -> str: return default -def _build_dynamic_values(language: str) -> Dict[str, Any]: - language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower() +def _build_dynamic_values(language: str) -> dict[str, Any]: + language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower() language_code = _LANGUAGE_ALIASES.get(language_code, language_code) config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code) @@ -107,8 +108,8 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: if not config: return {} - values: Dict[str, Any] = {} - traffic_pattern = config["traffic_pattern"] + values: dict[str, Any] = {} + traffic_pattern = config['traffic_pattern'] for key, size, price_attr in _TRAFFIC_TIERS: price_value = getattr(settings, price_attr) values[key] = traffic_pattern.format( @@ -116,15 +117,13 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]: price=settings.format_price(price_value), ) - values["TRAFFIC_UNLIMITED"] = config["unlimited_pattern"].format( + values['TRAFFIC_UNLIMITED'] = config['unlimited_pattern'].format( price=settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED) ) - support_template = config.get("support_info") + support_template = config.get('support_info') if support_template: - values["SUPPORT_INFO"] = support_template.format( - support_username=settings.SUPPORT_USERNAME - ) + values['SUPPORT_INFO'] = support_template.format(support_username=settings.SUPPORT_USERNAME) return values @@ -140,14 +139,12 @@ class Texts: else: fallback_data = self._values - self._fallback_values = { - key: value for key, value in fallback_data.items() if key not in self._values - } + self._fallback_values = {key: value for key, value in fallback_data.items() if key not in self._values} self._values.update(_build_dynamic_values(self.language)) def __getattr__(self, item: str) -> Any: - if item == "language": + if item == 'language': return super().__getattribute__(item) try: return self._get_value(item) @@ -172,7 +169,7 @@ class Texts: raise def _get_value(self, item: str) -> Any: - if item == "RULES_TEXT": + if item == 'RULES_TEXT': return _get_cached_rules_value(self.language) if item in self._values: @@ -195,10 +192,10 @@ class Texts: @staticmethod def format_traffic(gb: float) -> str: if gb == 0: - return "∞ (безлимит)" + return '∞ (безлимит)' if gb >= 1024: - return f"{gb / 1024:.1f} ТБ" - return f"{gb:.0f} ГБ" + return f'{gb / 1024:.1f} ТБ' + return f'{gb:.0f} ГБ' def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts: @@ -207,8 +204,8 @@ def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts: async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str: try: - from app.database.database import AsyncSessionLocal from app.database.crud.rules import get_current_rules_content + from app.database.database import AsyncSessionLocal async with AsyncSessionLocal() as db: rules = await get_current_rules_content(db, language) @@ -217,7 +214,7 @@ async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str: return rules except Exception as error: # pragma: no cover - defensive logging - _logger.warning("Failed to load rules from DB for %s: %s", language, error) + _logger.warning('Failed to load rules from DB for %s: %s', language, error) default = _get_default_rules(language) _cached_rules[language] = default @@ -225,21 +222,21 @@ async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str: def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str: - default_key = "RULES_TEXT_DEFAULT" + default_key = 'RULES_TEXT_DEFAULT' locale = load_locale(language) if default_key in locale: return locale[default_key] fallback = load_locale(DEFAULT_LANGUAGE) - return fallback.get(default_key, "") + return fallback.get(default_key, '') def _get_default_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str: - default_key = "PRIVACY_POLICY_TEXT_DEFAULT" + default_key = 'PRIVACY_POLICY_TEXT_DEFAULT' locale = load_locale(language) if default_key in locale: return locale[default_key] fallback = load_locale(DEFAULT_LANGUAGE) - return fallback.get(default_key, "") + return fallback.get(default_key, '') def get_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str: @@ -267,8 +264,7 @@ async def get_rules(language: str = DEFAULT_LANGUAGE) -> str: async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None: - if language in _cached_rules: - del _cached_rules[language] + _cached_rules.pop(language, None) await get_rules_from_db(language) diff --git a/app/middlewares/auth.py b/app/middlewares/auth.py index 56e3ccdf..ae995ad9 100644 --- a/app/middlewares/auth.py +++ b/app/middlewares/auth.py @@ -1,49 +1,42 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from datetime import datetime -from typing import Callable, Dict, Any, Awaitable +from typing import Any + from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser, InaccessibleMessage from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser from app.config import settings +from app.database.crud.user import get_user_by_telegram_id from app.database.database import AsyncSessionLocal -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__) -async def _refresh_remnawave_description( - remnawave_uuid: str, - description: str, - telegram_id: int -) -> None: +async def _refresh_remnawave_description(remnawave_uuid: str, description: str, telegram_id: int) -> None: try: remnawave_service = RemnaWaveService() 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" - ) + logger.info(f'✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave') except Exception as remnawave_error: - logger.error( - f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}" - ) + logger.error(f'❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}') class AuthMiddleware(BaseMiddleware): - async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: - # Callback с недоступным сообщением (>48ч) — пропускаем к хендлерам, # они сами отправят новое сообщение через edit_or_answer_photo # if isinstance(event, CallbackQuery) and isinstance(event.message, InaccessibleMessage): @@ -55,10 +48,10 @@ class AuthMiddleware(BaseMiddleware): if not user: return await handler(event, data) - + if user.is_bot: return await handler(event, data) - + async with AsyncSessionLocal() as db: try: db_user = await get_user_by_telegram_id(db, user.id) @@ -72,140 +65,139 @@ class AuthMiddleware(BaseMiddleware): is_reg_process = is_registration_process(event, current_state) - is_channel_check = (isinstance(event, CallbackQuery) - and event.data == "sub_channel_check") + is_channel_check = isinstance(event, CallbackQuery) and event.data == 'sub_channel_check' - is_start_command = (isinstance(event, Message) - and event.text - and event.text.startswith('/start')) + is_start_command = isinstance(event, Message) and event.text and event.text.startswith('/start') if is_reg_process or is_channel_check or is_start_command: if is_start_command: - logger.info(f"🚀 Пропускаем команду /start от пользователя {user.id}") + logger.info(f'🚀 Пропускаем команду /start от пользователя {user.id}') elif is_channel_check: - logger.info(f"🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала") + logger.info( + f'🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала' + ) else: - logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации") + logger.info(f'🔍 Пропускаем пользователя {user.id} в процессе регистрации') data['db'] = db data['db_user'] = None data['is_admin'] = False result = await handler(event, data) await db.commit() return result - else: - if isinstance(event, Message): - await event.answer( - "▶️ Для начала работы необходимо выполнить команду /start" - ) - elif isinstance(event, CallbackQuery): - await event.answer( - "▶️ Необходимо начать с команды /start", - show_alert=True - ) - logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}") - return - else: - from app.database.models import UserStatus + if isinstance(event, Message): + await event.answer('▶️ Для начала работы необходимо выполнить команду /start') + elif isinstance(event, CallbackQuery): + await event.answer('▶️ Необходимо начать с команды /start', show_alert=True) + logger.info(f'🚫 Заблокирован незарегистрированный пользователь {user.id}') + return None + from app.database.models import UserStatus - if db_user.status == UserStatus.BLOCKED.value: - if isinstance(event, Message): - await event.answer("🚫 Ваш аккаунт заблокирован администратором.") - elif isinstance(event, CallbackQuery): - await event.answer("🚫 Ваш аккаунт заблокирован администратором.", show_alert=True) - logger.info(f"🚫 Заблокированный пользователь {user.id} попытался использовать бота") - return + if db_user.status == UserStatus.BLOCKED.value: + if isinstance(event, Message): + await event.answer('🚫 Ваш аккаунт заблокирован администратором.') + elif isinstance(event, CallbackQuery): + await event.answer('🚫 Ваш аккаунт заблокирован администратором.', show_alert=True) + logger.info(f'🚫 Заблокированный пользователь {user.id} попытался использовать бота') + return None - if db_user.status == UserStatus.DELETED.value: - state: FSMContext = data.get('state') - current_state = None + if db_user.status == UserStatus.DELETED.value: + state: FSMContext = data.get('state') + current_state = None - if state: - current_state = await state.get_state() + if state: + current_state = await state.get_state() - registration_states = [ - RegistrationStates.waiting_for_language.state, - RegistrationStates.waiting_for_rules_accept.state, - RegistrationStates.waiting_for_privacy_policy_accept.state, - RegistrationStates.waiting_for_referral_code.state - ] + registration_states = [ + RegistrationStates.waiting_for_language.state, + RegistrationStates.waiting_for_rules_accept.state, + RegistrationStates.waiting_for_privacy_policy_accept.state, + RegistrationStates.waiting_for_referral_code.state, + ] - is_start_or_registration = ( - (isinstance(event, Message) and event.text and event.text.startswith('/start')) - or (current_state in registration_states) - or ( - isinstance(event, CallbackQuery) - and event.data - and ( - event.data in ['rules_accept', 'rules_decline', 'privacy_policy_accept', 'privacy_policy_decline', 'referral_skip'] - or event.data.startswith('language_select:') - ) + is_start_or_registration = ( + (isinstance(event, Message) and event.text and event.text.startswith('/start')) + or (current_state in registration_states) + or ( + isinstance(event, CallbackQuery) + and event.data + and ( + event.data + in [ + 'rules_accept', + 'rules_decline', + 'privacy_policy_accept', + 'privacy_policy_decline', + 'referral_skip', + ] + or event.data.startswith('language_select:') ) ) + ) - if is_start_or_registration: - logger.info(f"🔄 Удаленный пользователь {user.id} начинает повторную регистрацию") - data['db'] = db - data['db_user'] = None - data['is_admin'] = False - result = await handler(event, data) - await db.commit() - return result - else: - if isinstance(event, Message): - await event.answer( - "❌ Ваш аккаунт был удален.\n" - "🔄 Для повторной регистрации выполните команду /start" - ) - elif isinstance(event, CallbackQuery): - await event.answer( - "❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start", - show_alert=True - ) - logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start") - return + if is_start_or_registration: + logger.info(f'🔄 Удаленный пользователь {user.id} начинает повторную регистрацию') + data['db'] = db + data['db_user'] = None + data['is_admin'] = False + result = await handler(event, data) + await db.commit() + return result + if isinstance(event, Message): + await event.answer( + '❌ Ваш аккаунт был удален.\n🔄 Для повторной регистрации выполните команду /start' + ) + elif isinstance(event, CallbackQuery): + await event.answer( + '❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start', show_alert=True + ) + logger.info(f'❌ Удаленный пользователь {user.id} попытался использовать бота без /start') + return None + profile_updated = False - profile_updated = False + if db_user.username != user.username: + old_username = db_user.username + db_user.username = user.username + logger.info( + f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'" + ) + profile_updated = True - if db_user.username != user.username: - old_username = db_user.username - db_user.username = user.username - logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'") - profile_updated = True + 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 = safe_first + logger.info( + f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'" + ) + profile_updated = True - 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 = safe_first - logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'") - profile_updated = True + if db_user.last_name != safe_last: + old_last_name = db_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 - if db_user.last_name != safe_last: - old_last_name = db_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 + db_user.last_activity = datetime.utcnow() - db_user.last_activity = datetime.utcnow() + if profile_updated: + db_user.updated_at = datetime.utcnow() + logger.info(f'💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware') - if profile_updated: - db_user.updated_at = datetime.utcnow() - logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware") - - if db_user.remnawave_uuid: - description = settings.format_remnawave_user_description( - full_name=db_user.full_name, - username=db_user.username, - telegram_id=db_user.telegram_id - ) - asyncio.create_task( - _refresh_remnawave_description( - remnawave_uuid=db_user.remnawave_uuid, - description=description, - telegram_id=db_user.telegram_id - ) + if db_user.remnawave_uuid: + description = settings.format_remnawave_user_description( + full_name=db_user.full_name, username=db_user.username, telegram_id=db_user.telegram_id + ) + asyncio.create_task( + _refresh_remnawave_description( + remnawave_uuid=db_user.remnawave_uuid, + description=description, + telegram_id=db_user.telegram_id, ) + ) data['db'] = db data['db_user'] = db_user @@ -216,9 +208,9 @@ class AuthMiddleware(BaseMiddleware): return result except Exception as e: - logger.error(f"Ошибка в AuthMiddleware: {e}") - logger.error(f"Event type: {type(event)}") + logger.error(f'Ошибка в AuthMiddleware: {e}') + logger.error(f'Event type: {type(event)}') if hasattr(event, 'data'): - logger.error(f"Callback data: {event.data}") + logger.error(f'Callback data: {event.data}') await db.rollback() raise diff --git a/app/middlewares/button_stats.py b/app/middlewares/button_stats.py index 66881c29..5f5f975d 100644 --- a/app/middlewares/button_stats.py +++ b/app/middlewares/button_stats.py @@ -2,7 +2,8 @@ import asyncio import logging -from typing import Callable, Dict, Any, Awaitable, Set +from collections.abc import Awaitable, Callable +from typing import Any from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, TelegramObject @@ -10,76 +11,77 @@ from aiogram.types import CallbackQuery, TelegramObject from app.config import settings from app.database.database import AsyncSessionLocal + logger = logging.getLogger(__name__) # Известные builtin callback_data из меню -BUILTIN_CALLBACKS: Set[str] = { +BUILTIN_CALLBACKS: set[str] = { # Основные кнопки меню - "subscription_connect", - "subscription_happ_download", - "menu_subscription", - "buy_traffic", - "menu_balance", - "menu_trial", - "menu_buy", - "simple_subscription_purchase", - "return_to_saved_cart", - "menu_promocode", - "menu_referrals", - "contests_menu", - "menu_support", - "menu_info", - "menu_language", - "admin_panel", - "moderator_panel", + 'subscription_connect', + 'subscription_happ_download', + 'menu_subscription', + 'buy_traffic', + 'menu_balance', + 'menu_trial', + 'menu_buy', + 'simple_subscription_purchase', + 'return_to_saved_cart', + 'menu_promocode', + 'menu_referrals', + 'contests_menu', + 'menu_support', + 'menu_info', + 'menu_language', + 'admin_panel', + 'moderator_panel', # Навигация - "back_to_menu", - "menu_faq", - "menu_info_promo_groups", - "menu_privacy_policy", - "menu_public_offer", - "menu_rules", - "menu_server_status", + 'back_to_menu', + 'menu_faq', + 'menu_info_promo_groups', + 'menu_privacy_policy', + 'menu_public_offer', + 'menu_rules', + 'menu_server_status', # Баланс - "balance_history", - "balance_topup", + 'balance_history', + 'balance_topup', # Подписка - "subscription_extend", - "subscription_autopay", - "subscription_settings", - "open_subscription_link", - "subscription_add_countries", - "subscription_reset_traffic", - "subscription_switch_traffic", - "subscription_change_devices", - "subscription_manage_devices", - "subscription_upgrade", + 'subscription_extend', + 'subscription_autopay', + 'subscription_settings', + 'open_subscription_link', + 'subscription_add_countries', + 'subscription_reset_traffic', + 'subscription_switch_traffic', + 'subscription_change_devices', + 'subscription_manage_devices', + 'subscription_upgrade', # Устройства - "device_guide_ios", - "device_guide_android", - "device_guide_windows", - "device_guide_mac", - "device_guide_tv", - "device_guide_appletv", + 'device_guide_ios', + 'device_guide_android', + 'device_guide_windows', + 'device_guide_mac', + 'device_guide_tv', + 'device_guide_appletv', # Happ - "happ_download_ios", - "happ_download_android", - "happ_download_macos", - "happ_download_windows", + 'happ_download_ios', + 'happ_download_android', + 'happ_download_macos', + 'happ_download_windows', # Рефералы - "referral_create_invite", - "referral_show_qr", - "referral_list", - "referral_analytics", + 'referral_create_invite', + 'referral_show_qr', + 'referral_list', + 'referral_analytics', # Поддержка - "create_ticket", - "my_tickets", + 'create_ticket', + 'my_tickets', # Триал - "trial_activate", + 'trial_activate', # Покупка - "clear_saved_cart", - "subscription_confirm", - "subscription_cancel", + 'clear_saved_cart', + 'subscription_confirm', + 'subscription_cancel', } @@ -88,9 +90,9 @@ class ButtonStatsMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: """Перехватывает CallbackQuery и логирует клики по кнопкам.""" @@ -127,16 +129,16 @@ class ButtonStatsMiddleware(BaseMiddleware): user_id=user_id, callback_data=callback_data, button_type=button_type, - button_text=button_text + button_text=button_text, ) ) except Exception as e: # Не прерываем обработку при ошибке логирования - logger.error(f"Ошибка логирования клика по кнопке: {e}", exc_info=True) - + logger.error(f'Ошибка логирования клика по кнопке: {e}', exc_info=True) + # Продолжаем обработку return await handler(event, data) - + def _determine_button_type(self, callback_data: str) -> str: """Определяет тип кнопки по callback_data. @@ -146,30 +148,30 @@ class ButtonStatsMiddleware(BaseMiddleware): """ # Проверяем по известному списку builtin кнопок if callback_data in BUILTIN_CALLBACKS: - return "builtin" + return 'builtin' # Дополнительная проверка по префиксам для динамических callback_data builtin_prefixes = ( - "menu_", - "admin_", - "subscription_", - "balance_", - "referral_", - "device_guide_", - "happ_download_", + 'menu_', + 'admin_', + 'subscription_', + 'balance_', + 'referral_', + 'device_guide_', + 'happ_download_', ) if callback_data.startswith(builtin_prefixes): - return "builtin" + return 'builtin' # Всё остальное - кастомные callback кнопки - return "callback" - + return 'callback' + def _extract_button_text(self, reply_markup, callback_data: str) -> str: """Извлекает текст кнопки из клавиатуры.""" try: if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'): return None - + for row in reply_markup.inline_keyboard: for button in row: if hasattr(button, 'callback_data') and button.callback_data == callback_data: @@ -178,14 +180,14 @@ class ButtonStatsMiddleware(BaseMiddleware): except Exception: pass return None - + async def _log_button_click_async( self, button_id: str, user_id: int = None, callback_data: str = None, button_type: str = None, - button_text: str = None + button_text: str = None, ): """Асинхронно логирует клик по кнопке.""" try: @@ -199,10 +201,9 @@ class ButtonStatsMiddleware(BaseMiddleware): user_id=user_id, callback_data=callback_data, button_type=button_type, - button_text=button_text + button_text=button_text, ) except Exception as e: - logger.debug(f"Ошибка записи клика в БД {button_id}: {e}") + logger.debug(f'Ошибка записи клика в БД {button_id}: {e}') except Exception as e: - logger.debug(f"Ошибка создания сессии БД для логирования клика: {e}") - + logger.debug(f'Ошибка создания сессии БД для логирования клика: {e}') diff --git a/app/middlewares/channel_checker.py b/app/middlewares/channel_checker.py index 23459e05..a3ff6f10 100644 --- a/app/middlewares/channel_checker.py +++ b/app/middlewares/channel_checker.py @@ -1,31 +1,33 @@ import logging -from typing import Callable, Dict, Any, Awaitable, Optional +from collections.abc import Awaitable, Callable from datetime import datetime -from aiogram import BaseMiddleware, Bot, types -from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest -from aiogram.fsm.context import FSMContext -from aiogram.types import TelegramObject, Update, Message, CallbackQuery -from aiogram.enums import ChatMemberStatus -from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any + import redis.asyncio as aioredis +from aiogram import BaseMiddleware, Bot, types +from aiogram.enums import ChatMemberStatus +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message, TelegramObject, Update from app.config import settings -from app.database.database import AsyncSessionLocal from app.database.crud.campaign import get_campaign_by_start_parameter from app.database.crud.subscription import deactivate_subscription, reactivate_subscription from app.database.crud.user import get_user_by_telegram_id -from app.database.models import SubscriptionStatus, User +from app.database.database import AsyncSessionLocal +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 from app.services.admin_notification_service import AdminNotificationService +from app.services.subscription_service import SubscriptionService +from app.utils.check_reg_process import is_registration_process + logger = logging.getLogger(__name__) # Ключ для хранения pending_start_payload в Redis (резервный механизм) -REDIS_PAYLOAD_KEY_PREFIX = "pending_start_payload:" +REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:' REDIS_PAYLOAD_TTL = 3600 # 1 час @@ -33,7 +35,7 @@ async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool: """Сохраняет pending_start_payload в Redis напрямую (резервный механизм).""" try: redis_client = aioredis.from_url(settings.REDIS_URL) - key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}" + key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}' await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL) await redis_client.aclose() logger.info( @@ -44,25 +46,25 @@ async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool: return True except Exception as e: logger.error( - "❌ [Redis fallback] Ошибка сохранения payload для %s: %s", + '❌ [Redis fallback] Ошибка сохранения payload для %s: %s', telegram_id, e, ) return False -async def get_pending_payload_from_redis(telegram_id: int) -> Optional[str]: +async def get_pending_payload_from_redis(telegram_id: int) -> str | None: """Получает pending_start_payload из Redis (резервный механизм).""" try: redis_client = aioredis.from_url(settings.REDIS_URL) - key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}" + key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}' payload = await redis_client.get(key) await redis_client.aclose() if payload: - return payload.decode("utf-8") if isinstance(payload, bytes) else payload + return payload.decode('utf-8') if isinstance(payload, bytes) else payload return None except Exception as e: - logger.debug("❌ [Redis fallback] Ошибка получения payload для %s: %s", telegram_id, e) + logger.debug('❌ [Redis fallback] Ошибка получения payload для %s: %s', telegram_id, e) return None @@ -70,7 +72,7 @@ async def delete_pending_payload_from_redis(telegram_id: int) -> None: """Удаляет pending_start_payload из Redis.""" try: redis_client = aioredis.from_url(settings.REDIS_URL) - key = f"{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}" + key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}' await redis_client.delete(key) await redis_client.aclose() except Exception: @@ -84,23 +86,15 @@ class ChannelCheckerMiddleware(BaseMiddleware): """ def __init__(self): - self.BAD_MEMBER_STATUS = ( - ChatMemberStatus.LEFT, - ChatMemberStatus.KICKED, - ChatMemberStatus.RESTRICTED - ) - self.GOOD_MEMBER_STATUS = ( - ChatMemberStatus.MEMBER, - ChatMemberStatus.ADMINISTRATOR, - ChatMemberStatus.CREATOR - ) - logger.info("🔧 ChannelCheckerMiddleware инициализирован") + self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED) + self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR) + logger.info('🔧 ChannelCheckerMiddleware инициализирован') async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: telegram_id = None if isinstance(event, (Message, CallbackQuery)): @@ -112,13 +106,13 @@ class ChannelCheckerMiddleware(BaseMiddleware): telegram_id = event.callback_query.from_user.id if telegram_id is None: - logger.debug("❌ telegram_id не найден, пропускаем") + logger.debug('❌ telegram_id не найден, пропускаем') return await handler(event, data) # Админам разрешаем пропускать проверку подписки if settings.is_admin(telegram_id): logger.debug( - "✅ Пользователь %s является администратором — пропускаем проверку подписки", + '✅ Пользователь %s является администратором — пропускаем проверку подписки', telegram_id, ) return await handler(event, data) @@ -132,29 +126,27 @@ class ChannelCheckerMiddleware(BaseMiddleware): is_reg_process = is_registration_process(event, current_state) if is_reg_process: - logger.debug("✅ Событие разрешено (процесс регистрации), пропускаем проверку") + logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку') return await handler(event, data) - bot: Bot = data["bot"] + bot: Bot = data['bot'] channel_id = settings.CHANNEL_SUB_ID if not channel_id: - logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку") + logger.warning('⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку') return await handler(event, data) is_required = settings.CHANNEL_IS_REQUIRED_SUB if not is_required: - logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку") + logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку') return await handler(event, data) channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id) if not channel_link: - logger.warning( - "⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта" - ) + logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта') try: member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id) @@ -164,84 +156,81 @@ class ChannelCheckerMiddleware(BaseMiddleware): if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): await self._reactivate_subscription_on_subscribe(telegram_id, bot) return await handler(event, data) - elif member.status in self.BAD_MEMBER_STATUS: - logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})") + if member.status in self.BAD_MEMBER_STATUS: + logger.info(f'❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})') if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL): await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link) await self._capture_start_payload(state, event, bot) - if isinstance(event, CallbackQuery) and event.data == "sub_channel_check": - await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True) - return + if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check': + await event.answer( + '❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.', show_alert=True + ) + return None return await self._deny_message(event, bot, channel_link, channel_id) - else: - logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}") - await self._capture_start_payload(state, event, bot) - return await self._deny_message(event, bot, channel_link, channel_id) + logger.warning(f'⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}') + await self._capture_start_payload(state, event, bot) + return await self._deny_message(event, bot, channel_link, channel_id) except TelegramForbiddenError as e: - logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}") + logger.error(f'❌ Бот заблокирован в канале {channel_id}: {e}') await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) except TelegramBadRequest as e: - if "chat not found" in str(e).lower(): - logger.error(f"❌ Канал {channel_id} не найден: {e}") - elif "user not found" in str(e).lower(): - logger.error(f"❌ Пользователь {telegram_id} не найден: {e}") + if 'chat not found' in str(e).lower(): + logger.error(f'❌ Канал {channel_id} не найден: {e}') + elif 'user not found' in str(e).lower(): + logger.error(f'❌ Пользователь {telegram_id} не найден: {e}') else: - logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}") + logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}') await self._capture_start_payload(state, event, bot) return await self._deny_message(event, bot, channel_link, channel_id) except Exception as e: - logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}") + logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}') return await handler(event, data) @staticmethod - def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]: - link = (channel_link or "").strip() + def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None: + link = (channel_link or '').strip() - if link.startswith("@"): # raw username - return f"https://t.me/{link.lstrip('@')}" + if link.startswith('@'): # raw username + return f'https://t.me/{link.lstrip("@")}' - if link and not link.lower().startswith(("http://", "https://", "tg://")): - return f"https://{link}" + if link and not link.lower().startswith(('http://', 'https://', 'tg://')): + return f'https://{link}' if link: return link - if channel_id and str(channel_id).startswith("@"): - return f"https://t.me/{str(channel_id).lstrip('@')}" + if channel_id and str(channel_id).startswith('@'): + return f'https://t.me/{str(channel_id).lstrip("@")}' return None async def _capture_start_payload( self, - state: Optional[FSMContext], + state: FSMContext | None, event: TelegramObject, - bot: Optional[Bot] = None, + bot: Bot | None = None, ) -> None: telegram_id = None - if isinstance(event, Message): - telegram_id = event.from_user.id if event.from_user else None - elif isinstance(event, CallbackQuery): + if isinstance(event, (Message, CallbackQuery)): telegram_id = event.from_user.id if event.from_user else None - message: Optional[Message] = None + message: Message | None = None if isinstance(event, Message): message = event - elif isinstance(event, CallbackQuery): - message = event.message - elif isinstance(event, Update): + elif isinstance(event, (CallbackQuery, Update)): message = event.message if not message or not message.text: return text = message.text.strip() - if not text.startswith("/start"): + if not text.startswith('/start'): return parts = text.split(maxsplit=1) @@ -253,8 +242,8 @@ class ChannelCheckerMiddleware(BaseMiddleware): # Сохраняем в FSM state if state: state_data = await state.get_data() or {} - if state_data.get("pending_start_payload") != payload: - state_data["pending_start_payload"] = payload + if state_data.get('pending_start_payload') != payload: + state_data['pending_start_payload'] = payload await state.set_data(state_data) logger.info( "💾 Сохранен start payload '%s' для пользователя %s (FSM)", @@ -263,7 +252,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): ) else: logger.warning( - "⚠️ _capture_start_payload: state=None для пользователя %s", + '⚠️ _capture_start_payload: state=None для пользователя %s', telegram_id, ) @@ -290,13 +279,13 @@ class ChannelCheckerMiddleware(BaseMiddleware): state_data = await state.get_data() or {} except Exception as error: logger.error( - "❌ Не удалось получить данные состояния для уведомления по кампании %s: %s", + '❌ Не удалось получить данные состояния для уведомления по кампании %s: %s', payload, error, ) return - if state_data.get("campaign_notification_sent"): + if state_data.get('campaign_notification_sent'): return async with AsyncSessionLocal() as db: @@ -323,14 +312,14 @@ class ChannelCheckerMiddleware(BaseMiddleware): await db.commit() except Exception as error: logger.error( - "❌ Ошибка отправки уведомления о переходе по кампании %s: %s", + '❌ Ошибка отправки уведомления о переходе по кампании %s: %s', payload, error, ) await db.rollback() async def _deactivate_subscription_on_unsubscribe( - self, telegram_id: int, bot: Bot, channel_link: Optional[str] + self, telegram_id: int, bot: Bot, channel_link: str | None ) -> None: """Деактивация подписки при отписке от канала.""" if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: @@ -353,9 +342,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): return await deactivate_subscription(db, subscription) - sub_type = "Триальная" if subscription.is_trial else "Платная" + sub_type = 'Триальная' if subscription.is_trial else 'Платная' logger.info( - "🚫 %s подписка пользователя %s отключена после отписки от канала", + '🚫 %s подписка пользователя %s отключена после отписки от канала', sub_type, telegram_id, ) @@ -366,7 +355,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): await service.disable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - "❌ Не удалось отключить пользователя RemnaWave %s: %s", + '❌ Не удалось отключить пользователя RemnaWave %s: %s', user.remnawave_uuid, api_error, ) @@ -375,22 +364,22 @@ class ChannelCheckerMiddleware(BaseMiddleware): try: texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) notification_text = texts.t( - "SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE", - "🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n" - "Подпишитесь на канал снова, чтобы восстановить доступ к VPN." + 'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE', + '🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n' + 'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.', ) channel_kb = get_channel_sub_keyboard(channel_link, language=user.language) await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb) except Exception as notify_error: logger.error( - "❌ Не удалось отправить уведомление о деактивации пользователю %s: %s", + '❌ Не удалось отправить уведомление о деактивации пользователю %s: %s', telegram_id, notify_error, ) await db.commit() except Exception as db_error: logger.error( - "❌ Ошибка деактивации подписки пользователя %s после отписки: %s", + '❌ Ошибка деактивации подписки пользователя %s после отписки: %s', telegram_id, db_error, ) @@ -419,9 +408,9 @@ class ChannelCheckerMiddleware(BaseMiddleware): # Реактивируем в БД await reactivate_subscription(db, subscription) - sub_type = "Триальная" if subscription.is_trial else "Платная" + sub_type = 'Триальная' if subscription.is_trial else 'Платная' logger.info( - "✅ %s подписка пользователя %s реактивирована после подписки на канал", + '✅ %s подписка пользователя %s реактивирована после подписки на канал', sub_type, telegram_id, ) @@ -433,7 +422,7 @@ class ChannelCheckerMiddleware(BaseMiddleware): await service.enable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - "❌ Не удалось включить пользователя RemnaWave %s: %s", + '❌ Не удалось включить пользователя RemnaWave %s: %s', user.remnawave_uuid, api_error, ) @@ -442,21 +431,20 @@ class ChannelCheckerMiddleware(BaseMiddleware): try: texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE) notification_text = texts.t( - "SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE", - "✅ Ваша подписка восстановлена!\n\n" - "Спасибо, что подписались на канал. VPN снова работает." + 'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE', + '✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.', ) await bot.send_message(telegram_id, notification_text) except Exception as notify_error: logger.warning( - "Не удалось отправить уведомление о реактивации пользователю %s: %s", + 'Не удалось отправить уведомление о реактивации пользователю %s: %s', telegram_id, notify_error, ) await db.commit() except Exception as db_error: logger.error( - "❌ Ошибка реактивации подписки пользователя %s: %s", + '❌ Ошибка реактивации подписки пользователя %s: %s', telegram_id, db_error, ) @@ -466,14 +454,14 @@ class ChannelCheckerMiddleware(BaseMiddleware): async def _deny_message( event: TelegramObject, bot: Bot, - channel_link: Optional[str], - channel_id: Optional[str], + channel_link: str | None, + channel_id: str | None, ): - logger.debug("🚫 Отправляем сообщение о необходимости подписки") + logger.debug('🚫 Отправляем сообщение о необходимости подписки') user = None if isinstance(event, (Message, CallbackQuery)): - user = getattr(event, "from_user", None) + user = getattr(event, 'from_user', None) elif isinstance(event, Update): if event.message and event.message.from_user: user = event.message.from_user @@ -487,31 +475,31 @@ class ChannelCheckerMiddleware(BaseMiddleware): texts = get_texts(language) channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language) text = texts.t( - "CHANNEL_REQUIRED_TEXT", - "🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!", + 'CHANNEL_REQUIRED_TEXT', + '🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!', ) if not channel_link and channel_id: channel_hint = None - if str(channel_id).startswith("@"): # username-based channel id - channel_hint = f"@{str(channel_id).lstrip('@')}" + if str(channel_id).startswith('@'): # username-based channel id + channel_hint = f'@{str(channel_id).lstrip("@")}' if channel_hint: - text = f"{text}\n\n{channel_hint}" + text = f'{text}\n\n{channel_hint}' try: if isinstance(event, Message): return await event.answer(text, reply_markup=channel_sub_kb) - elif isinstance(event, CallbackQuery): + if isinstance(event, CallbackQuery): try: return await event.message.edit_text(text, reply_markup=channel_sub_kb) except TelegramBadRequest as e: - if "message is not modified" in str(e).lower(): - logger.debug("ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование") + if 'message is not modified' in str(e).lower(): + logger.debug('ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование') return await event.answer(text, show_alert=True) raise elif isinstance(event, Update) and event.message: return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb) except Exception as e: - logger.error(f"❌ Ошибка при отправке сообщения о подписке: {e}") + logger.error(f'❌ Ошибка при отправке сообщения о подписке: {e}') diff --git a/app/middlewares/display_name_restriction.py b/app/middlewares/display_name_restriction.py index 3826f244..03e4bcf8 100644 --- a/app/middlewares/display_name_restriction.py +++ b/app/middlewares/display_name_restriction.py @@ -1,6 +1,7 @@ import logging import re -from typing import Any, Awaitable, Callable, Dict +from collections.abc import Awaitable, Callable +from typing import Any from aiogram import BaseMiddleware from aiogram.types import ( @@ -14,62 +15,66 @@ from aiogram.types import ( from app.config import settings from app.localization.texts import get_texts + logger = logging.getLogger(__name__) -ZERO_WIDTH_PATTERN = re.compile(r"[\u200B-\u200D\uFEFF]") +ZERO_WIDTH_PATTERN = re.compile(r'[\u200B-\u200D\uFEFF]') LINK_PATTERNS = [ re.compile(pattern, re.IGNORECASE) for pattern in ( - r"t\.me/\+", - r"joinchat", - r"https?://", - r"www\.", - r"tg://", - r"telegram\.me", - r"t\.me", + r't\.me/\+', + r'joinchat', + r'https?://', + r'www\.', + r'tg://', + r'telegram\.me', + r't\.me', ) ] DOMAIN_OBFUSCATION_PATTERN = re.compile( - r"(?+=]+") + class DisplayNameRestrictionMiddleware(BaseMiddleware): """Blocks users whose display name imitates links or official accounts.""" async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any], + data: dict[str, Any], ) -> Any: user: TgUser | None = None @@ -80,7 +85,7 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): return await handler(event, data) display_name = self._build_display_name(user) - username = user.username or "" + username = user.username or '' display_suspicious = self._is_suspicious(display_name) username_suspicious = self._is_suspicious(username) @@ -90,9 +95,9 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): language = self._resolve_language(user, data) texts = get_texts(language) warning = texts.get( - "SUSPICIOUS_DISPLAY_NAME_BLOCKED", - "🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. " - "Пожалуйста, измените имя и попробуйте снова.", + 'SUSPICIOUS_DISPLAY_NAME_BLOCKED', + '🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. ' + 'Пожалуйста, измените имя и попробуйте снова.', ) logger.warning( @@ -113,25 +118,25 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): @staticmethod def _build_display_name(user: TgUser) -> str: - parts = [user.first_name or "", user.last_name or ""] - return " ".join(part for part in parts if part).strip() + parts = [user.first_name or '', user.last_name or ''] + return ' '.join(part for part in parts if part).strip() @staticmethod - def _resolve_language(user: TgUser, data: Dict[str, Any]) -> str: - db_user = data.get("db_user") - if db_user and getattr(db_user, "language", None): + def _resolve_language(user: TgUser, data: dict[str, Any]) -> str: + db_user = data.get('db_user') + if db_user and getattr(db_user, 'language', None): return db_user.language - language_code = getattr(user, "language_code", None) + language_code = getattr(user, 'language_code', None) return language_code or settings.DEFAULT_LANGUAGE def _is_suspicious(self, value: str) -> bool: if not value: return False - cleaned = ZERO_WIDTH_PATTERN.sub("", value) + cleaned = ZERO_WIDTH_PATTERN.sub('', value) lower_value = cleaned.lower() - if "@" in cleaned or "@" in cleaned: + if '@' in cleaned or '@' in cleaned: return True if any(pattern.search(lower_value) for pattern in LINK_PATTERNS): @@ -154,11 +159,11 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): return True normalized = self._normalize_text(lower_value) - collapsed = COLLAPSE_PATTERN.sub("", normalized) + collapsed = COLLAPSE_PATTERN.sub('', normalized) # Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах) # Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг - if re.search(r"(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)", collapsed, re.IGNORECASE): + if re.search(r'(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)', collapsed, re.IGNORECASE): return True banned_keywords = settings.get_display_name_banned_keywords() @@ -167,12 +172,8 @@ class DisplayNameRestrictionMiddleware(BaseMiddleware): if not banned_keywords: return False - return any( - keyword in normalized or keyword in collapsed - for keyword in banned_keywords - ) + return any(keyword in normalized or keyword in collapsed for keyword in banned_keywords) @staticmethod def _normalize_text(value: str) -> str: return value.translate(CHAR_TRANSLATION) - diff --git a/app/middlewares/global_error.py b/app/middlewares/global_error.py index 127994b7..48fb7069 100644 --- a/app/middlewares/global_error.py +++ b/app/middlewares/global_error.py @@ -1,160 +1,149 @@ import logging -from typing import Callable, Dict, Any, Awaitable +from collections.abc import Awaitable, Callable +from typing import Any + from aiogram import BaseMiddleware -from aiogram.types import TelegramObject, CallbackQuery, Message from aiogram.exceptions import TelegramBadRequest +from aiogram.types import CallbackQuery, TelegramObject + logger = logging.getLogger(__name__) class GlobalErrorMiddleware(BaseMiddleware): - async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: try: return await handler(event, data) except TelegramBadRequest as e: return await self._handle_telegram_error(event, e) except Exception as e: - logger.error(f"Неожиданная ошибка в GlobalErrorMiddleware: {e}", exc_info=True) + logger.error(f'Неожиданная ошибка в GlobalErrorMiddleware: {e}', exc_info=True) raise - + async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest): error_message = str(error).lower() if self._is_old_query_error(error_message): return await self._handle_old_query(event, error) - elif self._is_message_not_modified_error(error_message): + if self._is_message_not_modified_error(error_message): return await self._handle_message_not_modified(event, error) - elif self._is_topic_required_error(error_message): + if self._is_topic_required_error(error_message): # Канал с топиками — просто игнорируем - logger.debug(f"📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}") + logger.debug(f'📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}') return None - elif self._is_bad_request_error(error_message): + if self._is_bad_request_error(error_message): return await self._handle_bad_request(event, error) - else: - logger.error(f"Неизвестная Telegram API ошибка: {error}") - raise error - + logger.error(f'Неизвестная Telegram API ошибка: {error}') + raise error + def _is_old_query_error(self, error_message: str) -> bool: - return any(phrase in error_message for phrase in [ - "query is too old", - "query id is invalid", - "response timeout expired" - ]) - + return any( + phrase in error_message + for phrase in ['query is too old', 'query id is invalid', 'response timeout expired'] + ) + def _is_message_not_modified_error(self, error_message: str) -> bool: - return "message is not modified" in error_message - + return 'message is not modified' in error_message + def _is_bad_request_error(self, error_message: str) -> bool: - return any(phrase in error_message for phrase in [ - "message not found", - "chat not found", - "bot was blocked by the user", - "user is deactivated" - ]) + return any( + phrase in error_message + for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated'] + ) def _is_topic_required_error(self, error_message: str) -> bool: - return any(phrase in error_message for phrase in [ - "topic must be specified", - "topic_closed", - "topic_deleted", - "forum_closed" - ]) - + return any( + phrase in error_message + for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed'] + ) + async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest): if isinstance(event, CallbackQuery): user_info = self._get_user_info(event) logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}") else: - logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}") - - return None - + logger.warning(f'🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}') + async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest): - logger.debug(f"📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}") - + logger.debug(f'📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}') + if isinstance(event, CallbackQuery): try: await event.answer() logger.debug("✅ Успешно ответили на callback после 'message not modified'") except TelegramBadRequest as answer_error: if not self._is_old_query_error(str(answer_error).lower()): - logger.error(f"❌ Ошибка при ответе на callback: {answer_error}") - - return None - + logger.error(f'❌ Ошибка при ответе на callback: {answer_error}') + async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest): error_message = str(error).lower() - - if "bot was blocked" in error_message: - user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown" - logger.info(f"🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}") - return None - elif "user is deactivated" in error_message: - user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown" - logger.info(f"👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}") - return None - elif "chat not found" in error_message or "message not found" in error_message: - logger.warning(f"🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}") - return None - else: - logger.error(f"❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}") - raise error - + + if 'bot was blocked' in error_message: + user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' + logger.info(f'🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}') + return + if 'user is deactivated' in error_message: + user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown' + logger.info(f'👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}') + return + if 'chat not found' in error_message or 'message not found' in error_message: + logger.warning(f'🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}') + return + logger.error(f'❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}') + raise error + def _get_user_info(self, event: TelegramObject) -> str: if hasattr(event, 'from_user') and event.from_user: if event.from_user.username: - return f"@{event.from_user.username}" - else: - return f"ID:{event.from_user.id}" - return "Unknown" + return f'@{event.from_user.username}' + return f'ID:{event.from_user.id}' + return 'Unknown' class ErrorStatisticsMiddleware(BaseMiddleware): - def __init__(self): self.error_counts = { 'old_queries': 0, 'message_not_modified': 0, 'bot_blocked': 0, 'user_deactivated': 0, - 'other_errors': 0 + 'other_errors': 0, } - + async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: try: return await handler(event, data) except TelegramBadRequest as e: self._count_error(e) - raise - + raise + def _count_error(self, error: TelegramBadRequest): error_message = str(error).lower() - - if "query is too old" in error_message: + + if 'query is too old' in error_message: self.error_counts['old_queries'] += 1 - elif "message is not modified" in error_message: + elif 'message is not modified' in error_message: self.error_counts['message_not_modified'] += 1 - elif "bot was blocked" in error_message: + elif 'bot was blocked' in error_message: self.error_counts['bot_blocked'] += 1 - elif "user is deactivated" in error_message: + elif 'user is deactivated' in error_message: self.error_counts['user_deactivated'] += 1 else: self.error_counts['other_errors'] += 1 - + def get_statistics(self) -> dict: return self.error_counts.copy() - + def reset_statistics(self): for key in self.error_counts: self.error_counts[key] = 0 diff --git a/app/middlewares/logging.py b/app/middlewares/logging.py index d7cd75b5..d118106d 100644 --- a/app/middlewares/logging.py +++ b/app/middlewares/logging.py @@ -1,42 +1,43 @@ import logging import time -from typing import Callable, Dict, Any, Awaitable +from collections.abc import Awaitable, Callable +from typing import Any + from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery, TelegramObject +from aiogram.types import CallbackQuery, Message, TelegramObject + logger = logging.getLogger(__name__) class LoggingMiddleware(BaseMiddleware): - async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: - start_time = time.time() - + try: if isinstance(event, Message): - user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" - text = event.text or event.caption or "[медиа]" - logger.info(f"📩 Сообщение от {user_info}: {text}") - + user_info = f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' + text = event.text or event.caption or '[медиа]' + logger.info(f'📩 Сообщение от {user_info}: {text}') + elif isinstance(event, CallbackQuery): - user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" - logger.info(f"🔘 Callback от {user_info}: {event.data}") - + user_info = f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' + logger.info(f'🔘 Callback от {user_info}: {event.data}') + result = await handler(event, data) - + execution_time = time.time() - start_time - if execution_time > 1.0: - logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s") - + if execution_time > 1.0: + logger.warning(f'⏱️ Медленная операция: {execution_time:.2f}s') + return result - + except Exception as e: execution_time = time.time() - start_time - logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}") - raise \ No newline at end of file + logger.error(f'❌ Ошибка при обработке события за {execution_time:.2f}s: {e}') + raise diff --git a/app/middlewares/maintenance.py b/app/middlewares/maintenance.py index 5ecdad7c..de8a05fd 100644 --- a/app/middlewares/maintenance.py +++ b/app/middlewares/maintenance.py @@ -1,45 +1,46 @@ import logging -from typing import Callable, Dict, Any, Awaitable +from collections.abc import Awaitable, Callable +from typing import Any + from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser +from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser from app.config import settings from app.services.maintenance_service import maintenance_service + logger = logging.getLogger(__name__) class MaintenanceMiddleware(BaseMiddleware): - async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: - user: TgUser = None if isinstance(event, (Message, CallbackQuery)): user = event.from_user - + if not user or user.is_bot: return await handler(event, data) - + if not maintenance_service.is_maintenance_active(): return await handler(event, data) - + if settings.is_admin(user.id): return await handler(event, data) - + maintenance_message = maintenance_service.get_maintenance_message() - + try: if isinstance(event, Message): - await event.answer(maintenance_message, parse_mode="HTML") + await event.answer(maintenance_message, parse_mode='HTML') elif isinstance(event, CallbackQuery): await event.answer(maintenance_message, show_alert=True) except Exception as e: - logger.error(f"Ошибка отправки сообщения о техработах пользователю {user.id}: {e}") - - logger.info(f"🔧 Пользователь {user.id} заблокирован во время техработ") - return + logger.error(f'Ошибка отправки сообщения о техработах пользователю {user.id}: {e}') + + logger.info(f'🔧 Пользователь {user.id} заблокирован во время техработ') + return None diff --git a/app/middlewares/subscription_checker.py b/app/middlewares/subscription_checker.py index 2f65f30b..17fd5700 100644 --- a/app/middlewares/subscription_checker.py +++ b/app/middlewares/subscription_checker.py @@ -1,11 +1,14 @@ import logging -from typing import Callable, Dict, Any, Awaitable +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta +from typing import Any + from aiogram import BaseMiddleware from aiogram.types import TelegramObject from app.database.models import SubscriptionStatus + logger = logging.getLogger(__name__) # Буфер времени перед деактивацией (защита от race condition при продлении) @@ -24,9 +27,9 @@ class SubscriptionStatusMiddleware(BaseMiddleware): async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: # Используем db и user из AuthMiddleware - не создаём новую сессию! db = data.get('db') @@ -37,10 +40,11 @@ class SubscriptionStatusMiddleware(BaseMiddleware): current_time = datetime.utcnow() subscription = user.subscription - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date and - subscription.end_date <= current_time): - + if ( + subscription.status == SubscriptionStatus.ACTIVE.value + and subscription.end_date + and subscription.end_date <= current_time + ): # Вычисляем насколько давно истекла подписка time_since_expiry = current_time - subscription.end_date @@ -51,18 +55,18 @@ class SubscriptionStatusMiddleware(BaseMiddleware): await db.commit() logger.warning( - f"⏰ Middleware DEACTIVATION: подписка {subscription.id} " - f"(user_id={user.id}) деактивирована. " - f"end_date={subscription.end_date}, просрочена на {time_since_expiry}" + f'⏰ Middleware DEACTIVATION: подписка {subscription.id} ' + f'(user_id={user.id}) деактивирована. ' + f'end_date={subscription.end_date}, просрочена на {time_since_expiry}' ) else: # Подписка только что истекла - не деактивируем сразу (может быть продление) logger.debug( - f"⏰ Middleware: подписка пользователя {user.id} истекла недавно " - f"({time_since_expiry}), ждём буфер {EXPIRATION_BUFFER_MINUTES} мин" + f'⏰ Middleware: подписка пользователя {user.id} истекла недавно ' + f'({time_since_expiry}), ждём буфер {EXPIRATION_BUFFER_MINUTES} мин' ) except Exception as e: - logger.error(f"Ошибка проверки статуса подписки: {e}") + logger.error(f'Ошибка проверки статуса подписки: {e}') return await handler(event, data) diff --git a/app/middlewares/throttling.py b/app/middlewares/throttling.py index b31b3cd7..ae99da34 100644 --- a/app/middlewares/throttling.py +++ b/app/middlewares/throttling.py @@ -1,44 +1,44 @@ -import asyncio import logging import time -from typing import Callable, Dict, Any, Awaitable +from collections.abc import Awaitable, Callable +from typing import Any + from aiogram import BaseMiddleware -from aiogram.types import Message, CallbackQuery, TelegramObject from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message, TelegramObject + logger = logging.getLogger(__name__) class ThrottlingMiddleware(BaseMiddleware): - def __init__(self, rate_limit: float = 0.5): self.rate_limit = rate_limit - self.user_buckets: Dict[int, float] = {} - + self.user_buckets: dict[int, float] = {} + async def __call__( self, - handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, - data: Dict[str, Any] + data: dict[str, Any], ) -> Any: - user_id = None if isinstance(event, (Message, CallbackQuery)): user_id = event.from_user.id - + if not user_id: return await handler(event, data) - + now = time.time() last_call = self.user_buckets.get(user_id, 0) - + if now - last_call < self.rate_limit: - logger.warning(f"🚫 Throttling для пользователя {user_id}") + logger.warning(f'🚫 Throttling для пользователя {user_id}') # Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок if isinstance(event, Message): try: - fsm: FSMContext = data.get("state") # может отсутствовать + fsm: FSMContext = data.get('state') # может отсутствовать current = await fsm.get_state() if fsm else None except Exception: current = None @@ -46,27 +46,24 @@ class ThrottlingMiddleware(BaseMiddleware): if current: # Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply lowered = str(current) - is_ticket_state = ( - (":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and - ("TicketStates" in lowered or "AdminTicketStates" in lowered) + is_ticket_state = (':waiting_for_message' in lowered or ':waiting_for_reply' in lowered) and ( + 'TicketStates' in lowered or 'AdminTicketStates' in lowered ) if is_ticket_state: - return + return None # В остальных случаях — явный блок - await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!") - return + await event.answer('⏳ Пожалуйста, не отправляйте сообщения так часто!') + return None # Для callback допустим краткое уведомление - elif isinstance(event, CallbackQuery): - await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True) - return - + if isinstance(event, CallbackQuery): + await event.answer('⏳ Слишком быстро! Подождите немного.', show_alert=True) + return None + self.user_buckets[user_id] = now - + cleanup_threshold = now - 60 self.user_buckets = { - uid: timestamp - for uid, timestamp in self.user_buckets.items() - if timestamp > cleanup_threshold + uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold } - - return await handler(event, data) \ No newline at end of file + + return await handler(event, data) diff --git a/app/services/__init__.py b/app/services/__init__.py index 6d83dabc..a040bc98 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,3 +1,3 @@ """ Сервисы бизнес-логики -""" \ No newline at end of file +""" diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index a3d91fcf..5767fb48 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -1,82 +1,81 @@ import logging -from typing import Optional, Dict, Any, List from datetime import datetime +from typing import Any + from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import MissingGreenlet +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.promo_group import get_promo_group_by_id from app.database.crud.subscription_event import create_subscription_event -from app.database.crud.user import get_user_by_id from app.database.crud.transaction import get_transaction_by_id +from app.database.crud.user import get_user_by_id from app.database.models import ( AdvertisingCampaign, PromoCodeType, PromoGroup, Subscription, Transaction, - TransactionType, User, ) from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) class AdminNotificationService: - def __init__(self, bot: Bot): self.bot = bot self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None) self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None) self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None) self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) - - async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str: + + async def _get_referrer_info(self, db: AsyncSession, referred_by_id: int | None) -> str: if not referred_by_id: - return "Нет" + return 'Нет' try: referrer = await get_user_by_id(db, referred_by_id) if not referrer: - return f"ID {referred_by_id} (не найден)" + return f'ID {referred_by_id} (не найден)' if referrer.username: - return f"@{referrer.username} (ID: {referred_by_id})" - elif referrer.telegram_id: - return f"ID {referrer.telegram_id}" - elif referrer.email: - return f"📧 {referrer.email}" - else: - return f"User#{referred_by_id}" + return f'@{referrer.username} (ID: {referred_by_id})' + if referrer.telegram_id: + return f'ID {referrer.telegram_id}' + if referrer.email: + return f'📧 {referrer.email}' + return f'User#{referred_by_id}' except Exception as e: - logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}") - return f"ID {referred_by_id}" + 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): + async def _get_user_promo_group(self, db: AsyncSession, user: User) -> PromoGroup | None: + 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"]) + 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): + 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", + 'Ошибка загрузки промогруппы %s пользователя %s: %s', user.promo_group_id, user.telegram_id, e, @@ -84,43 +83,43 @@ class AdminNotificationService: return None def _get_user_display(self, user: User) -> str: - first_name = getattr(user, "first_name", "") or "" + first_name = getattr(user, 'first_name', '') or '' if first_name: return first_name - username = getattr(user, "username", "") or "" + username = getattr(user, 'username', '') or '' if username: return username - telegram_id = getattr(user, "telegram_id", None) + telegram_id = getattr(user, 'telegram_id', None) if telegram_id is None: - email = getattr(user, "email", None) + email = getattr(user, 'email', None) if email: return email - return f"User#{getattr(user, 'id', 'Unknown')}" - return f"ID{telegram_id}" + return f'User#{getattr(user, "id", "Unknown")}' + return f'ID{telegram_id}' def _get_user_identifier_display(self, user: User) -> str: """Get user identifier for display in notifications (telegram_id or email).""" - telegram_id = getattr(user, "telegram_id", None) + telegram_id = getattr(user, 'telegram_id', None) if telegram_id: - return f"{telegram_id}" + return f'{telegram_id}' - email = getattr(user, "email", None) + email = getattr(user, 'email', None) if email: - return f"📧 {email}" + return f'📧 {email}' - return f"User#{getattr(user, 'id', 'Unknown')}" + return f'User#{getattr(user, "id", "Unknown")}' def _get_user_identifier_label(self, user: User) -> str: """Get label for user identifier (Telegram ID or Email).""" - telegram_id = getattr(user, "telegram_id", None) + telegram_id = getattr(user, 'telegram_id', None) if telegram_id: - return "Telegram ID" - email = getattr(user, "email", None) + return 'Telegram ID' + email = getattr(user, 'email', None) if email: - return "Email" - return "ID" + return 'Email' + return 'ID' async def _record_subscription_event( self, @@ -132,7 +131,7 @@ class AdminNotificationService: transaction: Transaction | None = None, amount_kopeks: int | None = None, message: str | None = None, - extra: Dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, occurred_at: datetime | None = None, ) -> None: """Persist subscription-related event for external dashboards.""" @@ -152,9 +151,9 @@ class AdminNotificationService: ) except Exception: logger.error( - "Не удалось сохранить событие подписки (%s) для пользователя %s", + 'Не удалось сохранить событие подписки (%s) для пользователя %s', event_type, - getattr(user, "id", "unknown"), + getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -162,26 +161,26 @@ class AdminNotificationService: await db.rollback() except Exception: logger.error( - "Не удалось выполнить rollback после ошибки события подписки пользователя %s", - getattr(user, "id", "unknown"), + 'Не удалось выполнить rollback после ошибки события подписки пользователя %s', + getattr(user, 'id', 'unknown'), exc_info=True, ) - def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]: - discount_lines: List[str] = [] + 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), + '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(): + for title, percent in discount_map.values(): if percent and percent > 0: - discount_lines.append(f"• {title}: -{percent}%") + discount_lines.append(f'• {title}: -{percent}%') period_discounts_raw = promo_group.period_discounts or {} - period_items: List[tuple[int, int]] = [] + period_items: list[tuple[int, int]] = [] if isinstance(period_discounts_raw, dict): for raw_days, raw_percent in period_discounts_raw.items(): @@ -197,100 +196,98 @@ class AdminNotificationService: 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}") + 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("• Доп. услуги: ✅ скидка действует") + discount_lines.append('• Доп. услуги: ✅ скидка действует') else: - discount_lines.append("• Доп. услуги: ❌ без скидки") + discount_lines.append('• Доп. услуги: ❌ без скидки') return discount_lines def _format_promo_group_block( self, - promo_group: Optional[PromoGroup], + promo_group: PromoGroup | None, *, - title: str = "Промогруппа", - icon: str = "🏷️", + title: str = 'Промогруппа', + icon: str = '🏷️', ) -> str: if not promo_group: - return f"{icon} {title}: —" + return f'{icon} {title}: —' - lines = [f"{icon} {title}: {promo_group.name}"] + lines = [f'{icon} {title}: {promo_group.name}'] discount_lines = self._format_promo_group_discounts(promo_group) if discount_lines: - lines.append("💸 Скидки:") + lines.append('💸 Скидки:') lines.extend(discount_lines) else: - lines.append("💸 Скидки: отсутствуют") + lines.append('💸 Скидки: отсутствуют') - return "\n".join(lines) + return '\n'.join(lines) - def _get_promocode_type_display(self, promo_type: Optional[str]) -> str: + def _get_promocode_type_display(self, promo_type: str | None) -> str: mapping = { - PromoCodeType.BALANCE.value: "💰 Бонус на баланс", - PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки", - PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка", + PromoCodeType.BALANCE.value: '💰 Бонус на баланс', + PromoCodeType.SUBSCRIPTION_DAYS.value: '⏰ Доп. дни подписки', + PromoCodeType.TRIAL_SUBSCRIPTION.value: '🎁 Триал подписка', } if not promo_type: - return "ℹ️ Не указан" + return 'ℹ️ Не указан' - return mapping.get(promo_type, f"ℹ️ {promo_type}") + return mapping.get(promo_type, f'ℹ️ {promo_type}') - def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]: + 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)}", + f'💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}', ] if campaign.is_subscription_bonus: - default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) + default_devices = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1) details = [ - f"📅 Дней подписки: {campaign.subscription_duration_days or 0}", - f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ", - f"📱 Устройства: {campaign.subscription_device_limit or default_devices}", + f'📅 Дней подписки: {campaign.subscription_duration_days or 0}', + f'📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ', + f'📱 Устройства: {campaign.subscription_device_limit or default_devices}', ] if campaign.subscription_squads: - details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.") + details.append(f'🌐 Сквады: {len(campaign.subscription_squads)} шт.') return details - return ["ℹ️ Бонусы не предусмотрены"] - + return ['ℹ️ Бонусы не предусмотрены'] + async def send_trial_activation_notification( self, db: AsyncSession, user: User, subscription: Subscription, *, - charged_amount_kopeks: Optional[int] = None, + charged_amount_kopeks: int | None = None, ) -> bool: try: await self._record_subscription_event( db, - event_type="activation", + event_type='activation', user=user, subscription=subscription, transaction=None, amount_kopeks=charged_amount_kopeks, - message="Trial activation", + message='Trial activation', occurred_at=datetime.utcnow(), extra={ - "charged_amount_kopeks": charged_amount_kopeks, - "trial_duration_days": settings.TRIAL_DURATION_DAYS, - "traffic_limit_gb": settings.TRIAL_TRAFFIC_LIMIT_GB, - "device_limit": subscription.device_limit, + 'charged_amount_kopeks': charged_amount_kopeks, + 'trial_duration_days': settings.TRIAL_DURATION_DAYS, + 'traffic_limit_gb': settings.TRIAL_TRAFFIC_LIMIT_GB, + 'device_limit': subscription.device_limit, }, ) if not self._is_enabled(): return False - user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий" + 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) @@ -304,11 +301,9 @@ class AdminNotificationService: else: trial_device_limit = settings.TRIAL_DEVICE_LIMIT - payment_block = "" + payment_block = '' if charged_amount_kopeks and charged_amount_kopeks > 0: - payment_block = ( - f"\n💳 Оплата за активацию: {settings.format_price(charged_amount_kopeks)}" - ) + payment_block = f'\n💳 Оплата за активацию: {settings.format_price(charged_amount_kopeks)}' user_id_label = self._get_user_identifier_label(user) user_id_display = self._get_user_identifier_display(user) @@ -333,62 +328,66 @@ class AdminNotificationService: 🔗 Реферер: {referrer_info} ⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" - + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления о триале: {e}") + logger.error(f'Ошибка отправки уведомления о триале: {e}') return False - + async def send_subscription_purchase_notification( self, db: AsyncSession, user: User, subscription: Subscription, - transaction: Optional[Transaction], + transaction: Transaction | None, period_days: int, was_trial_conversion: bool = False, - amount_kopeks: Optional[int] = None, + amount_kopeks: int | None = None, ) -> bool: try: - total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0) + total_amount = ( + amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0) + ) await self._record_subscription_event( db, - event_type="purchase", + event_type='purchase', user=user, subscription=subscription, transaction=transaction, amount_kopeks=total_amount, - message="Subscription purchase", + message='Subscription purchase', occurred_at=(transaction.completed_at or transaction.created_at) if transaction else datetime.utcnow(), extra={ - "period_days": period_days, - "was_trial_conversion": was_trial_conversion, - "payment_method": self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс", + 'period_days': period_days, + 'was_trial_conversion': was_trial_conversion, + 'payment_method': self._get_payment_method_display(transaction.payment_method) + if transaction + else 'Баланс', }, ) if not self._is_enabled(): return False - event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ" + event_type = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА' if was_trial_conversion else '💎 ПОКУПКА ПОДПИСКИ' if was_trial_conversion: - user_status = "🎯 Конверсия из триала" + user_status = '🎯 Конверсия из триала' elif user.has_had_paid_subscription: - user_status = "🔄 Продление/Обновление" + user_status = '🔄 Продление/Обновление' else: - user_status = "🆕 Первая покупка" + user_status = '🆕 Первая покупка' servers_info = await self._get_servers_info(subscription.connected_squads) - payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс" + payment_method = self._get_payment_method_display(transaction.payment_method) if transaction 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) user_display = self._get_user_display(user) - transaction_id = transaction.id if transaction else "—" + transaction_id = transaction.id if transaction else '—' user_id_label = self._get_user_identifier_label(user) user_id_display = self._get_user_identifier_display(user) @@ -417,85 +416,76 @@ class AdminNotificationService: 🔗 Реферер: {referrer_info} ⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" - + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления о покупке: {e}") + logger.error(f'Ошибка отправки уведомления о покупке: {e}') return False - async def send_version_update_notification( - self, - current_version: str, - latest_version, - total_updates: int - ) -> bool: + async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool: """Отправляет уведомление о новых обновлениях""" if not self._is_enabled(): return False - + try: if latest_version.prerelease: - update_type = "🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ" - type_icon = "🧪" + update_type = '🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ' + type_icon = '🧪' elif latest_version.is_dev: - update_type = "🔧 DEV ВЕРСИЯ" - type_icon = "🔧" + update_type = '🔧 DEV ВЕРСИЯ' + type_icon = '🔧' else: - update_type = "📦 НОВАЯ ВЕРСИЯ" - type_icon = "📦" - + update_type = '📦 НОВАЯ ВЕРСИЯ' + type_icon = '📦' + description = latest_version.short_description if len(description) > 200: - description = description[:197] + "..." - + description = description[:197] + '...' + message = f"""{type_icon} {update_type} ДОСТУПНА - + 📦 Текущая версия: {current_version} 🆕 Новая версия: {latest_version.tag_name} 📅 Дата релиза: {latest_version.formatted_date} - + 📝 Описание: {description} - + 🔢 Всего доступно обновлений: {total_updates} 🔗 Репозиторий: https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')} - + ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория. - + ⚙️ Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" - + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления об обновлении: {e}") + logger.error(f'Ошибка отправки уведомления об обновлении: {e}') return False - - async def send_version_check_error_notification( - self, - error_message: str, - current_version: str - ) -> bool: + + async def send_version_check_error_notification(self, error_message: str, current_version: str) -> bool: if not self._is_enabled(): return False - + try: message = f"""⚠️ ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ - + 📦 Текущая версия: {current_version}Ошибка: {error_message} - + 🔄 Следующая попытка через час. ⚙️ Проверьте доступность GitHub API и настройки сети. - + ⚙️ Система автоматических обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}""" - + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}") + logger.error(f'Ошибка отправки уведомления об ошибке проверки версий: {e}') return False - + def _build_balance_topup_message( self, user: User, @@ -548,17 +538,13 @@ class AdminNotificationService: ) -> tuple[User, Transaction, Subscription | None, PromoGroup | None]: refreshed_user = await get_user_by_id(db, user.id) if not refreshed_user: - raise ValueError( - f"Не удалось повторно загрузить пользователя {user.id} для уведомления о пополнении" - ) + raise ValueError(f'Не удалось повторно загрузить пользователя {user.id} для уведомления о пополнении') refreshed_transaction = await get_transaction_by_id(db, transaction.id) if not refreshed_transaction: - raise ValueError( - f"Не удалось повторно загрузить транзакцию {transaction.id} для уведомления о пополнении" - ) + raise ValueError(f'Не удалось повторно загрузить транзакцию {transaction.id} для уведомления о пополнении') - subscription = getattr(refreshed_user, "subscription", None) + subscription = getattr(refreshed_user, 'subscription', None) promo_group = await self._get_user_promo_group(db, refreshed_user) return refreshed_user, refreshed_transaction, subscription, promo_group @@ -567,12 +553,11 @@ class AdminNotificationService: message = str(error).lower() return ( isinstance(error, MissingGreenlet) - or "greenlet_spawn" in message - or "await_only" in message - or "missinggreenlet" in message + or 'greenlet_spawn' in message + or 'await_only' in message + or 'missinggreenlet' in message ) - async def send_balance_topup_notification( self, user: User, @@ -585,32 +570,32 @@ class AdminNotificationService: promo_group: PromoGroup | None, db: AsyncSession | None = None, ) -> bool: - logger.info("Начинаем отправку уведомления о пополнении баланса") + logger.info('Начинаем отправку уведомления о пополнении баланса') if db: try: await self._record_subscription_event( db, - event_type="balance_topup", + event_type='balance_topup', user=user, subscription=subscription, transaction=transaction, amount_kopeks=transaction.amount_kopeks, - message="Balance top-up", + message='Balance top-up', occurred_at=transaction.completed_at or transaction.created_at, extra={ - "status": topup_status, - "balance_before": old_balance, - "balance_after": user.balance_kopeks, - "referrer_info": referrer_info, - "promo_group_id": getattr(promo_group, "id", None), - "promo_group_name": getattr(promo_group, "name", None), + 'status': topup_status, + 'balance_before': old_balance, + 'balance_after': user.balance_kopeks, + 'referrer_info': referrer_info, + 'promo_group_id': getattr(promo_group, 'id', None), + 'promo_group_name': getattr(promo_group, 'name', None), }, ) except Exception: logger.error( - "Не удалось сохранить событие пополнения баланса пользователя %s", - getattr(user, "id", "unknown"), + 'Не удалось сохранить событие пополнения баланса пользователя %s', + getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -618,7 +603,7 @@ class AdminNotificationService: return False try: - logger.info("Пытаемся создать сообщение уведомления") + logger.info('Пытаемся создать сообщение уведомления') message = self._build_balance_topup_message( user, transaction, @@ -628,12 +613,12 @@ class AdminNotificationService: subscription=subscription, promo_group=promo_group, ) - logger.info("Сообщение уведомления создано успешно") + logger.info('Сообщение уведомления создано успешно') except Exception as error: - logger.info(f"Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}") + logger.info(f'Перехвачена ошибка при создании сообщения уведомления: {type(error).__name__}: {error}') if not self._is_lazy_loading_error(error): logger.error( - "Ошибка подготовки уведомления о пополнении: %s", + 'Ошибка подготовки уведомления о пополнении: %s', error, exc_info=True, ) @@ -641,36 +626,36 @@ class AdminNotificationService: if db is None: logger.error( - "Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s", + 'Недостаточно данных для уведомления о пополнении и отсутствует доступ к БД: %s', error, exc_info=True, ) return False logger.warning( - "Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s", + 'Повторная загрузка данных для уведомления о пополнении после ошибки ленивой загрузки: %s', error, ) try: - logger.info("Пытаемся перезагрузить данные для уведомления") + logger.info('Пытаемся перезагрузить данные для уведомления') ( user, transaction, subscription, promo_group, ) = await self._reload_topup_notification_entities(db, user, transaction) - logger.info("Данные успешно перезагружены") + logger.info('Данные успешно перезагружены') except Exception as reload_error: logger.error( - "Ошибка повторной загрузки данных для уведомления о пополнении: %s", + 'Ошибка повторной загрузки данных для уведомления о пополнении: %s', reload_error, exc_info=True, ) return False try: - logger.info("Пытаемся создать сообщение после перезагрузки данных") + logger.info('Пытаемся создать сообщение после перезагрузки данных') message = self._build_balance_topup_message( user, transaction, @@ -680,10 +665,10 @@ class AdminNotificationService: subscription=subscription, promo_group=promo_group, ) - logger.info("Сообщение успешно создано после перезагрузки данных") + logger.info('Сообщение успешно создано после перезагрузки данных') except Exception as rebuild_error: logger.error( - "Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s", + 'Ошибка повторной подготовки уведомления о пополнении после повторной загрузки: %s', rebuild_error, exc_info=True, ) @@ -693,11 +678,11 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: logger.error( - f"Ошибка отправки уведомления о пополнении: {e}", + f'Ошибка отправки уведомления о пополнении: {e}', exc_info=True, ) return False - + async def send_subscription_extension_notification( self, db: AsyncSession, @@ -716,19 +701,19 @@ class AdminNotificationService: await self._record_subscription_event( db, - event_type="renewal", + event_type='renewal', user=user, subscription=subscription, transaction=transaction, amount_kopeks=transaction.amount_kopeks, - message="Subscription renewed", + message='Subscription renewed', occurred_at=transaction.completed_at or transaction.created_at, extra={ - "extended_days": extended_days, - "previous_end_date": old_end_date.isoformat(), - "new_end_date": current_end_date.isoformat(), - "payment_method": transaction.payment_method, - "balance_after": current_balance, + 'extended_days': extended_days, + 'previous_end_date': old_end_date.isoformat(), + 'new_end_date': current_end_date.isoformat(), + 'payment_method': transaction.payment_method, + 'balance_after': current_balance, }, ) @@ -773,14 +758,14 @@ class AdminNotificationService: return await self._send_message(message) except Exception as e: - logger.error(f"Ошибка отправки уведомления о продлении: {e}") + logger.error(f'Ошибка отправки уведомления о продлении: {e}') return False async def send_promocode_activation_notification( self, db: AsyncSession, user: User, - promocode_data: Dict[str, Any], + promocode_data: dict[str, Any], effect_description: str, balance_before_kopeks: int | None = None, balance_after_kopeks: int | None = None, @@ -788,32 +773,32 @@ class AdminNotificationService: try: await self._record_subscription_event( db, - event_type="promocode_activation", + event_type='promocode_activation', user=user, subscription=None, transaction=None, - amount_kopeks=promocode_data.get("balance_bonus_kopeks"), - message="Promocode activation", + amount_kopeks=promocode_data.get('balance_bonus_kopeks'), + message='Promocode activation', occurred_at=datetime.utcnow(), extra={ - "code": promocode_data.get("code"), - "type": promocode_data.get("type"), - "subscription_days": promocode_data.get("subscription_days"), - "balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"), - "description": effect_description, - "valid_until": ( - promocode_data.get("valid_until").isoformat() - if isinstance(promocode_data.get("valid_until"), datetime) - else promocode_data.get("valid_until") + 'code': promocode_data.get('code'), + 'type': promocode_data.get('type'), + 'subscription_days': promocode_data.get('subscription_days'), + 'balance_bonus_kopeks': promocode_data.get('balance_bonus_kopeks'), + 'description': effect_description, + 'valid_until': ( + promocode_data.get('valid_until').isoformat() + if isinstance(promocode_data.get('valid_until'), datetime) + else promocode_data.get('valid_until') ), - "balance_before_kopeks": balance_before_kopeks, - "balance_after_kopeks": balance_after_kopeks, + 'balance_before_kopeks': balance_before_kopeks, + 'balance_after_kopeks': balance_after_kopeks, }, ) except Exception: logger.error( - "Не удалось сохранить событие активации промокода пользователя %s", - getattr(user, "id", "unknown"), + 'Не удалось сохранить событие активации промокода пользователя %s', + getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -823,66 +808,64 @@ class AdminNotificationService: 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)}" + 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)}' user_display = self._get_user_display(user) user_id_label = self._get_user_identifier_label(user) user_id_display = self._get_user_identifier_display(user) message_lines = [ - "🎫 АКТИВАЦИЯ ПРОМОКОДА", - "", - f"👤 Пользователь: {user_display}", - f"🆔 {user_id_label}: {user_id_display}", - f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", - "", + '🎫 АКТИВАЦИЯ ПРОМОКОДА', + '', + f'👤 Пользователь: {user_display}', + f'🆔 {user_id_label}: {user_id_display}', + f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}', + '', promo_block, - "", - "🎟️ Промокод:", - f"🔖 Код: {promocode_data.get('code')}", - f"🧾 Тип: {type_display}", - f"📊 Использования: {usage_info}", + '', + '🎟️ Промокод:', + f'🔖 Код: {promocode_data.get("code")}', + f'🧾 Тип: {type_display}', + f'📊 Использования: {usage_info}', ] - balance_bonus = promocode_data.get("balance_bonus_kopeks", 0) + balance_bonus = promocode_data.get('balance_bonus_kopeks', 0) if balance_bonus: - message_lines.append( - f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}" - ) + message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}') - subscription_days = promocode_data.get("subscription_days", 0) + subscription_days = promocode_data.get('subscription_days', 0) if subscription_days: - message_lines.append(f"📅 Доп. дни подписки: {subscription_days}") + message_lines.append(f'📅 Доп. дни подписки: {subscription_days}') - valid_until = promocode_data.get("valid_until") + valid_until = promocode_data.get('valid_until') if valid_until: message_lines.append( - f"⏳ Действует до: {format_local_datetime(valid_until, '%d.%m.%Y %H:%M')}" + f'⏳ Действует до: {format_local_datetime(valid_until, "%d.%m.%Y %H:%M")}' if isinstance(valid_until, datetime) - else f"⏳ Действует до: {valid_until}" + else f'⏳ Действует до: {valid_until}' ) message_lines.extend( [ - "", - "💼 Баланс:", + '', + '💼 Баланс:', ( - f"{settings.format_price(balance_before_kopeks)} → {settings.format_price(balance_after_kopeks)}" + f'{settings.format_price(balance_before_kopeks)} → {settings.format_price(balance_after_kopeks)}' if balance_before_kopeks is not None and balance_after_kopeks is not None - else "ℹ️ Баланс не изменился" + else 'ℹ️ Баланс не изменился' ), - "", - "📝 Эффект:", - effect_description.strip() or "✅ Промокод активирован", - "", - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + '', + '📝 Эффект:', + effect_description.strip() or '✅ Промокод активирован', + '', + f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}', ] ) - return await self._send_message("\n".join(message_lines)) + return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f"Ошибка отправки уведомления об активации промокода: {e}") + logger.error(f'Ошибка отправки уведомления об активации промокода: {e}') return False async def send_campaign_link_visit_notification( @@ -890,30 +873,30 @@ class AdminNotificationService: db: AsyncSession, telegram_user: types.User, campaign: AdvertisingCampaign, - user: Optional[User] = None, + user: User | None = None, ) -> bool: if user: try: await self._record_subscription_event( db, - event_type="referral_link_visit", + event_type='referral_link_visit', user=user, subscription=None, transaction=None, amount_kopeks=None, - message="Referral link visit", + message='Referral link visit', occurred_at=datetime.utcnow(), extra={ - "campaign_id": campaign.id, - "campaign_name": campaign.name, - "start_parameter": campaign.start_parameter, - "was_registered": bool(user), + 'campaign_id': campaign.id, + 'campaign_name': campaign.name, + 'start_parameter': campaign.start_parameter, + 'was_registered': bool(user), }, ) except Exception: logger.error( - "Не удалось сохранить событие перехода по кампании для пользователя %s", - getattr(user, "id", "unknown"), + 'Не удалось сохранить событие перехода по кампании для пользователя %s', + getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -921,7 +904,7 @@ class AdminNotificationService: return False try: - user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован" + user_status = '🆕 Новый пользователь' if not user else '👥 Уже зарегистрирован' promo_block = ( self._format_promo_group_block(await self._get_user_promo_group(db, user)) if user @@ -929,23 +912,23 @@ class AdminNotificationService: ) 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 "отсутствует" + username = f'@{telegram_user.username}' if telegram_user.username else 'отсутствует' message_lines = [ - "📣 ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ", - "", - f"🧾 Кампания: {campaign.name}", - f"🆔 ID кампании: {campaign.id}", - f"🔗 Start-параметр: {campaign.start_parameter}", - "", - f"👤 Пользователь: {full_name}", - f"🆔 Telegram ID: {telegram_user.id}", - f"📱 Username: {username}", + '📣 ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ', + '', + f'🧾 Кампания: {campaign.name}', + f'🆔 ID кампании: {campaign.id}', + f'🔗 Start-параметр: {campaign.start_parameter}', + '', + f'👤 Пользователь: {full_name}', + f'🆔 Telegram ID: {telegram_user.id}', + f'📱 Username: {username}', user_status, - "", + '', promo_block, - "", - "🎯 Бонус кампании:", + '', + '🎯 Бонус кампании:', ] bonus_lines = self._format_campaign_bonus(campaign) @@ -953,52 +936,52 @@ class AdminNotificationService: message_lines.extend( [ - "", - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + '', + f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}', ] ) - return await self._send_message("\n".join(message_lines)) + return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}") + logger.error(f'Ошибка отправки уведомления о переходе по кампании: {e}') return False async def send_user_promo_group_change_notification( self, db: AsyncSession, user: User, - old_group: Optional[PromoGroup], + old_group: PromoGroup | None, new_group: PromoGroup, *, - reason: Optional[str] = None, - initiator: Optional[User] = None, + reason: str | None = None, + initiator: User | None = None, automatic: bool = False, ) -> bool: try: await self._record_subscription_event( db, - event_type="promo_group_change", + event_type='promo_group_change', user=user, subscription=None, transaction=None, - message="Promo group change", + message='Promo group change', occurred_at=datetime.utcnow(), extra={ - "old_group_id": getattr(old_group, "id", None), - "old_group_name": getattr(old_group, "name", None), - "new_group_id": new_group.id, - "new_group_name": new_group.name, - "reason": reason, - "initiator_id": getattr(initiator, "id", None), - "initiator_telegram_id": getattr(initiator, "telegram_id", None), - "automatic": automatic, + 'old_group_id': getattr(old_group, 'id', None), + 'old_group_name': getattr(old_group, 'name', None), + 'new_group_id': new_group.id, + 'new_group_name': new_group.name, + 'reason': reason, + 'initiator_id': getattr(initiator, 'id', None), + 'initiator_telegram_id': getattr(initiator, 'telegram_id', None), + 'automatic': automatic, }, ) except Exception: logger.error( - "Не удалось сохранить событие смены промогруппы пользователя %s", - getattr(user, "id", "unknown"), + 'Не удалось сохранить событие смены промогруппы пользователя %s', + getattr(user, 'id', 'unknown'), exc_info=True, ) @@ -1006,71 +989,69 @@ class AdminNotificationService: return False try: - title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ" + title = '🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ' if automatic else '👥 СМЕНА ПРОМОГРУППЫ' initiator_line = None if initiator: - initiator_line = ( - f"👮 Инициатор: {initiator.full_name} (ID: {initiator.telegram_id})" - ) + initiator_line = f'👮 Инициатор: {initiator.full_name} (ID: {initiator.telegram_id})' elif automatic: - initiator_line = "🤖 Автоматическое назначение" + initiator_line = '🤖 Автоматическое назначение' user_display = self._get_user_display(user) user_id_label = self._get_user_identifier_label(user) user_id_display = self._get_user_identifier_display(user) message_lines = [ - f"{title}", - "", - f"👤 Пользователь: {user_display}", - f"🆔 {user_id_label}: {user_id_display}", - f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", - "", - self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"), + f'{title}', + '', + f'👤 Пользователь: {user_display}', + f'🆔 {user_id_label}: {user_id_display}', + f'📱 Username: @{getattr(user, "username", None) 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="♻️" - ), + '', + self._format_promo_group_block(old_group, title='Предыдущая промогруппа', icon='♻️'), ] ) if initiator_line: - message_lines.extend(["", initiator_line]) + message_lines.extend(['', initiator_line]) if reason: - message_lines.extend(["", f"📝 Причина: {reason}"]) + message_lines.extend(['', f'📝 Причина: {reason}']) message_lines.extend( [ - "", - f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}", - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + '', + f'💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}', + f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}', ] ) - return await self._send_message("\n".join(message_lines)) + return await self._send_message('\n'.join(message_lines)) except Exception as e: - logger.error(f"Ошибка отправки уведомления о смене промогруппы: {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: + 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 не настроен") + logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен') return False - + try: message_kwargs = { 'chat_id': self.chat_id, 'text': text, 'parse_mode': 'HTML', - 'disable_web_page_preview': True + 'disable_web_page_preview': True, } - + # route to ticket-specific topic if provided thread_id = None if ticket_event and self.ticket_topic_id: @@ -1081,25 +1062,25 @@ class AdminNotificationService: message_kwargs['message_thread_id'] = thread_id if reply_markup is not None: message_kwargs['reply_markup'] = reply_markup - + await self.bot.send_message(**message_kwargs) - logger.info(f"Уведомление отправлено в чат {self.chat_id}") + logger.info(f'Уведомление отправлено в чат {self.chat_id}') return True - + except TelegramForbiddenError: - logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}") + logger.error(f'Бот не имеет прав для отправки в чат {self.chat_id}') return False except TelegramBadRequest as e: - logger.error(f"Ошибка отправки уведомления: {e}") + logger.error(f'Ошибка отправки уведомления: {e}') return False except Exception as e: - logger.error(f"Неожиданная ошибка при отправке уведомления: {e}") + logger.error(f'Неожиданная ошибка при отправке уведомления: {e}') return False - + def _is_enabled(self) -> bool: return self.enabled and bool(self.chat_id) - - def _get_payment_method_display(self, payment_method: Optional[str]) -> str: + + def _get_payment_method_display(self, payment_method: str | None) -> str: mulenpay_name = settings.get_mulenpay_display_name() method_names = { 'telegram_stars': '⭐ Telegram Stars', @@ -1108,280 +1089,270 @@ class AdminNotificationService: 'mulenpay': f'💳 {mulenpay_name} (карта)', 'pal24': '🏦 PayPalych (СБП)', 'manual': '🛠️ Вручную (админ)', - 'balance': '💰 С баланса' + 'balance': '💰 С баланса', } - + if not payment_method: return '💰 С баланса' - + return method_names.get(payment_method, '💰 С баланса') - + def _format_traffic(self, traffic_gb: int) -> str: if traffic_gb == 0: - return "∞ Безлимит" - return f"{traffic_gb} ГБ" - - def _get_subscription_status(self, subscription: Optional[Subscription]) -> str: + return '∞ Безлимит' + return f'{traffic_gb} ГБ' + + def _get_subscription_status(self, subscription: Subscription | None) -> str: if not subscription: - return "❌ Нет подписки" + return '❌ Нет подписки' if subscription.is_trial: - return f"🎯 Триал (до {format_local_datetime(subscription.end_date, '%d.%m')})" - elif subscription.is_active: - return f"✅ Активна (до {format_local_datetime(subscription.end_date, '%d.%m')})" - else: - return "❌ Неактивна" - + return f'🎯 Триал (до {format_local_datetime(subscription.end_date, "%d.%m")})' + if subscription.is_active: + return f'✅ Активна (до {format_local_datetime(subscription.end_date, "%d.%m")})' + return '❌ Неактивна' + async def _get_servers_info(self, squad_uuids: list) -> str: if not squad_uuids: - return "❌ Нет серверов" - + return '❌ Нет серверов' + try: from app.handlers.subscription import get_servers_display_names - servers_names = await get_servers_display_names(squad_uuids) - return f"{len(squad_uuids)} шт. ({servers_names})" - except Exception as e: - logger.warning(f"Не удалось получить названия серверов: {e}") - return f"{len(squad_uuids)} шт." + servers_names = await get_servers_display_names(squad_uuids) + return f'{len(squad_uuids)} шт. ({servers_names})' + except Exception as e: + logger.warning(f'Не удалось получить названия серверов: {e}') + return f'{len(squad_uuids)} шт.' async def send_maintenance_status_notification( - self, - event_type: str, - status: str, - details: Dict[str, Any] = None + self, event_type: str, status: str, details: dict[str, Any] = None ) -> bool: if not self._is_enabled(): return False - + try: details = details or {} - - if event_type == "enable": - if details.get("auto_enabled", False): - icon = "⚠️" - title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ" + + if event_type == 'enable': + if details.get('auto_enabled', False): + icon = '⚠️' + title = 'АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ' else: - icon = "🔧" - title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ" - - elif event_type == "disable": - icon = "✅" - title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ" - - elif event_type == "api_status": - if status == "online": - icon = "🟢" - title = "API REMNAWAVE ВОССТАНОВЛЕНО" + icon = '🔧' + title = 'ВКЛЮЧЕНИЕ ТЕХРАБОТ' + + elif event_type == 'disable': + icon = '✅' + title = 'ОТКЛЮЧЕНИЕ ТЕХРАБОТ' + + elif event_type == 'api_status': + if status == 'online': + icon = '🟢' + title = 'API REMNAWAVE ВОССТАНОВЛЕНО' else: - icon = "🔴" - title = "API REMNAWAVE НЕДОСТУПНО" - - elif event_type == "monitoring": - if status == "started": - icon = "🔍" - title = "МОНИТОРИНГ ЗАПУЩЕН" + icon = '🔴' + title = 'API REMNAWAVE НЕДОСТУПНО' + + elif event_type == 'monitoring': + if status == 'started': + icon = '🔍' + title = 'МОНИТОРИНГ ЗАПУЩЕН' else: - icon = "⏹️" - title = "МОНИТОРИНГ ОСТАНОВЛЕН" + icon = '⏹️' + title = 'МОНИТОРИНГ ОСТАНОВЛЕН' else: - icon = "ℹ️" - title = "СИСТЕМА ТЕХРАБОТ" - - message_parts = [f"{icon} {title}", ""] - - if event_type == "enable": - if details.get("reason"): - message_parts.append(f"📋 Причина: {details['reason']}") - - if details.get("enabled_at"): - enabled_at = details["enabled_at"] + icon = 'ℹ️' + title = 'СИСТЕМА ТЕХРАБОТ' + + message_parts = [f'{icon} {title}', ''] + + if event_type == 'enable': + if details.get('reason'): + message_parts.append(f'📋 Причина: {details["reason"]}') + + if details.get('enabled_at'): + enabled_at = details['enabled_at'] if isinstance(enabled_at, str): from datetime import datetime + enabled_at = datetime.fromisoformat(enabled_at) message_parts.append( - f"🕐 Время включения: {format_local_datetime(enabled_at, '%d.%m.%Y %H:%M:%S')}" + f'🕐 Время включения: {format_local_datetime(enabled_at, "%d.%m.%Y %H:%M:%S")}' ) - - message_parts.append(f"🤖 Автоматически: {'Да' if details.get('auto_enabled', False) else 'Нет'}") - message_parts.append("") - message_parts.append("❗ Обычные пользователи временно не могут использовать бота.") - - elif event_type == "disable": - if details.get("disabled_at"): - disabled_at = details["disabled_at"] + + message_parts.append( + f'🤖 Автоматически: {"Да" if details.get("auto_enabled", False) else "Нет"}' + ) + message_parts.append('') + message_parts.append('❗ Обычные пользователи временно не могут использовать бота.') + + elif event_type == 'disable': + if details.get('disabled_at'): + disabled_at = details['disabled_at'] if isinstance(disabled_at, str): from datetime import datetime + disabled_at = datetime.fromisoformat(disabled_at) message_parts.append( - f"🕐 Время отключения: {format_local_datetime(disabled_at, '%d.%m.%Y %H:%M:%S')}" + f'🕐 Время отключения: {format_local_datetime(disabled_at, "%d.%m.%Y %H:%M:%S")}' ) - - if details.get("duration"): - duration = details["duration"] + + if details.get('duration'): + duration = details['duration'] if isinstance(duration, (int, float)): hours = int(duration // 3600) minutes = int((duration % 3600) // 60) if hours > 0: - duration_str = f"{hours}ч {minutes}мин" + duration_str = f'{hours}ч {minutes}мин' else: - duration_str = f"{minutes}мин" - message_parts.append(f"⏱️ Длительность: {duration_str}") - - message_parts.append(f"🤖 Было автоматическим: {'Да' if details.get('was_auto', False) else 'Нет'}") - message_parts.append("") - message_parts.append("✅ Сервис снова доступен для пользователей.") - - elif event_type == "api_status": - message_parts.append(f"🔗 API URL: {details.get('api_url', 'неизвестно')}") - - if status == "online": - if details.get("response_time"): - message_parts.append(f"⚡ Время отклика: {details['response_time']} сек") - - if details.get("consecutive_failures", 0) > 0: - message_parts.append(f"🔄 Неудачных попыток было: {details['consecutive_failures']}") - - message_parts.append("") - message_parts.append("API снова отвечает на запросы.") - - else: - if details.get("consecutive_failures"): - message_parts.append(f"🔄 Попытка №: {details['consecutive_failures']}") - - if details.get("error"): - error_msg = str(details["error"])[:100] - message_parts.append(f"❌ Ошибка: {error_msg}") - - message_parts.append("") - message_parts.append("⚠️ Началась серия неудачных проверок API.") - - elif event_type == "monitoring": - if status == "started": - if details.get("check_interval"): - message_parts.append(f"🔄 Интервал проверки: {details['check_interval']} сек") - - if details.get("auto_enable_configured") is not None: - auto_enable = "Включено" if details["auto_enable_configured"] else "Отключено" - message_parts.append(f"🤖 Автовключение: {auto_enable}") - - if details.get("max_failures"): - message_parts.append(f"🎯 Порог ошибок: {details['max_failures']}") - - message_parts.append("") - message_parts.append("Система будет следить за доступностью API.") - - else: - message_parts.append("Автоматический мониторинг API остановлен.") - - message_parts.append("") - message_parts.append( - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}" - ) - - message = "\n".join(message_parts) - + duration_str = f'{minutes}мин' + message_parts.append(f'⏱️ Длительность: {duration_str}') + + message_parts.append( + f'🤖 Было автоматическим: {"Да" if details.get("was_auto", False) else "Нет"}' + ) + message_parts.append('') + message_parts.append('✅ Сервис снова доступен для пользователей.') + + elif event_type == 'api_status': + message_parts.append(f'🔗 API URL: {details.get("api_url", "неизвестно")}') + + if status == 'online': + if details.get('response_time'): + message_parts.append(f'⚡ Время отклика: {details["response_time"]} сек') + + if details.get('consecutive_failures', 0) > 0: + message_parts.append(f'🔄 Неудачных попыток было: {details["consecutive_failures"]}') + + message_parts.append('') + message_parts.append('API снова отвечает на запросы.') + + else: + if details.get('consecutive_failures'): + message_parts.append(f'🔄 Попытка №: {details["consecutive_failures"]}') + + if details.get('error'): + error_msg = str(details['error'])[:100] + message_parts.append(f'❌ Ошибка: {error_msg}') + + message_parts.append('') + message_parts.append('⚠️ Началась серия неудачных проверок API.') + + elif event_type == 'monitoring': + if status == 'started': + if details.get('check_interval'): + message_parts.append(f'🔄 Интервал проверки: {details["check_interval"]} сек') + + if details.get('auto_enable_configured') is not None: + auto_enable = 'Включено' if details['auto_enable_configured'] else 'Отключено' + message_parts.append(f'🤖 Автовключение: {auto_enable}') + + if details.get('max_failures'): + message_parts.append(f'🎯 Порог ошибок: {details["max_failures"]}') + + message_parts.append('') + message_parts.append('Система будет следить за доступностью API.') + + else: + message_parts.append('Автоматический мониторинг API остановлен.') + + message_parts.append('') + message_parts.append(f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}') + + message = '\n'.join(message_parts) + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления о техработах: {e}") + logger.error(f'Ошибка отправки уведомления о техработах: {e}') return False - - async def send_remnawave_panel_status_notification( - self, - status: str, - details: Dict[str, Any] = None - ) -> bool: + + async def send_remnawave_panel_status_notification(self, status: str, details: dict[str, Any] = None) -> bool: if not self._is_enabled(): return False - + try: details = details or {} - + status_config = { - "online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"}, - "offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"}, - "degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"}, - "maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"} + 'online': {'icon': '🟢', 'title': 'ПАНЕЛЬ REMNAWAVE ДОСТУПНА', 'alert_type': 'success'}, + 'offline': {'icon': '🔴', 'title': 'ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА', 'alert_type': 'error'}, + 'degraded': {'icon': '🟡', 'title': 'ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ', 'alert_type': 'warning'}, + 'maintenance': {'icon': '🔧', 'title': 'ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ', 'alert_type': 'info'}, } - - config = status_config.get(status, status_config["offline"]) - - message_parts = [ - f"{config['icon']} {config['title']}", - "" - ] - - if details.get("api_url"): - message_parts.append(f"🔗 URL: {details['api_url']}") - - if details.get("response_time"): - message_parts.append(f"⚡ Время отклика: {details['response_time']} сек") - - if details.get("last_check"): - last_check = details["last_check"] + + config = status_config.get(status, status_config['offline']) + + message_parts = [f'{config["icon"]} {config["title"]}', ''] + + if details.get('api_url'): + message_parts.append(f'🔗 URL: {details["api_url"]}') + + if details.get('response_time'): + message_parts.append(f'⚡ Время отклика: {details["response_time"]} сек') + + if details.get('last_check'): + last_check = details['last_check'] if isinstance(last_check, str): from datetime import datetime + last_check = datetime.fromisoformat(last_check) - message_parts.append( - f"🕐 Последняя проверка: {format_local_datetime(last_check, '%H:%M:%S')}" - ) - - if status == "online": - if details.get("uptime"): - message_parts.append(f"⏱️ Время работы: {details['uptime']}") - - if details.get("users_online"): - message_parts.append(f"👥 Пользователей онлайн: {details['users_online']}") - - message_parts.append("") - message_parts.append("✅ Все системы работают нормально.") - - elif status == "offline": - if details.get("error"): - error_msg = str(details["error"])[:150] - message_parts.append(f"❌ Ошибка: {error_msg}") - - if details.get("consecutive_failures"): - message_parts.append(f"🔄 Неудачных попыток: {details['consecutive_failures']}") - - message_parts.append("") - message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.") - - elif status == "degraded": - if details.get("issues"): - issues = details["issues"] + message_parts.append(f'🕐 Последняя проверка: {format_local_datetime(last_check, "%H:%M:%S")}') + + if status == 'online': + if details.get('uptime'): + message_parts.append(f'⏱️ Время работы: {details["uptime"]}') + + if details.get('users_online'): + message_parts.append(f'👥 Пользователей онлайн: {details["users_online"]}') + + message_parts.append('') + message_parts.append('✅ Все системы работают нормально.') + + elif status == 'offline': + if details.get('error'): + error_msg = str(details['error'])[:150] + message_parts.append(f'❌ Ошибка: {error_msg}') + + if details.get('consecutive_failures'): + message_parts.append(f'🔄 Неудачных попыток: {details["consecutive_failures"]}') + + message_parts.append('') + message_parts.append('⚠️ Панель недоступна. Проверьте соединение и статус сервера.') + + elif status == 'degraded': + if details.get('issues'): + issues = details['issues'] if isinstance(issues, list): - message_parts.append("⚠️ Обнаруженные проблемы:") - for issue in issues[:3]: - message_parts.append(f" • {issue}") + message_parts.append('⚠️ Обнаруженные проблемы:') + for issue in issues[:3]: + message_parts.append(f' • {issue}') else: - message_parts.append(f"⚠️ Проблема: {issues}") - - message_parts.append("") - message_parts.append("Панель работает, но возможны задержки или сбои.") - - elif status == "maintenance": - if details.get("maintenance_reason"): - message_parts.append(f"🔧 Причина: {details['maintenance_reason']}") - - if details.get("estimated_duration"): - message_parts.append(f"⏰ Ожидаемая длительность: {details['estimated_duration']}") - - message_parts.append("") - message_parts.append("Панель временно недоступна для обслуживания.") - - message_parts.append("") - message_parts.append( - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}" - ) - - message = "\n".join(message_parts) - + message_parts.append(f'⚠️ Проблема: {issues}') + + message_parts.append('') + message_parts.append('Панель работает, но возможны задержки или сбои.') + + elif status == 'maintenance': + if details.get('maintenance_reason'): + message_parts.append(f'🔧 Причина: {details["maintenance_reason"]}') + + if details.get('estimated_duration'): + message_parts.append(f'⏰ Ожидаемая длительность: {details["estimated_duration"]}') + + message_parts.append('') + message_parts.append('Панель временно недоступна для обслуживания.') + + message_parts.append('') + message_parts.append(f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}') + + message = '\n'.join(message_parts) + return await self._send_message(message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}") + logger.error(f'Ошибка отправки уведомления о статусе панели Remnawave: {e}') return False async def send_subscription_update_notification( @@ -1392,11 +1363,11 @@ class AdminNotificationService: update_type: str, old_value: Any, new_value: Any, - price_paid: int = 0 + price_paid: int = 0, ) -> bool: if not self._is_enabled(): return False - + try: referrer_info = await self._get_referrer_info(db, user.referred_by_id) promo_group = await self._get_user_promo_group(db, user) @@ -1404,98 +1375,98 @@ class AdminNotificationService: user_display = self._get_user_display(user) update_types = { - "traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"), - "devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"), - "servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы"), - "modem": ("📡 ИЗМЕНЕНИЕ МОДЕМА", "модем") + 'traffic': ('📊 ИЗМЕНЕНИЕ ТРАФИКА', 'трафик'), + 'devices': ('📱 ИЗМЕНЕНИЕ УСТРОЙСТВ', 'количество устройств'), + 'servers': ('🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ', 'серверы'), + 'modem': ('📡 ИЗМЕНЕНИЕ МОДЕМА', 'модем'), } - title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры")) + title, param_name = update_types.get(update_type, ('⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ', 'параметры')) user_id_label = self._get_user_identifier_label(user) user_id_display = self._get_user_identifier_display(user) message_lines = [ - f"{title}", - "", - f"👤 Пользователь: {user_display}", - f"🆔 {user_id_label}: {user_id_display}", - f"📱 Username: @{getattr(user, 'username', None) or 'отсутствует'}", - "", + f'{title}', + '', + f'👤 Пользователь: {user_display}', + f'🆔 {user_id_label}: {user_id_display}', + f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}', + '', promo_block, - "", - "🔧 Изменение:", - f"📋 Параметр: {param_name}", + '', + '🔧 Изменение:', + f'📋 Параметр: {param_name}', ] - if update_type == "servers": + 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_lines.extend( [ - f"📉 Было: {old_servers_info}", - f"📈 Стало: {new_servers_info}", + f'📉 Было: {old_servers_info}', + f'📈 Стало: {new_servers_info}', ] ) else: message_lines.extend( [ - f"📉 Было: {self._format_update_value(old_value, update_type)}", - f"📈 Стало: {self._format_update_value(new_value, update_type)}", + f'📉 Было: {self._format_update_value(old_value, update_type)}', + f'📈 Стало: {self._format_update_value(new_value, update_type)}', ] ) if price_paid > 0: - message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}") + message_lines.append(f'💰 Доплачено: {settings.format_price(price_paid)}') else: - message_lines.append("💸 Бесплатно") + message_lines.append('💸 Бесплатно') message_lines.extend( [ - "", - f"📅 Подписка действует до: {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}", - f"💰 Баланс после операции: {settings.format_price(user.balance_kopeks)}", - f"🔗 Рефер: {referrer_info}", - "", - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + '', + f'📅 Подписка действует до: {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}', + f'💰 Баланс после операции: {settings.format_price(user.balance_kopeks)}', + f'🔗 Рефер: {referrer_info}', + '', + f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}', ] ) - return await self._send_message("\n".join(message_lines)) - + return await self._send_message('\n'.join(message_lines)) + except Exception as e: - logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}") + logger.error(f'Ошибка отправки уведомления об изменении подписки: {e}') return False - async def _format_servers_detailed(self, server_uuids: List[str]) -> str: + async def _format_servers_detailed(self, server_uuids: list[str]) -> str: if not server_uuids: - return "Нет серверов" - + return 'Нет серверов' + try: from app.handlers.subscription import get_servers_display_names + servers_names = await get_servers_display_names(server_uuids) - - if servers_names and servers_names != "Нет серверов": - return f"{len(server_uuids)} серверов ({servers_names})" - else: - return f"{len(server_uuids)} серверов" - + + if servers_names and servers_names != 'Нет серверов': + return f'{len(server_uuids)} серверов ({servers_names})' + return f'{len(server_uuids)} серверов' + except Exception as e: - logger.warning(f"Ошибка получения названий серверов для уведомления: {e}") - return f"{len(server_uuids)} серверов" + logger.warning(f'Ошибка получения названий серверов для уведомления: {e}') + return f'{len(server_uuids)} серверов' def _format_update_value(self, value: Any, update_type: str) -> str: - if update_type == "traffic": + if update_type == 'traffic': if value == 0: - return "♾ Безлимитный" - return f"{value} ГБ" - elif update_type == "devices": - return f"{value} устройств" - elif update_type == "servers": + return '♾ Безлимитный' + return f'{value} ГБ' + if update_type == 'devices': + return f'{value} устройств' + if update_type == 'servers': if isinstance(value, list): - return f"{len(value)} серверов" + return f'{len(value)} серверов' return str(value) - elif update_type == "modem": - return "✅ Включён" if value else "❌ Выключен" + if update_type == 'modem': + return '✅ Включён' if value else '❌ Выключен' return str(value) async def send_bulk_ban_notification( @@ -1504,7 +1475,7 @@ class AdminNotificationService: successfully_banned: int, not_found: int, errors: int, - admin_name: str = "Администратор" + admin_name: str = 'Администратор', ) -> bool: """Отправляет уведомление о массовой блокировке пользователей""" if not self._is_enabled(): @@ -1512,40 +1483,38 @@ class AdminNotificationService: try: message_lines = [ - "🛑 МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ", - "", - f"👮 Администратор: {admin_name}", - f"🆔 ID администратора: {admin_user_id}", - "", - "📊 Результаты:", - f"✅ Успешно заблокировано: {successfully_banned}", - f"❌ Не найдено: {not_found}", - f"💥 Ошибок: {errors}" + '🛑 МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ', + '', + f'👮 Администратор: {admin_name}', + f'🆔 ID администратора: {admin_user_id}', + '', + '📊 Результаты:', + f'✅ Успешно заблокировано: {successfully_banned}', + f'❌ Не найдено: {not_found}', + f'💥 Ошибок: {errors}', ] total_processed = successfully_banned + not_found + errors if total_processed > 0: success_rate = (successfully_banned / total_processed) * 100 - message_lines.append(f"📈 Успешность: {success_rate:.1f}%") + message_lines.append(f'📈 Успешность: {success_rate:.1f}%') message_lines.extend( [ - "", - f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + '', + f'⏰ {format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}', ] ) - message = "\n".join(message_lines) + message = '\n'.join(message_lines) return await self._send_message(message) except Exception as e: - logger.error(f"Ошибка отправки уведомления о массовой блокировке: {e}") + logger.error(f'Ошибка отправки уведомления о массовой блокировке: {e}') return False async def send_ticket_event_notification( - self, - text: str, - keyboard: types.InlineKeyboardMarkup | None = None + self, text: str, keyboard: types.InlineKeyboardMarkup | None = None ) -> bool: """Публичный метод для отправки уведомлений по тикетам в админ-топик. Учитывает настройки включенности в settings. @@ -1553,20 +1522,18 @@ class AdminNotificationService: # Respect runtime toggle for admin ticket notifications try: from app.services.support_settings_service import SupportSettingsService + runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled() except Exception: runtime_enabled = True if not (self._is_enabled() and runtime_enabled): - logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}") + logger.info( + f'Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}' + ) return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) - async def send_suspicious_traffic_notification( - self, - message: str, - bot: Bot, - topic_id: Optional[int] = None - ) -> bool: + async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool: """ Отправляет уведомление о подозрительной активности трафика @@ -1576,7 +1543,7 @@ class AdminNotificationService: topic_id: ID топика для отправки уведомления (если не указан, использует стандартный) """ if not self.chat_id: - logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен") + logger.warning('ADMIN_NOTIFICATIONS_CHAT_ID не настроен') return False # Используем специальный топик для подозрительной активности, если он задан @@ -1587,22 +1554,24 @@ class AdminNotificationService: 'chat_id': self.chat_id, 'text': message, 'parse_mode': 'HTML', - 'disable_web_page_preview': True + 'disable_web_page_preview': True, } if notification_topic_id: message_kwargs['message_thread_id'] = notification_topic_id await bot.send_message(**message_kwargs) - logger.info(f"Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}") + logger.info( + f'Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}' + ) return True except TelegramForbiddenError: - logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}") + logger.error(f'Бот не имеет прав для отправки в чат {self.chat_id}') return False except TelegramBadRequest as e: - logger.error(f"Ошибка отправки уведомления о подозрительной активности: {e}") + logger.error(f'Ошибка отправки уведомления о подозрительной активности: {e}') return False except Exception as e: - logger.error(f"Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}") + logger.error(f'Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}') return False diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 35bdd117..0c32c342 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -9,7 +9,7 @@ import tempfile from dataclasses import asdict, dataclass from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import aiofiles import pyzipper @@ -21,51 +21,74 @@ from sqlalchemy.orm import selectinload from app.config import settings from app.database.database import AsyncSessionLocal, engine from app.database.models import ( - User, Subscription, Transaction, PromoCode, PromoCodeUse, - ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog, - SubscriptionConversion, SentNotification, BroadcastHistory, - ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment, - CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign, - AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage, - MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken, Tariff, - server_squad_promo_groups + AdvertisingCampaign, + AdvertisingCampaignRegistration, + BroadcastHistory, + CryptoBotPayment, + DiscountOffer, + MonitoringLog, + MulenPayPayment, + Pal24Payment, + PromoCode, + PromoCodeUse, + PromoGroup, + ReferralEarning, + SentNotification, + ServerSquad, + ServiceRule, + Squad, + Subscription, + SubscriptionConversion, + SubscriptionServer, + SupportAuditLog, + SystemSetting, + Tariff, + Ticket, + TicketMessage, + Transaction, + User, + UserMessage, + WebApiToken, + WelcomeText, + YooKassaPayment, + server_squad_promo_groups, ) + logger = logging.getLogger(__name__) @dataclass class BackupMetadata: timestamp: str - version: str = "1.2" - database_type: str = "postgresql" - backup_type: str = "full" + version: str = '1.2' + database_type: str = 'postgresql' + backup_type: str = 'full' tables_count: int = 0 total_records: int = 0 compressed: bool = True file_size_bytes: int = 0 - created_by: Optional[int] = None + created_by: int | None = None @dataclass class BackupSettings: auto_backup_enabled: bool = True backup_interval_hours: int = 24 - backup_time: str = "03:00" + backup_time: str = '03:00' max_backups_keep: int = 7 compression_enabled: bool = True include_logs: bool = False - backup_location: str = "/app/data/backups" + backup_location: str = '/app/data/backups' class BackupService: - def __init__(self, bot=None): self.bot = bot self.backup_dir = Path(settings.BACKUP_LOCATION).expanduser().resolve() self.backup_dir.mkdir(parents=True, exist_ok=True) self.data_dir = self.backup_dir.parent - self.archive_format_version = "2.0" + self.archive_format_version = '2.0' self._auto_backup_task = None self._settings = self._load_settings() @@ -107,32 +130,32 @@ class BackupService: self.backup_models_ordered.append(MonitoringLog) self.association_tables = { - "server_squad_promo_groups": server_squad_promo_groups, + '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", - backup_interval_hours=int(os.getenv("BACKUP_INTERVAL_HOURS", "24")), - backup_time=os.getenv("BACKUP_TIME", "03:00"), - max_backups_keep=int(os.getenv("BACKUP_MAX_KEEP", "7")), - compression_enabled=os.getenv("BACKUP_COMPRESSION", "true").lower() == "true", - include_logs=os.getenv("BACKUP_INCLUDE_LOGS", "false").lower() == "true", - backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups") + auto_backup_enabled=os.getenv('BACKUP_AUTO_ENABLED', 'true').lower() == 'true', + backup_interval_hours=int(os.getenv('BACKUP_INTERVAL_HOURS', '24')), + backup_time=os.getenv('BACKUP_TIME', '03:00'), + max_backups_keep=int(os.getenv('BACKUP_MAX_KEEP', '7')), + compression_enabled=os.getenv('BACKUP_COMPRESSION', 'true').lower() == 'true', + include_logs=os.getenv('BACKUP_INCLUDE_LOGS', 'false').lower() == 'true', + backup_location=os.getenv('BACKUP_LOCATION', '/app/data/backups'), ) - def _parse_backup_time(self) -> Tuple[int, int]: - time_str = (self._settings.backup_time or "").strip() + def _parse_backup_time(self) -> tuple[int, int]: + time_str = (self._settings.backup_time or '').strip() try: - parts = time_str.split(":") + parts = time_str.split(':') if len(parts) != 2: - raise ValueError("Invalid time format") + raise ValueError('Invalid time format') hours, minutes = map(int, parts) if not (0 <= hours < 24 and 0 <= minutes < 60): - raise ValueError("Hours or minutes out of range") + raise ValueError('Hours or minutes out of range') return hours, minutes @@ -140,12 +163,12 @@ class BackupService: default_hours, default_minutes = 3, 0 logger.warning( "Некорректное значение BACKUP_TIME='%s'. Используется значение по умолчанию 03:00.", - self._settings.backup_time + self._settings.backup_time, ) - self._settings.backup_time = "03:00" + self._settings.backup_time = '03:00' return default_hours, default_minutes - def _calculate_next_backup_datetime(self, reference: Optional[datetime] = None) -> datetime: + def _calculate_next_backup_datetime(self, reference: datetime | None = None) -> datetime: reference = reference or datetime.now() hours, minutes = self._parse_backup_time() @@ -160,15 +183,14 @@ class BackupService: if hours <= 0: logger.warning( - "Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.", - hours + 'Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.', hours ) hours = 24 self._settings.backup_interval_hours = hours return timedelta(hours=hours) - def _get_models_for_backup(self, include_logs: bool) -> List[Any]: + def _get_models_for_backup(self, include_logs: bool) -> list[Any]: models = self._base_backup_models.copy() if include_logs: @@ -179,14 +201,14 @@ class BackupService: return models - def _resolve_command_path(self, command: str, env_var: str) -> Optional[str]: + def _resolve_command_path(self, command: str, env_var: str) -> str | None: override = os.getenv(env_var) if override: override_path = Path(override) if override_path.exists() and os.access(override_path, os.X_OK): return str(override_path) logger.warning( - "Путь %s из %s недоступен или не является исполняемым", + 'Путь %s из %s недоступен или не является исполняемым', override, env_var, ) @@ -198,58 +220,52 @@ class BackupService: return None async def create_backup( - self, - created_by: Optional[int] = None, - compress: bool = True, - include_logs: bool = None - ) -> Tuple[bool, str, Optional[str]]: + self, created_by: int | None = None, compress: bool = True, include_logs: bool = None + ) -> tuple[bool, str, str | None]: try: - logger.info("📄 Начинаем создание бекапа...") + logger.info('📄 Начинаем создание бекапа...') if include_logs is None: include_logs = self._settings.include_logs overview = await self._collect_database_overview() - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - archive_suffix = ".tar.gz" if compress else ".tar" - filename = f"backup_{timestamp}{archive_suffix}" + timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + archive_suffix = '.tar.gz' if compress else '.tar' + filename = f'backup_{timestamp}{archive_suffix}' backup_path = self.backup_dir / filename with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - staging_dir = temp_path / "backup" + staging_dir = temp_path / 'backup' staging_dir.mkdir(parents=True, exist_ok=True) - database_info = await self._dump_database( - staging_dir, - include_logs=include_logs - ) - database_info.setdefault("tables_count", overview.get("tables_count", 0)) - database_info.setdefault("total_records", overview.get("total_records", 0)) + database_info = await self._dump_database(staging_dir, include_logs=include_logs) + database_info.setdefault('tables_count', overview.get('tables_count', 0)) + database_info.setdefault('total_records', overview.get('total_records', 0)) files_info = await self._collect_files(staging_dir, include_logs=include_logs) data_snapshot_info = await self._collect_data_snapshot(staging_dir) metadata = { - "format_version": self.archive_format_version, - "timestamp": datetime.utcnow().isoformat(), - "database_type": "postgresql" if settings.is_postgresql() else "sqlite", - "backup_type": "full", - "tables_count": overview.get("tables_count", 0), - "total_records": overview.get("total_records", 0), - "compressed": True, - "created_by": created_by, - "database": database_info, - "files": files_info, - "data_snapshot": data_snapshot_info, - "settings": asdict(self._settings), + 'format_version': self.archive_format_version, + 'timestamp': datetime.utcnow().isoformat(), + 'database_type': 'postgresql' if settings.is_postgresql() else 'sqlite', + 'backup_type': 'full', + 'tables_count': overview.get('tables_count', 0), + 'total_records': overview.get('total_records', 0), + 'compressed': True, + 'created_by': created_by, + 'database': database_info, + 'files': files_info, + 'data_snapshot': data_snapshot_info, + 'settings': asdict(self._settings), } - metadata_path = staging_dir / "metadata.json" - async with aiofiles.open(metadata_path, "w", encoding="utf-8") as meta_file: + metadata_path = staging_dir / 'metadata.json' + async with aiofiles.open(metadata_path, 'w', encoding='utf-8') as meta_file: await meta_file.write(json_lib.dumps(metadata, ensure_ascii=False, indent=2)) - mode = "w:gz" if compress else "w" + mode = 'w:gz' if compress else 'w' with tarfile.open(backup_path, mode) as tar: for item in staging_dir.iterdir(): tar.add(item, arcname=item.name) @@ -259,43 +275,39 @@ class BackupService: await self._cleanup_old_backups() size_mb = file_size / 1024 / 1024 - message = (f"✅ Бекап успешно создан!\n" - f"📁 Файл: {filename}\n" - f"📊 Таблиц: {overview.get('tables_count', 0)}\n" - f"📈 Записей: {overview.get('total_records', 0):,}\n" - f"💾 Размер: {size_mb:.2f} MB") + message = ( + f'✅ Бекап успешно создан!\n' + f'📁 Файл: {filename}\n' + f'📊 Таблиц: {overview.get("tables_count", 0)}\n' + f'📈 Записей: {overview.get("total_records", 0):,}\n' + f'💾 Размер: {size_mb:.2f} MB' + ) logger.info(message) if self.bot: - await self._send_backup_notification( - "success", message, str(backup_path) - ) + await self._send_backup_notification('success', message, str(backup_path)) await self._send_backup_file_to_chat(str(backup_path)) return True, message, str(backup_path) except Exception as e: - error_msg = f"❌ Ошибка создания бекапа: {str(e)}" + error_msg = f'❌ Ошибка создания бекапа: {e!s}' logger.error(error_msg, exc_info=True) if self.bot: - await self._send_backup_notification("error", error_msg) + await self._send_backup_notification('error', error_msg) return False, error_msg, None - async def restore_backup( - self, - backup_file_path: str, - clear_existing: bool = False - ) -> Tuple[bool, str]: + async def restore_backup(self, backup_file_path: str, clear_existing: bool = False) -> tuple[bool, str]: try: - logger.info(f"📄 Начинаем восстановление из {backup_file_path}") + logger.info(f'📄 Начинаем восстановление из {backup_file_path}') backup_path = Path(backup_file_path) if not backup_path.exists(): - return False, f"❌ Файл бекапа не найден: {backup_file_path}" + return False, f'❌ Файл бекапа не найден: {backup_file_path}' if self._is_archive_backup(backup_path): success, message = await self._restore_from_archive(backup_path, clear_existing) @@ -303,103 +315,101 @@ class BackupService: success, message = await self._restore_from_legacy(backup_path, clear_existing) if success and self.bot: - await self._send_backup_notification("restore_success", message) + await self._send_backup_notification('restore_success', message) elif not success and self.bot: - await self._send_backup_notification("restore_error", message) + await self._send_backup_notification('restore_error', message) return success, message except Exception as e: - error_msg = f"❌ Ошибка восстановления: {str(e)}" + error_msg = f'❌ Ошибка восстановления: {e!s}' logger.error(error_msg, exc_info=True) if self.bot: - await self._send_backup_notification("restore_error", error_msg) + await self._send_backup_notification('restore_error', error_msg) return False, error_msg - async def _collect_database_overview(self) -> Dict[str, Any]: - overview: Dict[str, Any] = { - "tables_count": 0, - "total_records": 0, - "tables": [], + async def _collect_database_overview(self) -> dict[str, Any]: + overview: dict[str, Any] = { + 'tables_count': 0, + 'total_records': 0, + 'tables': [], } try: async with engine.begin() as conn: - table_names = await conn.run_sync( - lambda sync_conn: inspect(sync_conn).get_table_names() - ) + table_names = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()) for table_name in table_names: try: - result = await conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")) + result = await conn.execute(text(f'SELECT COUNT(*) FROM {table_name}')) count = result.scalar_one() except Exception: count = 0 - overview["tables"].append({"name": table_name, "rows": count}) - overview["total_records"] += count + overview['tables'].append({'name': table_name, 'rows': count}) + overview['total_records'] += count - overview["tables_count"] = len(table_names) + overview['tables_count'] = len(table_names) except Exception as exc: - logger.warning("Не удалось собрать статистику по БД: %s", exc) + logger.warning('Не удалось собрать статистику по БД: %s', exc) return overview - async def _dump_database(self, staging_dir: Path, include_logs: bool) -> Dict[str, Any]: + async def _dump_database(self, staging_dir: Path, include_logs: bool) -> dict[str, Any]: if settings.is_postgresql(): - pg_dump_path = self._resolve_command_path("pg_dump", "PG_DUMP_PATH") + pg_dump_path = self._resolve_command_path('pg_dump', 'PG_DUMP_PATH') if pg_dump_path: - dump_path = staging_dir / "database.sql" + dump_path = staging_dir / 'database.sql' await self._dump_postgres(dump_path, pg_dump_path) size = dump_path.stat().st_size if dump_path.exists() else 0 return { - "type": "postgresql", - "path": dump_path.name, - "size_bytes": size, - "format": "sql", - "tool": pg_dump_path, + 'type': 'postgresql', + 'path': dump_path.name, + 'size_bytes': size, + 'format': 'sql', + 'tool': pg_dump_path, } - logger.info( - "pg_dump не найден в PATH. Используется ORM-дамп в формате JSON" - ) + logger.info('pg_dump не найден в PATH. Используется ORM-дамп в формате JSON') json_info = await self._dump_postgres_json(staging_dir, include_logs) return json_info - dump_path = staging_dir / "database.sqlite" + dump_path = staging_dir / 'database.sqlite' await self._dump_sqlite(dump_path) size = dump_path.stat().st_size if dump_path.exists() else 0 return { - "type": "sqlite", - "path": dump_path.name, - "size_bytes": size, - "format": "file", + 'type': 'sqlite', + 'path': dump_path.name, + 'size_bytes': size, + 'format': 'file', } async def _dump_postgres(self, dump_path: Path, pg_dump_path: str): env = os.environ.copy() - env.update({ - "PGHOST": settings.POSTGRES_HOST, - "PGPORT": str(settings.POSTGRES_PORT), - "PGUSER": settings.POSTGRES_USER, - "PGPASSWORD": settings.POSTGRES_PASSWORD, - }) + env.update( + { + 'PGHOST': settings.POSTGRES_HOST, + 'PGPORT': str(settings.POSTGRES_PORT), + 'PGUSER': settings.POSTGRES_USER, + 'PGPASSWORD': settings.POSTGRES_PASSWORD, + } + ) command = [ pg_dump_path, - "--format=plain", - "--no-owner", - "--no-privileges", + '--format=plain', + '--no-owner', + '--no-privileges', settings.POSTGRES_DB, ] - logger.info("📦 Экспорт PostgreSQL через pg_dump (%s)...", pg_dump_path) + logger.info('📦 Экспорт PostgreSQL через pg_dump (%s)...', pg_dump_path) dump_path.parent.mkdir(parents=True, exist_ok=True) - with dump_path.open("wb") as dump_file: + with dump_path.open('wb') as dump_file: process = await asyncio.create_subprocess_exec( *command, stdout=dump_file, @@ -409,12 +419,12 @@ class BackupService: _, stderr = await process.communicate() if process.returncode != 0: - error_text = stderr.decode() if stderr else "pg_dump error" - raise RuntimeError(f"pg_dump завершился с ошибкой: {error_text}") + error_text = stderr.decode() if stderr else 'pg_dump error' + raise RuntimeError(f'pg_dump завершился с ошибкой: {error_text}') - logger.info("✅ PostgreSQL dump создан (%s)", dump_path) + logger.info('✅ PostgreSQL dump создан (%s)', dump_path) - async def _dump_postgres_json(self, staging_dir: Path, include_logs: bool) -> Dict[str, Any]: + async def _dump_postgres_json(self, staging_dir: Path, include_logs: bool) -> dict[str, Any]: models_to_backup = self._get_models_for_backup(include_logs) ( backup_data, @@ -423,63 +433,61 @@ class BackupService: tables_count, ) = await self._export_database_via_orm(models_to_backup) - dump_path = staging_dir / "database.json" + dump_path = staging_dir / 'database.json' dump_structure = { - "metadata": { - "timestamp": datetime.utcnow().isoformat(), - "version": "orm-1.0", - "database_type": "postgresql", - "tables_count": tables_count, - "total_records": total_records, + 'metadata': { + 'timestamp': datetime.utcnow().isoformat(), + 'version': 'orm-1.0', + 'database_type': 'postgresql', + 'tables_count': tables_count, + 'total_records': total_records, }, - "data": backup_data, - "associations": association_data, + 'data': backup_data, + 'associations': association_data, } - async with aiofiles.open(dump_path, "w", encoding="utf-8") as dump_file: - await dump_file.write( - json_lib.dumps(dump_structure, ensure_ascii=False, indent=2) - ) + async with aiofiles.open(dump_path, 'w', encoding='utf-8') as dump_file: + await dump_file.write(json_lib.dumps(dump_structure, ensure_ascii=False, indent=2)) size = dump_path.stat().st_size if dump_path.exists() else 0 logger.info( - "✅ PostgreSQL экспортирован через ORM в JSON (%s)", + '✅ PostgreSQL экспортирован через ORM в JSON (%s)', dump_path, ) return { - "type": "postgresql", - "path": dump_path.name, - "size_bytes": size, - "format": "json", - "tool": "orm", - "format_version": "orm-1.0", - "tables_count": tables_count, - "total_records": total_records, + 'type': 'postgresql', + 'path': dump_path.name, + 'size_bytes': size, + 'format': 'json', + 'tool': 'orm', + 'format_version': 'orm-1.0', + 'tables_count': tables_count, + 'total_records': total_records, } async def _dump_sqlite(self, dump_path: Path): sqlite_path = Path(settings.SQLITE_PATH) if not sqlite_path.exists(): - raise FileNotFoundError(f"SQLite база данных не найдена по пути {sqlite_path}") + raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}') dump_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path) - logger.info("✅ SQLite база данных скопирована (%s)", dump_path) + logger.info('✅ SQLite база данных скопирована (%s)', dump_path) async def _export_database_via_orm( self, - models_to_backup: List[Any], - ) -> Tuple[Dict[str, List[Dict[str, Any]]], Dict[str, List[Dict[str, Any]]], int, int]: - backup_data: Dict[str, List[Dict[str, Any]]] = {} + models_to_backup: list[Any], + ) -> tuple[dict[str, list[dict[str, Any]]], dict[str, list[dict[str, Any]]], int, int]: + backup_data: dict[str, list[dict[str, Any]]] = {} total_records = 0 async with AsyncSessionLocal() as db: try: for model in models_to_backup: table_name = model.__tablename__ - logger.info("📊 Экспортируем таблицу: %s", table_name) + logger.info('📊 Экспортируем таблицу: %s', table_name) query = select(model) @@ -493,9 +501,9 @@ class BackupService: result = await db.execute(query) records = result.scalars().all() - table_data: List[Dict[str, Any]] = [] + table_data: list[dict[str, Any]] = [] for record in records: - record_dict: Dict[str, Any] = {} + record_dict: dict[str, Any] = {} for column in model.__table__.columns: value = getattr(record, column.name) @@ -504,10 +512,8 @@ class BackupService: elif isinstance(value, datetime): record_dict[column.name] = value.isoformat() elif isinstance(value, (list, dict)): - record_dict[column.name] = ( - json_lib.dumps(value) if value else None - ) - elif hasattr(value, "__dict__"): + record_dict[column.name] = json_lib.dumps(value) if value else None + elif hasattr(value, '__dict__'): record_dict[column.name] = str(value) else: record_dict[column.name] = value @@ -518,7 +524,7 @@ class BackupService: total_records += len(table_data) logger.info( - "✅ Экспортировано %s записей из %s", + '✅ Экспортировано %s записей из %s', len(table_data), table_name, ) @@ -531,12 +537,12 @@ class BackupService: return backup_data, association_data, total_records, tables_count except Exception as exc: - logger.error("Ошибка при экспорте данных: %s", exc) + logger.error('Ошибка при экспорте данных: %s', exc) raise exc - async def _collect_files(self, staging_dir: Path, include_logs: bool) -> List[Dict[str, Any]]: - files_info: List[Dict[str, Any]] = [] - files_dir = staging_dir / "files" + async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]: + files_info: list[dict[str, Any]] = [] + files_dir = staging_dir / 'files' files_dir.mkdir(parents=True, exist_ok=True) app_config_path = settings.get_app_config_path() @@ -545,37 +551,41 @@ class BackupService: if src.exists(): dest = files_dir / src.name await asyncio.to_thread(shutil.copy2, src, dest) - files_info.append({ - "path": str(src), - "relative_path": f"files/{src.name}", - }) + files_info.append( + { + 'path': str(src), + 'relative_path': f'files/{src.name}', + } + ) if include_logs and settings.LOG_FILE: log_path = Path(settings.LOG_FILE) if log_path.exists(): dest = files_dir / log_path.name await asyncio.to_thread(shutil.copy2, log_path, dest) - files_info.append({ - "path": str(log_path), - "relative_path": f"files/{log_path.name}", - }) + files_info.append( + { + 'path': str(log_path), + 'relative_path': f'files/{log_path.name}', + } + ) if not files_info and files_dir.exists(): files_dir.rmdir() return files_info - async def _collect_data_snapshot(self, staging_dir: Path) -> Dict[str, Any]: - data_dir = staging_dir / "data" - snapshot_info: Dict[str, Any] = { - "path": str(self.data_dir), - "items": 0, + async def _collect_data_snapshot(self, staging_dir: Path) -> dict[str, Any]: + data_dir = staging_dir / 'data' + snapshot_info: dict[str, Any] = { + 'path': str(self.data_dir), + 'items': 0, } if not self.data_dir.exists(): return snapshot_info - counter = {"items": 0} + counter = {'items': 0} def _copy_data(): data_dir.mkdir(parents=True, exist_ok=True) @@ -588,15 +598,15 @@ class BackupService: shutil.copytree(item, destination, dirs_exist_ok=True) else: shutil.copy2(item, destination) - counter["items"] += 1 + counter['items'] += 1 await asyncio.to_thread(_copy_data) - snapshot_info["items"] = counter["items"] + snapshot_info['items'] = counter['items'] return snapshot_info def _is_archive_backup(self, backup_path: Path) -> bool: suffixes = backup_path.suffixes - if (len(suffixes) >= 2 and suffixes[-2:] == [".tar", ".gz"]) or (suffixes and suffixes[-1] == ".tar"): + if (len(suffixes) >= 2 and suffixes[-2:] == ['.tar', '.gz']) or (suffixes and suffixes[-1] == '.tar'): return True try: return tarfile.is_tarfile(backup_path) @@ -607,80 +617,84 @@ class BackupService: self, backup_path: Path, clear_existing: bool, - ) -> Tuple[bool, str]: + ) -> tuple[bool, str]: with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - mode = "r:gz" if backup_path.suffixes and backup_path.suffixes[-1] == ".gz" else "r" + mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r' with tarfile.open(backup_path, mode) as tar: tar.extractall(temp_path) - metadata_path = temp_path / "metadata.json" + metadata_path = temp_path / 'metadata.json' if not metadata_path.exists(): - return False, "❌ Метаданные бекапа отсутствуют" + return False, '❌ Метаданные бекапа отсутствуют' - async with aiofiles.open(metadata_path, "r", encoding="utf-8") as meta_file: + async with aiofiles.open(metadata_path, encoding='utf-8') as meta_file: metadata = json_lib.loads(await meta_file.read()) - logger.info("📊 Загружен бекап формата %s", metadata.get("format_version", "unknown")) + logger.info('📊 Загружен бекап формата %s', metadata.get('format_version', 'unknown')) - database_info = metadata.get("database", {}) - data_snapshot_info = metadata.get("data_snapshot", {}) - files_info = metadata.get("files", []) + database_info = metadata.get('database', {}) + metadata.get('data_snapshot', {}) + files_info = metadata.get('files', []) - if database_info.get("type") == "postgresql": - db_format = database_info.get("format", "sql") - default_name = "database.json" if db_format == "json" else "database.sql" - dump_file = temp_path / database_info.get("path", default_name) + if database_info.get('type') == 'postgresql': + db_format = database_info.get('format', 'sql') + default_name = 'database.json' if db_format == 'json' else 'database.sql' + dump_file = temp_path / database_info.get('path', default_name) - if db_format == "json": + if db_format == 'json': await self._restore_postgres_json(dump_file, clear_existing) else: await self._restore_postgres(dump_file, clear_existing) else: - dump_file = temp_path / database_info.get("path", "database.sqlite") + dump_file = temp_path / database_info.get('path', 'database.sqlite') await self._restore_sqlite(dump_file, clear_existing) - data_dir = temp_path / "data" + data_dir = temp_path / 'data' if data_dir.exists(): await self._restore_data_snapshot(data_dir, clear_existing) if files_info: await self._restore_files(files_info, temp_path) - message = (f"✅ Восстановление завершено!\n" - f"📊 Таблиц: {metadata.get('tables_count', 0)}\n" - f"📈 Записей: {metadata.get('total_records', 0):,}\n" - f"📅 Дата бекапа: {metadata.get('timestamp', 'неизвестно')}") + message = ( + f'✅ Восстановление завершено!\n' + f'📊 Таблиц: {metadata.get("tables_count", 0)}\n' + f'📈 Записей: {metadata.get("total_records", 0):,}\n' + f'📅 Дата бекапа: {metadata.get("timestamp", "неизвестно")}' + ) logger.info(message) return True, message async def _restore_postgres(self, dump_path: Path, clear_existing: bool): if not dump_path.exists(): - raise FileNotFoundError(f"Dump PostgreSQL не найден: {dump_path}") + raise FileNotFoundError(f'Dump PostgreSQL не найден: {dump_path}') - psql_path = self._resolve_command_path("psql", "PSQL_PATH") + psql_path = self._resolve_command_path('psql', 'PSQL_PATH') if not psql_path: raise FileNotFoundError( - "psql не найден в PATH. Установите клиент PostgreSQL или выполните восстановление из JSON дампа" + 'psql не найден в PATH. Установите клиент PostgreSQL или выполните восстановление из JSON дампа' ) env = os.environ.copy() - env.update({ - "PGHOST": settings.POSTGRES_HOST, - "PGPORT": str(settings.POSTGRES_PORT), - "PGUSER": settings.POSTGRES_USER, - "PGPASSWORD": settings.POSTGRES_PASSWORD, - }) + env.update( + { + 'PGHOST': settings.POSTGRES_HOST, + 'PGPORT': str(settings.POSTGRES_PORT), + 'PGUSER': settings.POSTGRES_USER, + 'PGPASSWORD': settings.POSTGRES_PASSWORD, + } + ) if clear_existing: - logger.info("🗑️ Полная очистка схемы PostgreSQL перед восстановлением") + logger.info('🗑️ Полная очистка схемы PostgreSQL перед восстановлением') drop_command = [ psql_path, settings.POSTGRES_DB, - "-c", - "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public;", + '-c', + 'DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public;', ] proc = await asyncio.create_subprocess_exec( *drop_command, @@ -690,13 +704,13 @@ class BackupService: ) _, stderr = await proc.communicate() if proc.returncode != 0: - raise RuntimeError(f"Не удалось очистить схему: {stderr.decode()}") + raise RuntimeError(f'Не удалось очистить схему: {stderr.decode()}') - logger.info("📥 Восстановление PostgreSQL через psql (%s)...", psql_path) + logger.info('📥 Восстановление PostgreSQL через psql (%s)...', psql_path) restore_command = [ psql_path, settings.POSTGRES_DB, - "-f", + '-f', str(dump_path), ] proc = await asyncio.create_subprocess_exec( @@ -708,20 +722,20 @@ class BackupService: stdout, stderr = await proc.communicate() if proc.returncode != 0: - raise RuntimeError(f"Ошибка psql: {stderr.decode()}") + raise RuntimeError(f'Ошибка psql: {stderr.decode()}') - logger.info("✅ PostgreSQL восстановлен (%s)", dump_path) + logger.info('✅ PostgreSQL восстановлен (%s)', dump_path) async def _restore_postgres_json(self, dump_path: Path, clear_existing: bool): if not dump_path.exists(): - raise FileNotFoundError(f"JSON дамп PostgreSQL не найден: {dump_path}") + raise FileNotFoundError(f'JSON дамп PostgreSQL не найден: {dump_path}') - async with aiofiles.open(dump_path, "r", encoding="utf-8") as dump_file: + async with aiofiles.open(dump_path, encoding='utf-8') as dump_file: dump_data = json_lib.loads(await dump_file.read()) - metadata = dump_data.get("metadata", {}) - backup_data = dump_data.get("data", {}) - association_data = dump_data.get("associations", {}) + metadata = dump_data.get('metadata', {}) + backup_data = dump_data.get('data', {}) + association_data = dump_data.get('associations', {}) await self._restore_database_payload( backup_data, @@ -730,11 +744,11 @@ class BackupService: clear_existing, ) - logger.info("✅ PostgreSQL восстановлен из ORM JSON (%s)", dump_path) + logger.info('✅ PostgreSQL восстановлен из ORM JSON (%s)', dump_path) async def _restore_sqlite(self, dump_path: Path, clear_existing: bool): if not dump_path.exists(): - raise FileNotFoundError(f"SQLite файл не найден: {dump_path}") + raise FileNotFoundError(f'SQLite файл не найден: {dump_path}') target_path = Path(settings.SQLITE_PATH) target_path.parent.mkdir(parents=True, exist_ok=True) @@ -743,7 +757,7 @@ class BackupService: target_path.unlink() await asyncio.to_thread(shutil.copy2, dump_path, target_path) - logger.info("✅ SQLite база восстановлена (%s)", target_path) + logger.info('✅ SQLite база восстановлена (%s)', target_path) async def _restore_data_snapshot(self, source_dir: Path, clear_existing: bool): if not source_dir.exists(): @@ -768,45 +782,45 @@ class BackupService: shutil.copy2(item, destination) await asyncio.to_thread(_restore) - logger.info("📁 Снимок директории data восстановлен") + logger.info('📁 Снимок директории data восстановлен') - async def _restore_files(self, files_info: List[Dict[str, Any]], temp_path: Path): + async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path): for file_info in files_info: - relative_path = file_info.get("relative_path") - target_path = Path(file_info.get("path", "")) + relative_path = file_info.get('relative_path') + target_path = Path(file_info.get('path', '')) if not relative_path or not target_path: continue source_file = temp_path / relative_path if not source_file.exists(): - logger.warning("Файл %s отсутствует в архиве", relative_path) + logger.warning('Файл %s отсутствует в архиве', relative_path) continue target_path.parent.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(shutil.copy2, source_file, target_path) - logger.info("📁 Файл %s восстановлен", target_path) + logger.info('📁 Файл %s восстановлен', target_path) async def _restore_database_payload( self, - backup_data: Dict[str, List[Dict[str, Any]]], - association_data: Dict[str, List[Dict[str, Any]]], - metadata: Dict[str, Any], + backup_data: dict[str, list[dict[str, Any]]], + association_data: dict[str, list[dict[str, Any]]], + metadata: dict[str, Any], clear_existing: bool, - ) -> Tuple[int, int]: + ) -> tuple[int, int]: if not backup_data: - raise ValueError("❌ Файл бекапа не содержит данных") + raise ValueError('❌ Файл бекапа не содержит данных') logger.info( - "📊 Загружен дамп: %s", - metadata.get("timestamp", "неизвестная дата"), + '📊 Загружен дамп: %s', + metadata.get('timestamp', 'неизвестная дата'), ) - estimated_records = metadata.get("total_records") + estimated_records = metadata.get('total_records') if estimated_records is None: estimated_records = sum(len(records) for records in backup_data.values()) estimated_records += sum(len(records) for records in association_data.values()) - logger.info("📈 Содержит %s записей", estimated_records) + logger.info('📈 Содержит %s записей', estimated_records) restored_records = 0 restored_tables = 0 @@ -814,15 +828,13 @@ class BackupService: async with AsyncSessionLocal() as db: try: if clear_existing: - logger.warning("🗑️ Очищаем существующие данные...") + logger.warning('🗑️ Очищаем существующие данные...') await self._clear_database_tables(db, backup_data) models_for_restore = self._get_models_for_backup(True) - models_by_table = { - model.__tablename__: model for model in models_for_restore - } + models_by_table = {model.__tablename__: model for model in models_for_restore} - pre_restore_tables = {"promo_groups", "tariffs"} + pre_restore_tables = {'promo_groups', 'tariffs'} for table_name in pre_restore_tables: model = models_by_table.get(table_name) if not model: @@ -833,7 +845,7 @@ class BackupService: continue logger.info( - "🔥 Восстанавливаем таблицу %s (%s записей)", + '🔥 Восстанавливаем таблицу %s (%s записей)', table_name, len(records), ) @@ -848,7 +860,7 @@ class BackupService: if restored: restored_tables += 1 - logger.info("✅ Таблица %s восстановлена", table_name) + logger.info('✅ Таблица %s восстановлена', table_name) await self._restore_users_without_referrals( db, @@ -859,7 +871,7 @@ class BackupService: for model in models_for_restore: table_name = model.__tablename__ - if table_name == "users" or table_name in pre_restore_tables: + if table_name == 'users' or table_name in pre_restore_tables: continue records = backup_data.get(table_name, []) @@ -867,7 +879,7 @@ class BackupService: continue logger.info( - "🔥 Восстанавливаем таблицу %s (%s записей)", + '🔥 Восстанавливаем таблицу %s (%s записей)', table_name, len(records), ) @@ -882,7 +894,7 @@ class BackupService: if restored: restored_tables += 1 - logger.info("✅ Таблица %s восстановлена", table_name) + logger.info('✅ Таблица %s восстановлена', table_name) # Flush все изменения перед обновлением реферальных связей await db.flush() @@ -901,7 +913,7 @@ class BackupService: except Exception as exc: await db.rollback() - logger.error("Ошибка при восстановлении: %s", exc) + logger.error('Ошибка при восстановлении: %s', exc) raise exc return restored_tables, restored_records @@ -910,21 +922,21 @@ class BackupService: self, backup_path: Path, clear_existing: bool, - ) -> Tuple[bool, str]: + ) -> tuple[bool, str]: if backup_path.suffix == '.gz': async with aiofiles.open(backup_path, 'rb') as f: compressed_data = await f.read() uncompressed_data = gzip.decompress(compressed_data).decode('utf-8') backup_structure = json_lib.loads(uncompressed_data) else: - async with aiofiles.open(backup_path, 'r', encoding='utf-8') as f: + async with aiofiles.open(backup_path, encoding='utf-8') as f: file_content = await f.read() backup_structure = json_lib.loads(file_content) - metadata = backup_structure.get("metadata", {}) - backup_data = backup_structure.get("data", {}) - association_data = backup_structure.get("associations", {}) - file_snapshots = backup_structure.get("files", {}) + metadata = backup_structure.get('metadata', {}) + backup_data = backup_structure.get('data', {}) + association_data = backup_structure.get('associations', {}) + file_snapshots = backup_structure.get('files', {}) try: restored_tables, restored_records = await self._restore_database_payload( @@ -939,36 +951,36 @@ class BackupService: if file_snapshots: restored_files = await self._restore_file_snapshots(file_snapshots) if restored_files: - logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}") + logger.info(f'📁 Восстановлено файлов конфигурации: {restored_files}') - message = (f"✅ Восстановление завершено!\n" - f"📊 Таблиц: {restored_tables}\n" - f"📈 Записей: {restored_records:,}\n" - f"📅 Дата бекапа: {metadata.get('timestamp', 'неизвестно')}") + message = ( + f'✅ Восстановление завершено!\n' + f'📊 Таблиц: {restored_tables}\n' + f'📈 Записей: {restored_records:,}\n' + f'📅 Дата бекапа: {metadata.get("timestamp", "неизвестно")}' + ) logger.info(message) return True, message async def _restore_users_without_referrals(self, db: AsyncSession, backup_data: dict, models_by_table: dict): - users_data = backup_data.get("users", []) + users_data = backup_data.get('users', []) if not users_data: return - - logger.info(f"👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей") - - User = models_by_table["users"] - + + logger.info(f'👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей') + + User = models_by_table['users'] + for user_data in users_data: try: - processed_data = self._process_record_data(user_data, User, "users") - processed_data['referred_by_id'] = None - + processed_data = self._process_record_data(user_data, User, 'users') + processed_data['referred_by_id'] = None + if 'id' in processed_data: - existing_user = await db.execute( - select(User).where(User.id == processed_data['id']) - ) + existing_user = await db.execute(select(User).where(User.id == processed_data['id'])) existing = existing_user.scalar_one_or_none() - + if existing: for key, value in processed_data.items(): if key != 'id': @@ -979,68 +991,64 @@ class BackupService: else: instance = User(**processed_data) db.add(instance) - + except Exception as e: - logger.error(f"Ошибка при восстановлении пользователя: {e}") + logger.error(f'Ошибка при восстановлении пользователя: {e}') await db.rollback() raise e - + await db.commit() - logger.info("✅ Пользователи без реферальных связей восстановлены") + logger.info('✅ Пользователи без реферальных связей восстановлены') async def _update_user_referrals(self, db: AsyncSession, backup_data: dict): - users_data = backup_data.get("users", []) + users_data = backup_data.get('users', []) if not users_data: return - - logger.info("🔗 Обновляем реферальные связи пользователей") - + + logger.info('🔗 Обновляем реферальные связи пользователей') + for user_data in users_data: try: referred_by_id = user_data.get('referred_by_id') user_id = user_data.get('id') - + if referred_by_id and user_id: - referrer_result = await db.execute( - select(User).where(User.id == referred_by_id) - ) + referrer_result = await db.execute(select(User).where(User.id == referred_by_id)) referrer = referrer_result.scalar_one_or_none() - + if referrer: - user_result = await db.execute( - select(User).where(User.id == user_id) - ) + user_result = await db.execute(select(User).where(User.id == user_id)) user = user_result.scalar_one_or_none() - + if user: user.referred_by_id = referred_by_id else: - logger.warning(f"Пользователь {user_id} не найден для обновления реферальной связи") + logger.warning(f'Пользователь {user_id} не найден для обновления реферальной связи') else: - logger.warning(f"Реферер {referred_by_id} не найден для пользователя {user_id}") - + logger.warning(f'Реферер {referred_by_id} не найден для пользователя {user_id}') + except Exception as e: - logger.error(f"Ошибка при обновлении реферальной связи: {e}") + logger.error(f'Ошибка при обновлении реферальной связи: {e}') continue - + await db.commit() - logger.info("✅ Реферальные связи обновлены") + logger.info('✅ Реферальные связи обновлены') def _process_record_data(self, record_data: dict, model, table_name: str) -> dict: processed_data = {} - + for key, value in record_data.items(): if value is None: processed_data[key] = None continue - + column = getattr(model.__table__.columns, key, None) if column is None: - logger.warning(f"Колонка {key} не найдена в модели {table_name}") + logger.warning(f'Колонка {key} не найдена в модели {table_name}') continue - + column_type_str = str(column.type).upper() - + if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str): try: if 'T' in value: @@ -1048,16 +1056,20 @@ class BackupService: else: processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S') except (ValueError, TypeError) as e: - logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}") + logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}') processed_data[key] = datetime.utcnow() elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str): processed_data[key] = value.lower() in ('true', '1', 'yes', 'on') - elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str): + elif ( + 'INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str + ) and isinstance(value, str): try: processed_data[key] = int(value) except ValueError: processed_data[key] = 0 - elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str): + elif ( + 'FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str + ) and isinstance(value, str): try: processed_data[key] = float(value) except ValueError: @@ -1074,49 +1086,42 @@ class BackupService: processed_data[key] = None else: processed_data[key] = value - + return processed_data - def _get_primary_key_column(self, model) -> Optional[str]: + def _get_primary_key_column(self, model) -> str | None: for col in model.__table__.columns: if col.primary_key: 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]]] = {} + 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}") + 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}" - ) + logger.info(f'✅ Экспортировано {len(rows)} связей из {table_name}') except Exception as e: - logger.error(f"Ошибка экспорта таблицы связей {table_name}: {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]: + 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: + 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 + db, association_data['server_squad_promo_groups'], clear_existing ) restored_tables += 1 restored_records += restored @@ -1124,10 +1129,7 @@ class BackupService: return restored_tables, restored_records async def _restore_server_squad_promo_groups( - self, - db: AsyncSession, - records: List[Dict[str, Any]], - clear_existing: bool + self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool ) -> int: if not records: return 0 @@ -1138,14 +1140,11 @@ class BackupService: restored = 0 for record in records: - server_id = record.get("server_squad_id") - promo_id = record.get("promo_group_id") + 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 - ) + logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record) continue try: @@ -1161,25 +1160,19 @@ class BackupService: if existing.scalar_one_or_none() is not None: logger.debug( - "Запись server_squad_promo_groups (%s, %s) уже существует", + 'Запись server_squad_promo_groups (%s, %s) уже существует', server_id, promo_id, ) continue await db.execute( - server_squad_promo_groups.insert().values( - server_squad_id=server_id, - promo_group_id=promo_id - ) + server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id) ) restored += 1 except Exception as e: logger.error( - "Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s", - server_id, - promo_id, - e + 'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e ) await db.rollback() raise e @@ -1187,45 +1180,36 @@ class BackupService: return restored async def _restore_table_records( - self, - db: AsyncSession, - model, - table_name: str, - records: List[Dict[str, Any]], - clear_existing: bool + self, db: AsyncSession, model, table_name: str, records: list[dict[str, Any]], clear_existing: bool ) -> int: restored_count = 0 # Кешируем существующие tariff_id для проверки FK existing_tariff_ids = set() - if table_name == "subscriptions": + if table_name == 'subscriptions': try: result = await db.execute(select(Tariff.id)) existing_tariff_ids = {row[0] for row in result.fetchall()} - logger.info(f"📋 Найдено {len(existing_tariff_ids)} существующих тарифов для валидации FK") + logger.info(f'📋 Найдено {len(existing_tariff_ids)} существующих тарифов для валидации FK') except Exception as e: - logger.warning(f"⚠️ Не удалось получить список тарифов: {e}") + logger.warning(f'⚠️ Не удалось получить список тарифов: {e}') for record_data in records: try: processed_data = self._process_record_data(record_data, model, table_name) # Валидация FK для subscriptions.tariff_id - if table_name == "subscriptions" and "tariff_id" in processed_data: - tariff_id = processed_data.get("tariff_id") + if table_name == 'subscriptions' and 'tariff_id' in processed_data: + tariff_id = processed_data.get('tariff_id') if tariff_id is not None and tariff_id not in existing_tariff_ids: - logger.warning( - f"⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки" - ) - processed_data["tariff_id"] = None + logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки') + processed_data['tariff_id'] = None primary_key_col = self._get_primary_key_column(model) if primary_key_col and primary_key_col in processed_data: existing_record = await db.execute( - select(model).where( - getattr(model, primary_key_col) == processed_data[primary_key_col] - ) + select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col]) ) existing = existing_record.scalar_one_or_none() @@ -1243,103 +1227,112 @@ class BackupService: restored_count += 1 except Exception as e: - logger.error(f"Ошибка восстановления записи в {table_name}: {e}") - logger.error(f"Проблемные данные: {record_data}") + logger.error(f'Ошибка восстановления записи в {table_name}: {e}') + logger.error(f'Проблемные данные: {record_data}') await db.rollback() raise e return restored_count - async def _clear_database_tables(self, db: AsyncSession, backup_data: Optional[Dict[str, Any]] = None): + async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None): tables_order = [ - "server_squad_promo_groups", - "ticket_messages", "tickets", "support_audit_logs", - "advertising_campaign_registrations", "advertising_campaigns", - "subscription_servers", "sent_notifications", - "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", - "tariffs", # tariffs должен очищаться ПОСЛЕ subscriptions (FK зависимость) - "server_squads", "squads", "service_rules", - "system_settings", "web_api_tokens", "monitoring_logs" + 'server_squad_promo_groups', + 'ticket_messages', + 'tickets', + 'support_audit_logs', + 'advertising_campaign_registrations', + 'advertising_campaigns', + 'subscription_servers', + 'sent_notifications', + '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', + 'tariffs', # tariffs должен очищаться ПОСЛЕ subscriptions (FK зависимость) + 'server_squads', + 'squads', + 'service_rules', + 'system_settings', + 'web_api_tokens', + 'monitoring_logs', ] # Таблицы, которые не нужно очищать если в бекапе нет данных для них # (чтобы сохранить существующие настройки) - preserve_if_no_backup = {"tariffs", "promo_groups", "server_squads", "squads"} + preserve_if_no_backup = {'tariffs', 'promo_groups', 'server_squads', 'squads'} for table_name in tables_order: # Проверяем, нужно ли сохранить таблицу if backup_data and table_name in preserve_if_no_backup: if not backup_data.get(table_name): - logger.info(f"⏭️ Пропускаем очистку {table_name} (нет данных в бекапе)") + logger.info(f'⏭️ Пропускаем очистку {table_name} (нет данных в бекапе)') continue try: - await db.execute(text(f"DELETE FROM {table_name}")) - logger.info(f"🗑️ Очищена таблица {table_name}") + await db.execute(text(f'DELETE FROM {table_name}')) + logger.info(f'🗑️ Очищена таблица {table_name}') except Exception as e: - logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}") + logger.warning(f'⚠️ Не удалось очистить таблицу {table_name}: {e}') - async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]: - snapshots: Dict[str, Dict[str, Any]] = {} + 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: + async with aiofiles.open(path_obj, encoding='utf-8') as f: content = await f.read() - snapshots["app_config"] = { - "path": str(path_obj), - "content": content, - "modified_at": datetime.fromtimestamp( - path_obj.stat().st_mtime - ).isoformat() + snapshots['app_config'] = { + 'path': str(path_obj), + 'content': content, + 'modified_at': datetime.fromtimestamp(path_obj.stat().st_mtime).isoformat(), } - logger.info( - "📁 Добавлен в бекап файл конфигурации: %s", - path_obj - ) + logger.info('📁 Добавлен в бекап файл конфигурации: %s', path_obj) except Exception as e: - logger.error( - "Ошибка чтения файла конфигурации %s: %s", - path_obj, - e - ) + logger.error('Ошибка чтения файла конфигурации %s: %s', path_obj, e) return snapshots - async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int: + 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") + 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", "")) + await f.write(app_config_snapshot.get('content', '')) restored_files += 1 - logger.info("📁 Файл app-config восстановлен по пути %s", target_path) + logger.info('📁 Файл app-config восстановлен по пути %s', target_path) except Exception as e: - logger.error("Ошибка восстановления файла %s: %s", target_path, e) + logger.error('Ошибка восстановления файла %s: %s', target_path, e) return restored_files - async def get_backup_list(self) -> List[Dict[str, Any]]: + async def get_backup_list(self) -> list[dict[str, Any]]: backups = [] - + try: - for backup_file in sorted(self.backup_dir.glob("backup_*"), reverse=True): + for backup_file in sorted(self.backup_dir.glob('backup_*'), reverse=True): if not backup_file.is_file(): continue @@ -1347,10 +1340,10 @@ class BackupService: metadata = {} if self._is_archive_backup(backup_file): - mode = "r:gz" if backup_file.suffixes and backup_file.suffixes[-1] == ".gz" else "r" + mode = 'r:gz' if backup_file.suffixes and backup_file.suffixes[-1] == '.gz' else 'r' with tarfile.open(backup_file, mode) as tar: try: - member = tar.getmember("metadata.json") + member = tar.getmember('metadata.json') with tar.extractfile(member) as meta_file: metadata = json_lib.load(meta_file) except KeyError: @@ -1360,85 +1353,93 @@ class BackupService: with gzip.open(backup_file, 'rt', encoding='utf-8') as f: backup_structure = json_lib.load(f) else: - with open(backup_file, 'r', encoding='utf-8') as f: + with open(backup_file, encoding='utf-8') as f: backup_structure = json_lib.load(f) - metadata = backup_structure.get("metadata", {}) + metadata = backup_structure.get('metadata', {}) file_stats = backup_file.stat() backup_info = { - "filename": backup_file.name, - "filepath": str(backup_file), - "timestamp": metadata.get("timestamp", datetime.fromtimestamp(file_stats.st_mtime).isoformat()), - "tables_count": metadata.get("tables_count", metadata.get("database", {}).get("tables_count", 0)), - "total_records": metadata.get("total_records", metadata.get("database", {}).get("total_records", 0)), - "compressed": self._is_archive_backup(backup_file) or backup_file.suffix == '.gz', - "file_size_bytes": file_stats.st_size, - "file_size_mb": round(file_stats.st_size / 1024 / 1024, 2), - "created_by": metadata.get("created_by"), - "database_type": metadata.get("database_type", metadata.get("database", {}).get("type", "unknown")), - "version": metadata.get("format_version", metadata.get("version", "1.0")), + 'filename': backup_file.name, + 'filepath': str(backup_file), + 'timestamp': metadata.get('timestamp', datetime.fromtimestamp(file_stats.st_mtime).isoformat()), + 'tables_count': metadata.get( + 'tables_count', metadata.get('database', {}).get('tables_count', 0) + ), + 'total_records': metadata.get( + 'total_records', metadata.get('database', {}).get('total_records', 0) + ), + 'compressed': self._is_archive_backup(backup_file) or backup_file.suffix == '.gz', + 'file_size_bytes': file_stats.st_size, + 'file_size_mb': round(file_stats.st_size / 1024 / 1024, 2), + 'created_by': metadata.get('created_by'), + 'database_type': metadata.get( + 'database_type', metadata.get('database', {}).get('type', 'unknown') + ), + 'version': metadata.get('format_version', metadata.get('version', '1.0')), } backups.append(backup_info) except Exception as e: - logger.error(f"Ошибка чтения метаданных {backup_file}: {e}") + logger.error(f'Ошибка чтения метаданных {backup_file}: {e}') file_stats = backup_file.stat() - backups.append({ - "filename": backup_file.name, - "filepath": str(backup_file), - "timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(), - "tables_count": "?", - "total_records": "?", - "compressed": backup_file.suffix == '.gz', - "file_size_bytes": file_stats.st_size, - "file_size_mb": round(file_stats.st_size / 1024 / 1024, 2), - "created_by": None, - "database_type": "unknown", - "version": "unknown", - "error": f"Ошибка чтения: {str(e)}" - }) - + backups.append( + { + 'filename': backup_file.name, + 'filepath': str(backup_file), + 'timestamp': datetime.fromtimestamp(file_stats.st_mtime).isoformat(), + 'tables_count': '?', + 'total_records': '?', + 'compressed': backup_file.suffix == '.gz', + 'file_size_bytes': file_stats.st_size, + 'file_size_mb': round(file_stats.st_size / 1024 / 1024, 2), + 'created_by': None, + 'database_type': 'unknown', + 'version': 'unknown', + 'error': f'Ошибка чтения: {e!s}', + } + ) + except Exception as e: - logger.error(f"Ошибка получения списка бекапов: {e}") - + logger.error(f'Ошибка получения списка бекапов: {e}') + return backups - async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]: + async def delete_backup(self, backup_filename: str) -> tuple[bool, str]: try: backup_path = self.backup_dir / backup_filename - + if not backup_path.exists(): - return False, f"❌ Файл бекапа не найден: {backup_filename}" - + return False, f'❌ Файл бекапа не найден: {backup_filename}' + backup_path.unlink() - message = f"✅ Бекап {backup_filename} удален" + message = f'✅ Бекап {backup_filename} удален' logger.info(message) - + return True, message - + except Exception as e: - error_msg = f"❌ Ошибка удаления бекапа: {str(e)}" + error_msg = f'❌ Ошибка удаления бекапа: {e!s}' logger.error(error_msg) return False, error_msg async def _cleanup_old_backups(self): try: backups = await self.get_backup_list() - + if len(backups) > self._settings.max_backups_keep: - backups.sort(key=lambda x: x.get("timestamp", ""), reverse=True) - - for backup in backups[self._settings.max_backups_keep:]: + backups.sort(key=lambda x: x.get('timestamp', ''), reverse=True) + + for backup in backups[self._settings.max_backups_keep :]: try: - await self.delete_backup(backup["filename"]) - logger.info(f"🗑️ Удален старый бекап: {backup['filename']}") + await self.delete_backup(backup['filename']) + logger.info(f'🗑️ Удален старый бекап: {backup["filename"]}') except Exception as e: - logger.error(f"Ошибка удаления старого бекапа {backup['filename']}: {e}") - + logger.error(f'Ошибка удаления старого бекапа {backup["filename"]}: {e}') + except Exception as e: - logger.error(f"Ошибка очистки старых бекапов: {e}") + logger.error(f'Ошибка очистки старых бекапов: {e}') async def get_backup_settings(self) -> BackupSettings: return self._settings @@ -1448,16 +1449,16 @@ class BackupService: for key, value in kwargs.items(): if hasattr(self._settings, key): setattr(self._settings, key, value) - + if self._settings.auto_backup_enabled: await self.start_auto_backup() else: await self.stop_auto_backup() - + return True - + except Exception as e: - logger.error(f"Ошибка обновления настроек бекапов: {e}") + logger.error(f'Ошибка обновления настроек бекапов: {e}') return False async def start_auto_backup(self): @@ -1469,17 +1470,17 @@ class BackupService: interval = self._get_backup_interval() self._auto_backup_task = asyncio.create_task(self._auto_backup_loop(next_run)) logger.info( - "📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s", + '📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s', interval.total_seconds() / 3600, - next_run.strftime("%d.%m.%Y %H:%M:%S") + next_run.strftime('%d.%m.%Y %H:%M:%S'), ) async def stop_auto_backup(self): if self._auto_backup_task and not self._auto_backup_task.done(): self._auto_backup_task.cancel() - logger.info("ℹ️ Автобекапы остановлены") + logger.info('ℹ️ Автобекапы остановлены') - async def _auto_backup_loop(self, next_run: Optional[datetime] = None): + async def _auto_backup_loop(self, next_run: datetime | None = None): next_run = next_run or self._calculate_next_backup_datetime() interval = self._get_backup_interval() @@ -1490,67 +1491,58 @@ class BackupService: if delay > 0: logger.info( - "⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)", - next_run.strftime("%d.%m.%Y %H:%M:%S"), - delay / 3600 + '⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)', + next_run.strftime('%d.%m.%Y %H:%M:%S'), + delay / 3600, ) await asyncio.sleep(delay) else: logger.info( - "⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно", - next_run.strftime("%d.%m.%Y %H:%M:%S") + '⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно', + next_run.strftime('%d.%m.%Y %H:%M:%S'), ) - logger.info("📄 Запуск автоматического бекапа...") + logger.info('📄 Запуск автоматического бекапа...') success, message, _ = await self.create_backup() if success: - logger.info(f"✅ Автобекап завершен: {message}") + logger.info(f'✅ Автобекап завершен: {message}') else: - logger.error(f"❌ Ошибка автобекапа: {message}") + logger.error(f'❌ Ошибка автобекапа: {message}') next_run = next_run + interval except asyncio.CancelledError: break except Exception as e: - logger.error(f"Ошибка в цикле автобекапов: {e}") + logger.error(f'Ошибка в цикле автобекапов: {e}') next_run = datetime.now() + interval - async def _send_backup_notification( - self, - event_type: str, - message: str, - file_path: str = None - ): + async def _send_backup_notification(self, event_type: str, message: str, file_path: str = None): try: if not settings.is_admin_notifications_enabled(): return - - icons = { - "success": "✅", - "error": "❌", - "restore_success": "🔥", - "restore_error": "❌" - } - - icon = icons.get(event_type, "ℹ️") - notification_text = f"{icon} СИСТЕМА БЕКАПОВ\n\n{message}" - + + icons = {'success': '✅', 'error': '❌', 'restore_success': '🔥', 'restore_error': '❌'} + + icon = icons.get(event_type, 'ℹ️') + notification_text = f'{icon} СИСТЕМА БЕКАПОВ\n\n{message}' + if file_path: - notification_text += f"\n📁 {Path(file_path).name}" - - notification_text += f"\n\n⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" - + notification_text += f'\n📁 {Path(file_path).name}' + + notification_text += f'\n\n⏰ {datetime.now().strftime("%d.%m.%Y %H:%M:%S")}' + try: from app.services.admin_notification_service import AdminNotificationService + admin_service = AdminNotificationService(self.bot) await admin_service._send_message(notification_text) except Exception as e: - logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}") - + logger.error(f'Ошибка отправки уведомления через AdminNotificationService: {e}') + except Exception as e: - logger.error(f"Ошибка отправки уведомления о бекапе: {e}") + logger.error(f'Ошибка отправки уведомления о бекапе: {e}') async def _send_backup_file_to_chat(self, file_path: str): try: @@ -1570,59 +1562,56 @@ class BackupService: if temp_zip_path: file_to_send = temp_zip_path - caption = f"📦 Резервная копия\n\n" + caption = '📦 Резервная копия\n\n' if temp_zip_path: - caption += f"🔐 Архив защищён паролем\n\n" - caption += f"⏰ {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}" + caption += '🔐 Архив защищён паролем\n\n' + caption += f'⏰ {datetime.now().strftime("%d.%m.%Y %H:%M:%S")}' send_kwargs = { 'chat_id': chat_id, 'document': FSInputFile(file_to_send), 'caption': caption, - 'parse_mode': 'HTML' + 'parse_mode': 'HTML', } if settings.BACKUP_SEND_TOPIC_ID: send_kwargs['message_thread_id'] = settings.BACKUP_SEND_TOPIC_ID await self.bot.send_document(**send_kwargs) - logger.info(f"Бекап отправлен в чат {chat_id}") + logger.info(f'Бекап отправлен в чат {chat_id}') if temp_zip_path and Path(temp_zip_path).exists(): try: Path(temp_zip_path).unlink() except Exception as cleanup_error: - logger.warning(f"Не удалось удалить временный архив: {cleanup_error}") + logger.warning(f'Не удалось удалить временный архив: {cleanup_error}') except Exception as e: - logger.error(f"Ошибка отправки бекапа в чат: {e}") + logger.error(f'Ошибка отправки бекапа в чат: {e}') - async def _create_password_protected_archive(self, file_path: str, password: str) -> Optional[str]: + async def _create_password_protected_archive(self, file_path: str, password: str) -> str | None: try: source_path = Path(file_path) if not source_path.exists(): - logger.error(f"Исходный файл бекапа не найден: {file_path}") + logger.error(f'Исходный файл бекапа не найден: {file_path}') return None - zip_filename = source_path.stem + ".zip" + zip_filename = source_path.stem + '.zip' zip_path = source_path.parent / zip_filename def create_zip(): with pyzipper.AESZipFile( - zip_path, - 'w', - compression=pyzipper.ZIP_DEFLATED, - encryption=pyzipper.WZ_AES + zip_path, 'w', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES ) as zf: zf.setpassword(password.encode('utf-8')) zf.write(source_path, arcname=source_path.name) await asyncio.to_thread(create_zip) - logger.info(f"Создан защищённый паролем архив: {zip_path}") + logger.info(f'Создан защищённый паролем архив: {zip_path}') return str(zip_path) except Exception as e: - logger.error(f"Ошибка создания защищённого архива: {e}") + logger.error(f'Ошибка создания защищённого архива: {e}') return None diff --git a/app/services/ban_notification_service.py b/app/services/ban_notification_service.py index 630bf391..730bbbe1 100644 --- a/app/services/ban_notification_service.py +++ b/app/services/ban_notification_service.py @@ -1,23 +1,22 @@ """ Сервис для отправки уведомлений от ban системы пользователям """ + import logging -from typing import Optional, Tuple -from datetime import datetime from aiogram import Bot from aiogram.exceptions import TelegramAPIError -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from sqlalchemy.ext.asyncio import AsyncSession +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import User -from app.services.remnawave_service import remnawave_service -from app.services.notification_delivery_service import ( - notification_delivery_service, - NotificationType, -) from app.config import settings +from app.database.models import User +from app.services.notification_delivery_service import ( + NotificationType, + notification_delivery_service, +) +from app.services.remnawave_service import remnawave_service logger = logging.getLogger(__name__) @@ -25,26 +24,22 @@ logger = logging.getLogger(__name__) def get_delete_keyboard() -> InlineKeyboardMarkup: """Клавиатура с кнопкой удаления уведомления""" - return InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🗑 Удалить", callback_data="ban_notify:delete")] - ]) + return InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='🗑 Удалить', callback_data='ban_notify:delete')]] + ) class BanNotificationService: """Сервис для отправки уведомлений о банах пользователям""" def __init__(self): - self._bot: Optional[Bot] = None + self._bot: Bot | None = None def set_bot(self, bot: Bot): """Установить инстанс бота для отправки сообщений""" self._bot = bot - async def _find_user_by_identifier( - self, - db: AsyncSession, - user_identifier: str - ) -> Optional[User]: + async def _find_user_by_identifier(self, db: AsyncSession, user_identifier: str) -> User | None: """ Найти пользователя по email или user_id из Remnawave Panel @@ -60,14 +55,12 @@ class BanNotificationService: telegram_id = await remnawave_service.get_telegram_id_by_email(user_identifier) if telegram_id: # Ищем пользователя по telegram_id - result = await db.execute( - select(User).where(User.telegram_id == telegram_id) - ) + result = await db.execute(select(User).where(User.telegram_id == telegram_id)) user = result.scalar_one_or_none() if user: return user except Exception as e: - logger.warning(f"Не удалось получить telegram_id через remnawave: {e}") + logger.warning(f'Не удалось получить telegram_id через remnawave: {e}') # Если не нашли через remnawave, пытаемся искать по email в подписках # (это может быть полезно если у пользователя есть подписка с таким email) @@ -76,16 +69,13 @@ class BanNotificationService: from app.database.models import Subscription result = await db.execute( - select(User) - .join(Subscription) - .where(Subscription.email == user_identifier) - .limit(1) + select(User).join(Subscription).where(Subscription.email == user_identifier).limit(1) ) user = result.scalar_one_or_none() if user: return user except Exception as e: - logger.warning(f"Ошибка поиска пользователя по email в подписках: {e}") + logger.warning(f'Ошибка поиска пользователя по email в подписках: {e}') return None @@ -97,8 +87,8 @@ class BanNotificationService: ip_count: int, limit: int, ban_minutes: int, - node_name: Optional[str] = None - ) -> Tuple[bool, str, Optional[int]]: + node_name: str | None = None, + ) -> tuple[bool, str, int | None]: """ Отправить уведомление о блокировке пользователю @@ -106,79 +96,58 @@ class BanNotificationService: (success, message, telegram_id) """ if not self._bot: - return False, "Бот не инициализирован", None + return False, 'Бот не инициализирован', None # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f"Пользователь {user_identifier} не найден в базе данных") - return False, f"Пользователь не найден: {user_identifier}", None + logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + return False, f'Пользователь не найден: {user_identifier}', None # Формируем информацию о ноде (заметно выделяем) - node_info = f"🖥 Нода: {node_name}" if node_name else "" + node_info = f'🖥 Нода: {node_name}' if node_name else '' # Формируем сообщение из настроек # Используем безопасное форматирование - если {node_info} отсутствует в шаблоне, не будет ошибки - format_vars = { - "ip_count": ip_count, - "limit": limit, - "ban_minutes": ban_minutes, - "node_info": node_info - } + format_vars = {'ip_count': ip_count, 'limit': limit, 'ban_minutes': ban_minutes, 'node_info': node_info} try: message_text = settings.BAN_MSG_PUNISHMENT.format(**format_vars) except KeyError: # Старый шаблон без {node_info} - форматируем без него - message_text = settings.BAN_MSG_PUNISHMENT.format( - ip_count=ip_count, - limit=limit, - ban_minutes=ban_minutes - ) + message_text = settings.BAN_MSG_PUNISHMENT.format(ip_count=ip_count, limit=limit, ban_minutes=ban_minutes) # Добавляем информацию о ноде в конец, если она есть if node_info: - message_text = message_text.rstrip() + f"\n\n{node_info.rstrip()}" + message_text = message_text.rstrip() + f'\n\n{node_info.rstrip()}' # Handle email-only users via notification delivery service if not user.telegram_id: - reason = f"IP лимит превышен: {ip_count}/{limit}. Бан на {ban_minutes} минут." + reason = f'IP лимит превышен: {ip_count}/{limit}. Бан на {ban_minutes} минут.' if node_name: - reason += f" Нода: {node_name}" + reason += f' Нода: {node_name}' success = await notification_delivery_service.notify_ban( user=user, reason=reason, ) if success: - logger.info(f"Email уведомление о бане отправлено пользователю {user.id}") - return True, "Email уведомление отправлено", None - return False, "Не удалось отправить email уведомление", None + logger.info(f'Email уведомление о бане отправлено пользователю {user.id}') + return True, 'Email уведомление отправлено', None + return False, 'Не удалось отправить email уведомление', None # Отправляем сообщение с кнопкой удаления try: await self._bot.send_message( - chat_id=user.telegram_id, - text=message_text, - parse_mode="HTML", - reply_markup=get_delete_keyboard() + chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info( - f"Уведомление о бане отправлено пользователю {username} " - f"(telegram_id: {user.telegram_id})" - ) - return True, "Уведомление отправлено", user.telegram_id + logger.info(f'Уведомление о бане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: - logger.error( - f"Ошибка отправки уведомления пользователю {username} " - f"(telegram_id: {user.telegram_id}): {e}" - ) - return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + logger.error(f'Ошибка отправки уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}') + return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_enabled_notification( - self, - db: AsyncSession, - user_identifier: str, - username: str - ) -> Tuple[bool, str, Optional[int]]: + self, db: AsyncSession, user_identifier: str, username: str + ) -> tuple[bool, str, int | None]: """ Отправить уведомление о разблокировке пользователю @@ -186,13 +155,13 @@ class BanNotificationService: (success, message, telegram_id) """ if not self._bot: - return False, "Бот не инициализирован", None + return False, 'Бот не инициализирован', None # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f"Пользователь {user_identifier} не найден в базе данных") - return False, f"Пользователь не найден: {user_identifier}", None + logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек message_text = settings.BAN_MSG_ENABLED @@ -201,38 +170,25 @@ class BanNotificationService: if not user.telegram_id: success = await notification_delivery_service.notify_unban(user=user) if success: - logger.info(f"Email уведомление о разбане отправлено пользователю {user.id}") - return True, "Email уведомление отправлено", None - return False, "Не удалось отправить email уведомление", None + logger.info(f'Email уведомление о разбане отправлено пользователю {user.id}') + return True, 'Email уведомление отправлено', None + return False, 'Не удалось отправить email уведомление', None # Отправляем сообщение с кнопкой удаления try: await self._bot.send_message( - chat_id=user.telegram_id, - text=message_text, - parse_mode="HTML", - reply_markup=get_delete_keyboard() + chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info( - f"Уведомление о разбане отправлено пользователю {username} " - f"(telegram_id: {user.telegram_id})" - ) - return True, "Уведомление отправлено", user.telegram_id + logger.info(f'Уведомление о разбане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: - logger.error( - f"Ошибка отправки уведомления пользователю {username} " - f"(telegram_id: {user.telegram_id}): {e}" - ) - return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + logger.error(f'Ошибка отправки уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}') + return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_warning_notification( - self, - db: AsyncSession, - user_identifier: str, - username: str, - warning_message: str - ) -> Tuple[bool, str, Optional[int]]: + self, db: AsyncSession, user_identifier: str, username: str, warning_message: str + ) -> tuple[bool, str, int | None]: """ Отправить предупреждение пользователю @@ -240,52 +196,43 @@ class BanNotificationService: (success, message, telegram_id) """ if not self._bot: - return False, "Бот не инициализирован", None + return False, 'Бот не инициализирован', None # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f"Пользователь {user_identifier} не найден в базе данных") - return False, f"Пользователь не найден: {user_identifier}", None + logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек - message_text = settings.BAN_MSG_WARNING.format( - warning_message=warning_message - ) + message_text = settings.BAN_MSG_WARNING.format(warning_message=warning_message) # Handle email-only users via notification delivery service if not user.telegram_id: - context = {"message": warning_message} + context = {'message': warning_message} success = await notification_delivery_service.send_notification( user=user, notification_type=NotificationType.WARNING_NOTIFICATION, context=context, ) if success: - logger.info(f"Email предупреждение отправлено пользователю {user.id}") - return True, "Email предупреждение отправлено", None - return False, "Не удалось отправить email предупреждение", None + logger.info(f'Email предупреждение отправлено пользователю {user.id}') + return True, 'Email предупреждение отправлено', None + return False, 'Не удалось отправить email предупреждение', None # Отправляем сообщение с кнопкой удаления try: await self._bot.send_message( - chat_id=user.telegram_id, - text=message_text, - parse_mode="HTML", - reply_markup=get_delete_keyboard() + chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info( - f"Предупреждение отправлено пользователю {username} " - f"(telegram_id: {user.telegram_id})" - ) - return True, "Предупреждение отправлено", user.telegram_id + logger.info(f'Предупреждение отправлено пользователю {username} (telegram_id: {user.telegram_id})') + return True, 'Предупреждение отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f"Ошибка отправки предупреждения пользователю {username} " - f"(telegram_id: {user.telegram_id}): {e}" + f'Ошибка отправки предупреждения пользователю {username} (telegram_id: {user.telegram_id}): {e}' ) - return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_network_wifi_notification( self, @@ -293,9 +240,9 @@ class BanNotificationService: user_identifier: str, username: str, ban_minutes: int, - network_type: Optional[str] = None, - node_name: Optional[str] = None - ) -> Tuple[bool, str, Optional[int]]: + network_type: str | None = None, + node_name: str | None = None, + ) -> tuple[bool, str, int | None]: """ Отправить уведомление о блокировке за использование WiFi сети @@ -303,71 +250,60 @@ class BanNotificationService: (success, message, telegram_id) """ if not self._bot: - return False, "Бот не инициализирован", None + return False, 'Бот не инициализирован', None # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f"Пользователь {user_identifier} не найден в базе данных") - return False, f"Пользователь не найден: {user_identifier}", None + logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек (заметно выделяем) - network_info = f"├ 🌐 Сеть: {network_type}\n" if network_type else "" - node_info = f"🖥 Нода: {node_name}" if node_name else "" + network_info = f'├ 🌐 Сеть: {network_type}\n' if network_type else '' + node_info = f'🖥 Нода: {node_name}' if node_name else '' - logger.info(f"WiFi notification: node_name={node_name!r}, node_info={node_info!r}") + logger.info(f'WiFi notification: node_name={node_name!r}, node_info={node_info!r}') # Безопасное форматирование - format_vars = { - "ban_minutes": ban_minutes, - "network_info": network_info, - "node_info": node_info - } + format_vars = {'ban_minutes': ban_minutes, 'network_info': network_info, 'node_info': node_info} try: message_text = settings.BAN_MSG_WIFI.format(**format_vars) except KeyError: - logger.warning("BAN_MSG_WIFI template missing placeholders, adding node_info to end") + logger.warning('BAN_MSG_WIFI template missing placeholders, adding node_info to end') message_text = settings.BAN_MSG_WIFI.format(ban_minutes=ban_minutes) extra_info = (network_info + node_info).strip() if extra_info: - message_text = message_text.rstrip() + f"\n\n{extra_info}" + message_text = message_text.rstrip() + f'\n\n{extra_info}' # Handle email-only users via notification delivery service if not user.telegram_id: - reason = f"Использование WiFi сети запрещено. Бан на {ban_minutes} минут." + reason = f'Использование WiFi сети запрещено. Бан на {ban_minutes} минут.' if network_type: - reason += f" Сеть: {network_type}" + reason += f' Сеть: {network_type}' if node_name: - reason += f" Нода: {node_name}" + reason += f' Нода: {node_name}' success = await notification_delivery_service.notify_ban( user=user, reason=reason, ) if success: - logger.info(f"Email WiFi уведомление отправлено пользователю {user.id}") - return True, "Email уведомление отправлено", None - return False, "Не удалось отправить email уведомление", None + logger.info(f'Email WiFi уведомление отправлено пользователю {user.id}') + return True, 'Email уведомление отправлено', None + return False, 'Не удалось отправить email уведомление', None # Отправляем сообщение с кнопкой удаления try: await self._bot.send_message( - chat_id=user.telegram_id, - text=message_text, - parse_mode="HTML", - reply_markup=get_delete_keyboard() + chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) - logger.info( - f"Уведомление о WiFi бане отправлено пользователю {username} " - f"(telegram_id: {user.telegram_id})" - ) - return True, "Уведомление отправлено", user.telegram_id + logger.info(f'Уведомление о WiFi бане отправлено пользователю {username} (telegram_id: {user.telegram_id})') + return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f"Ошибка отправки WiFi уведомления пользователю {username} " - f"(telegram_id: {user.telegram_id}): {e}" + f'Ошибка отправки WiFi уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}' ) - return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + return False, f'Ошибка Telegram API: {e!s}', user.telegram_id async def send_network_mobile_notification( self, @@ -375,9 +311,9 @@ class BanNotificationService: user_identifier: str, username: str, ban_minutes: int, - network_type: Optional[str] = None, - node_name: Optional[str] = None - ) -> Tuple[bool, str, Optional[int]]: + network_type: str | None = None, + node_name: str | None = None, + ) -> tuple[bool, str, int | None]: """ Отправить уведомление о блокировке за использование мобильной сети @@ -385,68 +321,59 @@ class BanNotificationService: (success, message, telegram_id) """ if not self._bot: - return False, "Бот не инициализирован", None + return False, 'Бот не инициализирован', None # Находим пользователя user = await self._find_user_by_identifier(db, user_identifier) if not user: - logger.warning(f"Пользователь {user_identifier} не найден в базе данных") - return False, f"Пользователь не найден: {user_identifier}", None + logger.warning(f'Пользователь {user_identifier} не найден в базе данных') + return False, f'Пользователь не найден: {user_identifier}', None # Формируем сообщение из настроек (заметно выделяем) - network_info = f"├ 🌐 Сеть: {network_type}\n" if network_type else "" - node_info = f"🖥 Нода: {node_name}" if node_name else "" + network_info = f'├ 🌐 Сеть: {network_type}\n' if network_type else '' + node_info = f'🖥 Нода: {node_name}' if node_name else '' # Безопасное форматирование - format_vars = { - "ban_minutes": ban_minutes, - "network_info": network_info, - "node_info": node_info - } + format_vars = {'ban_minutes': ban_minutes, 'network_info': network_info, 'node_info': node_info} try: message_text = settings.BAN_MSG_MOBILE.format(**format_vars) except KeyError: message_text = settings.BAN_MSG_MOBILE.format(ban_minutes=ban_minutes) extra_info = (network_info + node_info).strip() if extra_info: - message_text = message_text.rstrip() + f"\n\n{extra_info}" + message_text = message_text.rstrip() + f'\n\n{extra_info}' # Handle email-only users via notification delivery service if not user.telegram_id: - reason = f"Использование мобильной сети запрещено. Бан на {ban_minutes} минут." + reason = f'Использование мобильной сети запрещено. Бан на {ban_minutes} минут.' if network_type: - reason += f" Сеть: {network_type}" + reason += f' Сеть: {network_type}' if node_name: - reason += f" Нода: {node_name}" + reason += f' Нода: {node_name}' success = await notification_delivery_service.notify_ban( user=user, reason=reason, ) if success: - logger.info(f"Email Mobile уведомление отправлено пользователю {user.id}") - return True, "Email уведомление отправлено", None - return False, "Не удалось отправить email уведомление", None + logger.info(f'Email Mobile уведомление отправлено пользователю {user.id}') + return True, 'Email уведомление отправлено', None + return False, 'Не удалось отправить email уведомление', None # Отправляем сообщение с кнопкой удаления try: await self._bot.send_message( - chat_id=user.telegram_id, - text=message_text, - parse_mode="HTML", - reply_markup=get_delete_keyboard() + chat_id=user.telegram_id, text=message_text, parse_mode='HTML', reply_markup=get_delete_keyboard() ) logger.info( - f"Уведомление о Mobile бане отправлено пользователю {username} " - f"(telegram_id: {user.telegram_id})" + f'Уведомление о Mobile бане отправлено пользователю {username} (telegram_id: {user.telegram_id})' ) - return True, "Уведомление отправлено", user.telegram_id + return True, 'Уведомление отправлено', user.telegram_id except TelegramAPIError as e: logger.error( - f"Ошибка отправки Mobile уведомления пользователю {username} " - f"(telegram_id: {user.telegram_id}): {e}" + f'Ошибка отправки Mobile уведомления пользователю {username} (telegram_id: {user.telegram_id}): {e}' ) - return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id + return False, f'Ошибка Telegram API: {e!s}', user.telegram_id # Глобальный экземпляр сервиса diff --git a/app/services/blacklist_service.py b/app/services/blacklist_service.py index 2f011394..aec7db7d 100644 --- a/app/services/blacklist_service.py +++ b/app/services/blacklist_service.py @@ -2,11 +2,13 @@ Сервис для работы с черным списком пользователей Проверяет пользователей по списку из GitHub репозитория """ + import asyncio import logging -from typing import List, Dict, Optional, Tuple from datetime import datetime, timedelta + import aiohttp + from app.config import settings @@ -17,7 +19,7 @@ class BlacklistService: """ Сервис для проверки пользователей по черному списку """ - + def __init__(self): self.blacklist_data = [] # Список в формате [(telegram_id, username, reason), ...] self.last_update = None @@ -30,7 +32,7 @@ class BlacklistService: """Проверяет, включена ли проверка черного списка""" return getattr(settings, 'BLACKLIST_CHECK_ENABLED', False) - def get_blacklist_github_url(self) -> Optional[str]: + def get_blacklist_github_url(self) -> str | None: """Получает URL к файлу черного списка на GitHub""" return getattr(settings, 'BLACKLIST_GITHUB_URL', None) @@ -53,24 +55,23 @@ class BlacklistService: async with self.lock: github_url = self.get_blacklist_github_url() if not github_url: - logger.warning("URL к черному списку не задан в настройках") + logger.warning('URL к черному списку не задан в настройках') return False try: # Заменяем github.com на raw.githubusercontent.com для получения raw содержимого - if "github.com" in github_url: - raw_url = github_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") + if 'github.com' in github_url: + raw_url = github_url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/') else: raw_url = github_url # Получаем содержимое файла - async with aiohttp.ClientSession() as session: - async with session.get(raw_url) as response: - if response.status != 200: - logger.error(f"Ошибка при получении черного списка: статус {response.status}") - return False + async with aiohttp.ClientSession() as session, session.get(raw_url) as response: + if response.status != 200: + logger.error(f'Ошибка при получении черного списка: статус {response.status}') + return False - content = await response.text() + content = await response.text() # Разбираем содержимое файла blacklist_data = [] @@ -91,17 +92,17 @@ class BlacklistService: telegram_id = int(parts[0]) # Первое число - это Telegram ID # Всё остальное - просто комментарий, не используем его для логики # Но можем использовать первую часть после ID как username для отображения - username = "" + username = '' if len(parts) > 1: # Берем вторую часть как username (если начинается с @) if parts[1].startswith('@'): username = parts[1] # По умолчанию используем "Занесен в черный список", если нет другой информации - reason = "Занесен в черный список" + reason = 'Занесен в черный список' # Если есть запятая в строке, можем использовать часть после нее как причину - full_line_after_id = line[len(str(telegram_id)):].strip() + full_line_after_id = line[len(str(telegram_id)) :].strip() if ',' in full_line_after_id: # Извлекаем причину после запятой after_comma = full_line_after_id.split(',', 1)[1].strip() @@ -110,21 +111,23 @@ class BlacklistService: blacklist_data.append((telegram_id, username, reason)) except ValueError: # Если не удается преобразовать в число, это не ID - logger.warning(f"Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}") + logger.warning( + f'Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}' + ) self.blacklist_data = blacklist_data self.last_update = datetime.utcnow() - logger.info(f"Черный список успешно обновлен. Найдено {len(blacklist_data)} записей") + logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей') return True except ValueError as e: - logger.error(f"Ошибка при парсинге ID из черного списка: {e}") + logger.error(f'Ошибка при парсинге ID из черного списка: {e}') return False except Exception as e: - logger.error(f"Ошибка при обновлении черного списка: {e}") + logger.error(f'Ошибка при обновлении черного списка: {e}') return False - async def is_user_blacklisted(self, telegram_id: int, username: Optional[str] = None) -> Tuple[bool, Optional[str]]: + async def is_user_blacklisted(self, telegram_id: int, username: str | None = None) -> tuple[bool, str | None]: """ Проверяет, находится ли пользователь в черном списке @@ -140,44 +143,44 @@ class BlacklistService: # Проверяем, является ли пользователь администратором и нужно ли его игнорировать if self.should_ignore_admins() and self.is_admin(telegram_id): - logger.info(f"Пользователь {telegram_id} является администратором, игнорируем проверку черного списка") + logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка') return False, None # Если черный список пуст или устарел, обновляем его interval_hours = self.get_blacklist_update_interval_hours() required_interval = timedelta(hours=interval_hours) - if not self.blacklist_data or (self.last_update and - datetime.utcnow() - self.last_update > required_interval): + if not self.blacklist_data or (self.last_update and datetime.utcnow() - self.last_update > required_interval): await self.update_blacklist() # Проверяем по Telegram ID for bl_id, bl_username, bl_reason in self.blacklist_data: if bl_id == telegram_id: - logger.info(f"Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}") + logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}') return True, bl_reason # Проверяем по username, если он передан if username: for bl_id, bl_username, bl_reason in self.blacklist_data: - if bl_username and (bl_username == username or bl_username == f"@{username}"): - logger.info(f"Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}") + if bl_username and (bl_username == username or bl_username == f'@{username}'): + logger.info( + f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}' + ) return True, bl_reason return False, None - async def get_all_blacklisted_users(self) -> List[Tuple[int, str, str]]: + async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]: """ Возвращает весь черный список """ interval_hours = self.get_blacklist_update_interval_hours() required_interval = timedelta(hours=interval_hours) - if not self.blacklist_data or (self.last_update and - datetime.utcnow() - self.last_update > required_interval): + if not self.blacklist_data or (self.last_update and datetime.utcnow() - self.last_update > required_interval): await self.update_blacklist() return self.blacklist_data.copy() - async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[Tuple[int, str, str]]: + async def get_user_by_telegram_id(self, telegram_id: int) -> tuple[int, str, str] | None: """ Возвращает информацию о пользователе из черного списка по Telegram ID @@ -192,7 +195,7 @@ class BlacklistService: return (bl_id, bl_username, bl_reason) return None - async def get_user_by_username(self, username: str) -> Optional[Tuple[int, str, str]]: + async def get_user_by_username(self, username: str) -> tuple[int, str, str] | None: """ Возвращает информацию о пользователе из черного списка по username @@ -203,7 +206,7 @@ class BlacklistService: Кортеж (telegram_id, username, reason) или None, если не найден """ # Проверяем как с @, так и без - username_with_at = f"@{username}" if not username.startswith('@') else username + username_with_at = f'@{username}' if not username.startswith('@') else username username_without_at = username.lstrip('@') for bl_id, bl_username, bl_reason in self.blacklist_data: @@ -211,19 +214,18 @@ class BlacklistService: return (bl_id, bl_username, bl_reason) return None - async def force_update_blacklist(self) -> Tuple[bool, str]: + async def force_update_blacklist(self) -> tuple[bool, str]: """ Принудительно обновляет черный список - + Returns: Кортеж (успешно, сообщение) """ success = await self.update_blacklist() if success: - return True, f"Черный список обновлен успешно. Записей: {len(self.blacklist_data)}" - else: - return False, "Ошибка обновления черного списка" + return True, f'Черный список обновлен успешно. Записей: {len(self.blacklist_data)}' + return False, 'Ошибка обновления черного списка' # Глобальный экземпляр сервиса -blacklist_service = BlacklistService() \ No newline at end of file +blacklist_service = BlacklistService() diff --git a/app/services/broadcast_service.py b/app/services/broadcast_service.py index 59d530c5..36353645 100644 --- a/app/services/broadcast_service.py +++ b/app/services/broadcast_service.py @@ -4,7 +4,6 @@ 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 @@ -22,7 +21,7 @@ from app.handlers.admin.messages import ( logger = logging.getLogger(__name__) -VALID_MEDIA_TYPES = {"photo", "video", "document"} +VALID_MEDIA_TYPES = {'photo', 'video', 'document'} LARGE_BROADCAST_THRESHOLD = 20_000 PROGRESS_UPDATE_STEP = 5_000 @@ -31,7 +30,7 @@ PROGRESS_UPDATE_STEP = 5_000 class BroadcastMediaConfig: type: str file_id: str - caption: Optional[str] = None + caption: str | None = None @dataclass(slots=True) @@ -39,8 +38,8 @@ class BroadcastConfig: target: str message_text: str selected_buttons: list[str] - media: Optional[BroadcastMediaConfig] = None - initiator_name: Optional[str] = None + media: BroadcastMediaConfig | None = None + initiator_name: str | None = None @dataclass(slots=True) @@ -53,7 +52,7 @@ class BroadcastService: """Handles broadcast execution triggered from the admin web API.""" def __init__(self) -> None: - self._bot: Optional[Bot] = None + self._bot: Bot | None = None self._tasks: dict[int, _BroadcastTask] = {} self._lock = asyncio.Lock() @@ -66,7 +65,7 @@ class BroadcastService: async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None: if self._bot is None: - logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id) + logger.error('Невозможно запустить рассылку %s: бот не инициализирован', broadcast_id) await self._mark_failed(broadcast_id) return @@ -74,12 +73,12 @@ class BroadcastService: async with self._lock: if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done(): - logger.warning("Рассылка %s уже запущена", broadcast_id) + logger.warning('Рассылка %s уже запущена', broadcast_id) return task = asyncio.create_task( self._run_broadcast(broadcast_id, config, cancel_event), - name=f"broadcast-{broadcast_id}", + 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)) @@ -110,10 +109,10 @@ class BroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error("Запись рассылки %s не найдена в БД", broadcast_id) + logger.error('Запись рассылки %s не найдена в БД', broadcast_id) return - broadcast.status = "in_progress" + broadcast.status = 'in_progress' broadcast.sent_count = 0 broadcast.failed_count = 0 await session.commit() @@ -123,7 +122,7 @@ class BroadcastService: async with AsyncSessionLocal() as session: broadcast = await session.get(BroadcastHistory, broadcast_id) if not broadcast: - logger.error("Запись рассылки %s удалена до запуска", broadcast_id) + logger.error('Запись рассылки %s удалена до запуска', broadcast_id) return broadcast.total_count = len(recipients) @@ -134,16 +133,14 @@ class BroadcastService: return if not recipients: - logger.info("Рассылка %s: получатели не найдены", broadcast_id) + 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) if len(recipients) > LARGE_BROADCAST_THRESHOLD: - logger.info( - "Запускаем стабильный режим рассылки для %s получателей", len(recipients) - ) + logger.info('Запускаем стабильный режим рассылки для %s получателей', len(recipients)) ( sent_count, failed_count, @@ -170,14 +167,14 @@ class BroadcastService: if cancelled_during_run: logger.info( - "Рассылка %s была отменена во время выполнения, финальный статус уже установлен", + 'Рассылка %s была отменена во время выполнения, финальный статус уже установлен', broadcast_id, ) return if cancel_event.is_set(): logger.info( - "Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус", + 'Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус', broadcast_id, ) @@ -191,14 +188,14 @@ class BroadcastService: 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) + except Exception as exc: + 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_"):] + if target.startswith('custom_'): + criteria = target[len('custom_') :] return await get_custom_users(session, criteria) return await get_target_users(session, target) @@ -207,7 +204,7 @@ class BroadcastService: broadcast_id: int, recipients: list, config: BroadcastConfig, - keyboard: Optional[InlineKeyboardMarkup], + keyboard: InlineKeyboardMarkup | None, cancel_event: asyncio.Event, ) -> tuple[int, int, bool]: """Базовый режим рассылки для небольших списков.""" @@ -224,7 +221,7 @@ class BroadcastService: if cancel_event.is_set(): return False - telegram_id = getattr(user, "telegram_id", None) + telegram_id = getattr(user, 'telegram_id', None) if telegram_id is None: # Email-пользователи без telegram_id - пропускаем (не считаем ошибкой) return None @@ -232,9 +229,9 @@ class BroadcastService: try: await self._deliver_message(telegram_id, config, keyboard) return True - except Exception as exc: # noqa: BLE001 + except Exception as exc: logger.error( - "Ошибка отправки рассылки %s пользователю %s: %s", + 'Ошибка отправки рассылки %s пользователю %s: %s', broadcast_id, telegram_id, exc, @@ -249,7 +246,7 @@ class BroadcastService: await self._mark_cancelled(broadcast_id, sent_count, failed_count) return sent_count, failed_count, True - batch = recipients[i:i + batch_size] + batch = recipients[i : i + batch_size] tasks = [send_single_message(user) for user in batch] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -272,7 +269,7 @@ class BroadcastService: broadcast_id: int, recipients: list, config: BroadcastConfig, - keyboard: Optional[InlineKeyboardMarkup], + keyboard: InlineKeyboardMarkup | None, cancel_event: asyncio.Event, ) -> tuple[int, int, bool]: """Режим рассылки с периодическим обновлением статуса для больших списков.""" @@ -288,7 +285,7 @@ class BroadcastService: if cancel_event.is_set(): return False - telegram_id = getattr(user, "telegram_id", None) + telegram_id = getattr(user, 'telegram_id', None) if telegram_id is None: # Email-пользователи без telegram_id - пропускаем (не считаем ошибкой) return None @@ -296,9 +293,9 @@ class BroadcastService: try: await self._deliver_message(telegram_id, config, keyboard) return True - except Exception as exc: # noqa: BLE001 + except Exception as exc: logger.error( - "Ошибка отправки рассылки %s пользователю %s: %s", + 'Ошибка отправки рассылки %s пользователю %s: %s', broadcast_id, telegram_id, exc, @@ -311,7 +308,7 @@ class BroadcastService: await self._mark_cancelled(broadcast_id, sent_count, failed_count) return sent_count, failed_count, True - batch = recipients[i:i + batch_size] + batch = recipients[i : i + batch_size] tasks = [send_single_message(user) for user in batch] results = await asyncio.gather(*tasks, return_exceptions=True) @@ -332,7 +329,7 @@ class BroadcastService: return sent_count, failed_count, False - def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]: + def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None: if selected_buttons is None: selected_buttons = [] return create_broadcast_keyboard(selected_buttons) @@ -341,28 +338,28 @@ class BroadcastService: self, telegram_id: int, config: BroadcastConfig, - keyboard: Optional[InlineKeyboardMarkup], + keyboard: InlineKeyboardMarkup | None, ) -> None: if not self._bot: - raise RuntimeError("Телеграм-бот не инициализирован") + 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": + 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": + 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": + elif config.media.type == 'document': await self._bot.send_document( chat_id=telegram_id, document=config.media.file_id, @@ -389,9 +386,7 @@ class BroadcastService: broadcast_id, sent_count, failed_count, - status="cancelled" if cancelled else ( - "completed" if failed_count == 0 else "partial" - ), + status='cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial'), ) async def _mark_cancelled( @@ -417,7 +412,7 @@ class BroadcastService: broadcast_id, sent_count, failed_count, - status="failed", + status='failed', ) async def _update_progress( @@ -432,7 +427,7 @@ class BroadcastService: broadcast_id, sent_count, failed_count, - status="in_progress", + status='in_progress', update_completed_at=False, ) @@ -466,18 +461,15 @@ class BroadcastService: except InterfaceError as exc: attempts += 1 logger.warning( - "Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2", + 'Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2', broadcast_id, exc, attempts, ) await asyncio.sleep(0.2) except SQLAlchemyError: - logger.exception( - "Не удалось обновить статус рассылки %s", broadcast_id - ) + logger.exception('Не удалось обновить статус рассылки %s', broadcast_id) return broadcast_service = BroadcastService() - diff --git a/app/services/bulk_ban_service.py b/app/services/bulk_ban_service.py index e07f0386..7884201a 100644 --- a/app/services/bulk_ban_service.py +++ b/app/services/bulk_ban_service.py @@ -3,15 +3,14 @@ """ import logging -from typing import List, Tuple -from sqlalchemy.ext.asyncio import AsyncSession + from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.user import get_user_by_telegram_id -from app.services.user_service import UserService -from app.services.admin_notification_service import AdminNotificationService -from app.config import settings from app.database.models import UserStatus +from app.services.admin_notification_service import AdminNotificationService +from app.services.user_service import UserService logger = logging.getLogger(__name__) @@ -21,7 +20,7 @@ class BulkBanService: """ Сервис для массовой блокировки пользователей по списку Telegram ID """ - + def __init__(self): self.user_service = UserService() @@ -29,15 +28,15 @@ class BulkBanService: self, db: AsyncSession, admin_user_id: int, - telegram_ids: List[int], - reason: str = "Заблокирован администратором по списку", + telegram_ids: list[int], + reason: str = 'Заблокирован администратором по списку', bot: Bot = None, notify_admin: bool = True, - admin_name: str = "Администратор" - ) -> Tuple[int, int, List[int]]: + admin_name: str = 'Администратор', + ) -> tuple[int, int, list[int]]: """ Массовая блокировка пользователей по Telegram ID - + Args: db: Асинхронная сессия базы данных admin_user_id: ID администратора, который осуществляет блокировку @@ -46,83 +45,77 @@ class BulkBanService: bot: Бот для отправки уведомлений notify_admin: Отправлять ли уведомления администратору admin_name: Имя администратора для логирования - + Returns: Кортеж из (успешно заблокированных, не найденных, список ID с ошибками) """ successfully_banned = 0 not_found_users = [] error_ids = [] - + for telegram_id in telegram_ids: try: # Получаем пользователя по Telegram ID user = await get_user_by_telegram_id(db, telegram_id) - + if not user: - logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден") + logger.warning(f'Пользователь с Telegram ID {telegram_id} не найден') not_found_users.append(telegram_id) continue - + # Проверяем, что пользователь не заблокирован уже if user.status == UserStatus.BLOCKED.value: - logger.info(f"Пользователь {telegram_id} уже заблокирован") + logger.info(f'Пользователь {telegram_id} уже заблокирован') continue - + # Блокируем пользователя - ban_success = await self.user_service.block_user( - db, user.id, admin_user_id, reason - ) - + ban_success = await self.user_service.block_user(db, user.id, admin_user_id, reason) + if ban_success: successfully_banned += 1 - logger.info(f"Пользователь {telegram_id} успешно заблокирован") - + logger.info(f'Пользователь {telegram_id} успешно заблокирован') + # Отправляем уведомление пользователю, если возможно if bot: try: await bot.send_message( chat_id=telegram_id, text=( - f"🚫 Ваш аккаунт заблокирован\n\n" - f"Причина: {reason}\n\n" - f"Если вы считаете, что блокировка произошла ошибочно, " - f"обратитесь в поддержку." + f'🚫 Ваш аккаунт заблокирован\n\n' + f'Причина: {reason}\n\n' + f'Если вы считаете, что блокировка произошла ошибочно, ' + f'обратитесь в поддержку.' ), - parse_mode="HTML" + parse_mode='HTML', ) except Exception as e: - logger.warning(f"Не удалось отправить уведомление пользователю {telegram_id}: {e}") + logger.warning(f'Не удалось отправить уведомление пользователю {telegram_id}: {e}') else: - logger.error(f"Не удалось заблокировать пользователя {telegram_id}") + logger.error(f'Не удалось заблокировать пользователя {telegram_id}') error_ids.append(telegram_id) - + except Exception as e: - logger.error(f"Ошибка при блокировке пользователя {telegram_id}: {e}") + logger.error(f'Ошибка при блокировке пользователя {telegram_id}: {e}') error_ids.append(telegram_id) - + # Отправляем уведомление администратору if notify_admin and bot: try: admin_notification_service = AdminNotificationService(bot) await admin_notification_service.send_bulk_ban_notification( - admin_user_id, - successfully_banned, - len(not_found_users), - len(error_ids), - admin_name + admin_user_id, successfully_banned, len(not_found_users), len(error_ids), admin_name ) except Exception as e: - logger.error(f"Ошибка при отправке уведомления администратору: {e}") - + logger.error(f'Ошибка при отправке уведомления администратору: {e}') + logger.info( - f"Массовая блокировка завершена: успешно={successfully_banned}, " - f"не найдено={len(not_found_users)}, ошибки={len(error_ids)}" + f'Массовая блокировка завершена: успешно={successfully_banned}, ' + f'не найдено={len(not_found_users)}, ошибки={len(error_ids)}' ) - + return successfully_banned, len(not_found_users), error_ids - async def parse_telegram_ids_from_text(self, text: str) -> List[int]: + async def parse_telegram_ids_from_text(self, text: str) -> list[int]: """ Парсит Telegram ID из текста. Поддерживает различные форматы: - по одному ID на строку @@ -132,27 +125,26 @@ class BulkBanService: """ if not text: return [] - + # Удаляем лишние пробелы и разбиваем по переносам строк lines = text.strip().split('\n') ids = [] - + for line in lines: # Убираем комментарии и лишние пробелы line = line.strip() if not line or line.startswith('#'): continue - + # Разбиваем строку по запятым или пробелам tokens = line.replace(',', ' ').split() - + for token in tokens: token = token.strip() - + # Убираем символ @ если присутствует - if token.startswith('@'): - token = token[1:] - + token = token.removeprefix('@') + # Проверяем, является ли токен числом (Telegram ID) try: telegram_id = int(token) @@ -161,7 +153,7 @@ class BulkBanService: except ValueError: # Пропускаем, если не является числом continue - + # Убираем дубликаты, сохранив порядок unique_ids = [] seen = set() @@ -169,9 +161,9 @@ class BulkBanService: if tid not in seen: unique_ids.append(tid) seen.add(tid) - + return unique_ids # Создаем глобальный экземпляр сервиса -bulk_ban_service = BulkBanService() \ No newline at end of file +bulk_ban_service = BulkBanService() diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index ea701152..ffb9658c 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -1,6 +1,5 @@ import logging from dataclasses import dataclass -from typing import List, Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -10,11 +9,12 @@ from app.database.crud.subscription import ( create_paid_subscription, get_subscription_by_user_id, ) -from app.database.crud.user import add_user_balance from app.database.crud.tariff import get_tariff_by_id +from app.database.crud.user import add_user_balance from app.database.models import AdvertisingCampaign, User from app.services.subscription_service import SubscriptionService + logger = logging.getLogger(__name__) @@ -23,23 +23,23 @@ def _format_user_log(user: User) -> str: if user.telegram_id: return str(user.telegram_id) if user.email: - return f"{user.id} ({user.email})" - return f"#{user.id}" + return f'{user.id} ({user.email})' + return f'#{user.id}' @dataclass class CampaignBonusResult: success: bool - bonus_type: Optional[str] = None + bonus_type: str | None = None balance_kopeks: int = 0 - subscription_days: Optional[int] = None - subscription_traffic_gb: Optional[int] = None - subscription_device_limit: Optional[int] = None - subscription_squads: Optional[List[str]] = None + subscription_days: int | None = None + subscription_traffic_gb: int | None = None + subscription_device_limit: int | None = None + subscription_squads: list[str] | None = None # Поля для tariff - tariff_id: Optional[int] = None - tariff_name: Optional[str] = None - tariff_duration_days: Optional[int] = None + tariff_id: int | None = None + tariff_name: str | None = None + tariff_duration_days: int | None = None class AdvertisingCampaignService: @@ -53,9 +53,7 @@ class AdvertisingCampaignService: campaign: AdvertisingCampaign, ) -> CampaignBonusResult: if not campaign.is_active: - logger.warning( - "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id - ) + logger.warning('⚠️ Попытка выдать бонус по неактивной кампании %s', campaign.id) return CampaignBonusResult(success=False) if campaign.is_balance_bonus: @@ -70,7 +68,7 @@ class AdvertisingCampaignService: if campaign.is_tariff_bonus: return await self._apply_tariff_bonus(db, user, campaign) - logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type) + logger.error('❌ Неизвестный тип бонуса кампании: %s', campaign.bonus_type) return CampaignBonusResult(success=False) async def _apply_balance_bonus( @@ -81,7 +79,7 @@ class AdvertisingCampaignService: ) -> CampaignBonusResult: amount = campaign.balance_bonus_kopeks or 0 if amount <= 0: - logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id) + logger.info('ℹ️ Кампания %s не имеет бонуса на баланс', campaign.id) return CampaignBonusResult(success=False) description = f"Бонус за регистрацию по кампании '{campaign.name}'" @@ -99,12 +97,12 @@ class AdvertisingCampaignService: db, campaign_id=campaign.id, user_id=user.id, - bonus_type="balance", + bonus_type='balance', balance_bonus_kopeks=amount, ) logger.info( - "💰 Пользователю %s начислен бонус %s₽ по кампании %s", + '💰 Пользователю %s начислен бонус %s₽ по кампании %s', _format_user_log(user), amount / 100, campaign.id, @@ -112,7 +110,7 @@ class AdvertisingCampaignService: return CampaignBonusResult( success=True, - bonus_type="balance", + bonus_type='balance', balance_kopeks=amount, ) @@ -125,7 +123,7 @@ class AdvertisingCampaignService: existing_subscription = await get_subscription_by_user_id(db, user.id) if existing_subscription: logger.warning( - "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен", + '⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен', _format_user_log(user), campaign.id, ) @@ -134,7 +132,7 @@ class AdvertisingCampaignService: duration_days = campaign.subscription_duration_days or 0 if duration_days <= 0: logger.info( - "ℹ️ Кампания %s не содержит корректной длительности подписки", + 'ℹ️ Кампания %s не содержит корректной длительности подписки', campaign.id, ) return CampaignBonusResult(success=False) @@ -154,12 +152,12 @@ class AdvertisingCampaignService: squads = [trial_uuid] except Exception as error: logger.error( - "Не удалось подобрать сквад для кампании %s: %s", + 'Не удалось подобрать сквад для кампании %s: %s', campaign.id, error, ) - squad_uuid = squads[0] if squads else None + squads[0] if squads else None new_subscription = await create_paid_subscription( db=db, @@ -176,7 +174,7 @@ class AdvertisingCampaignService: await self.subscription_service.create_remnawave_user(db, new_subscription) except Exception as error: logger.error( - "❌ Ошибка синхронизации RemnaWave для кампании %s: %s", + '❌ Ошибка синхронизации RemnaWave для кампании %s: %s', campaign.id, error, ) @@ -185,12 +183,12 @@ class AdvertisingCampaignService: db, campaign_id=campaign.id, user_id=user.id, - bonus_type="subscription", + bonus_type='subscription', subscription_duration_days=duration_days, ) logger.info( - "🎁 Пользователю %s выдана подписка по кампании %s на %s дней", + '🎁 Пользователю %s выдана подписка по кампании %s на %s дней', _format_user_log(user), campaign.id, duration_days, @@ -198,7 +196,7 @@ class AdvertisingCampaignService: return CampaignBonusResult( success=True, - bonus_type="subscription", + bonus_type='subscription', subscription_days=duration_days, subscription_traffic_gb=traffic_limit or 0, subscription_device_limit=device_limit, @@ -216,18 +214,18 @@ class AdvertisingCampaignService: db, campaign_id=campaign.id, user_id=user.id, - bonus_type="none", + bonus_type='none', ) logger.info( - "📊 Пользователь %s зарегистрирован по ссылке кампании %s (без награды)", + '📊 Пользователь %s зарегистрирован по ссылке кампании %s (без награды)', _format_user_log(user), campaign.id, ) return CampaignBonusResult( success=True, - bonus_type="none", + bonus_type='none', ) async def _apply_tariff_bonus( @@ -240,7 +238,7 @@ class AdvertisingCampaignService: existing_subscription = await get_subscription_by_user_id(db, user.id) if existing_subscription: logger.warning( - "⚠️ У пользователя %s уже есть подписка, бонус тарифа кампании %s пропущен", + '⚠️ У пользователя %s уже есть подписка, бонус тарифа кампании %s пропущен', _format_user_log(user), campaign.id, ) @@ -248,7 +246,7 @@ class AdvertisingCampaignService: if not campaign.tariff_id: logger.error( - "❌ Кампания %s не имеет указанного тарифа для выдачи", + '❌ Кампания %s не имеет указанного тарифа для выдачи', campaign.id, ) return CampaignBonusResult(success=False) @@ -256,7 +254,7 @@ class AdvertisingCampaignService: duration_days = campaign.tariff_duration_days or 0 if duration_days <= 0: logger.error( - "❌ Кампания %s не имеет указанной длительности тарифа", + '❌ Кампания %s не имеет указанной длительности тарифа', campaign.id, ) return CampaignBonusResult(success=False) @@ -265,7 +263,7 @@ class AdvertisingCampaignService: tariff = await get_tariff_by_id(db, campaign.tariff_id) if not tariff: logger.error( - "❌ Тариф %s не найден для кампании %s", + '❌ Тариф %s не найден для кампании %s', campaign.tariff_id, campaign.id, ) @@ -273,7 +271,7 @@ class AdvertisingCampaignService: if not tariff.is_active: logger.warning( - "⚠️ Тариф %s неактивен, бонус кампании %s пропущен", + '⚠️ Тариф %s неактивен, бонус кампании %s пропущен', tariff.id, campaign.id, ) @@ -292,7 +290,7 @@ class AdvertisingCampaignService: squads = [trial_uuid] except Exception as error: logger.error( - "Не удалось подобрать сквад для тарифа кампании %s: %s", + 'Не удалось подобрать сквад для тарифа кампании %s: %s', campaign.id, error, ) @@ -314,7 +312,7 @@ class AdvertisingCampaignService: await self.subscription_service.create_remnawave_user(db, new_subscription) except Exception as error: logger.error( - "❌ Ошибка синхронизации RemnaWave для тарифа кампании %s: %s", + '❌ Ошибка синхронизации RemnaWave для тарифа кампании %s: %s', campaign.id, error, ) @@ -323,7 +321,7 @@ class AdvertisingCampaignService: db, campaign_id=campaign.id, user_id=user.id, - bonus_type="tariff", + bonus_type='tariff', tariff_id=tariff.id, tariff_duration_days=duration_days, ) @@ -338,7 +336,7 @@ class AdvertisingCampaignService: return CampaignBonusResult( success=True, - bonus_type="tariff", + bonus_type='tariff', tariff_id=tariff.id, tariff_name=tariff.name, tariff_duration_days=duration_days, diff --git a/app/services/cloudpayments_service.py b/app/services/cloudpayments_service.py index 6ac82204..611393cd 100644 --- a/app/services/cloudpayments_service.py +++ b/app/services/cloudpayments_service.py @@ -7,20 +7,21 @@ import hashlib import hmac import logging import time -from typing import Any, Dict, Optional +from typing import Any from urllib.parse import unquote_plus import httpx from app.config import settings + logger = logging.getLogger(__name__) class CloudPaymentsAPIError(RuntimeError): """Raised when the CloudPayments API returns an error response.""" - def __init__(self, message: str, reason_code: Optional[int] = None): + def __init__(self, message: str, reason_code: int | None = None): super().__init__(message) self.reason_code = reason_code @@ -31,34 +32,30 @@ class CloudPaymentsService: def __init__( self, *, - public_id: Optional[str] = None, - api_secret: Optional[str] = None, - api_url: Optional[str] = None, + public_id: str | None = None, + api_secret: str | None = None, + api_url: str | None = None, ) -> None: self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET - self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/") + self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip('/') @property def is_configured(self) -> bool: - return bool( - settings.is_cloudpayments_enabled() - and self.public_id - and self.api_secret - ) + return bool(settings.is_cloudpayments_enabled() and self.public_id and self.api_secret) def _get_auth_header(self) -> str: """Generate Basic Auth header for CloudPayments API.""" if not self.public_id or not self.api_secret: - raise CloudPaymentsAPIError("CloudPayments credentials not configured") - credentials = f"{self.public_id}:{self.api_secret}" + raise CloudPaymentsAPIError('CloudPayments credentials not configured') + credentials = f'{self.public_id}:{self.api_secret}' encoded = base64.b64encode(credentials.encode()).decode() - return f"Basic {encoded}" + return f'Basic {encoded}' - def _build_headers(self) -> Dict[str, str]: + def _build_headers(self) -> dict[str, str]: return { - "Authorization": self._get_auth_header(), - "Content-Type": "application/json", + 'Authorization': self._get_auth_header(), + 'Content-Type': 'application/json', } async def _request( @@ -66,13 +63,13 @@ class CloudPaymentsService: method: str, path: str, *, - json: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: """Make a request to CloudPayments API.""" if not self.is_configured: - raise CloudPaymentsAPIError("CloudPayments service is not configured") + raise CloudPaymentsAPIError('CloudPayments service is not configured') - url = f"{self.api_url}/{path.lstrip('/')}" + url = f'{self.api_url}/{path.lstrip("/")}' try: async with httpx.AsyncClient(timeout=30.0) as client: @@ -86,20 +83,14 @@ class CloudPaymentsService: data = response.json() if response.status_code >= 400: - logger.error( - "CloudPayments API error %s: %s", response.status_code, data - ) - raise CloudPaymentsAPIError( - f"CloudPayments API returned status {response.status_code}" - ) + logger.error('CloudPayments API error %s: %s', response.status_code, data) + raise CloudPaymentsAPIError(f'CloudPayments API returned status {response.status_code}') return data except httpx.RequestError as error: - logger.error("Error communicating with CloudPayments API: %s", error) - raise CloudPaymentsAPIError( - "Failed to communicate with CloudPayments API" - ) from error + logger.error('Error communicating with CloudPayments API: %s', error) + raise CloudPaymentsAPIError('Failed to communicate with CloudPayments API') from error @staticmethod def _amount_from_kopeks(amount_kopeks: int) -> float: @@ -113,14 +104,14 @@ class CloudPaymentsService: async def generate_payment_link( self, - telegram_id: Optional[int], + telegram_id: int | None, user_id: int, amount_kopeks: int, invoice_id: str, - description: Optional[str] = None, - email: Optional[str] = None, - success_redirect_url: Optional[str] = None, - fail_redirect_url: Optional[str] = None, + description: str | None = None, + email: str | None = None, + success_redirect_url: str | None = None, + fail_redirect_url: str | None = None, ) -> str: """ Create a payment order via CloudPayments API and return payment URL. @@ -139,56 +130,56 @@ class CloudPaymentsService: URL to CloudPayments payment page """ if not self.is_configured: - raise CloudPaymentsAPIError("CloudPayments is not configured") + raise CloudPaymentsAPIError('CloudPayments is not configured') amount = self._amount_from_kopeks(amount_kopeks) # Формируем данные для создания заказа через API /orders/create # AccountId uses user_id for consistency (works for both Telegram and email users) - payload: Dict[str, Any] = { - "Amount": amount, - "Currency": settings.CLOUDPAYMENTS_CURRENCY, - "Description": description or settings.CLOUDPAYMENTS_DESCRIPTION, - "AccountId": str(user_id), - "InvoiceId": invoice_id, - "JsonData": { - "user_id": user_id, - "telegram_id": telegram_id, - "invoice_id": invoice_id, + payload: dict[str, Any] = { + 'Amount': amount, + 'Currency': settings.CLOUDPAYMENTS_CURRENCY, + 'Description': description or settings.CLOUDPAYMENTS_DESCRIPTION, + 'AccountId': str(user_id), + 'InvoiceId': invoice_id, + 'JsonData': { + 'user_id': user_id, + 'telegram_id': telegram_id, + 'invoice_id': invoice_id, }, } if email: - payload["Email"] = email + payload['Email'] = email if settings.CLOUDPAYMENTS_REQUIRE_EMAIL: - payload["RequireConfirmation"] = False + payload['RequireConfirmation'] = False # URL для редиректа после оплаты if success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL: - payload["SuccessRedirectUrl"] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL + payload['SuccessRedirectUrl'] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL if fail_redirect_url: - payload["FailRedirectUrl"] = fail_redirect_url + payload['FailRedirectUrl'] = fail_redirect_url # Создаём заказ через API - response = await self._request("POST", "/orders/create", json=payload) + response = await self._request('POST', '/orders/create', json=payload) - if not response.get("Success"): - error_message = response.get("Message", "Unknown error") - logger.error("CloudPayments orders/create failed: %s", error_message) - raise CloudPaymentsAPIError(f"Failed to create order: {error_message}") + if not response.get('Success'): + error_message = response.get('Message', 'Unknown error') + logger.error('CloudPayments orders/create failed: %s', error_message) + raise CloudPaymentsAPIError(f'Failed to create order: {error_message}') - model = response.get("Model", {}) - payment_url = model.get("Url") + model = response.get('Model', {}) + payment_url = model.get('Url') if not payment_url: - logger.error("CloudPayments orders/create returned no URL: %s", response) - raise CloudPaymentsAPIError("CloudPayments API returned no payment URL") + logger.error('CloudPayments orders/create returned no URL: %s', response) + raise CloudPaymentsAPIError('CloudPayments API returned no payment URL') logger.info( - "CloudPayments order created: id=%s, url=%s", - model.get("Id"), + 'CloudPayments order created: id=%s, url=%s', + model.get('Id'), payment_url, ) @@ -196,7 +187,7 @@ class CloudPaymentsService: def generate_invoice_id(self, user_id: int) -> str: """Generate unique invoice ID for a payment using internal user ID.""" - return f"cp_{user_id}_{int(time.time())}" + return f'cp_{user_id}_{int(time.time())}' async def charge_by_token( self, @@ -204,8 +195,8 @@ class CloudPaymentsService: amount_kopeks: int, account_id: str, invoice_id: str, - description: Optional[str] = None, - ) -> Dict[str, Any]: + description: str | None = None, + ) -> dict[str, Any]: """ Charge a payment using saved card token (recurrent payment). @@ -222,37 +213,37 @@ class CloudPaymentsService: amount = self._amount_from_kopeks(amount_kopeks) payload = { - "Amount": amount, - "Currency": settings.CLOUDPAYMENTS_CURRENCY, - "AccountId": account_id, - "Token": token, - "InvoiceId": invoice_id, - "Description": description or settings.CLOUDPAYMENTS_DESCRIPTION, + 'Amount': amount, + 'Currency': settings.CLOUDPAYMENTS_CURRENCY, + 'AccountId': account_id, + 'Token': token, + 'InvoiceId': invoice_id, + 'Description': description or settings.CLOUDPAYMENTS_DESCRIPTION, } - return await self._request("POST", "/payments/tokens/charge", json=payload) + return await self._request('POST', '/payments/tokens/charge', json=payload) - async def get_payment(self, transaction_id: int) -> Dict[str, Any]: + async def get_payment(self, transaction_id: int) -> dict[str, Any]: """Get payment details by CloudPayments transaction ID.""" return await self._request( - "POST", - "/payments/get", - json={"TransactionId": transaction_id}, + 'POST', + '/payments/get', + json={'TransactionId': transaction_id}, ) - async def find_payment(self, invoice_id: str) -> Dict[str, Any]: + async def find_payment(self, invoice_id: str) -> dict[str, Any]: """Find payment by invoice ID.""" return await self._request( - "POST", - "/payments/find", - json={"InvoiceId": invoice_id}, + 'POST', + '/payments/find', + json={'InvoiceId': invoice_id}, ) async def refund_payment( self, transaction_id: int, - amount_kopeks: Optional[int] = None, - ) -> Dict[str, Any]: + amount_kopeks: int | None = None, + ) -> dict[str, Any]: """ Refund a payment (full or partial). @@ -263,19 +254,19 @@ class CloudPaymentsService: Returns: CloudPayments API response """ - payload: Dict[str, Any] = {"TransactionId": transaction_id} + payload: dict[str, Any] = {'TransactionId': transaction_id} if amount_kopeks is not None: - payload["Amount"] = self._amount_from_kopeks(amount_kopeks) + payload['Amount'] = self._amount_from_kopeks(amount_kopeks) - return await self._request("POST", "/payments/refund", json=payload) + return await self._request('POST', '/payments/refund', json=payload) - async def void_payment(self, transaction_id: int) -> Dict[str, Any]: + async def void_payment(self, transaction_id: int) -> dict[str, Any]: """Cancel an authorized but not captured payment.""" return await self._request( - "POST", - "/payments/void", - json={"TransactionId": transaction_id}, + 'POST', + '/payments/void', + json={'TransactionId': transaction_id}, ) @staticmethod @@ -317,7 +308,7 @@ class CloudPaymentsService: # Try with URL-decoded body (for X-Content-HMAC) calculated_decoded = None try: - decoded_body = unquote_plus(body.decode("utf-8")).encode("utf-8") + decoded_body = unquote_plus(body.decode('utf-8')).encode('utf-8') calculated_decoded = calc_hmac(decoded_body) if hmac.compare_digest(calculated_decoded, signature): return True @@ -325,15 +316,15 @@ class CloudPaymentsService: pass logger.warning( - "CloudPayments signature mismatch: expected_raw=%s..., expected_decoded=%s..., got=%s...", + 'CloudPayments signature mismatch: expected_raw=%s..., expected_decoded=%s..., got=%s...', calculated_raw[:20], - calculated_decoded[:20] if calculated_decoded else "N/A", + calculated_decoded[:20] if calculated_decoded else 'N/A', signature[:20], ) return False @staticmethod - def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]: + def parse_webhook_data(form_data: dict[str, Any]) -> dict[str, Any]: """ Parse webhook form data into structured format. @@ -344,21 +335,21 @@ class CloudPaymentsService: Parsed payment data """ return { - "transaction_id": int(form_data.get("TransactionId", 0)), - "amount": float(form_data.get("Amount", 0)), - "currency": form_data.get("Currency", "RUB"), - "invoice_id": form_data.get("InvoiceId", ""), - "account_id": form_data.get("AccountId", ""), - "token": form_data.get("Token"), - "card_first_six": form_data.get("CardFirstSix"), - "card_last_four": form_data.get("CardLastFour"), - "card_type": form_data.get("CardType"), - "card_exp_date": form_data.get("CardExpDate"), - "email": form_data.get("Email"), - "status": form_data.get("Status", ""), - "test_mode": form_data.get("TestMode") == "1" or form_data.get("TestMode") == "True", - "reason": form_data.get("Reason"), - "reason_code": int(form_data.get("ReasonCode", 0)) if form_data.get("ReasonCode") else None, - "card_holder_message": form_data.get("CardHolderMessage"), - "data": form_data.get("Data"), # JSON string with custom data + 'transaction_id': int(form_data.get('TransactionId', 0)), + 'amount': float(form_data.get('Amount', 0)), + 'currency': form_data.get('Currency', 'RUB'), + 'invoice_id': form_data.get('InvoiceId', ''), + 'account_id': form_data.get('AccountId', ''), + 'token': form_data.get('Token'), + 'card_first_six': form_data.get('CardFirstSix'), + 'card_last_four': form_data.get('CardLastFour'), + 'card_type': form_data.get('CardType'), + 'card_exp_date': form_data.get('CardExpDate'), + 'email': form_data.get('Email'), + 'status': form_data.get('Status', ''), + 'test_mode': form_data.get('TestMode') == '1' or form_data.get('TestMode') == 'True', + 'reason': form_data.get('Reason'), + 'reason_code': int(form_data.get('ReasonCode', 0)) if form_data.get('ReasonCode') else None, + 'card_holder_message': form_data.get('CardHolderMessage'), + 'data': form_data.get('Data'), # JSON string with custom data } diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py index eeb44ddd..9bd5ab2f 100644 --- a/app/services/contest_rotation_service.py +++ b/app/services/contest_rotation_service.py @@ -1,11 +1,10 @@ import asyncio import logging -from datetime import datetime, timedelta, time, timezone -from typing import Dict, List, Optional +from datetime import UTC, datetime, time, timedelta from zoneinfo import ZoneInfo from aiogram import Bot -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -17,9 +16,10 @@ from app.database.crud.contest import ( ) from app.database.database import AsyncSessionLocal from app.database.models import ContestTemplate, SubscriptionStatus, User -from app.services.contests.enums import GameType, PrizeType, RoundStatus +from app.services.contests.enums import GameType, PrizeType from app.services.contests.games import get_game_strategy + logger = logging.getLogger(__name__) # Legacy aliases for backward compatibility @@ -34,103 +34,103 @@ GAME_ANAGRAM = GameType.ANAGRAM.value DEFAULT_TEMPLATES = [ { - "slug": GAME_QUEST, - "name": "Квест-кнопки", - "description": "Найди секретную кнопку 3×3", - "prize_type": "days", - "prize_value": "1", - "max_winners": 3, - "attempts_per_user": 1, - "times_per_day": 2, - "schedule_times": "10:00,18:00", - "payload": {"rows": 3, "cols": 3}, - "is_enabled": False, + 'slug': GAME_QUEST, + 'name': 'Квест-кнопки', + 'description': 'Найди секретную кнопку 3×3', + 'prize_type': 'days', + 'prize_value': '1', + 'max_winners': 3, + 'attempts_per_user': 1, + 'times_per_day': 2, + 'schedule_times': '10:00,18:00', + 'payload': {'rows': 3, 'cols': 3}, + 'is_enabled': False, }, { - "slug": GAME_LOCKS, - "name": "Кнопочный взлом", - "description": "Найди взломанную кнопку среди 20 замков", - "prize_type": "days", - "prize_value": "5", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 2, - "schedule_times": "09:00,19:00", - "payload": {"buttons": 20}, - "is_enabled": False, + 'slug': GAME_LOCKS, + 'name': 'Кнопочный взлом', + 'description': 'Найди взломанную кнопку среди 20 замков', + 'prize_type': 'days', + 'prize_value': '5', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 2, + 'schedule_times': '09:00,19:00', + 'payload': {'buttons': 20}, + 'is_enabled': False, }, { - "slug": GAME_CIPHER, - "name": "Шифр букв", - "description": "Расшифруй слово по номерам", - "prize_type": "days", - "prize_value": "1", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 2, - "schedule_times": "12:00,20:00", - "payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]}, - "is_enabled": False, + 'slug': GAME_CIPHER, + 'name': 'Шифр букв', + 'description': 'Расшифруй слово по номерам', + 'prize_type': 'days', + 'prize_value': '1', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 2, + 'schedule_times': '12:00,20:00', + 'payload': {'words': ['VPN', 'SERVER', 'PROXY', 'XRAY']}, + 'is_enabled': False, }, { - "slug": GAME_SERVER, - "name": "Сервер-лотерея", - "description": "Угадай доступный сервер", - "prize_type": "days", - "prize_value": "7", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 1, - "schedule_times": "15:00", - "payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]}, - "is_enabled": False, + 'slug': GAME_SERVER, + 'name': 'Сервер-лотерея', + 'description': 'Угадай доступный сервер', + 'prize_type': 'days', + 'prize_value': '7', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 1, + 'schedule_times': '15:00', + 'payload': {'flags': ['🇸🇪', '🇸🇬', '🇺🇸', '🇷🇺', '🇩🇪', '🇯🇵', '🇧🇷', '🇦🇺', '🇨🇦', '🇫🇷']}, + 'is_enabled': False, }, { - "slug": GAME_BLITZ, - "name": "Блиц-реакция", - "description": "Нажми кнопку за 10 секунд", - "prize_type": "days", - "prize_value": "1", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 2, - "schedule_times": "11:00,21:00", - "payload": {"timeout_seconds": 10}, - "is_enabled": False, + 'slug': GAME_BLITZ, + 'name': 'Блиц-реакция', + 'description': 'Нажми кнопку за 10 секунд', + 'prize_type': 'days', + 'prize_value': '1', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 2, + 'schedule_times': '11:00,21:00', + 'payload': {'timeout_seconds': 10}, + 'is_enabled': False, }, { - "slug": GAME_EMOJI, - "name": "Угадай сервис по эмодзи", - "description": "Определи сервис по эмодзи", - "prize_type": "days", - "prize_value": "1", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 1, - "schedule_times": "13:00", - "payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]}, - "is_enabled": False, + 'slug': GAME_EMOJI, + 'name': 'Угадай сервис по эмодзи', + 'description': 'Определи сервис по эмодзи', + 'prize_type': 'days', + 'prize_value': '1', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 1, + 'schedule_times': '13:00', + 'payload': {'pairs': [{'question': '🔐📡🌐', 'answer': 'VPN'}]}, + 'is_enabled': False, }, { - "slug": GAME_ANAGRAM, - "name": "Анаграмма дня", - "description": "Собери слово из букв", - "prize_type": "days", - "prize_value": "1", - "max_winners": 1, - "attempts_per_user": 1, - "times_per_day": 1, - "schedule_times": "17:00", - "payload": {"words": ["SERVER", "XRAY", "VPN"]}, - "is_enabled": False, + 'slug': GAME_ANAGRAM, + 'name': 'Анаграмма дня', + 'description': 'Собери слово из букв', + 'prize_type': 'days', + 'prize_value': '1', + 'max_winners': 1, + 'attempts_per_user': 1, + 'times_per_day': 1, + 'schedule_times': '17:00', + 'payload': {'words': ['SERVER', 'XRAY', 'VPN']}, + 'is_enabled': False, }, ] class ContestRotationService: def __init__(self) -> None: - self.bot: Optional[Bot] = None - self._task: Optional[asyncio.Task] = None + self.bot: Bot | None = None + self._task: asyncio.Task | None = None self._interval_seconds = 60 def is_running(self) -> bool: @@ -143,13 +143,13 @@ class ContestRotationService: await self.stop() if not settings.is_contests_enabled(): - logger.info("Сервис игр отключён настройками") + logger.info('Сервис игр отключён настройками') return await self._ensure_default_templates() self._task = asyncio.create_task(self._loop()) - logger.info("🎲 Сервис ротационных конкурсов запущен") + logger.info('🎲 Сервис ротационных конкурсов запущен') async def stop(self) -> None: if self._task and not self._task.done(): @@ -166,7 +166,7 @@ class ContestRotationService: try: await upsert_template(db, **tpl) except Exception as exc: - logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc) + logger.error('Не удалось создать шаблон %s: %s', tpl['slug'], exc) async def _loop(self) -> None: try: @@ -175,23 +175,23 @@ class ContestRotationService: await self._tick() except asyncio.CancelledError: raise - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка в ротации конкурсов: %s", exc) + except Exception as exc: + logger.error('Ошибка в ротации конкурсов: %s', exc) await asyncio.sleep(self._interval_seconds) except asyncio.CancelledError: - logger.info("Сервис ротации конкурсов остановлен") + logger.info('Сервис ротации конкурсов остановлен') raise - def _parse_times(self, times_str: Optional[str]) -> List[time]: + def _parse_times(self, times_str: str | None) -> list[time]: if not times_str: return [] - times: List[time] = [] - for part in times_str.split(","): + times: list[time] = [] + for part in times_str.split(','): part = part.strip() if not part: continue try: - hh, mm = part.split(":") + hh, mm = part.split(':') times.append(time(int(hh), int(mm))) except Exception: continue @@ -202,16 +202,14 @@ class ContestRotationService: templates = await list_templates(db) # Get current time in configured timezone tz = self._get_timezone() - now_utc = datetime.now(timezone.utc) + now_utc = datetime.now(UTC) now_local = now_utc.astimezone(tz) for tpl in templates: times = self._parse_times(tpl.schedule_times) or [] for slot in times[: tpl.times_per_day]: # Apply schedule time to local date - starts_at_local = now_local.replace( - hour=slot.hour, minute=slot.minute, second=0, microsecond=0 - ) + starts_at_local = now_local.replace(hour=slot.hour, minute=slot.minute, second=0, microsecond=0) if starts_at_local > now_local: starts_at_local -= timedelta(days=1) ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours) @@ -223,8 +221,8 @@ class ContestRotationService: continue # Convert to UTC for storage - starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None) - ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None) + starts_at_utc = starts_at_local.astimezone(UTC).replace(tzinfo=None) + ends_at_utc = ends_at_local.astimezone(UTC).replace(tzinfo=None) # Анонс перед созданием раунда await self._announce_round_start(tpl, starts_at_local, ends_at_local) @@ -236,17 +234,17 @@ class ContestRotationService: ends_at=ends_at_utc, payload=payload, ) - logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug) + logger.info('Создан раунд %s для шаблона %s', round_obj.id, tpl.slug) def _get_timezone(self) -> ZoneInfo: - tz_name = settings.TIMEZONE or "UTC" + tz_name = settings.TIMEZONE or 'UTC' try: return ZoneInfo(tz_name) except Exception: - logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) - return ZoneInfo("UTC") + logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + return ZoneInfo('UTC') - def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict: + def _build_payload_for_template(self, tpl: ContestTemplate) -> dict: """Build round-specific payload using game strategy.""" strategy = get_game_strategy(tpl.slug) if strategy: @@ -264,27 +262,28 @@ class ContestRotationService: return from app.localization.texts import get_texts - texts = get_texts("ru") # Default to ru for announcements + + texts = get_texts('ru') # Default to ru for announcements # Format prize display based on prize_type prize_type = tpl.prize_type or PrizeType.DAYS.value - prize_value = tpl.prize_value or "1" + prize_value = tpl.prize_value or '1' if prize_type == PrizeType.DAYS.value: - prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}" + prize_display = f'{prize_value} {texts.t("DAYS", "дн. подписки")}' elif prize_type == PrizeType.BALANCE.value: - prize_display = f"{prize_value} коп." + prize_display = f'{prize_value} коп.' elif prize_type == PrizeType.CUSTOM.value: prize_display = prize_value else: prize_display = prize_value - + text = ( - f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: {tpl.name}\n" - f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} • {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n" - f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n" - f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n" - f"💡 {texts.t('REMINDER', 'Напоминание')}: {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!" + f'🎲 {texts.t("CONTEST_START_ANNOUNCEMENT", "Стартует игра")}: {tpl.name}\n' + f'{texts.t("CONTEST_PRIZE", "Приз")}: {prize_display} • {texts.t("CONTEST_WINNERS", "Победителей")}: {tpl.max_winners}\n' + f'{texts.t("CONTEST_ATTEMPTS", "Попыток/польз")}: {tpl.attempts_per_user}\n\n' + f'{texts.t("CONTEST_ELIGIBILITY", "Участвовать могут только с активной или триальной подпиской")}.\n' + f'💡 {texts.t("REMINDER", "Напоминание")}: {texts.t("CONTEST_REMINDER_TEXT", "Не забудьте участвовать в конкурсах для получения бонусов")}!' ) await asyncio.gather( @@ -304,9 +303,9 @@ class ContestRotationService: except Exception: channel_id = channel_id_raw - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")] - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]] + ) try: await self.bot.send_message( @@ -315,8 +314,8 @@ class ContestRotationService: disable_web_page_preview=True, reply_markup=keyboard, ) - except Exception as exc: # noqa: BLE001 - logger.error("Не удалось отправить анонс в канал %s: %s", channel_id_raw, exc) + except Exception as exc: + logger.error('Не удалось отправить анонс в канал %s: %s', channel_id_raw, exc) async def _broadcast_to_users(self, text: str) -> None: """Отправляет анонс всем пользователям с активной/триальной подпиской.""" @@ -328,9 +327,9 @@ class ContestRotationService: offset = 0 sent = failed = 0 - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")] - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]] + ) while True: async with AsyncSessionLocal() as db: @@ -365,11 +364,11 @@ class ContestRotationService: await asyncio.gather(*tasks, return_exceptions=True) - logger.info("Анонс игр: отправлено=%s, ошибок=%s", sent, failed) - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка рассылки анонса игр пользователям: %s", exc) + logger.info('Анонс игр: отправлено=%s, ошибок=%s', sent, failed) + except Exception as exc: + logger.error('Ошибка рассылки анонса игр пользователям: %s', exc) - async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> List[User]: + async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> list[User]: from app.database.crud.user import get_users_list users = await get_users_list( @@ -378,9 +377,9 @@ class ContestRotationService: limit=limit, status=None, ) - allowed: List[User] = [] + allowed: list[User] = [] for u in users: - sub = getattr(u, "subscription", None) + sub = getattr(u, 'subscription', None) if not sub: continue if sub.status in {SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value}: diff --git a/app/services/contests/__init__.py b/app/services/contests/__init__.py index 831ae551..484a85d9 100644 --- a/app/services/contests/__init__.py +++ b/app/services/contests/__init__.py @@ -1,14 +1,15 @@ """Contest services module.""" -from app.services.contests.enums import GameType, RoundStatus, PrizeType -from app.services.contests.games import get_game_strategy, BaseGameStrategy from app.services.contests.attempt_service import ContestAttemptService +from app.services.contests.enums import GameType, PrizeType, RoundStatus +from app.services.contests.games import BaseGameStrategy, get_game_strategy + __all__ = [ - "GameType", - "RoundStatus", - "PrizeType", - "get_game_strategy", - "BaseGameStrategy", - "ContestAttemptService", + 'BaseGameStrategy', + 'ContestAttemptService', + 'GameType', + 'PrizeType', + 'RoundStatus', + 'get_game_strategy', ] diff --git a/app/services/contests/attempt_service.py b/app/services/contests/attempt_service.py index 97bc6cfd..0e4db4bc 100644 --- a/app/services/contests/attempt_service.py +++ b/app/services/contests/attempt_service.py @@ -2,7 +2,6 @@ import logging from dataclasses import dataclass -from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,6 +14,7 @@ from app.database.models import ContestAttempt, ContestRound, ContestTemplate from app.services.contests.enums import PrizeType from app.services.contests.games import get_game_strategy + logger = logging.getLogger(__name__) @@ -58,7 +58,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="Конкурс не найден", + message='Конкурс не найден', ) # Check if user already played @@ -67,7 +67,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="У вас уже была попытка", + message='У вас уже была попытка', already_played=True, ) @@ -77,7 +77,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="Тип игры не поддерживается", + message='Тип игры не поддерживается', ) check_result = strategy.check_answer(pick, round_obj.payload or {}, language) @@ -95,23 +95,20 @@ class ContestAttemptService: is_winner=is_winner, ) - logger.info( - "Contest attempt: user %s, round %s, pick '%s', winner %s", - user_id, round_obj.id, pick, is_winner - ) + logger.info("Contest attempt: user %s, round %s, pick '%s', winner %s", user_id, round_obj.id, pick, is_winner) if is_winner: prize_msg = await self._award_prize(db, user_id, tpl, language) return AttemptResult( success=True, is_winner=True, - message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!", + message=f'🎉 Победа! {prize_msg}' if prize_msg else '🎉 Победа!', ) return AttemptResult( success=True, is_winner=False, - message=check_result.response_text or "Неудача", + message=check_result.response_text or 'Неудача', ) async def process_text_attempt( @@ -140,7 +137,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="Конкурс не найден", + message='Конкурс не найден', ) # For text games, attempt should already exist (created in render phase) @@ -149,7 +146,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="Сначала начните игру", + message='Сначала начните игру', ) # Check if already answered @@ -157,7 +154,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="У вас уже была попытка", + message='У вас уже была попытка', already_played=True, ) @@ -167,7 +164,7 @@ class ContestAttemptService: return AttemptResult( success=False, is_winner=False, - message="Тип игры не поддерживается", + message='Тип игры не поддерживается', ) check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language) @@ -181,7 +178,10 @@ class ContestAttemptService: logger.info( "Contest text attempt: user %s, round %s, answer '%s', winner %s", - user_id, round_obj.id, text_answer, is_winner + user_id, + round_obj.id, + text_answer, + is_winner, ) if is_winner: @@ -189,13 +189,13 @@ class ContestAttemptService: return AttemptResult( success=True, is_winner=True, - message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!", + message=f'🎉 Победа! {prize_msg}' if prize_msg else '🎉 Победа!', ) return AttemptResult( success=True, is_winner=False, - message=check_result.response_text or "Неверно, попробуй в следующем раунде", + message=check_result.response_text or 'Неверно, попробуй в следующем раунде', ) async def create_pending_attempt( @@ -203,7 +203,7 @@ class ContestAttemptService: db: AsyncSession, round_id: int, user_id: int, - ) -> Optional[ContestAttempt]: + ) -> ContestAttempt | None: """ Create a pending attempt for text-input games. This blocks re-entry while user is answering. @@ -280,36 +280,36 @@ class ContestAttemptService: Prize notification message """ from app.localization.texts import get_texts + texts = get_texts(language) prize_type = template.prize_type or PrizeType.DAYS.value - prize_value = template.prize_value or "1" + prize_value = template.prize_value or '1' if prize_type == PrizeType.DAYS.value: subscription = await get_subscription_by_user_id(db, user_id) if not subscription: - return "" + return '' days = int(prize_value) if prize_value.isdigit() else 1 await extend_subscription(db, subscription, days) - return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days) + return texts.t('CONTEST_PRIZE_GRANTED', 'Бонус {days} дней зачислен!').format(days=days) - elif prize_type == PrizeType.BALANCE.value: + if prize_type == PrizeType.BALANCE.value: user = await get_user_by_id(db, user_id) if not user: - return "" + return '' kopeks = int(prize_value) if prize_value.isdigit() else 0 if kopeks > 0: user.balance_kopeks += kopeks await db.commit() - return texts.t( - "CONTEST_BALANCE_GRANTED", - "Бонус {amount} зачислен!" - ).format(amount=settings.format_price(kopeks)) + return texts.t('CONTEST_BALANCE_GRANTED', 'Бонус {amount} зачислен!').format( + amount=settings.format_price(kopeks) + ) elif prize_type == PrizeType.CUSTOM.value: - return f"🎁 {prize_value}" + return f'🎁 {prize_value}' - return "" + return '' # Singleton instance diff --git a/app/services/contests/enums.py b/app/services/contests/enums.py index 070477a8..4054e630 100644 --- a/app/services/contests/enums.py +++ b/app/services/contests/enums.py @@ -6,21 +6,21 @@ from enum import Enum class GameType(str, Enum): """Types of daily contest games.""" - QUEST_BUTTONS = "quest_buttons" - LOCK_HACK = "lock_hack" - LETTER_CIPHER = "letter_cipher" - SERVER_LOTTERY = "server_lottery" - BLITZ_REACTION = "blitz_reaction" - EMOJI_GUESS = "emoji_guess" - ANAGRAM = "anagram" + QUEST_BUTTONS = 'quest_buttons' + LOCK_HACK = 'lock_hack' + LETTER_CIPHER = 'letter_cipher' + SERVER_LOTTERY = 'server_lottery' + BLITZ_REACTION = 'blitz_reaction' + EMOJI_GUESS = 'emoji_guess' + ANAGRAM = 'anagram' @classmethod - def is_text_input(cls, game_type: "GameType") -> bool: + def is_text_input(cls, game_type: 'GameType') -> bool: """Check if game requires text input from user.""" return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM} @classmethod - def is_button_pick(cls, game_type: "GameType") -> bool: + def is_button_pick(cls, game_type: 'GameType') -> bool: """Check if game uses button selection.""" return game_type in { cls.QUEST_BUTTONS, @@ -33,13 +33,13 @@ class GameType(str, Enum): class RoundStatus(str, Enum): """Contest round status.""" - ACTIVE = "active" - FINISHED = "finished" + ACTIVE = 'active' + FINISHED = 'finished' class PrizeType(str, Enum): """Types of prizes for contests.""" - DAYS = "days" - BALANCE = "balance" - CUSTOM = "custom" + DAYS = 'days' + BALANCE = 'balance' + CUSTOM = 'custom' diff --git a/app/services/contests/games.py b/app/services/contests/games.py index 81d92ee0..84c645b1 100644 --- a/app/services/contests/games.py +++ b/app/services/contests/games.py @@ -3,7 +3,7 @@ import random from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Dict, List, Optional +from typing import Any from aiogram import types @@ -33,38 +33,37 @@ class BaseGameStrategy(ABC): game_type: GameType @abstractmethod - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: """Build round-specific payload from template config.""" - pass @abstractmethod def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: """Render game UI for user.""" - pass @abstractmethod def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: """Check if user's answer is correct.""" - pass def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton: from app.localization.texts import get_texts + texts = get_texts(language) return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback) def _get_texts(self, language: str): from app.localization.texts import get_texts + return get_texts(language) @@ -73,23 +72,23 @@ class QuestButtonsStrategy(BaseGameStrategy): game_type = GameType.QUEST_BUTTONS - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - rows = template_payload.get("rows", 3) - cols = template_payload.get("cols", 3) + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + rows = template_payload.get('rows', 3) + cols = template_payload.get('cols', 3) total = rows * cols secret_idx = random.randint(0, total - 1) - return {"rows": rows, "cols": cols, "secret_idx": secret_idx} + return {'rows': rows, 'cols': cols, 'secret_idx': secret_idx} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - rows = payload.get("rows", 3) - cols = payload.get("cols", 3) + rows = payload.get('rows', 3) + cols = payload.get('cols', 3) keyboard_rows = [] for r in range(rows): @@ -98,38 +97,38 @@ class QuestButtonsStrategy(BaseGameStrategy): idx = r * cols + c row_buttons.append( types.InlineKeyboardButton( - text="🎛", - callback_data=f"contest_pick_{round_id}_quest_{idx}", + text='🎛', + callback_data=f'contest_pick_{round_id}_quest_{idx}', ) ) keyboard_rows.append(row_buttons) keyboard_rows.append([self._get_back_button(language, back_callback)]) return GameRenderResult( - text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"), + text=texts.t('CONTEST_QUEST_PROMPT', 'Выбери один из узлов 3×3:'), keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - secret_idx = payload.get("secret_idx") + secret_idx = payload.get('secret_idx') try: - if user_answer.startswith("quest_"): - idx = int(user_answer.split("_")[1]) + if user_answer.startswith('quest_'): + idx = int(user_answer.split('_')[1]) is_correct = secret_idx is not None and idx == secret_idx else: is_correct = False except (ValueError, IndexError): is_correct = False - responses = ["Пусто", "Ложный сервер", "Найди другой узел"] + responses = ['Пусто', 'Ложный сервер', 'Найди другой узел'] return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else random.choice(responses), + response_text='' if is_correct else random.choice(responses), ) @@ -138,28 +137,28 @@ class LockHackStrategy(BaseGameStrategy): game_type = GameType.LOCK_HACK - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - total = template_payload.get("buttons", 20) + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + total = template_payload.get('buttons', 20) secret_idx = random.randint(0, max(0, total - 1)) - return {"total": total, "secret_idx": secret_idx} + return {'total': total, 'secret_idx': secret_idx} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - total = payload.get("total", 20) + total = payload.get('total', 20) keyboard_rows = [] row = [] for i in range(total): row.append( types.InlineKeyboardButton( - text="🔒", - callback_data=f"contest_pick_{round_id}_locks_{i}", + text='🔒', + callback_data=f'contest_pick_{round_id}_locks_{i}', ) ) if len(row) == 5: @@ -170,30 +169,30 @@ class LockHackStrategy(BaseGameStrategy): keyboard_rows.append([self._get_back_button(language, back_callback)]) return GameRenderResult( - text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"), + text=texts.t('CONTEST_LOCKS_PROMPT', 'Найди взломанную кнопку среди замков:'), keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - secret_idx = payload.get("secret_idx") + secret_idx = payload.get('secret_idx') try: - if user_answer.startswith("locks_"): - idx = int(user_answer.split("_")[1]) + if user_answer.startswith('locks_'): + idx = int(user_answer.split('_')[1]) is_correct = secret_idx is not None and idx == secret_idx else: is_correct = False except (ValueError, IndexError): is_correct = False - responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"] + responses = ['Заблокировано', 'Попробуй ещё', 'Нет доступа'] return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else random.choice(responses), + response_text='' if is_correct else random.choice(responses), ) @@ -202,22 +201,22 @@ class ServerLotteryStrategy(BaseGameStrategy): game_type = GameType.SERVER_LOTTERY - DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"] + DEFAULT_FLAGS = ['🇸🇪', '🇸🇬', '🇺🇸', '🇷🇺', '🇩🇪', '🇯🇵', '🇧🇷', '🇦🇺', '🇨🇦', '🇫🇷'] - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - flags = template_payload.get("flags") or self.DEFAULT_FLAGS + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + flags = template_payload.get('flags') or self.DEFAULT_FLAGS secret_idx = random.randint(0, len(flags) - 1) - return {"flags": flags, "secret_idx": secret_idx} + return {'flags': flags, 'secret_idx': secret_idx} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - flags = payload.get("flags") or [] + flags = payload.get('flags') or [] shuffled_flags = flags.copy() random.shuffle(shuffled_flags) @@ -227,7 +226,7 @@ class ServerLotteryStrategy(BaseGameStrategy): row.append( types.InlineKeyboardButton( text=flag, - callback_data=f"contest_pick_{round_id}_{flag}", + callback_data=f'contest_pick_{round_id}_{flag}', ) ) if len(row) == 5: @@ -238,25 +237,25 @@ class ServerLotteryStrategy(BaseGameStrategy): keyboard_rows.append([self._get_back_button(language, back_callback)]) return GameRenderResult( - text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"), + text=texts.t('CONTEST_SERVER_PROMPT', 'Выбери сервер:'), keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), ) def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - secret_idx = payload.get("secret_idx") - flags = payload.get("flags") or [] - correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else "" + secret_idx = payload.get('secret_idx') + flags = payload.get('flags') or [] + correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else '' is_correct = user_answer == correct_flag - responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"] + responses = ['Сервер перегружен', 'Нет ответа', 'Попробуй завтра'] return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else random.choice(responses), + response_text='' if is_correct else random.choice(responses), ) @@ -265,15 +264,15 @@ class BlitzReactionStrategy(BaseGameStrategy): game_type = GameType.BLITZ_REACTION - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - return {"timeout_seconds": template_payload.get("timeout_seconds", 10)} + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + return {'timeout_seconds': template_payload.get('timeout_seconds', 10)} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) @@ -281,8 +280,8 @@ class BlitzReactionStrategy(BaseGameStrategy): inline_keyboard=[ [ types.InlineKeyboardButton( - text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), - callback_data=f"contest_pick_{round_id}_blitz", + text=texts.t('CONTEST_BLITZ_BUTTON', 'Я здесь!'), + callback_data=f'contest_pick_{round_id}_blitz', ) ], [self._get_back_button(language, back_callback)], @@ -290,20 +289,20 @@ class BlitzReactionStrategy(BaseGameStrategy): ) return GameRenderResult( - text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"), + text=texts.t('CONTEST_BLITZ_PROMPT', '⚡️ Блиц! Нажми «Я здесь!»'), keyboard=keyboard, ) def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - is_correct = user_answer == "blitz" + is_correct = user_answer == 'blitz' return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else "Время вышло", + response_text='' if is_correct else 'Время вышло', ) @@ -312,27 +311,27 @@ class LetterCipherStrategy(BaseGameStrategy): game_type = GameType.LETTER_CIPHER - DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"] + DEFAULT_WORDS = ['VPN', 'SERVER', 'PROXY', 'XRAY'] - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - words = template_payload.get("words") or self.DEFAULT_WORDS + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + words = template_payload.get('words') or self.DEFAULT_WORDS word = random.choice(words) codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()] - return {"question": "-".join(codes), "answer": word.upper()} + return {'question': '-'.join(codes), 'answer': word.upper()} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - question = payload.get("question", "") + question = payload.get('question', '') from app.keyboards.inline import get_back_keyboard return GameRenderResult( - text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question), + text=texts.t('CONTEST_CIPHER_PROMPT', 'Расшифруй: {q}').format(q=question), keyboard=get_back_keyboard(language), requires_text_input=True, ) @@ -340,15 +339,15 @@ class LetterCipherStrategy(BaseGameStrategy): def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - correct = (payload.get("answer") or "").upper() + correct = (payload.get('answer') or '').upper() is_correct = correct and user_answer.strip().upper() == correct return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else "Неверно, попробуй в следующем раунде", + response_text='' if is_correct else 'Неверно, попробуй в следующем раунде', ) @@ -357,29 +356,27 @@ class EmojiGuessStrategy(BaseGameStrategy): game_type = GameType.EMOJI_GUESS - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}] + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + pairs = template_payload.get('pairs') or [{'question': '🔐📡🌐', 'answer': 'VPN'}] pair = random.choice(pairs) return pair def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - question = payload.get("question", "🤔") + question = payload.get('question', '🤔') emoji_list = question.split() random.shuffle(emoji_list) - shuffled_question = " ".join(emoji_list) + shuffled_question = ' '.join(emoji_list) from app.keyboards.inline import get_back_keyboard return GameRenderResult( - text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format( - q=shuffled_question - ), + text=texts.t('CONTEST_EMOJI_PROMPT', 'Угадай сервис по эмодзи: {q}').format(q=shuffled_question), keyboard=get_back_keyboard(language), requires_text_input=True, ) @@ -387,15 +384,15 @@ class EmojiGuessStrategy(BaseGameStrategy): def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - correct = (payload.get("answer") or "").upper() + correct = (payload.get('answer') or '').upper() is_correct = correct and user_answer.strip().upper() == correct return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else "Неверно, попробуй в следующем раунде", + response_text='' if is_correct else 'Неверно, попробуй в следующем раунде', ) @@ -404,29 +401,27 @@ class AnagramStrategy(BaseGameStrategy): game_type = GameType.ANAGRAM - DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"] + DEFAULT_WORDS = ['SERVER', 'XRAY', 'VPN'] - def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]: - words = template_payload.get("words") or self.DEFAULT_WORDS + def build_payload(self, template_payload: dict[str, Any]) -> dict[str, Any]: + words = template_payload.get('words') or self.DEFAULT_WORDS word = random.choice(words).upper() - shuffled = "".join(random.sample(word, len(word))) - return {"letters": shuffled, "answer": word} + shuffled = ''.join(random.sample(word, len(word))) + return {'letters': shuffled, 'answer': word} def render( self, round_id: int, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, - back_callback: str = "contests_menu", + back_callback: str = 'contests_menu', ) -> GameRenderResult: texts = self._get_texts(language) - letters = payload.get("letters", "") + letters = payload.get('letters', '') from app.keyboards.inline import get_back_keyboard return GameRenderResult( - text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format( - letters=letters - ), + text=texts.t('CONTEST_ANAGRAM_PROMPT', 'Составь слово: {letters}').format(letters=letters), keyboard=get_back_keyboard(language), requires_text_input=True, ) @@ -434,20 +429,20 @@ class AnagramStrategy(BaseGameStrategy): def check_answer( self, user_answer: str, - payload: Dict[str, Any], + payload: dict[str, Any], language: str, ) -> AnswerCheckResult: - correct = (payload.get("answer") or "").upper() + correct = (payload.get('answer') or '').upper() is_correct = correct and user_answer.strip().upper() == correct return AnswerCheckResult( is_correct=is_correct, - response_text="" if is_correct else "Неверно, попробуй в следующем раунде", + response_text='' if is_correct else 'Неверно, попробуй в следующем раунде', ) # Registry of game strategies -_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = { +_GAME_STRATEGIES: dict[GameType, BaseGameStrategy] = { GameType.QUEST_BUTTONS: QuestButtonsStrategy(), GameType.LOCK_HACK: LockHackStrategy(), GameType.SERVER_LOTTERY: ServerLotteryStrategy(), @@ -458,7 +453,7 @@ _GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = { } -def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]: +def get_game_strategy(game_type: GameType | str) -> BaseGameStrategy | None: """Get game strategy by type.""" if isinstance(game_type, str): try: @@ -468,6 +463,6 @@ def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]: return _GAME_STRATEGIES.get(game_type) -def get_all_game_types() -> List[GameType]: +def get_all_game_types() -> list[GameType]: """Get list of all supported game types.""" return list(_GAME_STRATEGIES.keys()) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index 52f99cac..1732f61e 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -3,29 +3,29 @@ Проверяет подписки с суточным тарифом и списывает плату раз в сутки. Также сбрасывает докупленный трафик по истечении 30 дней. """ -import logging + import asyncio +import logging from datetime import datetime -from typing import Optional from aiogram import Bot from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.database import AsyncSessionLocal from app.database.crud.subscription import ( get_daily_subscriptions_for_charge, - update_daily_charge_time, suspend_daily_subscription_insufficient_balance, + update_daily_charge_time, ) -from app.database.crud.user import subtract_user_balance, get_user_by_id from app.database.crud.transaction import create_transaction -from app.database.models import TransactionType, PaymentMethod, Subscription, User +from app.database.crud.user import get_user_by_id, subtract_user_balance +from app.database.database import AsyncSessionLocal +from app.database.models import PaymentMethod, Subscription, TransactionType, User from app.localization.texts import get_texts from app.services.notification_delivery_service import ( - notification_delivery_service, NotificationType, + notification_delivery_service, ) @@ -39,7 +39,7 @@ class DailySubscriptionService: def __init__(self): self._running = False - self._bot: Optional[Bot] = None + self._bot: Bot | None = None self._check_interval_minutes = 30 # Проверка каждые 30 минут def set_bot(self, bot: Bot): @@ -62,40 +62,37 @@ class DailySubscriptionService: dict: Статистика обработки """ stats = { - "checked": 0, - "charged": 0, - "suspended": 0, - "errors": 0, + 'checked': 0, + 'charged': 0, + 'suspended': 0, + 'errors': 0, } try: async with AsyncSessionLocal() as db: try: subscriptions = await get_daily_subscriptions_for_charge(db) - stats["checked"] = len(subscriptions) + stats['checked'] = len(subscriptions) for subscription in subscriptions: try: result = await self._process_single_charge(db, subscription) - if result == "charged": - stats["charged"] += 1 - elif result == "suspended": - stats["suspended"] += 1 - elif result == "error": - stats["errors"] += 1 + if result == 'charged': + stats['charged'] += 1 + elif result == 'suspended': + stats['suspended'] += 1 + elif result == 'error': + stats['errors'] += 1 except Exception as e: - logger.error( - f"Ошибка обработки суточной подписки {subscription.id}: {e}", - exc_info=True - ) - stats["errors"] += 1 + logger.error(f'Ошибка обработки суточной подписки {subscription.id}: {e}', exc_info=True) + stats['errors'] += 1 await db.commit() except Exception as e: - logger.error(f"Ошибка при обработке подписок: {e}", exc_info=True) + logger.error(f'Ошибка при обработке подписок: {e}', exc_info=True) await db.rollback() except Exception as e: - logger.error(f"Ошибка при получении подписок для списания: {e}", exc_info=True) + logger.error(f'Ошибка при получении подписок для списания: {e}', exc_info=True) return stats @@ -111,18 +108,18 @@ class DailySubscriptionService: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.warning(f"Пользователь не найден для подписки {subscription.id}") - return "error" + logger.warning(f'Пользователь не найден для подписки {subscription.id}') + return 'error' tariff = subscription.tariff if not tariff: - logger.warning(f"Тариф не найден для подписки {subscription.id}") - return "error" + logger.warning(f'Тариф не найден для подписки {subscription.id}') + return 'error' daily_price = tariff.daily_price_kopeks if daily_price <= 0: - logger.warning(f"Некорректная суточная цена для тарифа {tariff.id}") - return "error" + logger.warning(f'Некорректная суточная цена для тарифа {tariff.id}') + return 'error' # Проверяем баланс if user.balance_kopeks < daily_price: @@ -134,13 +131,13 @@ class DailySubscriptionService: await self._notify_insufficient_balance(user, subscription, daily_price) logger.info( - f"Подписка {subscription.id} приостановлена: недостаточно средств " - f"(баланс: {user.balance_kopeks}, требуется: {daily_price})" + f'Подписка {subscription.id} приостановлена: недостаточно средств ' + f'(баланс: {user.balance_kopeks}, требуется: {daily_price})' ) - return "suspended" + return 'suspended' # Списываем средства - description = f"Суточная оплата тарифа «{tariff.name}»" + description = f'Суточная оплата тарифа «{tariff.name}»' try: deducted = await subtract_user_balance( @@ -151,8 +148,8 @@ class DailySubscriptionService: ) if not deducted: - logger.warning(f"Не удалось списать средства для подписки {subscription.id}") - return "error" + logger.warning(f'Не удалось списать средства для подписки {subscription.id}') + return 'error' # Создаём транзакцию await create_transaction( @@ -167,15 +164,16 @@ class DailySubscriptionService: # Обновляем время последнего списания и продлеваем подписку subscription = await update_daily_charge_time(db, subscription) - user_id_display = user.telegram_id or user.email or f"#{user.id}" + user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( - f"✅ Суточное списание: подписка {subscription.id}, " - f"сумма {daily_price} коп., пользователь {user_id_display}" + f'✅ Суточное списание: подписка {subscription.id}, ' + f'сумма {daily_price} коп., пользователь {user_id_display}' ) # Синхронизируем с Remnawave (обновляем срок подписки) try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.create_remnawave_user( db, @@ -184,32 +182,29 @@ class DailySubscriptionService: reset_reason=None, ) except Exception as e: - logger.warning(f"Не удалось обновить Remnawave: {e}") + logger.warning(f'Не удалось обновить Remnawave: {e}') # Уведомляем пользователя if self._bot: await self._notify_daily_charge(user, subscription, daily_price) - return "charged" + return 'charged' except Exception as e: - logger.error( - f"Ошибка при списании средств для подписки {subscription.id}: {e}", - exc_info=True - ) - return "error" + logger.error(f'Ошибка при списании средств для подписки {subscription.id}: {e}', exc_info=True) + return 'error' async def _notify_daily_charge(self, user, subscription, amount_kopeks: int): """Уведомляет пользователя о суточном списании.""" - texts = get_texts(getattr(user, "language", "ru")) + get_texts(getattr(user, 'language', 'ru')) amount_rubles = amount_kopeks / 100 balance_rubles = user.balance_kopeks / 100 message = ( - f"💳 Суточное списание\n\n" - f"Списано: {amount_rubles:.2f} ₽\n" - f"Остаток баланса: {balance_rubles:.2f} ₽\n\n" - f"Следующее списание через 24 часа." + f'💳 Суточное списание\n\n' + f'Списано: {amount_rubles:.2f} ₽\n' + f'Остаток баланса: {balance_rubles:.2f} ₽\n\n' + f'Следующее списание через 24 часа.' ) # Use unified notification delivery service @@ -222,41 +217,35 @@ class DailySubscriptionService: telegram_message=message, ) except Exception as e: - logger.warning(f"Не удалось отправить уведомление о списании: {e}") + logger.warning(f'Не удалось отправить уведомление о списании: {e}') async def _notify_insufficient_balance(self, user, subscription, required_amount: int): """Уведомляет пользователя о недостатке средств.""" - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - texts = get_texts(getattr(user, "language", "ru")) + get_texts(getattr(user, 'language', 'ru')) required_rubles = required_amount / 100 balance_rubles = user.balance_kopeks / 100 message = ( - f"⚠️ Подписка приостановлена\n\n" - f"Недостаточно средств для суточной оплаты.\n\n" - f"Требуется: {required_rubles:.2f} ₽\n" - f"Баланс: {balance_rubles:.2f} ₽\n\n" - f"Пополните баланс, чтобы возобновить подписку." + f'⚠️ Подписка приостановлена\n\n' + f'Недостаточно средств для суточной оплаты.\n\n' + f'Требуется: {required_rubles:.2f} ₽\n' + f'Баланс: {balance_rubles:.2f} ₽\n\n' + f'Пополните баланс, чтобы возобновить подписку.' ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton( - text="💳 Пополнить баланс", - callback_data="menu_balance" - )], - [InlineKeyboardButton( - text="📱 Моя подписка", - callback_data="menu_subscription" - )], + [InlineKeyboardButton(text='💳 Пополнить баланс', callback_data='menu_balance')], + [InlineKeyboardButton(text='📱 Моя подписка', callback_data='menu_subscription')], ] ) # Use unified notification delivery service context = { - "required_amount": f"{required_rubles:.2f} ₽", - "current_balance": f"{balance_rubles:.2f} ₽", + 'required_amount': f'{required_rubles:.2f} ₽', + 'current_balance': f'{balance_rubles:.2f} ₽', } try: @@ -269,7 +258,7 @@ class DailySubscriptionService: telegram_markup=keyboard, ) except Exception as e: - logger.warning(f"Не удалось отправить уведомление о недостатке средств: {e}") + logger.warning(f'Не удалось отправить уведомление о недостатке средств: {e}') async def process_traffic_resets(self) -> dict: """ @@ -279,9 +268,9 @@ class DailySubscriptionService: dict: Статистика обработки """ stats = { - "checked": 0, - "reset": 0, - "errors": 0, + 'checked': 0, + 'reset': 0, + 'errors': 0, } from app.database.models import TrafficPurchase @@ -291,13 +280,10 @@ class DailySubscriptionService: try: # Находим все истекшие докупки now = datetime.utcnow() - query = ( - select(TrafficPurchase) - .where(TrafficPurchase.expires_at <= now) - ) + query = select(TrafficPurchase).where(TrafficPurchase.expires_at <= now) result = await db.execute(query) expired_purchases = result.scalars().all() - stats["checked"] = len(expired_purchases) + stats['checked'] = len(expired_purchases) # Группируем по подпискам для обновления subscriptions_to_update = {} @@ -310,20 +296,17 @@ class DailySubscriptionService: for subscription_id, purchases in subscriptions_to_update.items(): try: await self._reset_subscription_traffic(db, subscription_id, purchases) - stats["reset"] += len(purchases) + stats['reset'] += len(purchases) except Exception as e: - logger.error( - f"Ошибка сброса трафика подписки {subscription_id}: {e}", - exc_info=True - ) - stats["errors"] += 1 + logger.error(f'Ошибка сброса трафика подписки {subscription_id}: {e}', exc_info=True) + stats['errors'] += 1 await db.commit() except Exception as e: - logger.error(f"Ошибка при обработке сброса трафика: {e}", exc_info=True) + logger.error(f'Ошибка при обработке сброса трафика: {e}', exc_info=True) await db.rollback() except Exception as e: - logger.error(f"Ошибка при получении подписок для сброса трафика: {e}", exc_info=True) + logger.error(f'Ошибка при получении подписок для сброса трафика: {e}', exc_info=True) return stats @@ -347,9 +330,9 @@ class DailySubscriptionService: # КРИТИЧЕСКАЯ ПРОВЕРКА: защита от некорректных данных if total_expired_gb > old_purchased: logger.error( - f"⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, " - f"истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. " - f"Сбрасываем только {old_purchased} ГБ." + f'⚠️ ОШИБКА ДАННЫХ: подписка {subscription.id}, ' + f'истекает {total_expired_gb} ГБ, но purchased_traffic_gb = {old_purchased} ГБ. ' + f'Сбрасываем только {old_purchased} ГБ.' ) total_expired_gb = old_purchased @@ -359,14 +342,15 @@ class DailySubscriptionService: # Получаем базовый лимит из тарифа для проверки if subscription.tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: tariff_base_limit = tariff.traffic_limit_gb or 0 # Проверяем, что базовый лимит не отрицательный if base_limit < 0: logger.warning( - f"⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. " - f"Используем лимит из тарифа: {tariff_base_limit} ГБ" + f'⚠️ Базовый лимит отрицательный для подписки {subscription.id}: {base_limit} ГБ. ' + f'Используем лимит из тарифа: {tariff_base_limit} ГБ' ) base_limit = tariff_base_limit @@ -384,8 +368,8 @@ class DailySubscriptionService: # Двойная защита: новый лимит не может быть меньше базового if new_limit < base_limit: logger.error( - f"⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). " - f"Устанавливаем базовый лимит." + f'⚠️ КРИТИЧЕСКАЯ ОШИБКА: новый лимит ({new_limit} ГБ) меньше базового ({base_limit} ГБ). ' + f'Устанавливаем базовый лимит.' ) new_limit = base_limit new_purchased = 0 @@ -417,19 +401,20 @@ class DailySubscriptionService: await db.commit() logger.info( - f"🔄 Сброс истекших докупок: подписка {subscription.id}, " - f"было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), " - f"стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), " - f"убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок" + f'🔄 Сброс истекших докупок: подписка {subscription.id}, ' + f'было {old_limit} ГБ (базовый: {base_limit} ГБ, докуплено: {old_purchased} ГБ), ' + f'стало {subscription.traffic_limit_gb} ГБ (базовый: {base_limit} ГБ, докуплено: {new_purchased} ГБ), ' + f'убрано {total_expired_gb} ГБ из {len(expired_purchases)} покупок' ) # Синхронизируем с RemnaWave try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) except Exception as e: - logger.warning(f"Не удалось синхронизировать с RemnaWave после сброса трафика: {e}") + logger.warning(f'Не удалось синхронизировать с RemnaWave после сброса трафика: {e}') # Уведомляем пользователя if self._bot and subscription.user_id: @@ -440,16 +425,16 @@ class DailySubscriptionService: async def _notify_traffic_reset(self, user: User, subscription: Subscription, reset_gb: int): """Уведомляет пользователя о сбросе докупленного трафика.""" message = ( - f"ℹ️ Сброс докупленного трафика\n\n" - f"Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, " - f"так как прошло 30 дней с момента первой докупки.\n\n" - f"Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n" - f"Вы можете докупить трафик снова в любое время." + f'ℹ️ Сброс докупленного трафика\n\n' + f'Ваш докупленный трафик ({reset_gb} ГБ) был сброшен, ' + f'так как прошло 30 дней с момента первой докупки.\n\n' + f'Текущий лимит трафика: {subscription.traffic_limit_gb} ГБ\n\n' + f'Вы можете докупить трафик снова в любое время.' ) context = { - "reset_gb": reset_gb, - "current_limit_gb": subscription.traffic_limit_gb, + 'reset_gb': reset_gb, + 'current_limit_gb': subscription.traffic_limit_gb, } # Use unified notification delivery service @@ -462,49 +447,47 @@ class DailySubscriptionService: telegram_message=message, ) except Exception as e: - logger.warning(f"Не удалось отправить уведомление о сбросе трафика: {e}") + logger.warning(f'Не удалось отправить уведомление о сбросе трафика: {e}') async def start_monitoring(self): """Запускает периодическую проверку суточных подписок и сброса трафика.""" self._running = True interval_minutes = self.get_check_interval_minutes() - logger.info( - f"🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)" - ) + logger.info(f'🔄 Запуск сервиса суточных подписок (интервал: {interval_minutes} мин)') while self._running: try: # Обработка суточных списаний stats = await self.process_daily_charges() - if stats["charged"] > 0 or stats["suspended"] > 0: + if stats['charged'] > 0 or stats['suspended'] > 0: logger.info( - f"📊 Суточные списания: проверено={stats['checked']}, " - f"списано={stats['charged']}, приостановлено={stats['suspended']}, " - f"ошибок={stats['errors']}" + f'📊 Суточные списания: проверено={stats["checked"]}, ' + f'списано={stats["charged"]}, приостановлено={stats["suspended"]}, ' + f'ошибок={stats["errors"]}' ) # Обработка сброса докупленного трафика traffic_stats = await self.process_traffic_resets() - if traffic_stats["reset"] > 0: + if traffic_stats['reset'] > 0: logger.info( - f"📊 Сброс трафика: проверено={traffic_stats['checked']}, " - f"сброшено={traffic_stats['reset']}, ошибок={traffic_stats['errors']}" + f'📊 Сброс трафика: проверено={traffic_stats["checked"]}, ' + f'сброшено={traffic_stats["reset"]}, ошибок={traffic_stats["errors"]}' ) except Exception as e: - logger.error(f"Ошибка в цикле проверки суточных подписок: {e}", exc_info=True) + logger.error(f'Ошибка в цикле проверки суточных подписок: {e}', exc_info=True) await asyncio.sleep(interval_minutes * 60) def stop_monitoring(self): """Останавливает периодическую проверку.""" self._running = False - logger.info("⏹️ Сервис суточных подписок остановлен") + logger.info('⏹️ Сервис суточных подписок остановлен') # Глобальный экземпляр сервиса daily_subscription_service = DailySubscriptionService() -__all__ = ["DailySubscriptionService", "daily_subscription_service"] +__all__ = ['DailySubscriptionService', 'daily_subscription_service'] diff --git a/app/services/event_emitter.py b/app/services/event_emitter.py index 78cb043c..83a0a530 100644 --- a/app/services/event_emitter.py +++ b/app/services/event_emitter.py @@ -3,13 +3,15 @@ from __future__ import annotations import asyncio import json import logging +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.services.webhook_service import webhook_service + logger = logging.getLogger(__name__) @@ -37,24 +39,24 @@ class EventEmitter: def register_websocket(self, websocket: Any) -> None: """Зарегистрировать WebSocket подключение.""" self._websocket_connections.add(websocket) - logger.debug("WebSocket connection registered. Total: %d", len(self._websocket_connections)) + logger.debug('WebSocket connection registered. Total: %d', len(self._websocket_connections)) def unregister_websocket(self, websocket: Any) -> None: """Отменить регистрацию WebSocket подключения.""" self._websocket_connections.discard(websocket) - logger.debug("WebSocket connection unregistered. Total: %d", len(self._websocket_connections)) + logger.debug('WebSocket connection unregistered. Total: %d', len(self._websocket_connections)) async def emit( self, event_type: str, payload: dict[str, Any], - db: Optional[AsyncSession] = None, + db: AsyncSession | None = None, ) -> None: """Отправить событие всем подписчикам.""" event_data = { - "type": event_type, - "payload": payload, - "timestamp": str(datetime.utcnow()), + 'type': event_type, + 'payload': payload, + 'timestamp': str(datetime.utcnow()), } # Вызываем локальные слушатели @@ -66,7 +68,7 @@ class EventEmitter: else: callback(event_data) except Exception as error: - logger.exception("Error in event listener for %s: %s", event_type, error) + logger.exception('Error in event listener for %s: %s', event_type, error) # Отправляем через WebSocket await self._broadcast_to_websockets(event_data) @@ -87,7 +89,7 @@ class EventEmitter: try: await ws.send_text(message) except Exception as error: - logger.warning("Failed to send WebSocket message: %s", error) + logger.warning('Failed to send WebSocket message: %s', error) disconnected.add(ws) # Удаляем отключенные соединения @@ -97,4 +99,3 @@ class EventEmitter: # Глобальный экземпляр event emitter event_emitter = EventEmitter() - diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py index c70ba0a1..73cc950b 100644 --- a/app/services/external_admin_service.py +++ b/app/services/external_admin_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import Optional from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError @@ -21,64 +20,62 @@ logger = logging.getLogger(__name__) async def ensure_external_admin_token( - bot_username: Optional[str], - bot_id: Optional[int], -) -> Optional[str]: + bot_username: str | None, + bot_id: int | None, +) -> str | None: """Генерирует и сохраняет токен внешней админки, если требуется.""" - username_raw = (bot_username or "").strip() + username_raw = (bot_username or '').strip() if not username_raw: logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует", + '⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует', ) return None - normalized_username = username_raw.lstrip("@").lower() + normalized_username = username_raw.lstrip('@').lower() if not normalized_username: logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации", + '⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации', ) return None try: token = settings.build_external_admin_token(normalized_username) except Exception as error: # pragma: no cover - защитный блок - logger.error("❌ Ошибка генерации токена внешней админки: %s", error) + logger.error('❌ Ошибка генерации токена внешней админки: %s', error) return None try: async with AsyncSessionLocal() as session: result = await session.execute( select(SystemSetting.key, SystemSetting.value).where( - SystemSetting.key.in_( - ["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"] - ) + SystemSetting.key.in_(['EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID']) ) ) rows = dict(result.all()) - existing_token = rows.get("EXTERNAL_ADMIN_TOKEN") - existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID") + existing_token = rows.get('EXTERNAL_ADMIN_TOKEN') + existing_bot_id_raw = rows.get('EXTERNAL_ADMIN_TOKEN_BOT_ID') - existing_bot_id: Optional[int] = None + existing_bot_id: int | None = None if existing_bot_id_raw is not None: try: existing_bot_id = int(existing_bot_id_raw) except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений logger.warning( - "⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s", + '⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s', existing_bot_id_raw, ) if existing_token == token and existing_bot_id == bot_id: if settings.get_external_admin_token() != token: settings.EXTERNAL_ADMIN_TOKEN = token - if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id: + if existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID: settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id return token if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id: logger.error( - "❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s", + '❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s', existing_bot_id, bot_id, ) @@ -86,22 +83,22 @@ async def ensure_external_admin_token( try: await bot_configuration_service.reset_value( session, - "EXTERNAL_ADMIN_TOKEN", + 'EXTERNAL_ADMIN_TOKEN', force=True, ) await bot_configuration_service.reset_value( session, - "EXTERNAL_ADMIN_TOKEN_BOT_ID", + 'EXTERNAL_ADMIN_TOKEN_BOT_ID', force=True, ) await session.commit() logger.warning( - "⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота", + '⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота', ) except Exception as cleanup_error: # pragma: no cover - защитный блок await session.rollback() logger.error( - "❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s", + '❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s', cleanup_error, ) finally: @@ -112,24 +109,18 @@ async def ensure_external_admin_token( updates: list[tuple[str, object]] = [] if existing_token != token: - updates.append(("EXTERNAL_ADMIN_TOKEN", token)) + updates.append(('EXTERNAL_ADMIN_TOKEN', token)) if bot_id is not None and existing_bot_id != bot_id: - updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id)) + updates.append(('EXTERNAL_ADMIN_TOKEN_BOT_ID', bot_id)) if not updates: # Токен совпал, но могли отсутствовать значения в настройках приложения if settings.get_external_admin_token() != (existing_token or token): settings.EXTERNAL_ADMIN_TOKEN = existing_token or token - if existing_bot_id is not None and ( - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id - ): + if existing_bot_id is not None and (existing_bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID): settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - elif ( - bot_id is not None - and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id - and existing_bot_id is None - ): + elif bot_id is not None and bot_id != settings.EXTERNAL_ADMIN_TOKEN_BOT_ID and existing_bot_id is None: settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id return existing_token or token @@ -143,18 +134,17 @@ async def ensure_external_admin_token( ) await session.commit() logger.info( - "✅ Токен внешней админки синхронизирован для @%s", + '✅ Токен внешней админки синхронизирован для @%s', normalized_username, ) except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение await session.rollback() logger.warning( - "⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа", + '⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа', ) return None return token except SQLAlchemyError as error: - logger.error("❌ Ошибка сохранения токена внешней админки: %s", error) + logger.error('❌ Ошибка сохранения токена внешней админки: %s', error) return None - diff --git a/app/services/faq_service.py b/app/services/faq_service.py index c05ab65c..4e8b712f 100644 --- a/app/services/faq_service.py +++ b/app/services/faq_service.py @@ -1,5 +1,4 @@ import logging -from typing import List, Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -25,8 +24,8 @@ class FaqService: @staticmethod def _normalize_language(language: str) -> str: - base_language = language or settings.DEFAULT_LANGUAGE or "ru" - return base_language.split("-")[0].lower() + base_language = language or settings.DEFAULT_LANGUAGE or 'ru' + return base_language.split('-')[0].lower() @staticmethod def normalize_language(language: str) -> str: @@ -39,7 +38,7 @@ class FaqService: language: str, *, fallback: bool = True, - ) -> Optional[FaqSetting]: + ) -> FaqSetting | None: lang = cls._normalize_language(language) setting = await get_faq_setting(db, lang) @@ -88,7 +87,7 @@ class FaqService: *, include_inactive: bool = False, fallback: bool = True, - ) -> List[FaqPage]: + ) -> list[FaqPage]: lang = cls._normalize_language(language) pages = await get_faq_pages(db, lang, include_inactive=include_inactive) @@ -129,7 +128,7 @@ class FaqService: *, fallback: bool = True, include_inactive: bool = False, - ) -> Optional[FaqPage]: + ) -> FaqPage | None: page = await get_faq_page_by_id(db, page_id) if not page: return None @@ -156,8 +155,8 @@ class FaqService: language: str, title: str, content: str, - display_order: Optional[int] = None, - is_active: Optional[bool] = None, + display_order: int | None = None, + is_active: bool | None = None, ) -> FaqPage: lang = cls._normalize_language(language) is_active_value = True if is_active is None else bool(is_active) @@ -182,10 +181,10 @@ class FaqService: db: AsyncSession, page: FaqPage, *, - title: Optional[str] = None, - content: Optional[str] = None, - display_order: Optional[int] = None, - is_active: Optional[bool] = None, + title: str | None = None, + content: str | None = None, + display_order: int | None = None, + is_active: bool | None = None, ) -> FaqPage: return await update_faq_page( db, @@ -205,7 +204,7 @@ class FaqService: cls, db: AsyncSession, language: str, - pages: List[FaqPage], + pages: list[FaqPage], ) -> None: lang = cls._normalize_language(language) ordered = [page for page in pages if page.language == lang] @@ -216,12 +215,12 @@ class FaqService: def split_content_into_pages( content: str, *, - max_length: Optional[int] = None, - ) -> List[str]: + max_length: int | None = None, + ) -> list[str]: if not content: return [] - normalized = content.replace("\r\n", "\n").strip() + normalized = content.replace('\r\n', '\n').strip() if not normalized: return [] @@ -229,23 +228,19 @@ class FaqService: if len(normalized) <= limit: return [normalized] - paragraphs = [ - paragraph.strip() - for paragraph in normalized.split("\n\n") - if paragraph.strip() - ] + paragraphs = [paragraph.strip() for paragraph in normalized.split('\n\n') if paragraph.strip()] - pages: List[str] = [] - current = "" + pages: list[str] = [] + current = '' def flush_current() -> None: nonlocal current if current: pages.append(current.strip()) - current = "" + current = '' for paragraph in paragraphs: - candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph + candidate = f'{current}\n\n{paragraph}'.strip() if current else paragraph if len(candidate) <= limit: current = candidate continue @@ -262,7 +257,7 @@ class FaqService: pages.append(chunk.strip()) start += limit - current = "" + current = '' flush_current() @@ -270,4 +265,3 @@ class FaqService: return [normalized[:limit]] return pages - diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index d3621e73..1b90fe1a 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -1,40 +1,41 @@ """Сервис для работы с API Freekassa.""" +import asyncio import hashlib import hmac -import time -import logging -import asyncio import json +import logging +import time import urllib.request -from typing import Optional, Dict, Any, Set +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) # Кэш для публичного IP -_cached_public_ip: Optional[str] = None +_cached_public_ip: str | None = None _ip_fetch_lock = asyncio.Lock() # IP-адреса Freekassa для проверки webhook -FREEKASSA_IPS: Set[str] = { - "168.119.157.136", - "168.119.60.227", - "178.154.197.79", - "51.250.54.238", +FREEKASSA_IPS: set[str] = { + '168.119.157.136', + '168.119.60.227', + '178.154.197.79', + '51.250.54.238', } -API_BASE_URL = "https://api.fk.life/v1" +API_BASE_URL = 'https://api.fk.life/v1' # Сервисы для определения публичного IP (в порядке приоритета) IP_SERVICES = [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - "https://ipinfo.io/ip", + 'https://api.ipify.org', + 'https://ifconfig.me/ip', + 'https://icanhazip.com', + 'https://ipinfo.io/ip', ] @@ -64,24 +65,21 @@ async def get_public_ip() -> str: async with aiohttp.ClientSession() as session: for service_url in IP_SERVICES: try: - async with session.get( - service_url, - timeout=aiohttp.ClientTimeout(total=5) - ) as response: + async with session.get(service_url, timeout=aiohttp.ClientTimeout(total=5)) as response: if response.status == 200: ip = (await response.text()).strip() # Простая валидация IPv4 if ip and len(ip.split('.')) == 4: _cached_public_ip = ip - logger.info(f"Определён публичный IP сервера: {ip}") + logger.info(f'Определён публичный IP сервера: {ip}') return ip except Exception as e: - logger.debug(f"Не удалось получить IP от {service_url}: {e}") + logger.debug(f'Не удалось получить IP от {service_url}: {e}') continue # Fallback на известный рабочий IP если ничего не получилось - fallback_ip = "185.92.183.173" - logger.warning(f"Не удалось определить публичный IP, используем fallback: {fallback_ip}") + fallback_ip = '185.92.183.173' + logger.warning(f'Не удалось определить публичный IP, используем fallback: {fallback_ip}') _cached_public_ip = fallback_ip return fallback_ip @@ -90,10 +88,10 @@ class FreekassaService: """Сервис для работы с API Freekassa.""" def __init__(self): - self._shop_id: Optional[int] = None - self._api_key: Optional[str] = None - self._secret1: Optional[str] = None - self._secret2: Optional[str] = None + self._shop_id: int | None = None + self._api_key: str | None = None + self._secret1: str | None = None + self._secret2: str | None = None @property def shop_id(self) -> int: @@ -105,70 +103,60 @@ class FreekassaService: def api_key(self) -> str: if self._api_key is None: self._api_key = settings.FREEKASSA_API_KEY - return self._api_key or "" + return self._api_key or '' @property def secret1(self) -> str: if self._secret1 is None: self._secret1 = settings.FREEKASSA_SECRET_WORD_1 - return self._secret1 or "" + return self._secret1 or '' @property def secret2(self) -> str: if self._secret2 is None: self._secret2 = settings.FREEKASSA_SECRET_WORD_2 - return self._secret2 or "" + return self._secret2 or '' - def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str: + def _generate_api_signature_hmac(self, params: dict[str, Any]) -> str: """ Генерирует подпись для API запроса (HMAC-SHA256). Используется для API методов (создание заказа и т.д.) """ # Исключаем signature из параметров и сортируем по ключу - sign_data = {k: v for k, v in params.items() if k != "signature"} + sign_data = {k: v for k, v in params.items() if k != 'signature'} sorted_items = sorted(sign_data.items()) # Формируем строку: значения через | - msg = "|".join(str(v) for _, v in sorted_items) + msg = '|'.join(str(v) for _, v in sorted_items) # HMAC-SHA256 - return hmac.new( - self.api_key.encode("utf-8"), - msg.encode("utf-8"), - hashlib.sha256 - ).hexdigest() + return hmac.new(self.api_key.encode('utf-8'), msg.encode('utf-8'), hashlib.sha256).hexdigest() - def _generate_api_signature(self, params: Dict[str, Any]) -> str: + def _generate_api_signature(self, params: dict[str, Any]) -> str: """ Генерирует подпись для API запроса. Для новых API методов используется HMAC-SHA256. """ return self._generate_api_signature_hmac(params) - def generate_form_signature( - self, amount: float, currency: str, order_id: str - ) -> str: + def generate_form_signature(self, amount: float, currency: str, order_id: str) -> str: """ Генерирует подпись для платежной формы. Формат: MD5(shop_id:amount:secret1:currency:order_id) """ # Приводим amount к int, если это целое число final_amount = int(amount) if float(amount).is_integer() else amount - sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}" + sign_string = f'{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}' return hashlib.md5(sign_string.encode()).hexdigest() - def verify_webhook_signature( - self, shop_id: int, amount: float, order_id: str, sign: str - ) -> bool: + def verify_webhook_signature(self, shop_id: int, amount: float, order_id: str, sign: str) -> bool: """ Проверяет подпись webhook уведомления. Формат: MD5(shop_id:amount:secret2:order_id) """ # Приводим amount к int, если это целое число final_amount = int(amount) if float(amount).is_integer() else amount - expected_sign = hashlib.md5( - f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode() - ).hexdigest() + expected_sign = hashlib.md5(f'{shop_id}:{final_amount}:{self.secret2}:{order_id}'.encode()).hexdigest() return sign.lower() == expected_sign.lower() def verify_webhook_ip(self, ip: str) -> bool: @@ -179,12 +167,12 @@ class FreekassaService: self, order_id: str, amount: float, - currency: str = "RUB", - email: Optional[str] = None, - phone: Optional[str] = None, - payment_system_id: Optional[int] = None, - lang: str = "ru", - ip: Optional[str] = None, + currency: str = 'RUB', + email: str | None = None, + phone: str | None = None, + payment_system_id: int | None = None, + lang: str = 'ru', + ip: str | None = None, ) -> str: """ Формирует URL для перенаправления на оплату (форма выбора). @@ -201,85 +189,82 @@ class FreekassaService: try: # Определяем IP (важно для API запроса) - здесь синхронно, поэтому лучше иметь передачу IP # Если IP не передан, используем fallback - target_ip = ip or "185.92.183.173" - target_email = email or "test@example.com" + target_ip = ip or '185.92.183.173' + target_email = email or 'test@example.com' params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), - "paymentId": str(order_id), - "i": 44, - "email": target_email, - "ip": target_ip, - "amount": final_amount, - "currency": "RUB" + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), + 'paymentId': str(order_id), + 'i': 44, + 'email': target_email, + 'ip': target_ip, + 'amount': final_amount, + 'currency': 'RUB', } # Генерация подписи - params["signature"] = self._generate_api_signature(params) - - logger.info(f"Freekassa synchronous build_payment_url for 44: {params}") + params['signature'] = self._generate_api_signature(params) + + logger.info(f'Freekassa synchronous build_payment_url for 44: {params}') data_json = json.dumps(params).encode('utf-8') req = urllib.request.Request( - f"{API_BASE_URL}/orders/create", - data=data_json, - headers={"Content-Type": "application/json"} + f'{API_BASE_URL}/orders/create', data=data_json, headers={'Content-Type': 'application/json'} ) - + with urllib.request.urlopen(req, timeout=30) as response: resp_body = response.read().decode('utf-8') data = json.loads(resp_body) - - if data.get("type") == "error": - logger.error(f"Freekassa build_payment_url error: {data}") + + if data.get('type') == 'error': + logger.error(f'Freekassa build_payment_url error: {data}') # Fallback to standard flow if error? Or raise? # User wants it to work. Raise to see error is safer. # raise Exception(f"Freekassa API Error: {data.get('message')}") # Но чтобы не ломать полностью, можно попробовать вернуть обычную ссылку, # если API не сработал? Нет, вернем ошибку или ссылку из data. - - if data.get("location"): - return data.get("location") + + if data.get('location'): + return data.get('location') except Exception as e: - logger.error(f"Failed to create order 44 via sync API: {e}") + logger.error(f'Failed to create order 44 via sync API: {e}') # Если не получилось, попробуем сгенерировать обычную ссылку как fallback - pass signature = self.generate_form_signature(final_amount, currency, order_id) params = { - "m": self.shop_id, - "oa": final_amount, - "currency": currency, - "o": order_id, - "s": signature, - "lang": lang, + 'm': self.shop_id, + 'oa': final_amount, + 'currency': currency, + 'o': order_id, + 's': signature, + 'lang': lang, } if email: - params["em"] = email + params['em'] = email if phone: - params["phone"] = phone + params['phone'] = phone if ps_id: - params["i"] = ps_id + params['i'] = ps_id - query = "&".join(f"{k}={v}" for k, v in params.items()) - return f"https://pay.fk.money/?{query}" + query = '&'.join(f'{k}={v}' for k, v in params.items()) + return f'https://pay.fk.money/?{query}' async def create_order( self, order_id: str, amount: float, - currency: str = "RUB", - email: Optional[str] = None, - ip: Optional[str] = None, - payment_system_id: Optional[int] = None, - success_url: Optional[str] = None, - failure_url: Optional[str] = None, - notification_url: Optional[str] = None, - ) -> Dict[str, Any]: + currency: str = 'RUB', + email: str | None = None, + ip: str | None = None, + payment_system_id: int | None = None, + success_url: str | None = None, + failure_url: str | None = None, + notification_url: str | None = None, + ) -> dict[str, Any]: """ Создает заказ через API Freekassa. POST /orders/create @@ -293,61 +278,61 @@ class FreekassaService: # Используем payment_system_id из настроек, если не передан явно ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 - target_email = email or "test@example.com" - + target_email = email or 'test@example.com' + # Определяем публичный IP сервера - server_ip = ip or await get_public_ip() + server_ip = ip or await get_public_ip() params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), # Наносекунды для уникальности - "paymentId": str(order_id), - "i": ps_id, - "email": target_email, - "ip": server_ip, - "amount": final_amount, - "currency": currency, + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), # Наносекунды для уникальности + 'paymentId': str(order_id), + 'i': ps_id, + 'email': target_email, + 'ip': server_ip, + 'amount': final_amount, + 'currency': currency, } # Генерируем подпись HMAC-SHA256 - params["signature"] = self._generate_api_signature(params) + params['signature'] = self._generate_api_signature(params) - logger.info(f"Freekassa API create_order params: {params}") + logger.info(f'Freekassa API create_order params: {params}') try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/orders/create", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/orders/create', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - text = await response.text() - logger.info(f"Freekassa API response: {text}") + ) as response, + ): + text = await response.text() + logger.info(f'Freekassa API response: {text}') - data = await response.json() + data = await response.json() - # Проверяем на ошибку - API может вернуть error или type=error - error_msg = data.get("error") or data.get("message") - if response.status != 200 or data.get("type") == "error" or error_msg: - logger.error(f"Freekassa create_order error: {data}") - raise Exception( - f"Freekassa API error: {error_msg or 'Unknown error'}" - ) + # Проверяем на ошибку - API может вернуть error или type=error + error_msg = data.get('error') or data.get('message') + if response.status != 200 or data.get('type') == 'error' or error_msg: + logger.error(f'Freekassa create_order error: {data}') + raise Exception(f'Freekassa API error: {error_msg or "Unknown error"}') - return data + return data except aiohttp.ClientError as e: - logger.exception(f"Freekassa API connection error: {e}") + logger.exception(f'Freekassa API connection error: {e}') raise async def create_order_and_get_url( self, order_id: str, amount: float, - currency: str = "RUB", - email: Optional[str] = None, - ip: Optional[str] = None, - payment_system_id: Optional[int] = None, + currency: str = 'RUB', + email: str | None = None, + ip: str | None = None, + payment_system_id: int | None = None, ) -> str: """ Создает заказ через API и возвращает URL для оплаты. @@ -361,80 +346,86 @@ class FreekassaService: ip=ip, payment_system_id=payment_system_id, ) - location = result.get("location") + location = result.get('location') if not location: - raise Exception("Freekassa API did not return payment URL (location)") + raise Exception('Freekassa API did not return payment URL (location)') return location - async def get_order_status(self, order_id: str) -> Dict[str, Any]: + async def get_order_status(self, order_id: str) -> dict[str, Any]: """ Получает статус заказа. POST /orders """ params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), - "paymentId": str(order_id), + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), + 'paymentId': str(order_id), } - params["signature"] = self._generate_api_signature(params) + params['signature'] = self._generate_api_signature(params) - logger.debug(f"Freekassa get_order_status params: {params}") + logger.debug(f'Freekassa get_order_status params: {params}') try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/orders", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/orders', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - text = await response.text() - logger.debug(f"Freekassa get_order_status response: {text}") - return await response.json() + ) as response, + ): + text = await response.text() + logger.debug(f'Freekassa get_order_status response: {text}') + return await response.json() except aiohttp.ClientError as e: - logger.exception(f"Freekassa API connection error: {e}") + logger.exception(f'Freekassa API connection error: {e}') raise - async def get_balance(self) -> Dict[str, Any]: + async def get_balance(self) -> dict[str, Any]: """Получает баланс магазина.""" params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), } - params["signature"] = self._generate_api_signature(params) + params['signature'] = self._generate_api_signature(params) try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/balance", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/balance', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - return await response.json() + ) as response, + ): + return await response.json() except aiohttp.ClientError as e: - logger.exception(f"Freekassa API connection error: {e}") + logger.exception(f'Freekassa API connection error: {e}') raise - async def get_payment_systems(self) -> Dict[str, Any]: + async def get_payment_systems(self) -> dict[str, Any]: """Получает список доступных платежных систем.""" params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), } - params["signature"] = self._generate_api_signature(params) + params['signature'] = self._generate_api_signature(params) try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/currencies", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/currencies', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - return await response.json() + ) as response, + ): + return await response.json() except aiohttp.ClientError as e: - logger.exception(f"Freekassa API connection error: {e}") + logger.exception(f'Freekassa API connection error: {e}') raise diff --git a/app/services/kassa_ai_service.py b/app/services/kassa_ai_service.py index 689d9e4c..007c1328 100644 --- a/app/services/kassa_ai_service.py +++ b/app/services/kassa_ai_service.py @@ -1,30 +1,31 @@ """Сервис для работы с API KassaAI (api.fk.life).""" +import asyncio import hashlib import hmac -import time import logging -import asyncio -from typing import Optional, Dict, Any, Set +import time +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) # Кэш для публичного IP -_cached_public_ip: Optional[str] = None +_cached_public_ip: str | None = None _ip_fetch_lock = asyncio.Lock() -API_BASE_URL = "https://api.fk.life/v1" +API_BASE_URL = 'https://api.fk.life/v1' # Сервисы для определения публичного IP IP_SERVICES = [ - "https://api.ipify.org", - "https://ifconfig.me/ip", - "https://icanhazip.com", - "https://ipinfo.io/ip", + 'https://api.ipify.org', + 'https://ifconfig.me/ip', + 'https://icanhazip.com', + 'https://ipinfo.io/ip', ] @@ -50,22 +51,19 @@ async def get_public_ip() -> str: async with aiohttp.ClientSession() as session: for service_url in IP_SERVICES: try: - async with session.get( - service_url, - timeout=aiohttp.ClientTimeout(total=5) - ) as response: + async with session.get(service_url, timeout=aiohttp.ClientTimeout(total=5)) as response: if response.status == 200: ip = (await response.text()).strip() if ip and len(ip.split('.')) == 4: _cached_public_ip = ip - logger.info(f"KassaAI: определён публичный IP сервера: {ip}") + logger.info(f'KassaAI: определён публичный IP сервера: {ip}') return ip except Exception as e: - logger.debug(f"KassaAI: не удалось получить IP от {service_url}: {e}") + logger.debug(f'KassaAI: не удалось получить IP от {service_url}: {e}') continue - fallback_ip = "127.0.0.1" - logger.warning(f"KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}") + fallback_ip = '127.0.0.1' + logger.warning(f'KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}') _cached_public_ip = fallback_ip return fallback_ip @@ -74,9 +72,9 @@ class KassaAiService: """Сервис для работы с API KassaAI.""" def __init__(self): - self._shop_id: Optional[int] = None - self._api_key: Optional[str] = None - self._secret2: Optional[str] = None + self._shop_id: int | None = None + self._api_key: str | None = None + self._secret2: str | None = None @property def shop_id(self) -> int: @@ -88,32 +86,26 @@ class KassaAiService: def api_key(self) -> str: if self._api_key is None: self._api_key = settings.KASSA_AI_API_KEY - return self._api_key or "" + return self._api_key or '' @property def secret2(self) -> str: if self._secret2 is None: self._secret2 = settings.KASSA_AI_SECRET_WORD_2 - return self._secret2 or "" + return self._secret2 or '' - def _generate_hmac_signature(self, params: Dict[str, Any]) -> str: + def _generate_hmac_signature(self, params: dict[str, Any]) -> str: """ Генерирует подпись для API запроса (HMAC-SHA256). Сортирует ключи, соединяет значения через | """ - sign_data = {k: v for k, v in params.items() if k != "signature"} + sign_data = {k: v for k, v in params.items() if k != 'signature'} sorted_keys = sorted(sign_data.keys()) - msg = "|".join(str(sign_data[k]) for k in sorted_keys) + msg = '|'.join(str(sign_data[k]) for k in sorted_keys) - return hmac.new( - self.api_key.encode("utf-8"), - msg.encode("utf-8"), - hashlib.sha256 - ).hexdigest() + return hmac.new(self.api_key.encode('utf-8'), msg.encode('utf-8'), hashlib.sha256).hexdigest() - def verify_webhook_signature( - self, shop_id: int, amount: float, order_id: str, sign: str - ) -> bool: + def verify_webhook_signature(self, shop_id: int, amount: float, order_id: str, sign: str) -> bool: """ Проверяет подпись webhook уведомления. Формат: MD5(shop_id:amount:secret2:order_id) @@ -125,23 +117,23 @@ class KassaAiService: else: amount_str = str(amount) - sign_str = f"{shop_id}:{amount_str}:{self.secret2}:{order_id}" + sign_str = f'{shop_id}:{amount_str}:{self.secret2}:{order_id}' expected_sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest() return expected_sign.lower() == sign.lower() except Exception as e: - logger.error(f"KassaAI webhook verify error: {e}") + logger.error(f'KassaAI webhook verify error: {e}') return False async def create_order( self, order_id: str, amount: float, - currency: str = "RUB", - email: Optional[str] = None, - ip: Optional[str] = None, - payment_system_id: Optional[int] = None, - ) -> Dict[str, Any]: + currency: str = 'RUB', + email: str | None = None, + ip: str | None = None, + payment_system_id: int | None = None, + ) -> dict[str, Any]: """ Создает заказ через API KassaAI. POST /orders/create @@ -158,69 +150,73 @@ class KassaAiService: ps_id = payment_system_id or settings.KASSA_AI_PAYMENT_SYSTEM_ID or 44 # Email: используем telegram-формат если не указан - target_email = email or f"user_{order_id}@telegram.org" + target_email = email or f'user_{order_id}@telegram.org' # Определяем публичный IP сервера server_ip = ip or await get_public_ip() params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), - "paymentId": str(order_id), - "i": ps_id, - "email": target_email, - "ip": server_ip, - "amount": final_amount, - "currency": currency, + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), + 'paymentId': str(order_id), + 'i': ps_id, + 'email': target_email, + 'ip': server_ip, + 'amount': final_amount, + 'currency': currency, } # Генерируем подпись HMAC-SHA256 - params["signature"] = self._generate_hmac_signature(params) + params['signature'] = self._generate_hmac_signature(params) - logger.info(f"KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}") + logger.info( + f'KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}' + ) try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/orders/create", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/orders/create', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - text = await response.text() - logger.info(f"KassaAI API response: {text}") + ) as response, + ): + text = await response.text() + logger.info(f'KassaAI API response: {text}') - data = await response.json() + data = await response.json() - # Проверяем на ошибку - if data.get("type") == "error": - error_msg = data.get("error") or data.get("message") or "Unknown error" - logger.error(f"KassaAI create_order error: {error_msg}") - raise Exception(f"KassaAI API error: {error_msg}") + # Проверяем на ошибку + if data.get('type') == 'error': + error_msg = data.get('error') or data.get('message') or 'Unknown error' + logger.error(f'KassaAI create_order error: {error_msg}') + raise Exception(f'KassaAI API error: {error_msg}') - if data.get("type") == "success": - return { - "location": data.get("location"), - "orderId": data.get("orderId"), - "paymentId": data.get("paymentId"), - } + if data.get('type') == 'success': + return { + 'location': data.get('location'), + 'orderId': data.get('orderId'), + 'paymentId': data.get('paymentId'), + } - # Неизвестный формат ответа - logger.error(f"KassaAI unexpected response: {data}") - raise Exception(f"KassaAI unexpected response format") + # Неизвестный формат ответа + logger.error(f'KassaAI unexpected response: {data}') + raise Exception('KassaAI unexpected response format') except aiohttp.ClientError as e: - logger.exception(f"KassaAI API connection error: {e}") + logger.exception(f'KassaAI API connection error: {e}') raise async def create_order_and_get_url( self, order_id: str, amount: float, - currency: str = "RUB", - email: Optional[str] = None, - ip: Optional[str] = None, - payment_system_id: Optional[int] = None, + currency: str = 'RUB', + email: str | None = None, + ip: str | None = None, + payment_system_id: int | None = None, ) -> str: """ Создает заказ через API и возвращает URL для оплаты. @@ -233,59 +229,63 @@ class KassaAiService: ip=ip, payment_system_id=payment_system_id, ) - location = result.get("location") + location = result.get('location') if not location: - raise Exception("KassaAI API did not return payment URL (location)") + raise Exception('KassaAI API did not return payment URL (location)') return location - async def get_order_status(self, order_id: str) -> Dict[str, Any]: + async def get_order_status(self, order_id: str) -> dict[str, Any]: """ Получает статус заказа. POST /orders """ params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), - "paymentId": str(order_id), + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), + 'paymentId': str(order_id), } - params["signature"] = self._generate_hmac_signature(params) + params['signature'] = self._generate_hmac_signature(params) - logger.debug(f"KassaAI get_order_status: order_id={order_id}") + logger.debug(f'KassaAI get_order_status: order_id={order_id}') try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/orders", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/orders', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - text = await response.text() - logger.debug(f"KassaAI get_order_status response: {text}") - return await response.json() + ) as response, + ): + text = await response.text() + logger.debug(f'KassaAI get_order_status response: {text}') + return await response.json() except aiohttp.ClientError as e: - logger.exception(f"KassaAI API connection error: {e}") + logger.exception(f'KassaAI API connection error: {e}') raise - async def get_balance(self) -> Dict[str, Any]: + async def get_balance(self) -> dict[str, Any]: """Получает баланс магазина.""" params = { - "shopId": self.shop_id, - "nonce": int(time.time_ns()), + 'shopId': self.shop_id, + 'nonce': int(time.time_ns()), } - params["signature"] = self._generate_hmac_signature(params) + params['signature'] = self._generate_hmac_signature(params) try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{API_BASE_URL}/balance", + async with ( + aiohttp.ClientSession() as session, + session.post( + f'{API_BASE_URL}/balance', json=params, - headers={"Content-Type": "application/json"}, + headers={'Content-Type': 'application/json'}, timeout=aiohttp.ClientTimeout(total=30), - ) as response: - return await response.json() + ) as response, + ): + return await response.json() except aiohttp.ClientError as e: - logger.exception(f"KassaAI API connection error: {e}") + logger.exception(f'KassaAI API connection error: {e}') raise diff --git a/app/services/log_rotation_service.py b/app/services/log_rotation_service.py index 30090408..5331f9d9 100644 --- a/app/services/log_rotation_service.py +++ b/app/services/log_rotation_service.py @@ -17,7 +17,6 @@ import tarfile from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path -from typing import Dict, List, Optional, Tuple from aiogram import Bot from aiogram.types import FSInputFile @@ -25,6 +24,7 @@ from aiogram.types import FSInputFile from app.config import settings from app.utils.timezone import get_local_timezone + logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ class LogRotationStatus: rotation_time: str keep_days: int send_to_telegram: bool - next_rotation: Optional[str] + next_rotation: str | None log_dir: str archive_count: int @@ -45,33 +45,33 @@ class LogRotationStatus: class LogRotationService: """Сервис ежедневной ротации и архивации логов.""" - def __init__(self, bot: Optional[Bot] = None): + def __init__(self, bot: Bot | None = None): self.bot = bot - self._rotation_task: Optional[asyncio.Task] = None + self._rotation_task: asyncio.Task | None = None self._running = False - self._handlers: List[logging.Handler] = [] + self._handlers: list[logging.Handler] = [] # Пути self.log_dir = Path(settings.LOG_DIR).resolve() - self.current_dir = self.log_dir / "current" - self.archive_dir = self.log_dir / "archive" + self.current_dir = self.log_dir / 'current' + self.archive_dir = self.log_dir / 'archive' @property - def log_files(self) -> Dict[str, Path]: + def log_files(self) -> dict[str, Path]: """Пути к текущим лог-файлам.""" return { - "bot": self.current_dir / "bot.log", - "info": self.current_dir / settings.LOG_INFO_FILE, - "warning": self.current_dir / settings.LOG_WARNING_FILE, - "error": self.current_dir / settings.LOG_ERROR_FILE, - "payments": self.current_dir / settings.LOG_PAYMENTS_FILE, + 'bot': self.current_dir / 'bot.log', + 'info': self.current_dir / settings.LOG_INFO_FILE, + 'warning': self.current_dir / settings.LOG_WARNING_FILE, + 'error': self.current_dir / settings.LOG_ERROR_FILE, + 'payments': self.current_dir / settings.LOG_PAYMENTS_FILE, } def set_bot(self, bot: Bot) -> None: """Установить экземпляр бота для отправки логов.""" self.bot = bot - def register_handlers(self, handlers: List[logging.Handler]) -> None: + def register_handlers(self, handlers: list[logging.Handler]) -> None: """Зарегистрировать хэндлеры для управления при ротации.""" self._handlers = handlers @@ -87,7 +87,7 @@ class LogRotationService: self._running = True self._rotation_task = asyncio.create_task(self._rotation_loop()) - logger.info("Сервис ротации логов запущен") + logger.info('Сервис ротации логов запущен') async def stop(self) -> None: """Остановить сервис ротации.""" @@ -98,7 +98,7 @@ class LogRotationService: await self._rotation_task except asyncio.CancelledError: pass - logger.info("Сервис ротации логов остановлен") + logger.info('Сервис ротации логов остановлен') def is_running(self) -> bool: """Проверить, запущен ли сервис.""" @@ -113,8 +113,8 @@ class LogRotationService: if wait_seconds > 0: logger.info( - "Следующая ротация логов: %s (через %.1f часов)", - next_rotation.strftime("%Y-%m-%d %H:%M"), + 'Следующая ротация логов: %s (через %.1f часов)', + next_rotation.strftime('%Y-%m-%d %H:%M'), wait_seconds / 3600, ) try: @@ -132,12 +132,10 @@ class LogRotationService: # Парсим время ротации time_str = settings.LOG_ROTATION_TIME try: - hours, minutes = map(int, time_str.split(":")) + hours, minutes = map(int, time_str.split(':')) except ValueError: hours, minutes = 0, 0 - logger.warning( - "Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str - ) + logger.warning("Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str) next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0) @@ -147,7 +145,7 @@ class LogRotationService: return next_rotation - async def rotate_logs(self) -> Tuple[bool, str]: + async def rotate_logs(self) -> tuple[bool, str]: """Выполнить ротацию логов. Создаёт один архив logs_YYYY-MM-DD.tar.gz со всеми лог-файлами за день. @@ -156,12 +154,10 @@ class LogRotationService: Tuple[bool, str]: (успех, сообщение) """ try: - logger.info("Начинаем ротацию логов...") + logger.info('Начинаем ротацию логов...') # Дата для архива (вчера, т.к. логи были за предыдущие сутки) - yesterday = ( - datetime.now(get_local_timezone()) - timedelta(days=1) - ).strftime("%Y-%m-%d") + yesterday = (datetime.now(get_local_timezone()) - timedelta(days=1)).strftime('%Y-%m-%d') # Сбрасываем буферы хэндлеров перед архивацией for handler in self._handlers: @@ -171,13 +167,13 @@ class LogRotationService: pass # Собираем файлы для архивации - files_to_archive: List[Tuple[Path, str]] = [] + files_to_archive: list[tuple[Path, str]] = [] for name, log_path in self.log_files.items(): if log_path.exists() and log_path.stat().st_size > 0: - files_to_archive.append((log_path, f"{name}.log")) + files_to_archive.append((log_path, f'{name}.log')) if not files_to_archive: - message = "Нет логов для архивации" + message = 'Нет логов для архивации' logger.info(message) return True, message @@ -187,7 +183,7 @@ class LogRotationService: if archive_path: # Очищаем текущие лог-файлы for log_path, _ in files_to_archive: - log_path.write_text("") + log_path.write_text('') # Очистка старых архивов await self._cleanup_old_archives() @@ -196,24 +192,23 @@ class LogRotationService: if settings.LOG_ROTATION_SEND_TO_TELEGRAM and self.bot: await self._send_logs_to_telegram(archive_path, yesterday) - message = f"Ротация логов завершена. Архив: {archive_path.name}" + message = f'Ротация логов завершена. Архив: {archive_path.name}' logger.info(message) return True, message - else: - message = "Ошибка создания архива логов" - logger.error(message) - return False, message + message = 'Ошибка создания архива логов' + logger.error(message) + return False, message except Exception as error: - message = f"Ошибка ротации логов: {error}" + message = f'Ошибка ротации логов: {error}' logger.error(message, exc_info=True) return False, message async def _create_archive( self, - files: List[Tuple[Path, str]], + files: list[tuple[Path, str]], date_str: str, - ) -> Optional[Path]: + ) -> Path | None: """Создать архив со всеми логами за день. Args: @@ -225,11 +220,11 @@ class LogRotationService: """ try: if settings.LOG_ROTATION_COMPRESS: - archive_name = f"logs_{date_str}.tar.gz" - mode = "w:gz" + archive_name = f'logs_{date_str}.tar.gz' + mode = 'w:gz' else: - archive_name = f"logs_{date_str}.tar" - mode = "w" + archive_name = f'logs_{date_str}.tar' + mode = 'w' archive_path = self.archive_dir / archive_name @@ -239,11 +234,11 @@ class LogRotationService: tar.add(file_path, arcname=arcname) await asyncio.to_thread(_create_tar) - logger.debug("Создан архив: %s", archive_path) + logger.debug('Создан архив: %s', archive_path) return archive_path except Exception as error: - logger.error("Ошибка создания архива: %s", error) + logger.error('Ошибка создания архива: %s', error) return None async def _cleanup_old_archives(self) -> None: @@ -261,18 +256,18 @@ class LogRotationService: # Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz name = archive_file.name - if not name.startswith("logs_"): + if not name.startswith('logs_'): continue try: # logs_2025-01-26.tar.gz -> 2025-01-26 - date_part = name.replace("logs_", "").replace(".tar.gz", "").replace(".tar", "") - file_date = datetime.strptime(date_part, "%Y-%m-%d") + date_part = name.replace('logs_', '').replace('.tar.gz', '').replace('.tar', '') + file_date = datetime.strptime(date_part, '%Y-%m-%d') file_date = file_date.replace(tzinfo=get_local_timezone()) if file_date < cutoff_date: archive_file.unlink() - logger.info("Удален старый архив логов: %s", archive_file.name) + logger.info('Удален старый архив логов: %s', archive_file.name) except ValueError: # Пропускаем файлы с некорректным форматом имени pass @@ -285,7 +280,7 @@ class LogRotationService: """Отправить архив логов в Telegram.""" chat_id = settings.get_log_rotation_chat_id() if not chat_id: - logger.warning("LOG_ROTATION_CHAT_ID не задан, пропускаем отправку") + logger.warning('LOG_ROTATION_CHAT_ID не задан, пропускаем отправку') return topic_id = settings.get_log_rotation_topic_id() @@ -293,31 +288,31 @@ class LogRotationService: try: file_size_kb = archive_path.stat().st_size / 1024 caption = ( - f"Логи бота\n" - f"Дата: {date_str}\n" - f"Файл: {archive_path.name}\n" - f"Размер: {file_size_kb:.1f} KB" + f'Логи бота\n' + f'Дата: {date_str}\n' + f'Файл: {archive_path.name}\n' + f'Размер: {file_size_kb:.1f} KB' ) send_kwargs = { - "chat_id": chat_id, - "document": FSInputFile(archive_path), - "caption": caption, - "parse_mode": "HTML", + 'chat_id': chat_id, + 'document': FSInputFile(archive_path), + 'caption': caption, + 'parse_mode': 'HTML', } if topic_id: - send_kwargs["message_thread_id"] = topic_id + send_kwargs['message_thread_id'] = topic_id await self.bot.send_document(**send_kwargs) - logger.info("Архив логов отправлен: %s", archive_path.name) + logger.info('Архив логов отправлен: %s', archive_path.name) except Exception as error: - logger.error("Ошибка отправки архива %s: %s", archive_path.name, error) + logger.error('Ошибка отправки архива %s: %s', archive_path.name, error) # === Ручные операции === - async def force_rotate(self) -> Tuple[bool, str]: + async def force_rotate(self) -> tuple[bool, str]: """Принудительная ротация (для админ-команды).""" return await self.rotate_logs() @@ -326,10 +321,7 @@ class LogRotationService: archive_count = 0 if self.archive_dir.exists(): # Считаем файлы logs_*.tar.gz или logs_*.tar - archive_count = len( - [f for f in self.archive_dir.iterdir() - if f.is_file() and f.name.startswith("logs_")] - ) + archive_count = len([f for f in self.archive_dir.iterdir() if f.is_file() and f.name.startswith('logs_')]) next_rotation = None if self._running: diff --git a/app/services/main_menu_button_service.py b/app/services/main_menu_button_service.py index 5793fa32..3d8fd8cc 100644 --- a/app/services/main_menu_button_service.py +++ b/app/services/main_menu_button_service.py @@ -2,19 +2,18 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from typing import List from aiogram import types from aiogram.types import InlineKeyboardButton from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.models import ( MainMenuButton, MainMenuButtonActionType, MainMenuButtonVisibility, ) -from app.config import settings @dataclass(frozen=True) @@ -28,7 +27,7 @@ class _MainMenuButtonData: class MainMenuButtonService: - _cache: List[_MainMenuButtonData] | None = None + _cache: list[_MainMenuButtonData] | None = None _lock: asyncio.Lock = asyncio.Lock() @classmethod @@ -36,7 +35,7 @@ class MainMenuButtonService: cls._cache = None @classmethod - async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]: + async def _load_cache(cls, db: AsyncSession) -> list[_MainMenuButtonData]: if cls._cache is not None: return cls._cache @@ -51,10 +50,10 @@ class MainMenuButtonService: ) ) - items: List[_MainMenuButtonData] = [] + items: list[_MainMenuButtonData] = [] for record in result.scalars().all(): - text = (record.text or "").strip() - action_value = (record.action_value or "").strip() + text = (record.text or '').strip() + action_value = (record.action_value or '').strip() if not text or not action_value: continue @@ -107,15 +106,12 @@ class MainMenuButtonService: continue # Проверка реферальной программы: скрыть кнопки, связанные с рефералами, если программа отключена - if ( - not settings.is_referral_program_enabled() - and ( - "partner" in item.text.lower() - or "referr" in item.text.lower() - or "партнер" in item.text.lower() - or "реферал" in item.text.lower() - or "referral" in item.action_value.lower() - ) + if not settings.is_referral_program_enabled() and ( + 'partner' in item.text.lower() + or 'referr' in item.text.lower() + or 'партнер' in item.text.lower() + or 'реферал' in item.text.lower() + or 'referral' in item.action_value.lower() ): continue diff --git a/app/services/maintenance_service.py b/app/services/maintenance_service.py index 6de4fdd1..732d3832 100644 --- a/app/services/maintenance_service.py +++ b/app/services/maintenance_service.py @@ -1,54 +1,52 @@ import asyncio import logging -from datetime import datetime, timedelta -from typing import Optional, Dict, Any from dataclasses import dataclass +from datetime import datetime +from typing import Any from app.config import settings from app.external.remnawave_api import RemnaWaveAPI, test_api_connection from app.utils.cache import cache from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) @dataclass class MaintenanceStatus: is_active: bool - enabled_at: Optional[datetime] = None - last_check: Optional[datetime] = None - reason: Optional[str] = None + enabled_at: datetime | None = None + last_check: datetime | None = None + reason: str | None = None auto_enabled: bool = False api_status: bool = True consecutive_failures: int = 0 class MaintenanceService: - def __init__(self): self._status = MaintenanceStatus(is_active=False) - self._check_task: Optional[asyncio.Task] = None + self._check_task: asyncio.Task | None = None self._is_checking = False self._max_consecutive_failures = 3 - self._bot = None - self._last_notification_sent = None - + self._bot = None + self._last_notification_sent = None + def set_bot(self, bot): self._bot = bot - logger.info("Бот установлен для maintenance_service") - + logger.info('Бот установлен для maintenance_service') + @property def status(self) -> MaintenanceStatus: return self._status - + def is_maintenance_active(self) -> bool: return self._status.is_active - + def get_maintenance_message(self) -> str: if self._status.auto_enabled: - last_check_display = format_local_datetime( - self._status.last_check, "%H:%M:%S", "неизвестно" - ) + last_check_display = format_local_datetime(self._status.last_check, '%H:%M:%S', 'неизвестно') return f""" 🔧 Технические работы! @@ -58,112 +56,91 @@ class MaintenanceService: 🔄 Последняя проверка: {last_check_display} """ - else: - return settings.get_maintenance_message() - - async def _send_admin_notification(self, message: str, alert_type: str = "info"): + return settings.get_maintenance_message() + + async def _send_admin_notification(self, message: str, alert_type: str = 'info'): if not self._bot: - logger.warning("Бот не установлен, уведомления не могут быть отправлены") + logger.warning('Бот не установлен, уведомления не могут быть отправлены') return False - + try: from app.services.admin_notification_service import AdminNotificationService - + notification_service = AdminNotificationService(self._bot) - + if not notification_service._is_enabled(): - logger.debug("Уведомления администраторов отключены") + logger.debug('Уведомления администраторов отключены') return False - - emoji_map = { - "error": "🚨", - "warning": "⚠️", - "success": "✅", - "info": "ℹ️" - } - emoji = emoji_map.get(alert_type, "ℹ️") - - timestamp = format_local_datetime( - datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z" - ) - formatted_message = ( - f"{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}" - ) - + + emoji_map = {'error': '🚨', 'warning': '⚠️', 'success': '✅', 'info': 'ℹ️'} + emoji = emoji_map.get(alert_type, 'ℹ️') + + timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S %Z') + formatted_message = f'{emoji} ТЕХНИЧЕСКИЕ РАБОТЫ\n\n{message}\n\n⏰ {timestamp}' + return await notification_service._send_message(formatted_message) - + except Exception as e: - logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}") + logger.error(f'Ошибка отправки уведомления через AdminNotificationService: {e}') return False - - async def _notify_admins(self, message: str, alert_type: str = "info"): + + async def _notify_admins(self, message: str, alert_type: str = 'info'): if not self._bot: - logger.warning("Бот не установлен, уведомления не могут быть отправлены") + logger.warning('Бот не установлен, уведомления не могут быть отправлены') return - + notification_sent = await self._send_admin_notification(message, alert_type) - + if notification_sent: - logger.info("Уведомление успешно отправлено через AdminNotificationService") + logger.info('Уведомление успешно отправлено через AdminNotificationService') return - - logger.info("Отправляем уведомление напрямую администраторам") - - cache_key = f"maintenance_notification_{alert_type}" + + logger.info('Отправляем уведомление напрямую администраторам') + + cache_key = f'maintenance_notification_{alert_type}' if await cache.get(cache_key): return - + admin_ids = settings.get_admin_ids() if not admin_ids: - logger.warning("Список администраторов пуст") + logger.warning('Список администраторов пуст') return - - emoji_map = { - "error": "🚨", - "warning": "⚠️", - "success": "✅", - "info": "ℹ️" - } - emoji = emoji_map.get(alert_type, "ℹ️") - - formatted_message = f"{emoji} Maintenance Service\n\n{message}" - + + emoji_map = {'error': '🚨', 'warning': '⚠️', 'success': '✅', 'info': 'ℹ️'} + emoji = emoji_map.get(alert_type, 'ℹ️') + + formatted_message = f'{emoji} Maintenance Service\n\n{message}' + success_count = 0 for admin_id in admin_ids: try: - await self._bot.send_message( - chat_id=admin_id, - text=formatted_message, - parse_mode="HTML" - ) + await self._bot.send_message(chat_id=admin_id, text=formatted_message, parse_mode='HTML') success_count += 1 - await asyncio.sleep(0.1) - + await asyncio.sleep(0.1) + except Exception as e: - logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}") - + logger.error(f'Ошибка отправки уведомления админу {admin_id}: {e}') + if success_count > 0: - logger.info(f"Уведомление отправлено {success_count} администраторам") + logger.info(f'Уведомление отправлено {success_count} администраторам') await cache.set(cache_key, True, expire=300) else: - logger.error("Не удалось отправить уведомления ни одному администратору") - - async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool: + logger.error('Не удалось отправить уведомления ни одному администратору') + + async def enable_maintenance(self, reason: str | None = None, auto: bool = False) -> bool: try: if self._status.is_active: - logger.warning("Режим техработ уже включен") + logger.warning('Режим техработ уже включен') return True - + self._status.is_active = True self._status.enabled_at = datetime.utcnow() - self._status.reason = reason or ("Автоматическое включение" if auto else "Включено администратором") + self._status.reason = reason or ('Автоматическое включение' if auto else 'Включено администратором') self._status.auto_enabled = auto - + await self._save_status_to_cache() - - enabled_time = format_local_datetime( - self._status.enabled_at, "%d.%m.%Y %H:%M:%S %Z" - ) + + enabled_time = format_local_datetime(self._status.enabled_at, '%d.%m.%Y %H:%M:%S %Z') notification_msg = f"""Режим технических работ ВКЛЮЧЕН 📋 Причина: {self._status.reason} @@ -171,47 +148,45 @@ class MaintenanceService: 🕐 Время: {enabled_time} Обычные пользователи временно не смогут использовать бота.""" - - await self._notify_admins(notification_msg, "warning" if auto else "info") - - logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}") + + await self._notify_admins(notification_msg, 'warning' if auto else 'info') + + logger.warning(f'🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}') return True - + except Exception as e: - logger.error(f"Ошибка включения режима техработ: {e}") + logger.error(f'Ошибка включения режима техработ: {e}') return False - + async def disable_maintenance(self) -> bool: try: if not self._status.is_active: - logger.info("Режим техработ уже выключен") + logger.info('Режим техработ уже выключен') return True - + was_auto = self._status.auto_enabled duration = None if self._status.enabled_at: duration = datetime.utcnow() - self._status.enabled_at - + self._status.is_active = False self._status.enabled_at = None self._status.reason = None self._status.auto_enabled = False self._status.consecutive_failures = 0 - + await self._save_status_to_cache() - - duration_str = "" + + duration_str = '' if duration: hours = int(duration.total_seconds() // 3600) minutes = int((duration.total_seconds() % 3600) // 60) if hours > 0: - duration_str = f"\n⏱️ Длительность: {hours}ч {minutes}мин" + duration_str = f'\n⏱️ Длительность: {hours}ч {minutes}мин' else: - duration_str = f"\n⏱️ Длительность: {minutes}мин" - - notification_time = format_local_datetime( - datetime.utcnow(), "%d.%m.%Y %H:%M:%S %Z" - ) + duration_str = f'\n⏱️ Длительность: {minutes}мин' + + notification_time = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S %Z') notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН 🤖 Автоматически: {'Да' if was_auto else 'Нет'} @@ -219,27 +194,27 @@ class MaintenanceService: {duration_str} Сервис снова доступен для пользователей.""" - - await self._notify_admins(notification_msg, "success") - - logger.info("✅ Режим техработ ВЫКЛЮЧЕН") + + await self._notify_admins(notification_msg, 'success') + + logger.info('✅ Режим техработ ВЫКЛЮЧЕН') return True - + except Exception as e: - logger.error(f"Ошибка выключения режима техработ: {e}") + logger.error(f'Ошибка выключения режима техработ: {e}') return False - + async def start_monitoring(self) -> bool: try: if self._check_task and not self._check_task.done(): - logger.warning("Мониторинг уже запущен") + logger.warning('Мониторинг уже запущен') return True - + await self._load_status_from_cache() - + self._check_task = asyncio.create_task(self._monitoring_loop()) logger.info( - "🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)", + '🔄 Запущен мониторинг API Remnawave (интервал: %sс, попыток: %s)', settings.get_maintenance_check_interval(), settings.get_maintenance_retry_attempts(), ) @@ -253,15 +228,15 @@ class MaintenanceService: 🔁 Повторных попыток: {settings.get_maintenance_retry_attempts()} Система будет следить за доступностью API.""", - "info", + 'info', ) - + return True - + except Exception as e: - logger.error(f"Ошибка запуска мониторинга: {e}") + logger.error(f'Ошибка запуска мониторинга: {e}') return False - + async def stop_monitoring(self) -> bool: try: if self._check_task and not self._check_task.done(): @@ -270,15 +245,15 @@ class MaintenanceService: await self._check_task except asyncio.CancelledError: pass - - await self._notify_admins("Мониторинг технических работ остановлен", "info") - logger.info("ℹ️ Мониторинг API остановлен") + + await self._notify_admins('Мониторинг технических работ остановлен', 'info') + logger.info('ℹ️ Мониторинг API остановлен') return True - + except Exception as e: - logger.error(f"Ошибка остановки мониторинга: {e}") + logger.error(f'Ошибка остановки мониторинга: {e}') return False - + async def check_api_status(self) -> bool: try: if self._is_checking: @@ -288,22 +263,22 @@ class MaintenanceService: self._status.last_check = datetime.utcnow() auth_params = settings.get_remnawave_auth_params() - base_url = (auth_params.get("base_url") or "").strip() - api_key = (auth_params.get("api_key") or "").strip() - secret_key = (auth_params.get("secret_key") or "").strip() or None - username = (auth_params.get("username") or "").strip() or None - password = (auth_params.get("password") or "").strip() or None - caddy_token = (auth_params.get("caddy_token") or "").strip() or None - auth_type = (auth_params.get("auth_type") or "api_key").strip() + base_url = (auth_params.get('base_url') or '').strip() + api_key = (auth_params.get('api_key') or '').strip() + secret_key = (auth_params.get('secret_key') or '').strip() or None + username = (auth_params.get('username') or '').strip() or None + password = (auth_params.get('password') or '').strip() or None + caddy_token = (auth_params.get('caddy_token') or '').strip() or None + auth_type = (auth_params.get('auth_type') or 'api_key').strip() if not base_url: - logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API") + logger.error('REMNAWAVE_API_URL не настроен, пропускаем проверку API') self._status.api_status = False self._status.consecutive_failures = 0 return False if not api_key: - logger.error("REMNAWAVE_API_KEY не настроен, пропускаем проверку API") + logger.error('REMNAWAVE_API_KEY не настроен, пропускаем проверку API') self._status.api_status = False self._status.consecutive_failures = 0 return False @@ -326,14 +301,10 @@ class MaintenanceService: if is_connected: if attempt > 1: - logger.info( - "API Remnawave ответило с %s попытки", attempt - ) + logger.info('API Remnawave ответило с %s попытки', attempt) if not self._status.api_status: - recovery_time = format_local_datetime( - self._status.last_check, "%H:%M:%S %Z" - ) + recovery_time = format_local_datetime(self._status.last_check, '%H:%M:%S %Z') await self._notify_admins( f"""API Remnawave восстановлено! @@ -342,7 +313,7 @@ class MaintenanceService: 🔄 Неудачных попыток было: {self._status.consecutive_failures} API снова отвечает на запросы.""", - "success", + 'success', ) self._status.api_status = True @@ -350,13 +321,13 @@ API снова отвечает на запросы.""", if self._status.is_active and self._status.auto_enabled: await self.disable_maintenance() - logger.info("✅ API восстановился, режим техработ автоматически отключен") + logger.info('✅ API восстановился, режим техработ автоматически отключен') return True if attempt < attempts: logger.warning( - "API Remnawave недоступно (попытка %s/%s)", + 'API Remnawave недоступно (попытка %s/%s)', attempt, attempts, ) @@ -367,9 +338,7 @@ API снова отвечает на запросы.""", self._status.consecutive_failures += 1 if was_available: - detection_time = format_local_datetime( - self._status.last_check, "%H:%M:%S %Z" - ) + detection_time = format_local_datetime(self._status.last_check, '%H:%M:%S %Z') await self._notify_admins( f"""API Remnawave недоступно! @@ -378,7 +347,7 @@ API снова отвечает на запросы.""", 🔄 Попытка: {self._status.consecutive_failures} Началась серия неудачных проверок API.""", - "error", + 'error', ) if ( @@ -386,160 +355,153 @@ API снова отвечает на запросы.""", and not self._status.is_active and settings.is_maintenance_auto_enable() ): - await self.enable_maintenance( reason=( - f"Автоматическое включение после {self._status.consecutive_failures} " - "неудачных проверок API" + f'Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API' ), - auto=True + auto=True, ) return False except Exception as e: - logger.error(f"Ошибка проверки API: {e}") - + logger.error(f'Ошибка проверки API: {e}') + if self._status.api_status: - error_time = format_local_datetime(datetime.utcnow(), "%H:%M:%S %Z") + error_time = format_local_datetime(datetime.utcnow(), '%H:%M:%S %Z') await self._notify_admins( f"""Ошибка при проверке API Remnawave -❌ Ошибка: {str(e)} +❌ Ошибка: {e!s} 🕐 Время: {error_time} Не удалось выполнить проверку доступности API.""", - "error", + 'error', ) - + self._status.api_status = False self._status.consecutive_failures += 1 return False finally: self._is_checking = False await self._save_status_to_cache() - + async def _monitoring_loop(self): while True: try: await self.check_api_status() await asyncio.sleep(settings.get_maintenance_check_interval()) - + except asyncio.CancelledError: - logger.info("Мониторинг отменен") + logger.info('Мониторинг отменен') break except Exception as e: - logger.error(f"Ошибка в цикле мониторинга: {e}") - await asyncio.sleep(30) - + logger.error(f'Ошибка в цикле мониторинга: {e}') + await asyncio.sleep(30) + async def _save_status_to_cache(self): try: status_data = { - "is_active": self._status.is_active, - "enabled_at": self._status.enabled_at.isoformat() if self._status.enabled_at else None, - "reason": self._status.reason, - "auto_enabled": self._status.auto_enabled, - "consecutive_failures": self._status.consecutive_failures, - "last_check": self._status.last_check.isoformat() if self._status.last_check else None + 'is_active': self._status.is_active, + 'enabled_at': self._status.enabled_at.isoformat() if self._status.enabled_at else None, + 'reason': self._status.reason, + 'auto_enabled': self._status.auto_enabled, + 'consecutive_failures': self._status.consecutive_failures, + 'last_check': self._status.last_check.isoformat() if self._status.last_check else None, } - - await cache.set("maintenance_status", status_data, expire=3600) - + + await cache.set('maintenance_status', status_data, expire=3600) + except Exception as e: - logger.error(f"Ошибка сохранения состояния в кеш: {e}") - + logger.error(f'Ошибка сохранения состояния в кеш: {e}') + async def _load_status_from_cache(self): try: - status_data = await cache.get("maintenance_status") + status_data = await cache.get('maintenance_status') if not status_data: return - - self._status.is_active = status_data.get("is_active", False) - self._status.reason = status_data.get("reason") - self._status.auto_enabled = status_data.get("auto_enabled", False) - self._status.consecutive_failures = status_data.get("consecutive_failures", 0) - - if status_data.get("enabled_at"): - self._status.enabled_at = datetime.fromisoformat(status_data["enabled_at"]) - - if status_data.get("last_check"): - self._status.last_check = datetime.fromisoformat(status_data["last_check"]) - - logger.info(f"🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}") - + + self._status.is_active = status_data.get('is_active', False) + self._status.reason = status_data.get('reason') + self._status.auto_enabled = status_data.get('auto_enabled', False) + self._status.consecutive_failures = status_data.get('consecutive_failures', 0) + + if status_data.get('enabled_at'): + self._status.enabled_at = datetime.fromisoformat(status_data['enabled_at']) + + if status_data.get('last_check'): + self._status.last_check = datetime.fromisoformat(status_data['last_check']) + + logger.info(f'🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}') + except Exception as e: - logger.error(f"Ошибка загрузки состояния из кеша: {e}") - - def get_status_info(self) -> Dict[str, Any]: + logger.error(f'Ошибка загрузки состояния из кеша: {e}') + + def get_status_info(self) -> dict[str, Any]: return { - "is_active": self._status.is_active, - "enabled_at": self._status.enabled_at, - "last_check": self._status.last_check, - "reason": self._status.reason, - "auto_enabled": self._status.auto_enabled, - "api_status": self._status.api_status, - "consecutive_failures": self._status.consecutive_failures, - "monitoring_active": self._check_task is not None and not self._check_task.done(), - "monitoring_configured": settings.is_maintenance_monitoring_enabled(), - "auto_enable_configured": settings.is_maintenance_auto_enable(), - "check_interval": settings.get_maintenance_check_interval(), - "bot_connected": self._bot is not None + 'is_active': self._status.is_active, + 'enabled_at': self._status.enabled_at, + 'last_check': self._status.last_check, + 'reason': self._status.reason, + 'auto_enabled': self._status.auto_enabled, + 'api_status': self._status.api_status, + 'consecutive_failures': self._status.consecutive_failures, + 'monitoring_active': self._check_task is not None and not self._check_task.done(), + 'monitoring_configured': settings.is_maintenance_monitoring_enabled(), + 'auto_enable_configured': settings.is_maintenance_auto_enable(), + 'check_interval': settings.get_maintenance_check_interval(), + 'bot_connected': self._bot is not None, } - - async def force_api_check(self) -> Dict[str, Any]: + + async def force_api_check(self) -> dict[str, Any]: start_time = datetime.utcnow() - + try: api_status = await self.check_api_status() end_time = datetime.utcnow() response_time = (end_time - start_time).total_seconds() - + return { - "success": True, - "api_available": api_status, - "response_time": round(response_time, 2), - "checked_at": end_time, - "consecutive_failures": self._status.consecutive_failures + 'success': True, + 'api_available': api_status, + 'response_time': round(response_time, 2), + 'checked_at': end_time, + 'consecutive_failures': self._status.consecutive_failures, } - + except Exception as e: end_time = datetime.utcnow() response_time = (end_time - start_time).total_seconds() - + return { - "success": False, - "api_available": False, - "error": str(e), - "response_time": round(response_time, 2), - "checked_at": end_time, - "consecutive_failures": self._status.consecutive_failures + 'success': False, + 'api_available': False, + 'error': str(e), + 'response_time': round(response_time, 2), + 'checked_at': end_time, + 'consecutive_failures': self._status.consecutive_failures, } - - async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool: + + async def send_remnawave_status_notification(self, status: str, details: str = '') -> bool: try: - status_emojis = { - "online": "🟢", - "offline": "🔴", - "warning": "🟡", - "error": "⚠️" - } - - emoji = status_emojis.get(status, "ℹ️") - + status_emojis = {'online': '🟢', 'offline': '🔴', 'warning': '🟡', 'error': '⚠️'} + + emoji = status_emojis.get(status, 'ℹ️') + message = f"""Статус панели Remnawave изменился {emoji} Статус: {status.upper()} 🔗 URL: {settings.REMNAWAVE_API_URL} {details}""" - - alert_type = "error" if status in ["offline", "error"] else "info" + + alert_type = 'error' if status in ['offline', 'error'] else 'info' await self._notify_admins(message, alert_type) - - logger.info(f"Отправлено уведомление о статусе Remnawave: {status}") + + logger.info(f'Отправлено уведомление о статусе Remnawave: {status}') return True - + except Exception as e: - logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}") + logger.error(f'Ошибка отправки уведомления о статусе Remnawave: {e}') return False diff --git a/app/services/menu_layout/__init__.py b/app/services/menu_layout/__init__.py index 967c3d61..ecb4c5c4 100644 --- a/app/services/menu_layout/__init__.py +++ b/app/services/menu_layout/__init__.py @@ -10,27 +10,28 @@ """ from .constants import ( - MENU_LAYOUT_CONFIG_KEY, - DEFAULT_MENU_CONFIG, - BUILTIN_BUTTONS_INFO, AVAILABLE_CALLBACKS, + BUILTIN_BUTTONS_INFO, + DEFAULT_MENU_CONFIG, DYNAMIC_PLACEHOLDERS, + MENU_LAYOUT_CONFIG_KEY, ) from .context import MenuContext from .history_service import MenuLayoutHistoryService -from .stats_service import MenuLayoutStatsService from .service import MenuLayoutService +from .stats_service import MenuLayoutStatsService + __all__ = [ + 'AVAILABLE_CALLBACKS', + 'BUILTIN_BUTTONS_INFO', + 'DEFAULT_MENU_CONFIG', + 'DYNAMIC_PLACEHOLDERS', # Константы - "MENU_LAYOUT_CONFIG_KEY", - "DEFAULT_MENU_CONFIG", - "BUILTIN_BUTTONS_INFO", - "AVAILABLE_CALLBACKS", - "DYNAMIC_PLACEHOLDERS", + 'MENU_LAYOUT_CONFIG_KEY', # Классы - "MenuContext", - "MenuLayoutService", - "MenuLayoutHistoryService", - "MenuLayoutStatsService", + 'MenuContext', + 'MenuLayoutHistoryService', + 'MenuLayoutService', + 'MenuLayoutStatsService', ] diff --git a/app/services/menu_layout/constants.py b/app/services/menu_layout/constants.py index b76d75e8..60734d7c 100644 --- a/app/services/menu_layout/constants.py +++ b/app/services/menu_layout/constants.py @@ -1,501 +1,725 @@ """Константы для конструктора меню.""" -from typing import Any, Dict, List +from typing import Any + # Ключ для хранения конфигурации в SystemSetting -MENU_LAYOUT_CONFIG_KEY = "menu_layout_config" +MENU_LAYOUT_CONFIG_KEY = 'menu_layout_config' # Дефолтная конфигурация меню -DEFAULT_MENU_CONFIG: Dict[str, Any] = { - "version": 1, - "rows": [ +DEFAULT_MENU_CONFIG: dict[str, Any] = { + 'version': 1, + 'rows': [ { - "id": "connect_row", - "buttons": ["connect"], - "conditions": {"has_active_subscription": True, "subscription_is_active": True}, - "max_per_row": 1, + 'id': 'connect_row', + 'buttons': ['connect'], + 'conditions': {'has_active_subscription': True, 'subscription_is_active': True}, + 'max_per_row': 1, }, { - "id": "happ_row", - "buttons": ["happ_download"], - "conditions": {"has_active_subscription": True, "happ_enabled": True}, - "max_per_row": 1, + 'id': 'happ_row', + 'buttons': ['happ_download'], + 'conditions': {'has_active_subscription': True, 'happ_enabled': True}, + 'max_per_row': 1, }, { - "id": "subscription_traffic_row", - "buttons": ["subscription", "buy_traffic"], - "conditions": {"has_active_subscription": True}, - "max_per_row": 2, + 'id': 'subscription_traffic_row', + 'buttons': ['subscription', 'buy_traffic'], + 'conditions': {'has_active_subscription': True}, + 'max_per_row': 2, }, { - "id": "balance_row", - "buttons": ["balance"], - "conditions": None, - "max_per_row": 1, + 'id': 'balance_row', + 'buttons': ['balance'], + 'conditions': None, + 'max_per_row': 1, }, { - "id": "trial_buy_row", - "buttons": ["trial", "buy_subscription"], - "conditions": None, - "max_per_row": 2, + 'id': 'trial_buy_row', + 'buttons': ['trial', 'buy_subscription'], + 'conditions': None, + 'max_per_row': 2, }, { - "id": "simple_subscription_row", - "buttons": ["simple_subscription"], - "conditions": {"simple_subscription_enabled": True}, - "max_per_row": 1, + 'id': 'simple_subscription_row', + 'buttons': ['simple_subscription'], + 'conditions': {'simple_subscription_enabled': True}, + 'max_per_row': 1, }, { - "id": "resume_row", - "buttons": ["resume_checkout"], - "conditions": {"has_saved_cart": True}, - "max_per_row": 1, + 'id': 'resume_row', + 'buttons': ['resume_checkout'], + 'conditions': {'has_saved_cart': True}, + 'max_per_row': 1, }, { - "id": "promo_referral_row", - "buttons": ["promocode", "referrals"], - "conditions": None, - "max_per_row": 2, + 'id': 'promo_referral_row', + 'buttons': ['promocode', 'referrals'], + 'conditions': None, + 'max_per_row': 2, }, { - "id": "contests_row", - "buttons": ["contests"], - "conditions": {"contests_visible": True}, - "max_per_row": 2, + 'id': 'contests_row', + 'buttons': ['contests'], + 'conditions': {'contests_visible': True}, + 'max_per_row': 2, }, { - "id": "support_info_row", - "buttons": ["support", "info"], - "conditions": None, - "max_per_row": 2, + 'id': 'support_info_row', + 'buttons': ['support', 'info'], + 'conditions': None, + 'max_per_row': 2, }, { - "id": "language_row", - "buttons": ["language"], - "conditions": {"language_selection_enabled": True}, - "max_per_row": 2, + 'id': 'language_row', + 'buttons': ['language'], + 'conditions': {'language_selection_enabled': True}, + 'max_per_row': 2, }, { - "id": "admin_row", - "buttons": ["admin_panel"], - "conditions": {"is_admin": True}, - "max_per_row": 1, + 'id': 'admin_row', + 'buttons': ['admin_panel'], + 'conditions': {'is_admin': True}, + 'max_per_row': 1, }, { - "id": "moderator_row", - "buttons": ["moderator_panel"], - "conditions": {"is_moderator": True}, - "max_per_row": 1, + 'id': 'moderator_row', + 'buttons': ['moderator_panel'], + 'conditions': {'is_moderator': True}, + 'max_per_row': 1, }, ], - "buttons": { - "connect": { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, - "action": "subscription_connect", - "enabled": True, - "visibility": "subscribers", - "conditions": {"has_active_subscription": True, "subscription_is_active": True}, - "dynamic_text": False, - "open_mode": "callback", # "callback" или "direct" - "webapp_url": None, # URL для Mini App при open_mode="direct" + 'buttons': { + 'connect': { + 'type': 'builtin', + 'builtin_id': 'connect', + 'text': {'ru': '🔗 Подключиться', 'en': '🔗 Connect'}, + 'action': 'subscription_connect', + 'enabled': True, + 'visibility': 'subscribers', + 'conditions': {'has_active_subscription': True, 'subscription_is_active': True}, + 'dynamic_text': False, + 'open_mode': 'callback', # "callback" или "direct" + 'webapp_url': None, # URL для Mini App при open_mode="direct" }, - "happ_download": { - "type": "builtin", - "builtin_id": "happ_download", - "text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, - "action": "subscription_happ_download", - "enabled": True, - "visibility": "subscribers", - "conditions": None, - "dynamic_text": False, + 'happ_download': { + 'type': 'builtin', + 'builtin_id': 'happ_download', + 'text': {'ru': '⬇️ Скачать Happ', 'en': '⬇️ Download Happ'}, + 'action': 'subscription_happ_download', + 'enabled': True, + 'visibility': 'subscribers', + 'conditions': None, + 'dynamic_text': False, }, - "subscription": { - "type": "builtin", - "builtin_id": "subscription", - "text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, - "action": "menu_subscription", - "enabled": True, - "visibility": "subscribers", - "conditions": None, - "dynamic_text": False, + 'subscription': { + 'type': 'builtin', + 'builtin_id': 'subscription', + 'text': {'ru': '📊 Подписка', 'en': '📊 Subscription'}, + 'action': 'menu_subscription', + 'enabled': True, + 'visibility': 'subscribers', + 'conditions': None, + 'dynamic_text': False, }, - "buy_traffic": { - "type": "builtin", - "builtin_id": "buy_traffic", - "text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, - "action": "buy_traffic", - "enabled": True, - "visibility": "subscribers", - "conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True}, - "dynamic_text": False, + 'buy_traffic': { + 'type': 'builtin', + 'builtin_id': 'buy_traffic', + 'text': {'ru': '📈 Докупить трафик', 'en': '📈 Buy traffic'}, + 'action': 'buy_traffic', + 'enabled': True, + 'visibility': 'subscribers', + 'conditions': {'has_traffic_limit': True, 'traffic_topup_enabled': True}, + 'dynamic_text': False, }, - "balance": { - "type": "builtin", - "builtin_id": "balance", - "text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, - "action": "menu_balance", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": True, + 'balance': { + 'type': 'builtin', + 'builtin_id': 'balance', + 'text': {'ru': '💰 Баланс: {balance}', 'en': '💰 Balance: {balance}'}, + 'action': 'menu_balance', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': True, }, - "trial": { - "type": "builtin", - "builtin_id": "trial", - "text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, - "action": "menu_trial", - "enabled": True, - "visibility": "all", - "conditions": {"show_trial": True}, - "dynamic_text": False, + 'trial': { + 'type': 'builtin', + 'builtin_id': 'trial', + 'text': {'ru': '🎁 Пробный период', 'en': '🎁 Free trial'}, + 'action': 'menu_trial', + 'enabled': True, + 'visibility': 'all', + 'conditions': {'show_trial': True}, + 'dynamic_text': False, }, - "buy_subscription": { - "type": "builtin", - "builtin_id": "buy_subscription", - "text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, - "action": "menu_buy", - "enabled": True, - "visibility": "all", - "conditions": {"show_buy": True}, - "dynamic_text": False, + 'buy_subscription': { + 'type': 'builtin', + 'builtin_id': 'buy_subscription', + 'text': {'ru': '🛒 Купить подписку', 'en': '🛒 Buy subscription'}, + 'action': 'menu_buy', + 'enabled': True, + 'visibility': 'all', + 'conditions': {'show_buy': True}, + 'dynamic_text': False, }, - "simple_subscription": { - "type": "builtin", - "builtin_id": "simple_subscription", - "text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, - "action": "simple_subscription_purchase", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'simple_subscription': { + 'type': 'builtin', + 'builtin_id': 'simple_subscription', + 'text': {'ru': '💳 Простая подписка', 'en': '💳 Simple subscription'}, + 'action': 'simple_subscription_purchase', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "resume_checkout": { - "type": "builtin", - "builtin_id": "resume_checkout", - "text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, - "action": "return_to_saved_cart", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'resume_checkout': { + 'type': 'builtin', + 'builtin_id': 'resume_checkout', + 'text': {'ru': '↩️ Вернуться к оформлению', 'en': '↩️ Resume checkout'}, + 'action': 'return_to_saved_cart', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "promocode": { - "type": "builtin", - "builtin_id": "promocode", - "text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, - "action": "menu_promocode", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'promocode': { + 'type': 'builtin', + 'builtin_id': 'promocode', + 'text': {'ru': '🎟️ Промокод', 'en': '🎟️ Promo code'}, + 'action': 'menu_promocode', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "referrals": { - "type": "builtin", - "builtin_id": "referrals", - "text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, - "action": "menu_referrals", - "enabled": True, - "visibility": "all", - "conditions": {"referral_enabled": True}, - "dynamic_text": False, + 'referrals': { + 'type': 'builtin', + 'builtin_id': 'referrals', + 'text': {'ru': '👥 Рефералы', 'en': '👥 Referrals'}, + 'action': 'menu_referrals', + 'enabled': True, + 'visibility': 'all', + 'conditions': {'referral_enabled': True}, + 'dynamic_text': False, }, - "contests": { - "type": "builtin", - "builtin_id": "contests", - "text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, - "action": "contests_menu", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'contests': { + 'type': 'builtin', + 'builtin_id': 'contests', + 'text': {'ru': '🎲 Конкурсы', 'en': '🎲 Contests'}, + 'action': 'contests_menu', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "support": { - "type": "builtin", - "builtin_id": "support", - "text": {"ru": "💬 Поддержка", "en": "💬 Support"}, - "action": "menu_support", - "enabled": True, - "visibility": "all", - "conditions": {"support_enabled": True}, - "dynamic_text": False, + 'support': { + 'type': 'builtin', + 'builtin_id': 'support', + 'text': {'ru': '💬 Поддержка', 'en': '💬 Support'}, + 'action': 'menu_support', + 'enabled': True, + 'visibility': 'all', + 'conditions': {'support_enabled': True}, + 'dynamic_text': False, }, - "info": { - "type": "builtin", - "builtin_id": "info", - "text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, - "action": "menu_info", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'info': { + 'type': 'builtin', + 'builtin_id': 'info', + 'text': {'ru': 'ℹ️ Инфо', 'en': 'ℹ️ Info'}, + 'action': 'menu_info', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "language": { - "type": "builtin", - "builtin_id": "language", - "text": {"ru": "🌐 Язык", "en": "🌐 Language"}, - "action": "menu_language", - "enabled": True, - "visibility": "all", - "conditions": None, - "dynamic_text": False, + 'language': { + 'type': 'builtin', + 'builtin_id': 'language', + 'text': {'ru': '🌐 Язык', 'en': '🌐 Language'}, + 'action': 'menu_language', + 'enabled': True, + 'visibility': 'all', + 'conditions': None, + 'dynamic_text': False, }, - "admin_panel": { - "type": "builtin", - "builtin_id": "admin_panel", - "text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, - "action": "admin_panel", - "enabled": True, - "visibility": "admins", - "conditions": None, - "dynamic_text": False, + 'admin_panel': { + 'type': 'builtin', + 'builtin_id': 'admin_panel', + 'text': {'ru': '⚙️ Админ панель', 'en': '⚙️ Admin panel'}, + 'action': 'admin_panel', + 'enabled': True, + 'visibility': 'admins', + 'conditions': None, + 'dynamic_text': False, }, - "moderator_panel": { - "type": "builtin", - "builtin_id": "moderator_panel", - "text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, - "action": "moderator_panel", - "enabled": True, - "visibility": "moderators", - "conditions": None, - "dynamic_text": False, + 'moderator_panel': { + 'type': 'builtin', + 'builtin_id': 'moderator_panel', + 'text': {'ru': '🧑‍⚖️ Модерация', 'en': '🧑‍⚖️ Moderation'}, + 'action': 'moderator_panel', + 'enabled': True, + 'visibility': 'moderators', + 'conditions': None, + 'dynamic_text': False, }, }, } # Информация о встроенных кнопках для API -BUILTIN_BUTTONS_INFO: List[Dict[str, Any]] = [ +BUILTIN_BUTTONS_INFO: list[dict[str, Any]] = [ { - "id": "connect", - "default_text": {"ru": "🔗 Подключиться", "en": "🔗 Connect"}, - "callback_data": "subscription_connect", - "default_conditions": {"has_active_subscription": True, "subscription_is_active": True}, - "supports_dynamic_text": False, - "supports_direct_open": True, + 'id': 'connect', + 'default_text': {'ru': '🔗 Подключиться', 'en': '🔗 Connect'}, + 'callback_data': 'subscription_connect', + 'default_conditions': {'has_active_subscription': True, 'subscription_is_active': True}, + 'supports_dynamic_text': False, + 'supports_direct_open': True, }, { - "id": "happ_download", - "default_text": {"ru": "⬇️ Скачать Happ", "en": "⬇️ Download Happ"}, - "callback_data": "subscription_happ_download", - "default_conditions": {"happ_enabled": True}, - "supports_dynamic_text": False, + 'id': 'happ_download', + 'default_text': {'ru': '⬇️ Скачать Happ', 'en': '⬇️ Download Happ'}, + 'callback_data': 'subscription_happ_download', + 'default_conditions': {'happ_enabled': True}, + 'supports_dynamic_text': False, }, { - "id": "subscription", - "default_text": {"ru": "📊 Подписка", "en": "📊 Subscription"}, - "callback_data": "menu_subscription", - "default_conditions": {"has_active_subscription": True}, - "supports_dynamic_text": False, + 'id': 'subscription', + 'default_text': {'ru': '📊 Подписка', 'en': '📊 Subscription'}, + 'callback_data': 'menu_subscription', + 'default_conditions': {'has_active_subscription': True}, + 'supports_dynamic_text': False, }, { - "id": "buy_traffic", - "default_text": {"ru": "📈 Докупить трафик", "en": "📈 Buy traffic"}, - "callback_data": "buy_traffic", - "default_conditions": {"has_traffic_limit": True}, - "supports_dynamic_text": False, + 'id': 'buy_traffic', + 'default_text': {'ru': '📈 Докупить трафик', 'en': '📈 Buy traffic'}, + 'callback_data': 'buy_traffic', + 'default_conditions': {'has_traffic_limit': True}, + 'supports_dynamic_text': False, }, { - "id": "balance", - "default_text": {"ru": "💰 Баланс: {balance}", "en": "💰 Balance: {balance}"}, - "callback_data": "menu_balance", - "default_conditions": None, - "supports_dynamic_text": True, + 'id': 'balance', + 'default_text': {'ru': '💰 Баланс: {balance}', 'en': '💰 Balance: {balance}'}, + 'callback_data': 'menu_balance', + 'default_conditions': None, + 'supports_dynamic_text': True, }, { - "id": "trial", - "default_text": {"ru": "🎁 Пробный период", "en": "🎁 Free trial"}, - "callback_data": "menu_trial", - "default_conditions": {"show_trial": True}, - "supports_dynamic_text": False, + 'id': 'trial', + 'default_text': {'ru': '🎁 Пробный период', 'en': '🎁 Free trial'}, + 'callback_data': 'menu_trial', + 'default_conditions': {'show_trial': True}, + 'supports_dynamic_text': False, }, { - "id": "buy_subscription", - "default_text": {"ru": "🛒 Купить подписку", "en": "🛒 Buy subscription"}, - "callback_data": "menu_buy", - "default_conditions": {"show_buy": True}, - "supports_dynamic_text": False, + 'id': 'buy_subscription', + 'default_text': {'ru': '🛒 Купить подписку', 'en': '🛒 Buy subscription'}, + 'callback_data': 'menu_buy', + 'default_conditions': {'show_buy': True}, + 'supports_dynamic_text': False, }, { - "id": "simple_subscription", - "default_text": {"ru": "💳 Простая подписка", "en": "💳 Simple subscription"}, - "callback_data": "simple_subscription_purchase", - "default_conditions": {"simple_subscription_enabled": True}, - "supports_dynamic_text": False, + 'id': 'simple_subscription', + 'default_text': {'ru': '💳 Простая подписка', 'en': '💳 Simple subscription'}, + 'callback_data': 'simple_subscription_purchase', + 'default_conditions': {'simple_subscription_enabled': True}, + 'supports_dynamic_text': False, }, { - "id": "resume_checkout", - "default_text": {"ru": "↩️ Вернуться к оформлению", "en": "↩️ Resume checkout"}, - "callback_data": "return_to_saved_cart", - "default_conditions": {"has_saved_cart": True}, - "supports_dynamic_text": False, + 'id': 'resume_checkout', + 'default_text': {'ru': '↩️ Вернуться к оформлению', 'en': '↩️ Resume checkout'}, + 'callback_data': 'return_to_saved_cart', + 'default_conditions': {'has_saved_cart': True}, + 'supports_dynamic_text': False, }, { - "id": "promocode", - "default_text": {"ru": "🎟️ Промокод", "en": "🎟️ Promo code"}, - "callback_data": "menu_promocode", - "default_conditions": None, - "supports_dynamic_text": False, + 'id': 'promocode', + 'default_text': {'ru': '🎟️ Промокод', 'en': '🎟️ Promo code'}, + 'callback_data': 'menu_promocode', + 'default_conditions': None, + 'supports_dynamic_text': False, }, { - "id": "referrals", - "default_text": {"ru": "👥 Рефералы", "en": "👥 Referrals"}, - "callback_data": "menu_referrals", - "default_conditions": {"referral_enabled": True}, - "supports_dynamic_text": False, + 'id': 'referrals', + 'default_text': {'ru': '👥 Рефералы', 'en': '👥 Referrals'}, + 'callback_data': 'menu_referrals', + 'default_conditions': {'referral_enabled': True}, + 'supports_dynamic_text': False, }, { - "id": "contests", - "default_text": {"ru": "🎲 Конкурсы", "en": "🎲 Contests"}, - "callback_data": "contests_menu", - "default_conditions": {"contests_visible": True}, - "supports_dynamic_text": False, + 'id': 'contests', + 'default_text': {'ru': '🎲 Конкурсы', 'en': '🎲 Contests'}, + 'callback_data': 'contests_menu', + 'default_conditions': {'contests_visible': True}, + 'supports_dynamic_text': False, }, { - "id": "support", - "default_text": {"ru": "💬 Поддержка", "en": "💬 Support"}, - "callback_data": "menu_support", - "default_conditions": {"support_enabled": True}, - "supports_dynamic_text": False, + 'id': 'support', + 'default_text': {'ru': '💬 Поддержка', 'en': '💬 Support'}, + 'callback_data': 'menu_support', + 'default_conditions': {'support_enabled': True}, + 'supports_dynamic_text': False, }, { - "id": "info", - "default_text": {"ru": "ℹ️ Инфо", "en": "ℹ️ Info"}, - "callback_data": "menu_info", - "default_conditions": None, - "supports_dynamic_text": False, + 'id': 'info', + 'default_text': {'ru': 'ℹ️ Инфо', 'en': 'ℹ️ Info'}, + 'callback_data': 'menu_info', + 'default_conditions': None, + 'supports_dynamic_text': False, }, { - "id": "language", - "default_text": {"ru": "🌐 Язык", "en": "🌐 Language"}, - "callback_data": "menu_language", - "default_conditions": {"language_selection_enabled": True}, - "supports_dynamic_text": False, + 'id': 'language', + 'default_text': {'ru': '🌐 Язык', 'en': '🌐 Language'}, + 'callback_data': 'menu_language', + 'default_conditions': {'language_selection_enabled': True}, + 'supports_dynamic_text': False, }, { - "id": "admin_panel", - "default_text": {"ru": "⚙️ Админ панель", "en": "⚙️ Admin panel"}, - "callback_data": "admin_panel", - "default_conditions": {"is_admin": True}, - "supports_dynamic_text": False, + 'id': 'admin_panel', + 'default_text': {'ru': '⚙️ Админ панель', 'en': '⚙️ Admin panel'}, + 'callback_data': 'admin_panel', + 'default_conditions': {'is_admin': True}, + 'supports_dynamic_text': False, }, { - "id": "moderator_panel", - "default_text": {"ru": "🧑‍⚖️ Модерация", "en": "🧑‍⚖️ Moderation"}, - "callback_data": "moderator_panel", - "default_conditions": {"is_moderator": True}, - "supports_dynamic_text": False, + 'id': 'moderator_panel', + 'default_text': {'ru': '🧑‍⚖️ Модерация', 'en': '🧑‍⚖️ Moderation'}, + 'callback_data': 'moderator_panel', + 'default_conditions': {'is_moderator': True}, + 'supports_dynamic_text': False, }, ] # Все доступные callback_data в боте (для добавления кастомных кнопок) -AVAILABLE_CALLBACKS: List[Dict[str, Any]] = [ +AVAILABLE_CALLBACKS: list[dict[str, Any]] = [ # Меню - {"callback_data": "back_to_menu", "name": "Назад в меню", "category": "menu", "icon": "⬅️", - "text": {"ru": "⬅️ Назад", "en": "⬅️ Back"}}, - {"callback_data": "menu_faq", "name": "FAQ", "category": "menu", "icon": "❓", - "text": {"ru": "❓ FAQ", "en": "❓ FAQ"}}, - {"callback_data": "menu_info_promo_groups", "name": "Промо-группы", "category": "menu", "icon": "👥", - "text": {"ru": "👥 Промо-группы", "en": "👥 Promo groups"}}, - {"callback_data": "menu_privacy_policy", "name": "Политика конфиденциальности", "category": "menu", "icon": "🔒", - "text": {"ru": "🔒 Политика конфиденциальности", "en": "🔒 Privacy Policy"}}, - {"callback_data": "menu_public_offer", "name": "Публичная оферта", "category": "menu", "icon": "📜", - "text": {"ru": "📜 Публичная оферта", "en": "📜 Public Offer"}}, - {"callback_data": "menu_rules", "name": "Правила", "category": "menu", "icon": "📋", - "text": {"ru": "📋 Правила", "en": "📋 Rules"}}, - {"callback_data": "menu_server_status", "name": "Статус серверов", "category": "menu", "icon": "🖥️", - "text": {"ru": "🖥️ Статус серверов", "en": "🖥️ Server Status"}}, - + { + 'callback_data': 'back_to_menu', + 'name': 'Назад в меню', + 'category': 'menu', + 'icon': '⬅️', + 'text': {'ru': '⬅️ Назад', 'en': '⬅️ Back'}, + }, + { + 'callback_data': 'menu_faq', + 'name': 'FAQ', + 'category': 'menu', + 'icon': '❓', + 'text': {'ru': '❓ FAQ', 'en': '❓ FAQ'}, + }, + { + 'callback_data': 'menu_info_promo_groups', + 'name': 'Промо-группы', + 'category': 'menu', + 'icon': '👥', + 'text': {'ru': '👥 Промо-группы', 'en': '👥 Promo groups'}, + }, + { + 'callback_data': 'menu_privacy_policy', + 'name': 'Политика конфиденциальности', + 'category': 'menu', + 'icon': '🔒', + 'text': {'ru': '🔒 Политика конфиденциальности', 'en': '🔒 Privacy Policy'}, + }, + { + 'callback_data': 'menu_public_offer', + 'name': 'Публичная оферта', + 'category': 'menu', + 'icon': '📜', + 'text': {'ru': '📜 Публичная оферта', 'en': '📜 Public Offer'}, + }, + { + 'callback_data': 'menu_rules', + 'name': 'Правила', + 'category': 'menu', + 'icon': '📋', + 'text': {'ru': '📋 Правила', 'en': '📋 Rules'}, + }, + { + 'callback_data': 'menu_server_status', + 'name': 'Статус серверов', + 'category': 'menu', + 'icon': '🖥️', + 'text': {'ru': '🖥️ Статус серверов', 'en': '🖥️ Server Status'}, + }, # Баланс - {"callback_data": "balance_history", "name": "История баланса", "category": "balance", "icon": "📜", - "text": {"ru": "📜 История", "en": "📜 History"}}, - {"callback_data": "balance_topup", "name": "Пополнить баланс", "category": "balance", "icon": "💳", - "text": {"ru": "💳 Пополнить", "en": "💳 Top up"}}, - + { + 'callback_data': 'balance_history', + 'name': 'История баланса', + 'category': 'balance', + 'icon': '📜', + 'text': {'ru': '📜 История', 'en': '📜 History'}, + }, + { + 'callback_data': 'balance_topup', + 'name': 'Пополнить баланс', + 'category': 'balance', + 'icon': '💳', + 'text': {'ru': '💳 Пополнить', 'en': '💳 Top up'}, + }, # Подписка - {"callback_data": "subscription_extend", "name": "Продлить подписку", "category": "subscription", "icon": "📅", - "text": {"ru": "📅 Продлить", "en": "📅 Extend"}, "requires_subscription": True}, - {"callback_data": "subscription_autopay", "name": "Автоплатёж", "category": "subscription", "icon": "🔄", - "text": {"ru": "🔄 Автоплатёж", "en": "🔄 Autopay"}, "requires_subscription": True}, - {"callback_data": "subscription_settings", "name": "Настройки подписки", "category": "subscription", "icon": "⚙️", - "text": {"ru": "⚙️ Настройки", "en": "⚙️ Settings"}, "requires_subscription": True}, - {"callback_data": "open_subscription_link", "name": "Показать ссылку подписки", "category": "subscription", "icon": "🔗", - "text": {"ru": "🔗 Показать ссылку", "en": "🔗 Show link"}, "requires_subscription": True}, - {"callback_data": "subscription_add_countries", "name": "Добавить страны", "category": "subscription", "icon": "🌍", - "text": {"ru": "🌍 Добавить страны", "en": "🌍 Add countries"}, "requires_subscription": True}, - {"callback_data": "subscription_reset_traffic", "name": "Сбросить трафик", "category": "subscription", "icon": "🔄", - "text": {"ru": "🔄 Сбросить трафик", "en": "🔄 Reset traffic"}, "requires_subscription": True}, - {"callback_data": "subscription_switch_traffic", "name": "Переключить трафик", "category": "subscription", "icon": "🔀", - "text": {"ru": "🔀 Переключить трафик", "en": "🔀 Switch traffic"}, "requires_subscription": True}, - {"callback_data": "subscription_change_devices", "name": "Изменить устройства", "category": "subscription", "icon": "📱", - "text": {"ru": "📱 Изменить устройства", "en": "📱 Change devices"}, "requires_subscription": True}, - {"callback_data": "subscription_manage_devices", "name": "Управление устройствами", "category": "subscription", "icon": "📲", - "text": {"ru": "📲 Управление устройствами", "en": "📲 Manage devices"}, "requires_subscription": True}, - {"callback_data": "subscription_upgrade", "name": "Улучшить подписку", "category": "subscription", "icon": "⬆️", - "text": {"ru": "⬆️ Улучшить", "en": "⬆️ Upgrade"}, "requires_subscription": True}, - + { + 'callback_data': 'subscription_extend', + 'name': 'Продлить подписку', + 'category': 'subscription', + 'icon': '📅', + 'text': {'ru': '📅 Продлить', 'en': '📅 Extend'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_autopay', + 'name': 'Автоплатёж', + 'category': 'subscription', + 'icon': '🔄', + 'text': {'ru': '🔄 Автоплатёж', 'en': '🔄 Autopay'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_settings', + 'name': 'Настройки подписки', + 'category': 'subscription', + 'icon': '⚙️', + 'text': {'ru': '⚙️ Настройки', 'en': '⚙️ Settings'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'open_subscription_link', + 'name': 'Показать ссылку подписки', + 'category': 'subscription', + 'icon': '🔗', + 'text': {'ru': '🔗 Показать ссылку', 'en': '🔗 Show link'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_add_countries', + 'name': 'Добавить страны', + 'category': 'subscription', + 'icon': '🌍', + 'text': {'ru': '🌍 Добавить страны', 'en': '🌍 Add countries'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_reset_traffic', + 'name': 'Сбросить трафик', + 'category': 'subscription', + 'icon': '🔄', + 'text': {'ru': '🔄 Сбросить трафик', 'en': '🔄 Reset traffic'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_switch_traffic', + 'name': 'Переключить трафик', + 'category': 'subscription', + 'icon': '🔀', + 'text': {'ru': '🔀 Переключить трафик', 'en': '🔀 Switch traffic'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_change_devices', + 'name': 'Изменить устройства', + 'category': 'subscription', + 'icon': '📱', + 'text': {'ru': '📱 Изменить устройства', 'en': '📱 Change devices'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_manage_devices', + 'name': 'Управление устройствами', + 'category': 'subscription', + 'icon': '📲', + 'text': {'ru': '📲 Управление устройствами', 'en': '📲 Manage devices'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'subscription_upgrade', + 'name': 'Улучшить подписку', + 'category': 'subscription', + 'icon': '⬆️', + 'text': {'ru': '⬆️ Улучшить', 'en': '⬆️ Upgrade'}, + 'requires_subscription': True, + }, # Подключение устройств - {"callback_data": "device_guide_ios", "name": "Инструкция iOS", "category": "devices", "icon": "📱", - "text": {"ru": "📱 iOS", "en": "📱 iOS"}, "requires_subscription": True}, - {"callback_data": "device_guide_android", "name": "Инструкция Android", "category": "devices", "icon": "🤖", - "text": {"ru": "🤖 Android", "en": "🤖 Android"}, "requires_subscription": True}, - {"callback_data": "device_guide_windows", "name": "Инструкция Windows", "category": "devices", "icon": "💻", - "text": {"ru": "💻 Windows", "en": "💻 Windows"}, "requires_subscription": True}, - {"callback_data": "device_guide_mac", "name": "Инструкция macOS", "category": "devices", "icon": "🎯", - "text": {"ru": "🎯 macOS", "en": "🎯 macOS"}, "requires_subscription": True}, - {"callback_data": "device_guide_tv", "name": "Инструкция Android TV", "category": "devices", "icon": "📺", - "text": {"ru": "📺 Android TV", "en": "📺 Android TV"}, "requires_subscription": True}, - {"callback_data": "device_guide_appletv", "name": "Инструкция Apple TV", "category": "devices", "icon": "📺", - "text": {"ru": "📺 Apple TV", "en": "📺 Apple TV"}, "requires_subscription": True}, - + { + 'callback_data': 'device_guide_ios', + 'name': 'Инструкция iOS', + 'category': 'devices', + 'icon': '📱', + 'text': {'ru': '📱 iOS', 'en': '📱 iOS'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'device_guide_android', + 'name': 'Инструкция Android', + 'category': 'devices', + 'icon': '🤖', + 'text': {'ru': '🤖 Android', 'en': '🤖 Android'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'device_guide_windows', + 'name': 'Инструкция Windows', + 'category': 'devices', + 'icon': '💻', + 'text': {'ru': '💻 Windows', 'en': '💻 Windows'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'device_guide_mac', + 'name': 'Инструкция macOS', + 'category': 'devices', + 'icon': '🎯', + 'text': {'ru': '🎯 macOS', 'en': '🎯 macOS'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'device_guide_tv', + 'name': 'Инструкция Android TV', + 'category': 'devices', + 'icon': '📺', + 'text': {'ru': '📺 Android TV', 'en': '📺 Android TV'}, + 'requires_subscription': True, + }, + { + 'callback_data': 'device_guide_appletv', + 'name': 'Инструкция Apple TV', + 'category': 'devices', + 'icon': '📺', + 'text': {'ru': '📺 Apple TV', 'en': '📺 Apple TV'}, + 'requires_subscription': True, + }, # Happ - {"callback_data": "happ_download_ios", "name": "Скачать Happ iOS", "category": "happ", "icon": "🍎", - "text": {"ru": "🍎 iOS", "en": "🍎 iOS"}}, - {"callback_data": "happ_download_android", "name": "Скачать Happ Android", "category": "happ", "icon": "🤖", - "text": {"ru": "🤖 Android", "en": "🤖 Android"}}, - {"callback_data": "happ_download_macos", "name": "Скачать Happ macOS", "category": "happ", "icon": "🖥️", - "text": {"ru": "🖥️ macOS", "en": "🖥️ macOS"}}, - {"callback_data": "happ_download_windows", "name": "Скачать Happ Windows", "category": "happ", "icon": "💻", - "text": {"ru": "💻 Windows", "en": "💻 Windows"}}, - + { + 'callback_data': 'happ_download_ios', + 'name': 'Скачать Happ iOS', + 'category': 'happ', + 'icon': '🍎', + 'text': {'ru': '🍎 iOS', 'en': '🍎 iOS'}, + }, + { + 'callback_data': 'happ_download_android', + 'name': 'Скачать Happ Android', + 'category': 'happ', + 'icon': '🤖', + 'text': {'ru': '🤖 Android', 'en': '🤖 Android'}, + }, + { + 'callback_data': 'happ_download_macos', + 'name': 'Скачать Happ macOS', + 'category': 'happ', + 'icon': '🖥️', + 'text': {'ru': '🖥️ macOS', 'en': '🖥️ macOS'}, + }, + { + 'callback_data': 'happ_download_windows', + 'name': 'Скачать Happ Windows', + 'category': 'happ', + 'icon': '💻', + 'text': {'ru': '💻 Windows', 'en': '💻 Windows'}, + }, # Рефералы - {"callback_data": "referral_create_invite", "name": "Создать инвайт", "category": "referral", "icon": "✉️", - "text": {"ru": "✉️ Создать инвайт", "en": "✉️ Create invite"}}, - {"callback_data": "referral_show_qr", "name": "QR код реферала", "category": "referral", "icon": "📱", - "text": {"ru": "📱 QR код", "en": "📱 QR code"}}, - {"callback_data": "referral_list", "name": "Список рефералов", "category": "referral", "icon": "👥", - "text": {"ru": "👥 Мои рефералы", "en": "👥 My referrals"}}, - {"callback_data": "referral_analytics", "name": "Аналитика рефералов", "category": "referral", "icon": "📊", - "text": {"ru": "📊 Аналитика", "en": "📊 Analytics"}}, - + { + 'callback_data': 'referral_create_invite', + 'name': 'Создать инвайт', + 'category': 'referral', + 'icon': '✉️', + 'text': {'ru': '✉️ Создать инвайт', 'en': '✉️ Create invite'}, + }, + { + 'callback_data': 'referral_show_qr', + 'name': 'QR код реферала', + 'category': 'referral', + 'icon': '📱', + 'text': {'ru': '📱 QR код', 'en': '📱 QR code'}, + }, + { + 'callback_data': 'referral_list', + 'name': 'Список рефералов', + 'category': 'referral', + 'icon': '👥', + 'text': {'ru': '👥 Мои рефералы', 'en': '👥 My referrals'}, + }, + { + 'callback_data': 'referral_analytics', + 'name': 'Аналитика рефералов', + 'category': 'referral', + 'icon': '📊', + 'text': {'ru': '📊 Аналитика', 'en': '📊 Analytics'}, + }, # Поддержка - {"callback_data": "create_ticket", "name": "Создать тикет", "category": "support", "icon": "✏️", - "text": {"ru": "✏️ Создать тикет", "en": "✏️ Create ticket"}}, - {"callback_data": "my_tickets", "name": "Мои тикеты", "category": "support", "icon": "📋", - "text": {"ru": "📋 Мои тикеты", "en": "📋 My tickets"}}, - + { + 'callback_data': 'create_ticket', + 'name': 'Создать тикет', + 'category': 'support', + 'icon': '✏️', + 'text': {'ru': '✏️ Создать тикет', 'en': '✏️ Create ticket'}, + }, + { + 'callback_data': 'my_tickets', + 'name': 'Мои тикеты', + 'category': 'support', + 'icon': '📋', + 'text': {'ru': '📋 Мои тикеты', 'en': '📋 My tickets'}, + }, # Триал - {"callback_data": "trial_activate", "name": "Активировать триал", "category": "trial", "icon": "🎁", - "text": {"ru": "🎁 Активировать", "en": "🎁 Activate"}}, - + { + 'callback_data': 'trial_activate', + 'name': 'Активировать триал', + 'category': 'trial', + 'icon': '🎁', + 'text': {'ru': '🎁 Активировать', 'en': '🎁 Activate'}, + }, # Покупка - {"callback_data": "clear_saved_cart", "name": "Очистить корзину", "category": "purchase", "icon": "🗑️", - "text": {"ru": "🗑️ Очистить корзину", "en": "🗑️ Clear cart"}}, - {"callback_data": "subscription_confirm", "name": "Подтвердить покупку", "category": "purchase", "icon": "✅", - "text": {"ru": "✅ Подтвердить", "en": "✅ Confirm"}}, - {"callback_data": "subscription_cancel", "name": "Отменить покупку", "category": "purchase", "icon": "❌", - "text": {"ru": "❌ Отменить", "en": "❌ Cancel"}}, + { + 'callback_data': 'clear_saved_cart', + 'name': 'Очистить корзину', + 'category': 'purchase', + 'icon': '🗑️', + 'text': {'ru': '🗑️ Очистить корзину', 'en': '🗑️ Clear cart'}, + }, + { + 'callback_data': 'subscription_confirm', + 'name': 'Подтвердить покупку', + 'category': 'purchase', + 'icon': '✅', + 'text': {'ru': '✅ Подтвердить', 'en': '✅ Confirm'}, + }, + { + 'callback_data': 'subscription_cancel', + 'name': 'Отменить покупку', + 'category': 'purchase', + 'icon': '❌', + 'text': {'ru': '❌ Отменить', 'en': '❌ Cancel'}, + }, ] # Динамические плейсхолдеры для текста кнопок -DYNAMIC_PLACEHOLDERS: List[Dict[str, str]] = [ - {"placeholder": "{balance}", "description": "Баланс пользователя", "example": "1 500 ₽", "category": "user"}, - {"placeholder": "{username}", "description": "Имя пользователя", "example": "John", "category": "user"}, - {"placeholder": "{subscription_days}", "description": "Дней до окончания подписки", "example": "14", "category": "subscription"}, - {"placeholder": "{traffic_used}", "description": "Использованный трафик", "example": "5.2 GB", "category": "subscription"}, - {"placeholder": "{traffic_left}", "description": "Оставшийся трафик", "example": "94.8 GB", "category": "subscription"}, - {"placeholder": "{referral_count}", "description": "Количество рефералов", "example": "12", "category": "referral"}, - {"placeholder": "{referral_earnings}", "description": "Заработок с рефералов", "example": "500 ₽", "category": "referral"}, +DYNAMIC_PLACEHOLDERS: list[dict[str, str]] = [ + {'placeholder': '{balance}', 'description': 'Баланс пользователя', 'example': '1 500 ₽', 'category': 'user'}, + {'placeholder': '{username}', 'description': 'Имя пользователя', 'example': 'John', 'category': 'user'}, + { + 'placeholder': '{subscription_days}', + 'description': 'Дней до окончания подписки', + 'example': '14', + 'category': 'subscription', + }, + { + 'placeholder': '{traffic_used}', + 'description': 'Использованный трафик', + 'example': '5.2 GB', + 'category': 'subscription', + }, + { + 'placeholder': '{traffic_left}', + 'description': 'Оставшийся трафик', + 'example': '94.8 GB', + 'category': 'subscription', + }, + {'placeholder': '{referral_count}', 'description': 'Количество рефералов', 'example': '12', 'category': 'referral'}, + { + 'placeholder': '{referral_earnings}', + 'description': 'Заработок с рефералов', + 'example': '500 ₽', + 'category': 'referral', + }, ] diff --git a/app/services/menu_layout/context.py b/app/services/menu_layout/context.py index 5f0a24a3..8960fa7c 100644 --- a/app/services/menu_layout/context.py +++ b/app/services/menu_layout/context.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional +from typing import Any from aiogram.types import InlineKeyboardButton @@ -12,24 +12,24 @@ from aiogram.types import InlineKeyboardButton class MenuContext: """Контекст пользователя для построения меню.""" - language: str = "ru" + language: str = 'ru' is_admin: bool = False is_moderator: bool = False has_active_subscription: bool = False subscription_is_active: bool = False has_had_paid_subscription: bool = False balance_kopeks: int = 0 - subscription: Optional[Any] = None + subscription: Any | None = None show_resume_checkout: bool = False has_saved_cart: bool = False - custom_buttons: List[InlineKeyboardButton] = field(default_factory=list) + custom_buttons: list[InlineKeyboardButton] = field(default_factory=list) # Расширенные поля для плейсхолдеров и условий - username: str = "" + username: str = '' subscription_days: int = 0 traffic_used_gb: float = 0.0 traffic_left_gb: float = 0.0 referral_count: int = 0 referral_earnings_kopeks: int = 0 registration_days: int = 0 - promo_group_id: Optional[str] = None + promo_group_id: str | None = None has_autopay: bool = False diff --git a/app/services/menu_layout/history_service.py b/app/services/menu_layout/history_service.py index 4e94047f..51a50946 100644 --- a/app/services/menu_layout/history_service.py +++ b/app/services/menu_layout/history_service.py @@ -3,10 +3,9 @@ from __future__ import annotations import json -from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any -from sqlalchemy import select, func, desc +from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import MenuLayoutHistory @@ -19,16 +18,16 @@ class MenuLayoutHistoryService: async def save_history( cls, db: AsyncSession, - config: Dict[str, Any], + config: dict[str, Any], action: str, - changes_summary: Optional[str] = None, - user_info: Optional[str] = None, + changes_summary: str | None = None, + user_info: str | None = None, ) -> MenuLayoutHistory: """Сохранить запись в историю изменений.""" history = MenuLayoutHistory( config_json=json.dumps(config, ensure_ascii=False), action=action, - changes_summary=changes_summary or f"Action: {action}", + changes_summary=changes_summary or f'Action: {action}', user_info=user_info, ) db.add(history) @@ -42,23 +41,20 @@ class MenuLayoutHistoryService: db: AsyncSession, limit: int = 50, offset: int = 0, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить историю изменений.""" result = await db.execute( - select(MenuLayoutHistory) - .order_by(desc(MenuLayoutHistory.created_at)) - .limit(limit) - .offset(offset) + select(MenuLayoutHistory).order_by(desc(MenuLayoutHistory.created_at)).limit(limit).offset(offset) ) entries = result.scalars().all() return [ { - "id": entry.id, - "action": entry.action, - "changes_summary": entry.changes_summary, - "user_info": entry.user_info, - "created_at": entry.created_at, + 'id': entry.id, + 'action': entry.action, + 'changes_summary': entry.changes_summary, + 'user_info': entry.user_info, + 'created_at': entry.created_at, } for entry in entries ] @@ -66,9 +62,7 @@ class MenuLayoutHistoryService: @classmethod async def get_history_count(cls, db: AsyncSession) -> int: """Получить общее количество записей истории.""" - result = await db.execute( - select(func.count(MenuLayoutHistory.id)) - ) + result = await db.execute(select(func.count(MenuLayoutHistory.id))) return result.scalar() or 0 @classmethod @@ -76,23 +70,21 @@ class MenuLayoutHistoryService: cls, db: AsyncSession, history_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Получить конкретную запись истории с конфигурацией.""" - result = await db.execute( - select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id) - ) + result = await db.execute(select(MenuLayoutHistory).where(MenuLayoutHistory.id == history_id)) entry = result.scalar_one_or_none() if not entry: return None return { - "id": entry.id, - "action": entry.action, - "changes_summary": entry.changes_summary, - "user_info": entry.user_info, - "created_at": entry.created_at, - "config": json.loads(entry.config_json), + 'id': entry.id, + 'action': entry.action, + 'changes_summary': entry.changes_summary, + 'user_info': entry.user_info, + 'created_at': entry.created_at, + 'config': json.loads(entry.config_json), } @classmethod @@ -102,8 +94,8 @@ class MenuLayoutHistoryService: history_id: int, get_config_func, save_config_func, - user_info: Optional[str] = None, - ) -> Dict[str, Any]: + user_info: str | None = None, + ) -> dict[str, Any]: """ Откатить конфигурацию к записи из истории. @@ -116,26 +108,20 @@ class MenuLayoutHistoryService: """ entry = await cls.get_history_entry(db, history_id) if not entry: - raise KeyError(f"History entry {history_id} not found") + raise KeyError(f'History entry {history_id} not found') - config = entry["config"] + config = entry['config'] # Сохраняем текущую конфигурацию в историю перед откатом current_config = await get_config_func(db) await cls.save_history( - db, current_config, "rollback_backup", - f"Backup before rollback to history #{history_id}", - user_info + db, current_config, 'rollback_backup', f'Backup before rollback to history #{history_id}', user_info ) # Применяем конфигурацию из истории await save_config_func(db, config) # Сохраняем запись об откате - await cls.save_history( - db, config, "rollback", - f"Rollback to history #{history_id}", - user_info - ) + await cls.save_history(db, config, 'rollback', f'Rollback to history #{history_id}', user_info) return config diff --git a/app/services/menu_layout/service.py b/app/services/menu_layout/service.py index f7b6f5fe..b85b0be8 100644 --- a/app/services/menu_layout/service.py +++ b/app/services/menu_layout/service.py @@ -6,8 +6,8 @@ import asyncio import copy import json import logging -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime +from typing import Any from aiogram import types from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -15,29 +15,30 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import SystemSetting from app.database.crud.system_setting import upsert_system_setting +from app.database.models import SystemSetting from app.localization.texts import get_texts from .constants import ( - MENU_LAYOUT_CONFIG_KEY, - DEFAULT_MENU_CONFIG, - BUILTIN_BUTTONS_INFO, AVAILABLE_CALLBACKS, + BUILTIN_BUTTONS_INFO, + DEFAULT_MENU_CONFIG, DYNAMIC_PLACEHOLDERS, + MENU_LAYOUT_CONFIG_KEY, ) from .context import MenuContext from .history_service import MenuLayoutHistoryService from .stats_service import MenuLayoutStatsService + logger = logging.getLogger(__name__) class MenuLayoutService: """Сервис для управления конфигурацией меню.""" - _cache: Optional[Dict[str, Any]] = None - _cache_updated_at: Optional[datetime] = None + _cache: dict[str, Any] | None = None + _cache_updated_at: datetime | None = None _lock: asyncio.Lock = asyncio.Lock() # --- Управление кешем --- @@ -51,70 +52,72 @@ class MenuLayoutService: # --- Получение констант и информации --- @classmethod - def get_default_config(cls) -> Dict[str, Any]: + def get_default_config(cls) -> dict[str, Any]: """Получить дефолтную конфигурацию.""" return copy.deepcopy(DEFAULT_MENU_CONFIG) @classmethod - def get_builtin_buttons_info(cls) -> List[Dict[str, Any]]: + def get_builtin_buttons_info(cls) -> list[dict[str, Any]]: """Получить информацию о встроенных кнопках.""" return BUILTIN_BUTTONS_INFO.copy() @classmethod - async def get_available_callbacks(cls, db: AsyncSession) -> List[Dict[str, Any]]: + async def get_available_callbacks(cls, db: AsyncSession) -> list[dict[str, Any]]: """Получить список всех доступных callback_data.""" config = await cls.get_config(db) - buttons_in_menu = set(config.get("buttons", {}).keys()) + buttons_in_menu = set(config.get('buttons', {}).keys()) # Добавляем встроенные callback_data - builtin_callbacks = {btn["callback_data"] for btn in BUILTIN_BUTTONS_INFO} + builtin_callbacks = {btn['callback_data'] for btn in BUILTIN_BUTTONS_INFO} result = [] for cb in AVAILABLE_CALLBACKS: - is_in_menu = cb["callback_data"] in buttons_in_menu or cb["callback_data"] in builtin_callbacks - result.append({ - **cb, - "is_in_menu": is_in_menu, - "default_text": cb.get("text"), - "default_icon": cb.get("icon"), - }) + is_in_menu = cb['callback_data'] in buttons_in_menu or cb['callback_data'] in builtin_callbacks + result.append( + { + **cb, + 'is_in_menu': is_in_menu, + 'default_text': cb.get('text'), + 'default_icon': cb.get('icon'), + } + ) return result @classmethod - def get_dynamic_placeholders(cls) -> List[Dict[str, Any]]: + def get_dynamic_placeholders(cls) -> list[dict[str, Any]]: """Получить список доступных динамических плейсхолдеров.""" return DYNAMIC_PLACEHOLDERS.copy() # --- Экспорт/импорт --- @classmethod - async def export_config(cls, db: AsyncSession) -> Dict[str, Any]: + async def export_config(cls, db: AsyncSession) -> dict[str, Any]: """Экспортировать конфигурацию меню.""" config = await cls.get_config(db) return { - "version": config.get("version", 1), - "rows": config.get("rows", []), - "buttons": config.get("buttons", {}), - "exported_at": datetime.now(timezone.utc).isoformat(), + 'version': config.get('version', 1), + 'rows': config.get('rows', []), + 'buttons': config.get('buttons', {}), + 'exported_at': datetime.now(UTC).isoformat(), } @classmethod async def import_config( cls, db: AsyncSession, - import_data: Dict[str, Any], - merge_mode: str = "replace", - ) -> Dict[str, Any]: + import_data: dict[str, Any], + merge_mode: str = 'replace', + ) -> dict[str, Any]: """Импортировать конфигурацию меню.""" warnings = [] - if merge_mode == "replace": + if merge_mode == 'replace': # Полная замена new_config = { - "version": import_data.get("version", 1), - "rows": import_data.get("rows", []), - "buttons": import_data.get("buttons", {}), + 'version': import_data.get('version', 1), + 'rows': import_data.get('rows', []), + 'buttons': import_data.get('buttons', {}), } else: # Объединение @@ -122,88 +125,96 @@ class MenuLayoutService: new_config = current_config.copy() # Добавляем новые кнопки (не перезаписываем существующие) - for btn_id, btn_config in import_data.get("buttons", {}).items(): - if btn_id not in new_config["buttons"]: - new_config["buttons"][btn_id] = btn_config + for btn_id, btn_config in import_data.get('buttons', {}).items(): + if btn_id not in new_config['buttons']: + new_config['buttons'][btn_id] = btn_config else: warnings.append(f"Button '{btn_id}' already exists, skipped") # Добавляем новые строки - existing_row_ids = {row["id"] for row in new_config.get("rows", [])} - for row in import_data.get("rows", []): - if row["id"] not in existing_row_ids: - new_config["rows"].append(row) + existing_row_ids = {row['id'] for row in new_config.get('rows', [])} + for row in import_data.get('rows', []): + if row['id'] not in existing_row_ids: + new_config['rows'].append(row) else: warnings.append(f"Row '{row['id']}' already exists, skipped") await cls.save_config(db, new_config) return { - "success": True, - "imported_rows": len(import_data.get("rows", [])), - "imported_buttons": len(import_data.get("buttons", {})), - "warnings": warnings, + 'success': True, + 'imported_rows': len(import_data.get('rows', [])), + 'imported_buttons': len(import_data.get('buttons', {})), + 'warnings': warnings, } # --- Валидация --- @classmethod - def validate_config(cls, config: Dict[str, Any]) -> Dict[str, Any]: + def validate_config(cls, config: dict[str, Any]) -> dict[str, Any]: """Валидировать конфигурацию меню.""" errors = [] warnings = [] - rows = config.get("rows", []) - buttons = config.get("buttons", {}) + rows = config.get('rows', []) + buttons = config.get('buttons', {}) # Проверяем уникальность ID строк - row_ids = [row.get("id") for row in rows] + row_ids = [row.get('id') for row in rows] duplicate_rows = [rid for rid in row_ids if row_ids.count(rid) > 1] if duplicate_rows: - errors.append({ - "field": "rows", - "message": f"Duplicate row IDs: {set(duplicate_rows)}", - "severity": "error", - }) + errors.append( + { + 'field': 'rows', + 'message': f'Duplicate row IDs: {set(duplicate_rows)}', + 'severity': 'error', + } + ) # Проверяем ссылки на кнопки for row in rows: - for btn_id in row.get("buttons", []): + for btn_id in row.get('buttons', []): if btn_id not in buttons: - errors.append({ - "field": f"rows.{row.get('id')}.buttons", - "message": f"Button '{btn_id}' not found", - "severity": "error", - }) + errors.append( + { + 'field': f'rows.{row.get("id")}.buttons', + 'message': f"Button '{btn_id}' not found", + 'severity': 'error', + } + ) # Проверяем пустые строки for row in rows: - if not row.get("buttons"): - warnings.append({ - "field": f"rows.{row.get('id')}", - "message": "Row has no buttons", - "severity": "warning", - }) + if not row.get('buttons'): + warnings.append( + { + 'field': f'rows.{row.get("id")}', + 'message': 'Row has no buttons', + 'severity': 'warning', + } + ) # Проверяем отключенные кнопки - disabled_count = sum(1 for btn in buttons.values() if not btn.get("enabled", True)) + disabled_count = sum(1 for btn in buttons.values() if not btn.get('enabled', True)) if disabled_count > 0: - warnings.append({ - "field": "buttons", - "message": f"{disabled_count} buttons are disabled", - "severity": "warning", - }) + warnings.append( + { + 'field': 'buttons', + 'message': f'{disabled_count} buttons are disabled', + 'severity': 'warning', + } + ) return { - "is_valid": len(errors) == 0, - "errors": errors, - "warnings": warnings, + 'is_valid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings, } # --- Работа с конфигурацией --- @classmethod - async def get_config(cls, db: AsyncSession) -> Dict[str, Any]: + async def get_config(cls, db: AsyncSession) -> dict[str, Any]: """Получить конфигурацию меню.""" if cls._cache is not None: return cls._cache @@ -212,9 +223,7 @@ class MenuLayoutService: if cls._cache is not None: return cls._cache - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_CONFIG_KEY) - ) + result = await db.execute(select(SystemSetting).where(SystemSetting.key == MENU_LAYOUT_CONFIG_KEY)) setting = result.scalar_one_or_none() if setting and setting.value: @@ -222,7 +231,7 @@ class MenuLayoutService: cls._cache = json.loads(setting.value) cls._cache_updated_at = setting.updated_at except json.JSONDecodeError: - logger.warning("Invalid menu layout config JSON, using default") + logger.warning('Invalid menu layout config JSON, using default') cls._cache = cls.get_default_config() cls._cache_updated_at = None else: @@ -232,26 +241,26 @@ class MenuLayoutService: return cls._cache @classmethod - async def get_config_updated_at(cls, db: AsyncSession) -> Optional[datetime]: + async def get_config_updated_at(cls, db: AsyncSession) -> datetime | None: """Получить время последнего обновления конфигурации.""" await cls.get_config(db) # Ensure cache is loaded return cls._cache_updated_at @classmethod - async def save_config(cls, db: AsyncSession, config: Dict[str, Any]) -> None: + async def save_config(cls, db: AsyncSession, config: dict[str, Any]) -> None: """Сохранить конфигурацию меню.""" config_json = json.dumps(config, ensure_ascii=False, indent=2) await upsert_system_setting( db, MENU_LAYOUT_CONFIG_KEY, config_json, - description="Конфигурация конструктора меню", + description='Конфигурация конструктора меню', ) await db.commit() cls.invalidate_cache() @classmethod - async def reset_to_default(cls, db: AsyncSession) -> Dict[str, Any]: + async def reset_to_default(cls, db: AsyncSession) -> dict[str, Any]: """Сбросить конфигурацию к дефолтной.""" default_config = cls.get_default_config() await cls.save_config(db, default_config) @@ -264,30 +273,31 @@ class MenuLayoutService: cls, db: AsyncSession, button_id: str, - updates: Dict[str, Any], - ) -> Dict[str, Any]: + updates: dict[str, Any], + ) -> dict[str, Any]: """Обновить конфигурацию кнопки.""" config = await cls.get_config(db) config = config.copy() - buttons = config.get("buttons", {}) + buttons = config.get('buttons', {}) # Улучшенное определение кнопки connect для разных форматов ID actual_button_id = button_id if button_id not in buttons: # Пробуем найти кнопку connect по разным форматам - if "connect" in button_id.lower(): + if 'connect' in button_id.lower(): # Проверяем разные варианты: connect, callback:connect и т.д. for key in buttons.keys(): - if key == "connect" or buttons[key].get("builtin_id") == "connect": + if key == 'connect' or buttons[key].get('builtin_id') == 'connect': actual_button_id = key - logger.info( - f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'" - ) + logger.info(f"🔗 Найдена кнопка connect по ID '{button_id}' -> '{actual_button_id}'") break else: # Если не нашли, пробуем найти по builtin_id for key, button in buttons.items(): - if button.get("builtin_id") == "connect" or "connect" in str(button.get("builtin_id", "")).lower(): + if ( + button.get('builtin_id') == 'connect' + or 'connect' in str(button.get('builtin_id', '')).lower() + ): actual_button_id = key logger.info( f"🔗 Найдена кнопка connect по builtin_id '{button_id}' -> '{actual_button_id}'" @@ -300,48 +310,46 @@ class MenuLayoutService: raise KeyError(f"Button '{actual_button_id}' not found") button = buttons[actual_button_id].copy() - + # Логирование для отладки - if "connect" in actual_button_id.lower() or button.get("builtin_id") == "connect": + if 'connect' in actual_button_id.lower() or button.get('builtin_id') == 'connect': logger.info( - f"🔗 Обновление кнопки connect (ID: {actual_button_id}): " - f"open_mode={updates.get('open_mode')}, " - f"action={updates.get('action')}, " - f"webapp_url={updates.get('webapp_url')}" + f'🔗 Обновление кнопки connect (ID: {actual_button_id}): ' + f'open_mode={updates.get("open_mode")}, ' + f'action={updates.get("action")}, ' + f'webapp_url={updates.get("webapp_url")}' ) # Применяем обновления - if "text" in updates and updates["text"] is not None: - button["text"] = updates["text"] - if "icon" in updates: - button["icon"] = updates["icon"] - if "enabled" in updates and updates["enabled"] is not None: - button["enabled"] = updates["enabled"] - if "visibility" in updates and updates["visibility"] is not None: - button["visibility"] = updates["visibility"] - if "conditions" in updates: - button["conditions"] = updates["conditions"] - if "dynamic_text" in updates and updates["dynamic_text"] is not None: - button["dynamic_text"] = updates["dynamic_text"] - if "description" in updates: - button["description"] = updates["description"] - if "sort_order" in updates: - button["sort_order"] = updates["sort_order"] - if "action" in updates and updates["action"] is not None: + if 'text' in updates and updates['text'] is not None: + button['text'] = updates['text'] + if 'icon' in updates: + button['icon'] = updates['icon'] + if 'enabled' in updates and updates['enabled'] is not None: + button['enabled'] = updates['enabled'] + if 'visibility' in updates and updates['visibility'] is not None: + button['visibility'] = updates['visibility'] + if 'conditions' in updates: + button['conditions'] = updates['conditions'] + if 'dynamic_text' in updates and updates['dynamic_text'] is not None: + button['dynamic_text'] = updates['dynamic_text'] + if 'description' in updates: + button['description'] = updates['description'] + if 'sort_order' in updates: + button['sort_order'] = updates['sort_order'] + if 'action' in updates and updates['action'] is not None: # Для URL/MiniApp/callback кнопок можно менять action - if button.get("type") in ("url", "mini_app", "callback"): - button["action"] = updates["action"] - # Для builtin кнопок можно менять action, если open_mode == "direct" - # Это позволяет указать URL Mini App в поле action для кнопки connect - elif button.get("type") == "builtin" and updates.get("open_mode") == "direct": - button["action"] = updates["action"] - if "open_mode" in updates and updates["open_mode"] is not None: - button["open_mode"] = updates["open_mode"] - if "webapp_url" in updates: - button["webapp_url"] = updates["webapp_url"] + if button.get('type') in ('url', 'mini_app', 'callback') or ( + button.get('type') == 'builtin' and updates.get('open_mode') == 'direct' + ): + button['action'] = updates['action'] + if 'open_mode' in updates and updates['open_mode'] is not None: + button['open_mode'] = updates['open_mode'] + if 'webapp_url' in updates: + button['webapp_url'] = updates['webapp_url'] buttons[actual_button_id] = button - config["buttons"] = buttons + config['buttons'] = buttons await cls.save_config(db, config) return button @@ -352,15 +360,15 @@ class MenuLayoutService: async def reorder_rows( cls, db: AsyncSession, - ordered_ids: List[str], - ) -> List[Dict[str, Any]]: + ordered_ids: list[str], + ) -> list[dict[str, Any]]: """Изменить порядок строк.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) # Создаем словарь для быстрого поиска - rows_map = {row["id"]: row for row in rows} + rows_map = {row['id']: row for row in rows} # Проверяем что все ID существуют for row_id in ordered_ids: @@ -372,10 +380,10 @@ class MenuLayoutService: # Добавляем строки которые не были в списке (в конец) for row in rows: - if row["id"] not in ordered_ids: + if row['id'] not in ordered_ids: new_rows.append(row) - config["rows"] = new_rows + config['rows'] = new_rows await cls.save_config(db, config) return new_rows @@ -383,24 +391,24 @@ class MenuLayoutService: async def add_row( cls, db: AsyncSession, - row_config: Dict[str, Any], - position: Optional[int] = None, - ) -> Dict[str, Any]: + row_config: dict[str, Any], + position: int | None = None, + ) -> dict[str, Any]: """Добавить новую строку.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) # Проверяем уникальность ID - existing_ids = {row["id"] for row in rows} - if row_config["id"] in existing_ids: + existing_ids = {row['id'] for row in rows} + if row_config['id'] in existing_ids: raise ValueError(f"Row with id '{row_config['id']}' already exists") new_row = { - "id": row_config["id"], - "buttons": row_config.get("buttons", []), - "conditions": row_config.get("conditions"), - "max_per_row": row_config.get("max_per_row", 2), + 'id': row_config['id'], + 'buttons': row_config.get('buttons', []), + 'conditions': row_config.get('conditions'), + 'max_per_row': row_config.get('max_per_row', 2), } if position is not None and 0 <= position < len(rows): @@ -408,7 +416,7 @@ class MenuLayoutService: else: rows.append(new_row) - config["rows"] = rows + config['rows'] = rows await cls.save_config(db, config) return new_row @@ -417,13 +425,13 @@ class MenuLayoutService: """Удалить строку.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) - new_rows = [row for row in rows if row["id"] != row_id] + new_rows = [row for row in rows if row['id'] != row_id] if len(new_rows) == len(rows): raise KeyError(f"Row '{row_id}' not found") - config["rows"] = new_rows + config['rows'] = new_rows await cls.save_config(db, config) # --- Кастомные кнопки --- @@ -433,43 +441,43 @@ class MenuLayoutService: cls, db: AsyncSession, button_id: str, - button_config: Dict[str, Any], - row_id: Optional[str] = None, - ) -> Dict[str, Any]: + button_config: dict[str, Any], + row_id: str | None = None, + ) -> dict[str, Any]: """Добавить кастомную кнопку.""" config = await cls.get_config(db) config = config.copy() - buttons = config.get("buttons", {}) + buttons = config.get('buttons', {}) if button_id in buttons: raise ValueError(f"Button with id '{button_id}' already exists") # URL, MiniApp и callback кнопки могут быть добавлены - allowed_types = ("url", "mini_app", "callback") - if button_config.get("type") not in allowed_types: - raise ValueError(f"Only {allowed_types} buttons can be added") + allowed_types = ('url', 'mini_app', 'callback') + if button_config.get('type') not in allowed_types: + raise ValueError(f'Only {allowed_types} buttons can be added') buttons[button_id] = { - "type": button_config["type"], - "builtin_id": None, - "text": button_config["text"], - "icon": button_config.get("icon"), - "action": button_config["action"], - "enabled": button_config.get("enabled", True), - "visibility": button_config.get("visibility", "all"), - "conditions": button_config.get("conditions"), - "dynamic_text": button_config.get("dynamic_text", False), - "description": button_config.get("description"), + 'type': button_config['type'], + 'builtin_id': None, + 'text': button_config['text'], + 'icon': button_config.get('icon'), + 'action': button_config['action'], + 'enabled': button_config.get('enabled', True), + 'visibility': button_config.get('visibility', 'all'), + 'conditions': button_config.get('conditions'), + 'dynamic_text': button_config.get('dynamic_text', False), + 'description': button_config.get('description'), } - config["buttons"] = buttons + config['buttons'] = buttons # Добавляем в строку если указана if row_id: - rows = config.get("rows", []) + rows = config.get('rows', []) for row in rows: - if row["id"] == row_id: - row["buttons"].append(button_id) + if row['id'] == row_id: + row['buttons'].append(button_id) break await cls.save_config(db, config) @@ -480,88 +488,88 @@ class MenuLayoutService: """Удалить кастомную кнопку.""" config = await cls.get_config(db) config = config.copy() - buttons = config.get("buttons", {}) + buttons = config.get('buttons', {}) if button_id not in buttons: raise KeyError(f"Button '{button_id}' not found") # Нельзя удалять встроенные кнопки - if buttons[button_id].get("type") == "builtin": - raise ValueError("Cannot delete builtin buttons") + if buttons[button_id].get('type') == 'builtin': + raise ValueError('Cannot delete builtin buttons') del buttons[button_id] - config["buttons"] = buttons + config['buttons'] = buttons # Удаляем из всех строк - rows = config.get("rows", []) + rows = config.get('rows', []) for row in rows: - if button_id in row.get("buttons", []): - row["buttons"].remove(button_id) + if button_id in row.get('buttons', []): + row['buttons'].remove(button_id) await cls.save_config(db, config) # --- Перемещение кнопок --- @classmethod - def _find_button_row(cls, config: Dict[str, Any], button_id: str) -> Optional[int]: + def _find_button_row(cls, config: dict[str, Any], button_id: str) -> int | None: """Найти индекс строки содержащей кнопку.""" - rows = config.get("rows", []) + rows = config.get('rows', []) for i, row in enumerate(rows): - if button_id in row.get("buttons", []): + if button_id in row.get('buttons', []): return i return None @classmethod - async def move_button_up(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + async def move_button_up(cls, db: AsyncSession, button_id: str) -> dict[str, Any]: """Переместить кнопку на строку выше.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) current_row_idx = cls._find_button_row(config, button_id) if current_row_idx is None: raise KeyError(f"Button '{button_id}' not found in any row") if current_row_idx == 0: - raise ValueError("Button is already in the top row") + raise ValueError('Button is already in the top row') # Удаляем из текущей строки - rows[current_row_idx]["buttons"].remove(button_id) + rows[current_row_idx]['buttons'].remove(button_id) # Добавляем в строку выше - rows[current_row_idx - 1]["buttons"].append(button_id) + rows[current_row_idx - 1]['buttons'].append(button_id) # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] + config['rows'] = [row for row in rows if row.get('buttons')] await cls.save_config(db, config) - return {"button_id": button_id, "new_row_index": current_row_idx - 1} + return {'button_id': button_id, 'new_row_index': current_row_idx - 1} @classmethod - async def move_button_down(cls, db: AsyncSession, button_id: str) -> Dict[str, Any]: + async def move_button_down(cls, db: AsyncSession, button_id: str) -> dict[str, Any]: """Переместить кнопку на строку ниже.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) current_row_idx = cls._find_button_row(config, button_id) if current_row_idx is None: raise KeyError(f"Button '{button_id}' not found in any row") if current_row_idx >= len(rows) - 1: - raise ValueError("Button is already in the bottom row") + raise ValueError('Button is already in the bottom row') # Удаляем из текущей строки - rows[current_row_idx]["buttons"].remove(button_id) + rows[current_row_idx]['buttons'].remove(button_id) # Добавляем в строку ниже - rows[current_row_idx + 1]["buttons"].append(button_id) + rows[current_row_idx + 1]['buttons'].append(button_id) # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] + config['rows'] = [row for row in rows if row.get('buttons')] await cls.save_config(db, config) - return {"button_id": button_id, "new_row_index": current_row_idx + 1} + return {'button_id': button_id, 'new_row_index': current_row_idx + 1} @classmethod async def move_button_to_row( @@ -569,17 +577,17 @@ class MenuLayoutService: db: AsyncSession, button_id: str, target_row_id: str, - position: Optional[int] = None, - ) -> Dict[str, Any]: + position: int | None = None, + ) -> dict[str, Any]: """Переместить кнопку в указанную строку.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) # Находим целевую строку target_row_idx = None for i, row in enumerate(rows): - if row["id"] == target_row_id: + if row['id'] == target_row_id: target_row_idx = i break @@ -589,37 +597,37 @@ class MenuLayoutService: # Удаляем кнопку из текущей строки current_row_idx = cls._find_button_row(config, button_id) if current_row_idx is not None: - rows[current_row_idx]["buttons"].remove(button_id) + rows[current_row_idx]['buttons'].remove(button_id) # Добавляем в целевую строку - target_buttons = rows[target_row_idx]["buttons"] + target_buttons = rows[target_row_idx]['buttons'] if position is not None and 0 <= position <= len(target_buttons): target_buttons.insert(position, button_id) else: target_buttons.append(button_id) # Удаляем пустые строки - config["rows"] = [row for row in rows if row.get("buttons")] + config['rows'] = [row for row in rows if row.get('buttons')] await cls.save_config(db, config) - return {"button_id": button_id, "target_row_id": target_row_id, "position": position} + return {'button_id': button_id, 'target_row_id': target_row_id, 'position': position} @classmethod async def reorder_buttons_in_row( cls, db: AsyncSession, row_id: str, - ordered_button_ids: List[str], - ) -> Dict[str, Any]: + ordered_button_ids: list[str], + ) -> dict[str, Any]: """Изменить порядок кнопок внутри строки.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) # Находим строку target_row = None for row in rows: - if row["id"] == row_id: + if row['id'] == row_id: target_row = row break @@ -627,7 +635,7 @@ class MenuLayoutService: raise KeyError(f"Row '{row_id}' not found") # Проверяем что все кнопки принадлежат строке - current_buttons = set(target_row["buttons"]) + current_buttons = set(target_row['buttons']) ordered_buttons = set(ordered_button_ids) if current_buttons != ordered_buttons: @@ -635,15 +643,15 @@ class MenuLayoutService: extra = ordered_buttons - current_buttons errors = [] if missing: - errors.append(f"missing: {missing}") + errors.append(f'missing: {missing}') if extra: - errors.append(f"extra: {extra}") - raise ValueError(f"Button mismatch: {', '.join(errors)}") + errors.append(f'extra: {extra}') + raise ValueError(f'Button mismatch: {", ".join(errors)}') - target_row["buttons"] = ordered_button_ids + target_row['buttons'] = ordered_button_ids await cls.save_config(db, config) - return {"row_id": row_id, "buttons": ordered_button_ids} + return {'row_id': row_id, 'buttons': ordered_button_ids} @classmethod async def swap_buttons( @@ -651,18 +659,18 @@ class MenuLayoutService: db: AsyncSession, button_id_1: str, button_id_2: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Поменять две кнопки местами.""" config = await cls.get_config(db) config = config.copy() - rows = config.get("rows", []) + rows = config.get('rows', []) # Находим позиции обеих кнопок pos1 = None pos2 = None for row_idx, row in enumerate(rows): - buttons = row.get("buttons", []) + buttons = row.get('buttons', []) for btn_idx, btn_id in enumerate(buttons): if btn_id == button_id_1: pos1 = (row_idx, btn_idx) @@ -675,13 +683,13 @@ class MenuLayoutService: raise KeyError(f"Button '{button_id_2}' not found") # Меняем местами - rows[pos1[0]]["buttons"][pos1[1]] = button_id_2 - rows[pos2[0]]["buttons"][pos2[1]] = button_id_1 + rows[pos1[0]]['buttons'][pos1[1]] = button_id_2 + rows[pos2[0]]['buttons'][pos2[1]] = button_id_1 await cls.save_config(db, config) return { - "button_1": {"id": button_id_1, "new_row": pos2[0], "new_position": pos2[1]}, - "button_2": {"id": button_id_2, "new_row": pos1[0], "new_position": pos1[1]}, + 'button_1': {'id': button_id_1, 'new_row': pos2[0], 'new_position': pos2[1]}, + 'button_2': {'id': button_id_2, 'new_row': pos1[0], 'new_position': pos1[1]}, } # --- Проверка условий --- @@ -689,7 +697,7 @@ class MenuLayoutService: @classmethod def _evaluate_conditions( cls, - conditions: Optional[Dict[str, Any]], + conditions: dict[str, Any] | None, context: MenuContext, ) -> bool: """Проверить условия показа.""" @@ -697,26 +705,26 @@ class MenuLayoutService: return True # has_active_subscription - if conditions.get("has_active_subscription") is True: + if conditions.get('has_active_subscription') is True: if not context.has_active_subscription: return False # subscription_is_active - if conditions.get("subscription_is_active") is True: + if conditions.get('subscription_is_active') is True: if not context.subscription_is_active: return False # has_traffic_limit - подписка с лимитом трафика - if conditions.get("has_traffic_limit") is True: + if conditions.get('has_traffic_limit') is True: if not context.subscription: return False - traffic_limit = getattr(context.subscription, "traffic_limit_gb", 0) - is_trial = getattr(context.subscription, "is_trial", False) + traffic_limit = getattr(context.subscription, 'traffic_limit_gb', 0) + is_trial = getattr(context.subscription, 'is_trial', False) if is_trial or traffic_limit <= 0: return False # traffic_topup_enabled - функция докупки трафика включена - if conditions.get("traffic_topup_enabled") is True: + if conditions.get('traffic_topup_enabled') is True: if not settings.is_traffic_topup_enabled(): return False # В режиме тарифов докупка трафика недоступна @@ -724,31 +732,32 @@ class MenuLayoutService: return False # is_admin - if conditions.get("is_admin") is True: + if conditions.get('is_admin') is True: if not context.is_admin: return False # is_moderator - if conditions.get("is_moderator") is True: + if conditions.get('is_moderator') is True: if context.is_admin: # Админ не показывает кнопку модератора return False if not context.is_moderator: return False # referral_enabled - if conditions.get("referral_enabled") is True: + if conditions.get('referral_enabled') is True: if not settings.is_referral_program_enabled(): return False # contests_visible - if conditions.get("contests_visible") is True: + if conditions.get('contests_visible') is True: if not settings.CONTESTS_BUTTON_VISIBLE: return False # support_enabled - if conditions.get("support_enabled") is True: + if conditions.get('support_enabled') is True: try: from app.services.support_settings_service import SupportSettingsService + if not SupportSettingsService.is_support_menu_enabled(): return False except Exception: @@ -756,106 +765,106 @@ class MenuLayoutService: return False # language_selection_enabled - if conditions.get("language_selection_enabled") is True: + if conditions.get('language_selection_enabled') is True: if not settings.is_language_selection_enabled(): return False # happ_enabled - if conditions.get("happ_enabled") is True: + if conditions.get('happ_enabled') is True: if not settings.is_happ_download_button_enabled(): return False # simple_subscription_enabled - if conditions.get("simple_subscription_enabled") is True: + if conditions.get('simple_subscription_enabled') is True: if not settings.SIMPLE_SUBSCRIPTION_ENABLED: return False # show_trial - if conditions.get("show_trial") is True: + if conditions.get('show_trial') is True: if context.has_had_paid_subscription or context.has_active_subscription: return False # show_buy - if conditions.get("show_buy") is True: + if conditions.get('show_buy') is True: if context.has_active_subscription and context.subscription_is_active: return False # has_saved_cart - if conditions.get("has_saved_cart") is True: + if conditions.get('has_saved_cart') is True: if not context.has_saved_cart and not context.show_resume_checkout: return False # --- Расширенные условия --- # min_balance_kopeks - min_balance = conditions.get("min_balance_kopeks") + min_balance = conditions.get('min_balance_kopeks') if min_balance is not None: if context.balance_kopeks < min_balance: return False # max_balance_kopeks - max_balance = conditions.get("max_balance_kopeks") + max_balance = conditions.get('max_balance_kopeks') if max_balance is not None: if context.balance_kopeks > max_balance: return False # min_registration_days - min_reg_days = conditions.get("min_registration_days") + min_reg_days = conditions.get('min_registration_days') if min_reg_days is not None: if context.registration_days < min_reg_days: return False # max_registration_days - max_reg_days = conditions.get("max_registration_days") + max_reg_days = conditions.get('max_registration_days') if max_reg_days is not None: if context.registration_days > max_reg_days: return False # min_referrals - min_refs = conditions.get("min_referrals") + min_refs = conditions.get('min_referrals') if min_refs is not None: if context.referral_count < min_refs: return False # has_referrals - if conditions.get("has_referrals") is True: + if conditions.get('has_referrals') is True: if context.referral_count <= 0: return False # promo_group_ids - пользователь должен быть в одной из указанных групп - promo_groups = conditions.get("promo_group_ids") + promo_groups = conditions.get('promo_group_ids') if promo_groups and isinstance(promo_groups, list): if context.promo_group_id not in promo_groups: return False # exclude_promo_group_ids - исключить пользователей из этих групп - exclude_groups = conditions.get("exclude_promo_group_ids") + exclude_groups = conditions.get('exclude_promo_group_ids') if exclude_groups and isinstance(exclude_groups, list): if context.promo_group_id in exclude_groups: return False # has_subscription_days_left - минимум дней до окончания - min_sub_days = conditions.get("has_subscription_days_left") + min_sub_days = conditions.get('has_subscription_days_left') if min_sub_days is not None: if context.subscription_days < min_sub_days: return False # max_subscription_days_left - max_sub_days = conditions.get("max_subscription_days_left") + max_sub_days = conditions.get('max_subscription_days_left') if max_sub_days is not None: if context.subscription_days > max_sub_days: return False # is_trial_user - if conditions.get("is_trial_user") is True: + if conditions.get('is_trial_user') is True: if not context.subscription: return False - is_trial = getattr(context.subscription, "is_trial", False) + is_trial = getattr(context.subscription, 'is_trial', False) if not is_trial: return False # has_autopay - if conditions.get("has_autopay") is True: + if conditions.get('has_autopay') is True: if not context.has_autopay: return False @@ -868,13 +877,13 @@ class MenuLayoutService: context: MenuContext, ) -> bool: """Проверить видимость кнопки.""" - if visibility == "all": + if visibility == 'all': return True - if visibility == "admins": + if visibility == 'admins': return context.is_admin - if visibility == "moderators": + if visibility == 'moderators': return context.is_moderator and not context.is_admin - if visibility == "subscribers": + if visibility == 'subscribers': return context.has_active_subscription and context.subscription_is_active return True @@ -882,17 +891,17 @@ class MenuLayoutService: # Список всех поддерживаемых плейсхолдеров _PLACEHOLDERS = ( - "{balance}", - "{username}", - "{subscription_days}", - "{traffic_used}", - "{traffic_left}", - "{referral_count}", - "{referral_earnings}", + '{balance}', + '{username}', + '{subscription_days}', + '{traffic_used}', + '{traffic_left}', + '{referral_count}', + '{referral_earnings}', ) @classmethod - def _text_has_placeholders(cls, text_config: Dict[str, str]) -> bool: + def _text_has_placeholders(cls, text_config: dict[str, str]) -> bool: """Проверить, содержит ли текст динамические плейсхолдеры.""" if not text_config or not isinstance(text_config, dict): return False @@ -908,9 +917,9 @@ class MenuLayoutService: @classmethod def _get_localized_text( cls, - text_config: Dict[str, str], + text_config: dict[str, str], language: str, - fallback_language: str = "en", + fallback_language: str = 'en', ) -> str: """Получить локализованный текст.""" # Пробуем запрошенный язык @@ -922,7 +931,7 @@ class MenuLayoutService: # Возвращаем первый доступный if text_config: return next(iter(text_config.values())) - return "" + return '' @classmethod def _format_dynamic_text( @@ -933,36 +942,36 @@ class MenuLayoutService: ) -> str: """Форматировать динамический текст с плейсхолдерами.""" # Баланс - if "{balance}" in text: + if '{balance}' in text: formatted_balance = texts.format_price(context.balance_kopeks) - text = text.replace("{balance}", formatted_balance) + text = text.replace('{balance}', formatted_balance) # Имя пользователя - if "{username}" in text: - text = text.replace("{username}", context.username or "User") + if '{username}' in text: + text = text.replace('{username}', context.username or 'User') # Дней до окончания подписки - if "{subscription_days}" in text: - text = text.replace("{subscription_days}", str(context.subscription_days)) + if '{subscription_days}' in text: + text = text.replace('{subscription_days}', str(context.subscription_days)) # Использованный трафик - if "{traffic_used}" in text: - traffic = f"{context.traffic_used_gb:.1f} GB" - text = text.replace("{traffic_used}", traffic) + if '{traffic_used}' in text: + traffic = f'{context.traffic_used_gb:.1f} GB' + text = text.replace('{traffic_used}', traffic) # Оставшийся трафик - if "{traffic_left}" in text: - traffic = f"{context.traffic_left_gb:.1f} GB" - text = text.replace("{traffic_left}", traffic) + if '{traffic_left}' in text: + traffic = f'{context.traffic_left_gb:.1f} GB' + text = text.replace('{traffic_left}', traffic) # Количество рефералов - if "{referral_count}" in text: - text = text.replace("{referral_count}", str(context.referral_count)) + if '{referral_count}' in text: + text = text.replace('{referral_count}', str(context.referral_count)) # Заработок с рефералов - if "{referral_earnings}" in text: + if '{referral_earnings}' in text: formatted_earnings = texts.format_price(context.referral_earnings_kopeks) - text = text.replace("{referral_earnings}", formatted_earnings) + text = text.replace('{referral_earnings}', formatted_earnings) return text @@ -971,11 +980,11 @@ class MenuLayoutService: @classmethod def _build_button( cls, - button_config: Dict[str, Any], + button_config: dict[str, Any], context: MenuContext, texts: Any, - button_id: str = "", - ) -> Optional[InlineKeyboardButton]: + button_id: str = '', + ) -> InlineKeyboardButton | None: """Построить кнопку из конфигурации. Args: @@ -984,29 +993,29 @@ class MenuLayoutService: texts: Локализованные тексты button_id: ID кнопки (ключ в словаре buttons) """ - button_type = button_config.get("type", "builtin") + button_type = button_config.get('type', 'builtin') # Используем переданный button_id или fallback на builtin_id - effective_button_id = button_id or button_config.get("builtin_id", "") - text_config = button_config.get("text", {}) - action = button_config.get("action", "") - open_mode = button_config.get("open_mode", "callback") - webapp_url = button_config.get("webapp_url") - icon = button_config.get("icon", "") - + effective_button_id = button_id or button_config.get('builtin_id', '') + text_config = button_config.get('text', {}) + action = button_config.get('action', '') + open_mode = button_config.get('open_mode', 'callback') + webapp_url = button_config.get('webapp_url') + icon = button_config.get('icon', '') + # Логирование для отладки кнопки connect is_connect_button = ( - effective_button_id == "connect" or - "connect" in str(effective_button_id).lower() or - action == "subscription_connect" or - "connect" in str(action).lower() + effective_button_id == 'connect' + or 'connect' in str(effective_button_id).lower() + or action == 'subscription_connect' + or 'connect' in str(action).lower() ) if is_connect_button: logger.info( - f"🔗 Построение кнопки connect: " - f"button_id={effective_button_id}, type={button_type}, " - f"open_mode={open_mode}, action={action}, " - f"webapp_url={webapp_url}" + f'🔗 Построение кнопки connect: ' + f'button_id={effective_button_id}, type={button_type}, ' + f'open_mode={open_mode}, action={action}, ' + f'webapp_url={webapp_url}' ) # Получаем текст @@ -1016,70 +1025,56 @@ class MenuLayoutService: # Добавляем иконку если есть и текст не начинается с неё if icon and not text.startswith(icon): - text = f"{icon} {text}" + text = f'{icon} {text}' # Форматируем динамический текст - if button_config.get("dynamic_text"): + if button_config.get('dynamic_text'): text = cls._format_dynamic_text(text, context, texts) # Строим кнопку в зависимости от типа - if button_type == "url": + if button_type == 'url': return InlineKeyboardButton(text=text, url=action) - elif button_type == "mini_app": - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=action) - ) - elif button_type == "callback": + if button_type == 'mini_app': + return InlineKeyboardButton(text=text, web_app=types.WebAppInfo(url=action)) + if button_type == 'callback': # Кастомная кнопка с callback_data return InlineKeyboardButton(text=text, callback_data=action) - else: - # builtin - проверяем open_mode - if open_mode == "direct": - # Прямое открытие Mini App через WebAppInfo - # Используем webapp_url, если указан, иначе action (если это URL) - url = webapp_url or action - - # Для кнопки connect: если URL не указан или это callback_data, - # пытаемся получить URL из подписки пользователя - if is_connect_button and (not url or not (url.startswith("http://") or url.startswith("https://"))): - if context.subscription: - from app.utils.subscription_utils import get_display_subscription_link - subscription_url = get_display_subscription_link(context.subscription) - if subscription_url: - url = subscription_url - logger.info( - f"🔗 Кнопка connect: получен URL из подписки: {url[:50]}..." - ) - # Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL - if not url or not (url.startswith("http://") or url.startswith("https://")): - if settings.MINIAPP_CUSTOM_URL: - url = settings.MINIAPP_CUSTOM_URL - logger.info( - f"🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}..." - ) - - # Проверяем, что это действительно URL - if url and (url.startswith("http://") or url.startswith("https://")): - logger.info( - f"🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}..." - ) - return InlineKeyboardButton( - text=text, web_app=types.WebAppInfo(url=url) - ) - else: - logger.warning( - f"🔗 Кнопка connect: open_mode=direct, но URL не найден. " - f"webapp_url={webapp_url}, action={action}, " - f"subscription_url={'есть' if context.subscription else 'нет'}" - ) - # Fallback на callback_data - return InlineKeyboardButton(text=text, callback_data=action) - else: - # Стандартный callback_data - logger.debug( - f"Кнопка connect: open_mode={open_mode}, используем callback_data: {action}" - ) - return InlineKeyboardButton(text=text, callback_data=action) + # builtin - проверяем open_mode + if open_mode == 'direct': + # Прямое открытие Mini App через WebAppInfo + # Используем webapp_url, если указан, иначе action (если это URL) + url = webapp_url or action + + # Для кнопки connect: если URL не указан или это callback_data, + # пытаемся получить URL из подписки пользователя + if is_connect_button and (not url or not (url.startswith('http://') or url.startswith('https://'))): + if context.subscription: + from app.utils.subscription_utils import get_display_subscription_link + + subscription_url = get_display_subscription_link(context.subscription) + if subscription_url: + url = subscription_url + logger.info(f'🔗 Кнопка connect: получен URL из подписки: {url[:50]}...') + # Если все еще нет URL, пробуем использовать настройку MINIAPP_CUSTOM_URL + if not url or not (url.startswith('http://') or url.startswith('https://')): + if settings.MINIAPP_CUSTOM_URL: + url = settings.MINIAPP_CUSTOM_URL + logger.info(f'🔗 Кнопка connect: использован MINIAPP_CUSTOM_URL: {url[:50]}...') + + # Проверяем, что это действительно URL + if url and (url.startswith('http://') or url.startswith('https://')): + logger.info(f'🔗 Кнопка connect: open_mode=direct, используем URL: {url[:50]}...') + return InlineKeyboardButton(text=text, web_app=types.WebAppInfo(url=url)) + logger.warning( + f'🔗 Кнопка connect: open_mode=direct, но URL не найден. ' + f'webapp_url={webapp_url}, action={action}, ' + f'subscription_url={"есть" if context.subscription else "нет"}' + ) + # Fallback на callback_data + return InlineKeyboardButton(text=text, callback_data=action) + # Стандартный callback_data + logger.debug(f'Кнопка connect: open_mode={open_mode}, используем callback_data: {action}') + return InlineKeyboardButton(text=text, callback_data=action) # --- Построение клавиатуры --- @@ -1093,36 +1088,36 @@ class MenuLayoutService: config = await cls.get_config(db) texts = get_texts(context.language) - keyboard_rows: List[List[InlineKeyboardButton]] = [] - rows_config = config.get("rows", []) - buttons_config = config.get("buttons", {}) + keyboard_rows: list[list[InlineKeyboardButton]] = [] + rows_config = config.get('rows', []) + buttons_config = config.get('buttons', {}) for row_config in rows_config: # Проверяем условия строки - row_conditions = row_config.get("conditions") + row_conditions = row_config.get('conditions') if not cls._evaluate_conditions(row_conditions, context): continue - row_buttons: List[InlineKeyboardButton] = [] - max_per_row = row_config.get("max_per_row", 2) + row_buttons: list[InlineKeyboardButton] = [] + max_per_row = row_config.get('max_per_row', 2) - for button_id in row_config.get("buttons", []): + for button_id in row_config.get('buttons', []): if button_id not in buttons_config: continue button_cfg = buttons_config[button_id] # Проверяем включена ли кнопка - if not button_cfg.get("enabled", True): + if not button_cfg.get('enabled', True): continue # Проверяем видимость - visibility = button_cfg.get("visibility", "all") + visibility = button_cfg.get('visibility', 'all') if not cls._check_visibility(visibility, context): continue # Проверяем условия кнопки - button_conditions = button_cfg.get("conditions") + button_conditions = button_cfg.get('conditions') if not cls._evaluate_conditions(button_conditions, context): continue @@ -1143,55 +1138,57 @@ class MenuLayoutService: cls, db: AsyncSession, context: MenuContext, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Предпросмотр меню (возвращает структуру для API).""" config = await cls.get_config(db) texts = get_texts(context.language) - preview_rows: List[Dict[str, Any]] = [] - rows_config = config.get("rows", []) - buttons_config = config.get("buttons", {}) + preview_rows: list[dict[str, Any]] = [] + rows_config = config.get('rows', []) + buttons_config = config.get('buttons', {}) for row_config in rows_config: - row_conditions = row_config.get("conditions") + row_conditions = row_config.get('conditions') if not cls._evaluate_conditions(row_conditions, context): continue - row_buttons: List[Dict[str, Any]] = [] - max_per_row = row_config.get("max_per_row", 2) + row_buttons: list[dict[str, Any]] = [] + max_per_row = row_config.get('max_per_row', 2) - for button_id in row_config.get("buttons", []): + for button_id in row_config.get('buttons', []): if button_id not in buttons_config: continue button_cfg = buttons_config[button_id] - if not button_cfg.get("enabled", True): + if not button_cfg.get('enabled', True): continue - visibility = button_cfg.get("visibility", "all") + visibility = button_cfg.get('visibility', 'all') if not cls._check_visibility(visibility, context): continue - button_conditions = button_cfg.get("conditions") + button_conditions = button_cfg.get('conditions') if not cls._evaluate_conditions(button_conditions, context): continue - text_config = button_cfg.get("text", {}) + text_config = button_cfg.get('text', {}) text = cls._get_localized_text(text_config, context.language) - if button_cfg.get("dynamic_text"): + if button_cfg.get('dynamic_text'): text = cls._format_dynamic_text(text, context, texts) - row_buttons.append({ - "text": text, - "action": button_cfg.get("action", ""), - "type": button_cfg.get("type", "builtin"), - }) + row_buttons.append( + { + 'text': text, + 'action': button_cfg.get('action', ''), + 'type': button_cfg.get('type', 'builtin'), + } + ) if row_buttons: for i in range(0, len(row_buttons), max_per_row): - preview_rows.append({"buttons": row_buttons[i : i + max_per_row]}) + preview_rows.append({'buttons': row_buttons[i : i + max_per_row]}) return preview_rows @@ -1201,15 +1198,13 @@ class MenuLayoutService: async def save_history( cls, db: AsyncSession, - config: Dict[str, Any], + config: dict[str, Any], action: str, - changes_summary: Optional[str] = None, - user_info: Optional[str] = None, + changes_summary: str | None = None, + user_info: str | None = None, ): """Сохранить запись в историю изменений.""" - return await MenuLayoutHistoryService.save_history( - db, config, action, changes_summary, user_info - ) + return await MenuLayoutHistoryService.save_history(db, config, action, changes_summary, user_info) @classmethod async def get_history( @@ -1217,7 +1212,7 @@ class MenuLayoutService: db: AsyncSession, limit: int = 50, offset: int = 0, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить историю изменений.""" return await MenuLayoutHistoryService.get_history(db, limit, offset) @@ -1231,7 +1226,7 @@ class MenuLayoutService: cls, db: AsyncSession, history_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Получить конкретную запись истории с конфигурацией.""" return await MenuLayoutHistoryService.get_history_entry(db, history_id) @@ -1240,8 +1235,8 @@ class MenuLayoutService: cls, db: AsyncSession, history_id: int, - user_info: Optional[str] = None, - ) -> Dict[str, Any]: + user_info: str | None = None, + ) -> dict[str, Any]: """Откатить конфигурацию к записи из истории.""" return await MenuLayoutHistoryService.rollback_to_history( db, history_id, cls.get_config, cls.save_config, user_info @@ -1254,10 +1249,10 @@ class MenuLayoutService: cls, db: AsyncSession, button_id: str, - user_id: Optional[int] = None, - callback_data: Optional[str] = None, - button_type: Optional[str] = None, - button_text: Optional[str] = None, + user_id: int | None = None, + callback_data: str | None = None, + button_type: str | None = None, + button_text: str | None = None, ): """Записать клик по кнопке.""" return await MenuLayoutStatsService.log_button_click( @@ -1270,7 +1265,7 @@ class MenuLayoutService: db: AsyncSession, button_id: str, days: int = 30, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Получить статистику кликов по конкретной кнопке.""" return await MenuLayoutStatsService.get_button_stats(db, button_id, days) @@ -1280,7 +1275,7 @@ class MenuLayoutService: db: AsyncSession, button_id: str, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по дням.""" return await MenuLayoutStatsService.get_button_clicks_by_day(db, button_id, days) @@ -1289,7 +1284,7 @@ class MenuLayoutService: cls, db: AsyncSession, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику по всем кнопкам.""" return await MenuLayoutStatsService.get_all_buttons_stats(db, days) @@ -1307,7 +1302,7 @@ class MenuLayoutService: cls, db: AsyncSession, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по типам кнопок.""" return await MenuLayoutStatsService.get_stats_by_button_type(db, days) @@ -1315,9 +1310,9 @@ class MenuLayoutService: async def get_clicks_by_hour( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по часам дня.""" return await MenuLayoutStatsService.get_clicks_by_hour(db, button_id, days) @@ -1325,9 +1320,9 @@ class MenuLayoutService: async def get_clicks_by_weekday( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по дням недели.""" return await MenuLayoutStatsService.get_clicks_by_weekday(db, button_id, days) @@ -1335,10 +1330,10 @@ class MenuLayoutService: async def get_top_users( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, limit: int = 10, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить топ пользователей по количеству кликов.""" return await MenuLayoutStatsService.get_top_users(db, button_id, limit, days) @@ -1346,14 +1341,12 @@ class MenuLayoutService: async def get_period_comparison( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, current_days: int = 7, previous_days: int = 7, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Сравнить статистику текущего и предыдущего периода.""" - return await MenuLayoutStatsService.get_period_comparison( - db, button_id, current_days, previous_days - ) + return await MenuLayoutStatsService.get_period_comparison(db, button_id, current_days, previous_days) @classmethod async def get_user_click_sequences( @@ -1361,6 +1354,6 @@ class MenuLayoutService: db: AsyncSession, user_id: int, limit: int = 50, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить последовательности кликов пользователя.""" return await MenuLayoutStatsService.get_click_sequences(db, user_id, limit) diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index 9ceed3b9..6f050676 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -2,10 +2,10 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime, timedelta +from typing import Any -from sqlalchemy import select, func, and_, desc, case, Integer +from sqlalchemy import Integer, and_, case, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -17,7 +17,7 @@ def _utcnow() -> datetime: Замена deprecated datetime.utcnow(). """ - return datetime.now(timezone.utc).replace(tzinfo=None) + return datetime.now(UTC).replace(tzinfo=None) class MenuLayoutStatsService: @@ -34,9 +34,8 @@ class MenuLayoutStatsService: if cls._is_sqlite(): # SQLite: strftime('%H', column) возвращает строку return func.cast(func.strftime('%H', column), Integer) - else: - # PostgreSQL: EXTRACT(hour FROM column) - return func.extract('hour', column) + # PostgreSQL: EXTRACT(hour FROM column) + return func.extract('hour', column) @classmethod def _get_weekday_expr(cls, column): @@ -45,28 +44,21 @@ class MenuLayoutStatsService: # SQLite: strftime('%w', column) возвращает 0=воскресенье, 1-6=пн-сб # Преобразуем: 0->6, 1->0, 2->1, ..., 6->5 dow = func.cast(func.strftime('%w', column), Integer) - return case( - (dow == 0, 6), - else_=dow - 1 - ) - else: - # PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб - dow = func.extract('dow', column) - return case( - (dow == 0, 6), - else_=dow - 1 - ) + return case((dow == 0, 6), else_=dow - 1) + # PostgreSQL: EXTRACT(dow FROM column) возвращает 0=воскресенье, 1-6=пн-сб + dow = func.extract('dow', column) + return case((dow == 0, 6), else_=dow - 1) @classmethod async def log_button_click( cls, db: AsyncSession, button_id: str, - user_id: Optional[int] = None, - callback_data: Optional[str] = None, - button_type: Optional[str] = None, - button_text: Optional[str] = None, - ) -> Optional[ButtonClickLog]: + user_id: int | None = None, + callback_data: str | None = None, + button_type: str | None = None, + button_text: str | None = None, + ) -> ButtonClickLog | None: """Записать клик по кнопке. Args: @@ -78,17 +70,13 @@ class MenuLayoutStatsService: # user_id может быть telegram_id (из middleware) или internal id (из API) actual_user_id = None if user_id is not None: + from sqlalchemy import or_, select + from app.database.models import User - from sqlalchemy import select, or_ # Пробуем найти пользователя по telegram_id или по internal id result = await db.execute( - select(User.id).where( - or_( - User.telegram_id == user_id, - User.id == user_id - ) - ).limit(1) + select(User.id).where(or_(User.telegram_id == user_id, User.id == user_id)).limit(1) ) found_user_id = result.scalar_one_or_none() if found_user_id is not None: @@ -114,7 +102,7 @@ class MenuLayoutStatsService: db: AsyncSession, button_id: str, days: int = 30, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Получить статистику кликов по конкретной кнопке.""" now = _utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -123,45 +111,37 @@ class MenuLayoutStatsService: # Общее количество кликов total_result = await db.execute( - select(func.count(ButtonClickLog.id)) - .where(ButtonClickLog.button_id == button_id) + select(func.count(ButtonClickLog.id)).where(ButtonClickLog.button_id == button_id) ) clicks_total = total_result.scalar() or 0 # Клики сегодня today_result = await db.execute( - select(func.count(ButtonClickLog.id)) - .where(and_( - ButtonClickLog.button_id == button_id, - ButtonClickLog.clicked_at >= today_start - )) + select(func.count(ButtonClickLog.id)).where( + and_(ButtonClickLog.button_id == button_id, ButtonClickLog.clicked_at >= today_start) + ) ) clicks_today = today_result.scalar() or 0 # Клики за неделю week_result = await db.execute( - select(func.count(ButtonClickLog.id)) - .where(and_( - ButtonClickLog.button_id == button_id, - ButtonClickLog.clicked_at >= week_ago - )) + select(func.count(ButtonClickLog.id)).where( + and_(ButtonClickLog.button_id == button_id, ButtonClickLog.clicked_at >= week_ago) + ) ) clicks_week = week_result.scalar() or 0 # Клики за месяц month_result = await db.execute( - select(func.count(ButtonClickLog.id)) - .where(and_( - ButtonClickLog.button_id == button_id, - ButtonClickLog.clicked_at >= month_ago - )) + select(func.count(ButtonClickLog.id)).where( + and_(ButtonClickLog.button_id == button_id, ButtonClickLog.clicked_at >= month_ago) + ) ) clicks_month = month_result.scalar() or 0 # Уникальные пользователи unique_result = await db.execute( - select(func.count(func.distinct(ButtonClickLog.user_id))) - .where(ButtonClickLog.button_id == button_id) + select(func.count(func.distinct(ButtonClickLog.user_id))).where(ButtonClickLog.button_id == button_id) ) unique_users = unique_result.scalar() or 0 @@ -175,13 +155,13 @@ class MenuLayoutStatsService: last_click = last_click_result.scalar_one_or_none() return { - "button_id": button_id, - "clicks_total": clicks_total, - "clicks_today": clicks_today, - "clicks_week": clicks_week, - "clicks_month": clicks_month, - "unique_users": unique_users, - "last_click_at": last_click, + 'button_id': button_id, + 'clicks_total': clicks_total, + 'clicks_today': clicks_today, + 'clicks_week': clicks_week, + 'clicks_month': clicks_month, + 'unique_users': unique_users, + 'last_click_at': last_click, } @classmethod @@ -190,35 +170,26 @@ class MenuLayoutStatsService: db: AsyncSession, button_id: str, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по дням.""" start_date = _utcnow() - timedelta(days=days) # Группировка по дате result = await db.execute( - select( - func.date(ButtonClickLog.clicked_at).label("date"), - func.count(ButtonClickLog.id).label("count") - ) - .where(and_( - ButtonClickLog.button_id == button_id, - ButtonClickLog.clicked_at >= start_date - )) + select(func.date(ButtonClickLog.clicked_at).label('date'), func.count(ButtonClickLog.id).label('count')) + .where(and_(ButtonClickLog.button_id == button_id, ButtonClickLog.clicked_at >= start_date)) .group_by(func.date(ButtonClickLog.clicked_at)) .order_by(func.date(ButtonClickLog.clicked_at)) ) - return [ - {"date": str(row.date), "count": row.count} - for row in result.all() - ] + return [{'date': str(row.date), 'count': row.count} for row in result.all()] @classmethod async def get_all_buttons_stats( cls, db: AsyncSession, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику по всем кнопкам.""" now = _utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -230,23 +201,17 @@ class MenuLayoutStatsService: select( ButtonClickLog.button_id, # Общее количество кликов (все клики без фильтра по датам) - func.count(ButtonClickLog.id).label("clicks_total"), + func.count(ButtonClickLog.id).label('clicks_total'), # Уникальные пользователи (все время) - func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + func.count(func.distinct(ButtonClickLog.user_id)).label('unique_users'), # Последний клик (все время) - func.max(ButtonClickLog.clicked_at).label("last_click_at"), + func.max(ButtonClickLog.clicked_at).label('last_click_at'), # Подсчет кликов за сегодня - func.sum( - case((ButtonClickLog.clicked_at >= today_start, 1), else_=0) - ).label("clicks_today"), + func.sum(case((ButtonClickLog.clicked_at >= today_start, 1), else_=0)).label('clicks_today'), # Подсчет кликов за неделю - func.sum( - case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0) - ).label("clicks_week"), + func.sum(case((ButtonClickLog.clicked_at >= week_ago, 1), else_=0)).label('clicks_week'), # Подсчет кликов за месяц - func.sum( - case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0) - ).label("clicks_month"), + func.sum(case((ButtonClickLog.clicked_at >= month_ago, 1), else_=0)).label('clicks_month'), ) .group_by(ButtonClickLog.button_id) .order_by(desc(func.count(ButtonClickLog.id))) @@ -254,13 +219,13 @@ class MenuLayoutStatsService: return [ { - "button_id": row.button_id, - "clicks_total": row.clicks_total, - "clicks_today": row.clicks_today or 0, - "clicks_week": row.clicks_week or 0, - "clicks_month": row.clicks_month or 0, - "unique_users": row.unique_users, - "last_click_at": row.last_click_at, + 'button_id': row.button_id, + 'clicks_total': row.clicks_total, + 'clicks_today': row.clicks_today or 0, + 'clicks_week': row.clicks_week or 0, + 'clicks_month': row.clicks_month or 0, + 'unique_users': row.unique_users, + 'last_click_at': row.last_click_at, } for row in result.all() ] @@ -274,10 +239,7 @@ class MenuLayoutStatsService: """Получить общее количество кликов за период.""" start_date = _utcnow() - timedelta(days=days) - result = await db.execute( - select(func.count(ButtonClickLog.id)) - .where(ButtonClickLog.clicked_at >= start_date) - ) + result = await db.execute(select(func.count(ButtonClickLog.id)).where(ButtonClickLog.clicked_at >= start_date)) return result.scalar() or 0 @classmethod @@ -285,29 +247,26 @@ class MenuLayoutStatsService: cls, db: AsyncSession, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по типам кнопок.""" start_date = _utcnow() - timedelta(days=days) result = await db.execute( select( ButtonClickLog.button_type, - func.count(ButtonClickLog.id).label("clicks_total"), - func.count(func.distinct(ButtonClickLog.user_id)).label("unique_users"), + func.count(ButtonClickLog.id).label('clicks_total'), + func.count(func.distinct(ButtonClickLog.user_id)).label('unique_users'), ) - .where(and_( - ButtonClickLog.clicked_at >= start_date, - ButtonClickLog.button_type.isnot(None) - )) + .where(and_(ButtonClickLog.clicked_at >= start_date, ButtonClickLog.button_type.isnot(None))) .group_by(ButtonClickLog.button_type) .order_by(desc(func.count(ButtonClickLog.id))) ) return [ { - "button_type": row.button_type or "unknown", - "clicks_total": row.clicks_total, - "unique_users": row.unique_users, + 'button_type': row.button_type or 'unknown', + 'clicks_total': row.clicks_total, + 'unique_users': row.unique_users, } for row in result.all() ] @@ -316,48 +275,37 @@ class MenuLayoutStatsService: async def get_clicks_by_hour( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по часам дня.""" start_date = _utcnow() - timedelta(days=days) # Используем helper-метод для совместимости с SQLite и PostgreSQL - hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label("hour") + hour_expr = cls._get_hour_expr(ButtonClickLog.clicked_at).label('hour') - query = select( - hour_expr, - func.count(ButtonClickLog.id).label("count") - ).where(ButtonClickLog.clicked_at >= start_date) + query = select(hour_expr, func.count(ButtonClickLog.id).label('count')).where( + ButtonClickLog.clicked_at >= start_date + ) if button_id: query = query.where(ButtonClickLog.button_id == button_id) - result = await db.execute( - query - .group_by(hour_expr) - .order_by(hour_expr) - ) + result = await db.execute(query.group_by(hour_expr).order_by(hour_expr)) # Создаем словарь для быстрого доступа по часу - stats_dict = { - int(row.hour): row.count - for row in result.all() - } + stats_dict = {int(row.hour): row.count for row in result.all()} # Возвращаем все 24 часа, даже если count = 0 - return [ - {"hour": hour, "count": stats_dict.get(hour, 0)} - for hour in range(24) - ] + return [{'hour': hour, 'count': stats_dict.get(hour, 0)} for hour in range(24)] @classmethod async def get_clicks_by_weekday( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику кликов по дням недели. Возвращает 0=понедельник, 6=воскресенье. @@ -366,37 +314,25 @@ class MenuLayoutStatsService: start_date = _utcnow() - timedelta(days=days) # Используем helper-метод для совместимости с SQLite и PostgreSQL - weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label("weekday") + weekday_expr = cls._get_weekday_expr(ButtonClickLog.clicked_at).label('weekday') - query = select( - weekday_expr, - func.count(ButtonClickLog.id).label("count") - ).where(ButtonClickLog.clicked_at >= start_date) + query = select(weekday_expr, func.count(ButtonClickLog.id).label('count')).where( + ButtonClickLog.clicked_at >= start_date + ) if button_id: query = query.where(ButtonClickLog.button_id == button_id) - result = await db.execute( - query - .group_by(weekday_expr) - .order_by(weekday_expr) - ) + result = await db.execute(query.group_by(weekday_expr).order_by(weekday_expr)) + + weekday_names = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'] - weekday_names = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"] - # Создаем словарь для быстрого доступа по weekday - stats_dict = { - int(row.weekday): row.count - for row in result.all() - } - + stats_dict = {int(row.weekday): row.count for row in result.all()} + # Возвращаем все дни недели, даже если count = 0 return [ - { - "weekday": weekday, - "weekday_name": weekday_names[weekday], - "count": stats_dict.get(weekday, 0) - } + {'weekday': weekday, 'weekday_name': weekday_names[weekday], 'count': stats_dict.get(weekday, 0)} for weekday in range(7) ] @@ -404,37 +340,31 @@ class MenuLayoutStatsService: async def get_top_users( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, limit: int = 10, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить топ пользователей по количеству кликов.""" start_date = _utcnow() - timedelta(days=days) query = select( ButtonClickLog.user_id, - func.count(ButtonClickLog.id).label("clicks_count"), - func.max(ButtonClickLog.clicked_at).label("last_click_at") - ).where(and_( - ButtonClickLog.clicked_at >= start_date, - ButtonClickLog.user_id.isnot(None) - )) + func.count(ButtonClickLog.id).label('clicks_count'), + func.max(ButtonClickLog.clicked_at).label('last_click_at'), + ).where(and_(ButtonClickLog.clicked_at >= start_date, ButtonClickLog.user_id.isnot(None))) if button_id: query = query.where(ButtonClickLog.button_id == button_id) result = await db.execute( - query - .group_by(ButtonClickLog.user_id) - .order_by(desc(func.count(ButtonClickLog.id))) - .limit(limit) + query.group_by(ButtonClickLog.user_id).order_by(desc(func.count(ButtonClickLog.id))).limit(limit) ) return [ { - "user_id": row.user_id, - "clicks_count": row.clicks_count, - "last_click_at": row.last_click_at, + 'user_id': row.user_id, + 'clicks_count': row.clicks_count, + 'last_click_at': row.last_click_at, } for row in result.all() ] @@ -443,10 +373,10 @@ class MenuLayoutStatsService: async def get_period_comparison( cls, db: AsyncSession, - button_id: Optional[str] = None, + button_id: str | None = None, current_days: int = 7, previous_days: int = 7, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Сравнить статистику текущего и предыдущего периода.""" now = _utcnow() current_start = now - timedelta(days=current_days) @@ -460,14 +390,9 @@ class MenuLayoutStatsService: query_current = query_current.where(ButtonClickLog.button_id == button_id) query_previous = query_previous.where(ButtonClickLog.button_id == button_id) - query_current = query_current.where( - ButtonClickLog.clicked_at >= current_start - ) + query_current = query_current.where(ButtonClickLog.clicked_at >= current_start) query_previous = query_previous.where( - and_( - ButtonClickLog.clicked_at >= previous_start, - ButtonClickLog.clicked_at < previous_end - ) + and_(ButtonClickLog.clicked_at >= previous_start, ButtonClickLog.clicked_at < previous_end) ) current_result = await db.execute(query_current) @@ -481,22 +406,22 @@ class MenuLayoutStatsService: change_percent = ((current_count - previous_count) / previous_count) * 100 return { - "current_period": { - "clicks": current_count, - "days": current_days, - "start": current_start, - "end": now, + 'current_period': { + 'clicks': current_count, + 'days': current_days, + 'start': current_start, + 'end': now, }, - "previous_period": { - "clicks": previous_count, - "days": previous_days, - "start": previous_start, - "end": previous_end, + 'previous_period': { + 'clicks': previous_count, + 'days': previous_days, + 'start': previous_start, + 'end': previous_end, }, - "change": { - "absolute": current_count - previous_count, - "percent": round(change_percent, 2), - "trend": "up" if change_percent > 0 else "down" if change_percent < 0 else "stable", + 'change': { + 'absolute': current_count - previous_count, + 'percent': round(change_percent, 2), + 'trend': 'up' if change_percent > 0 else 'down' if change_percent < 0 else 'stable', }, } @@ -506,7 +431,7 @@ class MenuLayoutStatsService: db: AsyncSession, user_id: int, limit: int = 50, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить последовательности кликов пользователя.""" result = await db.execute( select( @@ -521,9 +446,9 @@ class MenuLayoutStatsService: return [ { - "button_id": row.button_id, - "button_text": row.button_text, - "clicked_at": row.clicked_at, + 'button_id': row.button_id, + 'button_text': row.button_text, + 'clicked_at': row.clicked_at, } for row in result.all() ] diff --git a/app/services/menu_layout_service.py b/app/services/menu_layout_service.py index 90ed0584..1702101b 100644 --- a/app/services/menu_layout_service.py +++ b/app/services/menu_layout_service.py @@ -14,27 +14,28 @@ # Реэкспорт для обратной совместимости from app.services.menu_layout import ( + AVAILABLE_CALLBACKS, + BUILTIN_BUTTONS_INFO, + DEFAULT_MENU_CONFIG, + DYNAMIC_PLACEHOLDERS, # Константы MENU_LAYOUT_CONFIG_KEY, - DEFAULT_MENU_CONFIG, - BUILTIN_BUTTONS_INFO, - AVAILABLE_CALLBACKS, - DYNAMIC_PLACEHOLDERS, # Классы MenuContext, - MenuLayoutService, MenuLayoutHistoryService, + MenuLayoutService, MenuLayoutStatsService, ) + __all__ = [ - "MENU_LAYOUT_CONFIG_KEY", - "DEFAULT_MENU_CONFIG", - "BUILTIN_BUTTONS_INFO", - "AVAILABLE_CALLBACKS", - "DYNAMIC_PLACEHOLDERS", - "MenuContext", - "MenuLayoutService", - "MenuLayoutHistoryService", - "MenuLayoutStatsService", + 'AVAILABLE_CALLBACKS', + 'BUILTIN_BUTTONS_INFO', + 'DEFAULT_MENU_CONFIG', + 'DYNAMIC_PLACEHOLDERS', + 'MENU_LAYOUT_CONFIG_KEY', + 'MenuContext', + 'MenuLayoutHistoryService', + 'MenuLayoutService', + 'MenuLayoutStatsService', ] diff --git a/app/services/modem_service.py b/app/services/modem_service.py index 7a031264..d410ebcb 100644 --- a/app/services/modem_service.py +++ b/app/services/modem_service.py @@ -9,43 +9,46 @@ import logging from dataclasses import dataclass from datetime import datetime from enum import Enum -from typing import Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import Subscription, User, TransactionType from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance +from app.database.models import Subscription, TransactionType, User from app.services.subscription_service import SubscriptionService -from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price +from app.utils.pricing_utils import calculate_prorated_price + logger = logging.getLogger(__name__) class ModemError(Enum): """Типы ошибок при работе с модемом.""" - NO_SUBSCRIPTION = "no_subscription" - TRIAL_SUBSCRIPTION = "trial_subscription" - MODEM_DISABLED = "modem_disabled" - ALREADY_ENABLED = "already_enabled" - NOT_ENABLED = "not_enabled" - INSUFFICIENT_FUNDS = "insufficient_funds" - CHARGE_ERROR = "charge_error" - UPDATE_ERROR = "update_error" + + NO_SUBSCRIPTION = 'no_subscription' + TRIAL_SUBSCRIPTION = 'trial_subscription' + MODEM_DISABLED = 'modem_disabled' + ALREADY_ENABLED = 'already_enabled' + NOT_ENABLED = 'not_enabled' + INSUFFICIENT_FUNDS = 'insufficient_funds' + CHARGE_ERROR = 'charge_error' + UPDATE_ERROR = 'update_error' @dataclass class ModemAvailabilityResult: """Результат проверки доступности модема.""" + available: bool - error: Optional[ModemError] = None + error: ModemError | None = None modem_enabled: bool = False @dataclass class ModemPriceResult: """Результат расчёта цены модема.""" + base_price: int final_price: int discount_percent: int @@ -62,8 +65,9 @@ class ModemPriceResult: @dataclass class ModemEnableResult: """Результат подключения модема.""" + success: bool - error: Optional[ModemError] = None + error: ModemError | None = None charged_amount: int = 0 new_device_limit: int = 0 @@ -71,8 +75,9 @@ class ModemEnableResult: @dataclass class ModemDisableResult: """Результат отключения модема.""" + success: bool - error: Optional[ModemError] = None + error: ModemError | None = None new_device_limit: int = 0 @@ -101,17 +106,14 @@ class ModemService: return settings.is_modem_enabled() @staticmethod - def get_modem_enabled(subscription: Optional[Subscription]) -> bool: + def get_modem_enabled(subscription: Subscription | None) -> bool: """Безопасно получает статус модема из подписки.""" if subscription is None: return False return getattr(subscription, 'modem_enabled', False) or False def check_availability( - self, - user: User, - for_enable: bool = False, - for_disable: bool = False + self, user: User, for_enable: bool = False, for_disable: bool = False ) -> ModemAvailabilityResult: """ Проверяет доступность модема для пользователя. @@ -129,43 +131,28 @@ class ModemService: if not subscription: return ModemAvailabilityResult( - available=False, - error=ModemError.NO_SUBSCRIPTION, - modem_enabled=modem_enabled + available=False, error=ModemError.NO_SUBSCRIPTION, modem_enabled=modem_enabled ) if subscription.is_trial: return ModemAvailabilityResult( - available=False, - error=ModemError.TRIAL_SUBSCRIPTION, - modem_enabled=modem_enabled + available=False, error=ModemError.TRIAL_SUBSCRIPTION, modem_enabled=modem_enabled ) if not self.is_modem_feature_enabled(): return ModemAvailabilityResult( - available=False, - error=ModemError.MODEM_DISABLED, - modem_enabled=modem_enabled + available=False, error=ModemError.MODEM_DISABLED, modem_enabled=modem_enabled ) if for_enable and modem_enabled: return ModemAvailabilityResult( - available=False, - error=ModemError.ALREADY_ENABLED, - modem_enabled=modem_enabled + available=False, error=ModemError.ALREADY_ENABLED, modem_enabled=modem_enabled ) if for_disable and not modem_enabled: - return ModemAvailabilityResult( - available=False, - error=ModemError.NOT_ENABLED, - modem_enabled=modem_enabled - ) + return ModemAvailabilityResult(available=False, error=ModemError.NOT_ENABLED, modem_enabled=modem_enabled) - return ModemAvailabilityResult( - available=True, - modem_enabled=modem_enabled - ) + return ModemAvailabilityResult(available=True, modem_enabled=modem_enabled) def calculate_price(self, subscription: Subscription) -> ModemPriceResult: """ @@ -205,10 +192,10 @@ class ModemService: discount_amount=discount_amount, charged_months=charged_months, remaining_days=remaining_days, - end_date=subscription.end_date + end_date=subscription.end_date, ) - def check_balance(self, user: User, price: int) -> Tuple[bool, int]: + def check_balance(self, user: User, price: int) -> tuple[bool, int]: """ Проверяет достаточность баланса. @@ -228,12 +215,7 @@ class ModemService: missing = price - user.balance_kopeks return False, missing - async def enable_modem( - self, - db: AsyncSession, - user: User, - subscription: Subscription - ) -> ModemEnableResult: + async def enable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemEnableResult: """ Подключает модем к подписке. @@ -258,30 +240,21 @@ class ModemService: has_funds, _ = self.check_balance(user, price) if not has_funds: - return ModemEnableResult( - success=False, - error=ModemError.INSUFFICIENT_FUNDS - ) + return ModemEnableResult(success=False, error=ModemError.INSUFFICIENT_FUNDS) try: if price > 0: - success = await subtract_user_balance( - db, user, price, - "Подключение модема" - ) + success = await subtract_user_balance(db, user, price, 'Подключение модема') if not success: - return ModemEnableResult( - success=False, - error=ModemError.CHARGE_ERROR - ) + return ModemEnableResult(success=False, error=ModemError.CHARGE_ERROR) await create_transaction( db=db, user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price, - description=f"Подключение модема на {price_info.charged_months} мес" + description=f'Подключение модема на {price_info.charged_months} мес', ) subscription.modem_enabled = True @@ -295,32 +268,18 @@ class ModemService: await db.refresh(user) await db.refresh(subscription) - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info( - f"Пользователь {user_id_display} подключил модем, списано: {price / 100}₽" - ) + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}₽') - return ModemEnableResult( - success=True, - charged_amount=price, - new_device_limit=subscription.device_limit - ) + return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit) except Exception as e: - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.error(f"Ошибка подключения модема для пользователя {user_id_display}: {e}") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {e}') await db.rollback() - return ModemEnableResult( - success=False, - error=ModemError.UPDATE_ERROR - ) + return ModemEnableResult(success=False, error=ModemError.UPDATE_ERROR) - async def disable_modem( - self, - db: AsyncSession, - user: User, - subscription: Subscription - ) -> ModemDisableResult: + async def disable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemDisableResult: """ Отключает модем от подписки. @@ -347,25 +306,19 @@ class ModemService: await db.refresh(user) await db.refresh(subscription) - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"Пользователь {user_id_display} отключил модем") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info(f'Пользователь {user_id_display} отключил модем') - return ModemDisableResult( - success=True, - new_device_limit=subscription.device_limit - ) + return ModemDisableResult(success=True, new_device_limit=subscription.device_limit) except Exception as e: - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.error(f"Ошибка отключения модема для пользователя {user_id_display}: {e}") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}') await db.rollback() - return ModemDisableResult( - success=False, - error=ModemError.UPDATE_ERROR - ) + return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR) @staticmethod - def get_period_warning_level(remaining_days: int) -> Optional[str]: + def get_period_warning_level(remaining_days: int) -> str | None: """ Определяет уровень предупреждения о сроке действия. @@ -378,14 +331,14 @@ class ModemService: None если больше 30 дней """ if remaining_days <= MODEM_WARNING_DAYS_CRITICAL: - return "critical" + return 'critical' if remaining_days <= MODEM_WARNING_DAYS_INFO: - return "info" + return 'info' return None # Singleton instance для использования в хендлерах -_modem_service: Optional[ModemService] = None +_modem_service: ModemService | None = None def get_modem_service() -> ModemService: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 5ba88bd8..9a6af9e1 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -2,28 +2,27 @@ import asyncio import logging from datetime import datetime, timedelta from pathlib import Path -from typing import Dict, List, Any, Optional, Set +from typing import Any -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.enums import ChatMemberStatus +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import FSInputFile -from sqlalchemy import select, and_, or_ +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.database import AsyncSessionLocal from app.database.crud.discount_offer import ( deactivate_expired_offers, get_latest_claimed_offer_for_user, upsert_discount_offer, ) -from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.crud.notification import ( clear_notification_by_type, notification_sent, record_notification, ) +from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.crud.subscription import ( deactivate_subscription, extend_subscription, @@ -32,44 +31,43 @@ from app.database.crud.subscription import ( get_subscriptions_for_autopay, ) from app.database.crud.user import ( + cleanup_expired_promo_offer_discounts, delete_user, get_inactive_users, get_user_by_id, subtract_user_balance, - cleanup_expired_promo_offer_discounts, -) -from app.utils.timezone import format_local_datetime -from app.utils.subscription_utils import ( - resolve_hwid_device_limit_for_payload, ) +from app.database.database import AsyncSessionLocal from app.database.models import ( MonitoringLog, - SubscriptionStatus, Subscription, - Tariff, - User, + SubscriptionStatus, Ticket, TicketStatus, + User, UserPromoGroup, ) -from app.localization.texts import get_texts -from app.services.notification_settings_service import NotificationSettingsService -from app.services.payment_service import PaymentService -from app.services.subscription_service import SubscriptionService -from app.services.promo_offer_service import promo_offer_service -from app.services.notification_delivery_service import ( - notification_delivery_service, - NotificationType, -) -from app.utils.pricing_utils import apply_percentage_discount -from app.utils.miniapp_buttons import build_miniapp_or_callback_button - from app.external.remnawave_api import ( RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus, ) +from app.localization.texts import get_texts +from app.services.notification_delivery_service import ( + notification_delivery_service, +) +from app.services.notification_settings_service import NotificationSettingsService +from app.services.payment_service import PaymentService +from app.services.promo_offer_service import promo_offer_service +from app.services.subscription_service import SubscriptionService +from app.utils.miniapp_buttons import build_miniapp_or_callback_button +from app.utils.pricing_utils import apply_percentage_discount +from app.utils.subscription_utils import ( + resolve_hwid_device_limit_for_payload, +) +from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) @@ -78,37 +76,32 @@ LOGO_PATH = Path(settings.LOGO_FILE) class MonitoringService: - def __init__(self, bot=None): self.is_running = False self.subscription_service = SubscriptionService() self.payment_service = PaymentService() self.bot = bot - self._notified_users: Set[str] = set() + self._notified_users: set[str] = set() self._last_cleanup = datetime.utcnow() self._sla_task = None async def _send_message_with_logo( self, - chat_id: Optional[int], + chat_id: int | None, text: str, reply_markup=None, - parse_mode: Optional[str] = "HTML", + parse_mode: str | None = 'HTML', ): """Отправляет сообщение, добавляя логотип при необходимости.""" if not self.bot: - raise RuntimeError("Bot instance is not available") + raise RuntimeError('Bot instance is not available') # Skip email-only users (no telegram_id) if not chat_id: - logger.debug("Пропуск уведомления: chat_id не указан (email-пользователь)") + logger.debug('Пропуск уведомления: chat_id не указан (email-пользователь)') return None - if ( - settings.ENABLE_LOGO_MODE - and LOGO_PATH.exists() - and (text is None or len(text) <= 1000) - ): + if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000): try: return await self.bot.send_photo( chat_id=chat_id, @@ -119,8 +112,7 @@ class MonitoringService: ) except TelegramBadRequest as exc: logger.warning( - "Не удалось отправить сообщение с логотипом пользователю %s: %s. " - "Отправляем текстовое сообщение.", + 'Не удалось отправить сообщение с логотипом пользователю %s: %s. Отправляем текстовое сообщение.', chat_id, exc, ) @@ -136,20 +128,20 @@ class MonitoringService: def _is_unreachable_error(error: TelegramBadRequest) -> bool: message = str(error).lower() unreachable_markers = ( - "chat not found", - "user is deactivated", - "bot was blocked by the user", + 'chat not found', + 'user is deactivated', + 'bot was blocked by the user', "bot can't initiate conversation", "can't initiate conversation", - "user not found", - "peer id invalid", + 'user not found', + 'peer id invalid', ) return any(marker in message for marker in unreachable_markers) def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool: if isinstance(error, TelegramForbiddenError): logger.warning( - "⚠️ Пользователь %s недоступен (%s): бот заблокирован", + '⚠️ Пользователь %s недоступен (%s): бот заблокирован', user.telegram_id, context, ) @@ -157,7 +149,7 @@ class MonitoringService: if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error): logger.warning( - "⚠️ Пользователь %s недоступен (%s): %s", + '⚠️ Пользователь %s недоступен (%s): %s', user.telegram_id, context, error, @@ -165,39 +157,39 @@ class MonitoringService: return True return False - + async def start_monitoring(self): if self.is_running: - logger.warning("Мониторинг уже запущен") + logger.warning('Мониторинг уже запущен') return - + self.is_running = True - logger.info("🔄 Запуск службы мониторинга") + logger.info('🔄 Запуск службы мониторинга') # Start dedicated SLA loop with its own interval for timely 5-min checks try: if not self._sla_task or self._sla_task.done(): self._sla_task = asyncio.create_task(self._sla_loop()) except Exception as e: - logger.error(f"Не удалось запустить SLA-мониторинг: {e}") - + logger.error(f'Не удалось запустить SLA-мониторинг: {e}') + while self.is_running: try: await self._monitoring_cycle() - await asyncio.sleep(settings.MONITORING_INTERVAL * 60) - + await asyncio.sleep(settings.MONITORING_INTERVAL * 60) + except Exception as e: - logger.error(f"Ошибка в цикле мониторинга: {e}") - await asyncio.sleep(60) - + logger.error(f'Ошибка в цикле мониторинга: {e}') + await asyncio.sleep(60) + def stop_monitoring(self): self.is_running = False - logger.info("ℹ️ Мониторинг остановлен") + logger.info('ℹ️ Мониторинг остановлен') try: if self._sla_task and not self._sla_task.done(): self._sla_task.cancel() except Exception: pass - + async def _monitoring_cycle(self): async with AsyncSessionLocal() as db: try: @@ -205,18 +197,18 @@ class MonitoringService: expired_offers = await deactivate_expired_offers(db) if expired_offers: - logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + logger.info(f'🧹 Деактивировано {expired_offers} просроченных скидочных предложений') expired_active_discounts = await cleanup_expired_promo_offer_discounts(db) if expired_active_discounts: logger.info( - "🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком", + '🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком', expired_active_discounts, ) cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db) if cleaned_test_access: - logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам") + logger.info(f'🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам') await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) @@ -230,68 +222,67 @@ class MonitoringService: await self._sync_with_remnawave(db) await self._log_monitoring_event( - db, "monitoring_cycle_completed", - "Цикл мониторинга успешно завершен", - {"timestamp": datetime.utcnow().isoformat()} + db, + 'monitoring_cycle_completed', + 'Цикл мониторинга успешно завершен', + {'timestamp': datetime.utcnow().isoformat()}, ) await db.commit() except Exception as e: - logger.error(f"Ошибка в цикле мониторинга: {e}") + logger.error(f'Ошибка в цикле мониторинга: {e}') try: await self._log_monitoring_event( - db, "monitoring_cycle_error", - f"Ошибка в цикле мониторинга: {str(e)}", - {"error": str(e)}, - is_success=False + db, + 'monitoring_cycle_error', + f'Ошибка в цикле мониторинга: {e!s}', + {'error': str(e)}, + is_success=False, ) except Exception: pass - await db.rollback() - + await db.rollback() + async def _cleanup_notification_cache(self): current_time = datetime.utcnow() - + if (current_time - self._last_cleanup).total_seconds() >= 3600: old_count = len(self._notified_users) self._notified_users.clear() self._last_cleanup = current_time - logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)") - + logger.info(f'🧹 Очищен кеш уведомлений ({old_count} записей)') + async def _check_expired_subscriptions(self, db: AsyncSession): try: expired_subscriptions = await get_expired_subscriptions(db) - + for subscription in expired_subscriptions: from app.database.crud.subscription import expire_subscription + await expire_subscription(db, subscription) - + user = await get_user_by_id(db, subscription.user_id) if user and self.bot: await self._send_subscription_expired_notification(user) - + logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'") - + if expired_subscriptions: await self._log_monitoring_event( - db, "expired_subscriptions_processed", - f"Обработано {len(expired_subscriptions)} истёкших подписок", - {"count": len(expired_subscriptions)} + db, + 'expired_subscriptions_processed', + f'Обработано {len(expired_subscriptions)} истёкших подписок', + {'count': len(expired_subscriptions)}, ) - - except Exception as e: - logger.error(f"Ошибка проверки истёкших подписок: {e}") - async def update_remnawave_user( - self, - db: AsyncSession, - subscription: Subscription - ) -> Optional[RemnaWaveUser]: - + except Exception as e: + logger.error(f'Ошибка проверки истёкших подписок: {e}') + + async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None: try: user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: - logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}") + logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}') return None # Обновляем subscription в сессии, чтобы избежать detached instance @@ -301,19 +292,17 @@ class MonitoringService: pass current_time = datetime.utcnow() - is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date > current_time) + is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): + if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: subscription.status = SubscriptionStatus.EXPIRED.value await db.commit() is_active = False logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'") - + if not self.subscription_service.is_configured: logger.warning( - "RemnaWave API не настроен. Пропускаем обновление пользователя %s", + 'RemnaWave API не настроен. Пропускаем обновление пользователя %s', subscription.user_id, ) return None @@ -328,9 +317,7 @@ class MonitoringService: traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb), traffic_limit_strategy=TrafficLimitStrategy.MONTH, description=settings.format_remnawave_user_description( - full_name=user.full_name, - username=user.username, - telegram_id=user.telegram_id + full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), active_internal_squads=subscription.connected_squads, ) @@ -339,43 +326,45 @@ class MonitoringService: update_kwargs['hwid_device_limit'] = hwid_limit updated_user = await api.update_user(**update_kwargs) - + 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 "истёкшим" - logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}") + + status_text = 'активным' if is_active else 'истёкшим' + logger.info(f'✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}') return updated_user - + except RemnaWaveAPIError as e: - logger.error(f"Ошибка обновления RemnaWave пользователя: {e}") + logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') return None except Exception as e: - logger.error(f"Ошибка обновления RemnaWave пользователя: {e}") + logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') return None - + async def _check_expiring_subscriptions(self, db: AsyncSession): try: warning_days = settings.get_autopay_warning_days() - all_processed_users = set() - + all_processed_users = set() + for days in warning_days: expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days) sent_count = 0 - + for subscription in expiring_subscriptions: user = await get_user_by_id(db, subscription.user_id) if not user: continue # Use user.id for key to support both Telegram and email users - user_key = f"user_{user.id}_today" - user_identifier = user.telegram_id or f"email:{user.id}" + user_key = f'user_{user.id}_today' + user_identifier = user.telegram_id or f'email:{user.id}' - if (await notification_sent(db, user.id, subscription.id, "expiring", days) or - user_key in all_processed_users): - logger.debug(f"🔄 Пропускаем дублирование для пользователя {user_identifier} на {days} дней") + if ( + await notification_sent(db, user.id, subscription.id, 'expiring', days) + or user_key in all_processed_users + ): + logger.debug(f'🔄 Пропускаем дублирование для пользователя {user_identifier} на {days} дней') continue should_send = True @@ -384,7 +373,9 @@ class MonitoringService: other_subs = await self._get_expiring_paid_subscriptions(db, other_days) if any(s.user_id == user.id for s in other_subs): should_send = False - logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user_identifier}, есть более срочное на {other_days} дней") + logger.debug( + f'🎯 Пропускаем уведомление на {days} дней для пользователя {user_identifier}, есть более срочное на {other_days} дней' + ) break if not should_send: @@ -398,32 +389,37 @@ class MonitoringService: expires_at=subscription.end_date, ) if success: - await record_notification(db, user.id, subscription.id, "expiring", days) + await record_notification(db, user.id, subscription.id, 'expiring', days) all_processed_users.add(user_key) sent_count += 1 - logger.info(f"✅ Email-пользователю {user.id} отправлено уведомление об истечении подписки через {days} дней") + logger.info( + f'✅ Email-пользователю {user.id} отправлено уведомление об истечении подписки через {days} дней' + ) continue if self.bot: success = await self._send_subscription_expiring_notification(user, subscription, days) if success: - await record_notification(db, user.id, subscription.id, "expiring", days) + await record_notification(db, user.id, subscription.id, 'expiring', days) all_processed_users.add(user_key) sent_count += 1 - logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней") + logger.info( + f'✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней' + ) else: - logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}") - + logger.warning(f'❌ Не удалось отправить уведомление пользователю {user.telegram_id}') + if sent_count > 0: await self._log_monitoring_event( - db, "expiring_notifications_sent", - f"Отправлено {sent_count} уведомлений об истечении через {days} дней", - {"days": days, "count": sent_count} + db, + 'expiring_notifications_sent', + f'Отправлено {sent_count} уведомлений об истечении через {days} дней', + {'days': days, 'count': sent_count}, ) - + except Exception as e: - logger.error(f"Ошибка проверки истекающих подписок: {e}") - + logger.error(f'Ошибка проверки истекающих подписок: {e}') + async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) @@ -441,35 +437,38 @@ class MonitoringService: Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_trial == True, Subscription.end_date <= threshold_time, - Subscription.end_date > datetime.utcnow() + Subscription.end_date > datetime.utcnow(), ) ) ) trial_expiring = result.scalars().all() - + for subscription in trial_expiring: user = subscription.user if not user: continue - if await notification_sent(db, user.id, subscription.id, "trial_2h"): + if await notification_sent(db, user.id, subscription.id, 'trial_2h'): continue if self.bot: success = await self._send_trial_ending_notification(user, subscription) if success: - await record_notification(db, user.id, subscription.id, "trial_2h") - logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа") - + await record_notification(db, user.id, subscription.id, 'trial_2h') + logger.info( + f'🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа' + ) + if trial_expiring: await self._log_monitoring_event( - db, "trial_expiring_notifications_sent", - f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок", - {"count": len(trial_expiring)} + db, + 'trial_expiring_notifications_sent', + f'Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок', + {'count': len(trial_expiring)}, ) - + except Exception as e: - logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") + logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}') async def _check_trial_inactivity_notifications(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): @@ -513,40 +512,41 @@ class MonitoringService: time_since_start = now - start_date - if (NotificationSettingsService.is_trial_inactive_1h_enabled() - and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + if NotificationSettingsService.is_trial_inactive_1h_enabled() and timedelta( + hours=1 + ) <= time_since_start < timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_1h'): success = await self._send_trial_inactive_notification(user, subscription, 1) if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + await record_notification(db, user.id, subscription.id, 'trial_inactive_1h') sent_1h += 1 - if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): - if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta( + hours=24 + ): + if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_24h'): success = await self._send_trial_inactive_notification(user, subscription, 24) if success: - await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + await record_notification(db, user.id, subscription.id, 'trial_inactive_24h') sent_24h += 1 if sent_1h or sent_24h: await self._log_monitoring_event( db, - "trial_inactivity_notifications", - f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", - {"sent_1h": sent_1h, "sent_24h": sent_24h}, + 'trial_inactivity_notifications', + f'Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа', + {'sent_1h': sent_1h, 'sent_24h': sent_24h}, ) except Exception as e: - logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}') async def _check_trial_channel_subscriptions(self, db: AsyncSession): if not settings.CHANNEL_IS_REQUIRED_SUB: return if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: - logger.debug( - "ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется" - ) + logger.debug('ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется') return channel_id = settings.CHANNEL_SUB_ID @@ -554,7 +554,7 @@ class MonitoringService: return if not self.bot: - logger.debug("⚠️ Пропускаем проверку подписки на канал — бот недоступен") + logger.debug('⚠️ Пропускаем проверку подписки на канал — бот недоступен') return try: @@ -605,7 +605,7 @@ class MonitoringService: ) except TelegramForbiddenError as error: logger.error( - "❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)", + '❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)', user.telegram_id, channel_id, error, @@ -614,28 +614,24 @@ class MonitoringService: except TelegramBadRequest as error: # PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально logger.warning( - "⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s", + '⚠️ Ошибка Telegram при проверке подписки пользователя %s: %s', user.telegram_id, error, ) continue except Exception as error: logger.error( - "❌ Неожиданная ошибка при проверке подписки пользователя %s: %s", + '❌ Неожиданная ошибка при проверке подписки пользователя %s: %s', user.telegram_id, error, ) continue - if ( - subscription.status == SubscriptionStatus.ACTIVE.value - and subscription.is_trial - and not is_member - ): + if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member: subscription = await deactivate_subscription(db, subscription) disabled_count += 1 logger.info( - "🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала", + '🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала', user.telegram_id, subscription.id, ) @@ -645,7 +641,7 @@ class MonitoringService: await self.subscription_service.disable_remnawave_user(user.remnawave_uuid) except Exception as api_error: logger.error( - "❌ Не удалось отключить пользователя RemnaWave %s: %s", + '❌ Не удалось отключить пользователя RemnaWave %s: %s', user.remnawave_uuid, api_error, ) @@ -655,7 +651,7 @@ class MonitoringService: db, user.id, subscription.id, - "trial_channel_unsubscribed", + 'trial_channel_unsubscribed', ): sent = await self._send_trial_channel_unsubscribed_notification(user) if sent: @@ -663,13 +659,9 @@ class MonitoringService: db, user.id, subscription.id, - "trial_channel_unsubscribed", + 'trial_channel_unsubscribed', ) - elif ( - subscription.status == SubscriptionStatus.DISABLED.value - and subscription.is_trial - and is_member - ): + elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member: subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = datetime.utcnow() await db.commit() @@ -677,7 +669,7 @@ class MonitoringService: restored_count += 1 logger.info( - "✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал", + '✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал', user.telegram_id, subscription.id, ) @@ -689,7 +681,7 @@ class MonitoringService: await self.subscription_service.create_remnawave_user(db, subscription) except Exception as api_error: logger.error( - "❌ Не удалось обновить RemnaWave пользователя %s: %s", + '❌ Не удалось обновить RemnaWave пользователя %s: %s', user.telegram_id, api_error, ) @@ -697,30 +689,26 @@ class MonitoringService: await clear_notification_by_type( db, subscription.id, - "trial_channel_unsubscribed", + 'trial_channel_unsubscribed', ) if disabled_count or restored_count: await self._log_monitoring_event( db, - "trial_channel_subscription_check", + 'trial_channel_subscription_check', ( - "Проверено {total} триальных подписок: отключено {disabled}, " - "восстановлено {restored}" - ).format( - total=len(subscriptions), - disabled=disabled_count, - restored=restored_count, + f'Проверено {len(subscriptions)} триальных подписок: отключено {disabled_count}, ' + f'восстановлено {restored_count}' ), { - "checked": 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}") + logger.error(f'Ошибка проверки подписки на канал для триальных пользователей: {error}') async def _check_expired_subscription_followups(self, db: AsyncSession): if not NotificationSettingsService.are_notifications_globally_enabled(): @@ -749,8 +737,7 @@ class MonitoringService: # Исключаем суточные тарифы - для них отдельная логика subscriptions = [ - sub for sub in all_subscriptions - if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + sub for sub in all_subscriptions if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) ] sent_day1 = 0 @@ -773,26 +760,26 @@ class MonitoringService: # Day 1 reminder if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: - if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + if not await notification_sent(db, user.id, subscription.id, 'expired_1d'): success = await self._send_expired_day1_notification(user, subscription) if success: - await record_notification(db, user.id, subscription.id, "expired_1d") + await record_notification(db, user.id, subscription.id, 'expired_1d') sent_day1 += 1 # Second wave (2-3 days) discount if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + if not await notification_sent(db, user.id, subscription.id, 'expired_discount_wave2'): percent = NotificationSettingsService.get_second_wave_discount_percent() valid_hours = NotificationSettingsService.get_second_wave_valid_hours() offer = await upsert_discount_offer( db, user_id=user.id, subscription_id=subscription.id, - notification_type="expired_discount_wave2", + notification_type='expired_discount_wave2', discount_percent=percent, bonus_amount_kopeks=0, valid_hours=valid_hours, - effect_type="percent_discount", + effect_type='percent_discount', ) success = await self._send_expired_discount_notification( user, @@ -800,28 +787,28 @@ class MonitoringService: percent, offer.expires_at, offer.id, - "second", + 'second', ) if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + await record_notification(db, user.id, subscription.id, 'expired_discount_wave2') sent_wave2 += 1 # Third wave (N days) discount if NotificationSettingsService.is_third_wave_enabled(): trigger_days = NotificationSettingsService.get_third_wave_trigger_days() if trigger_days <= days_since < trigger_days + 1: - if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + if not await notification_sent(db, user.id, subscription.id, 'expired_discount_wave3'): percent = NotificationSettingsService.get_third_wave_discount_percent() valid_hours = NotificationSettingsService.get_third_wave_valid_hours() offer = await upsert_discount_offer( db, user_id=user.id, subscription_id=subscription.id, - notification_type="expired_discount_wave3", + notification_type='expired_discount_wave3', discount_percent=percent, bonus_amount_kopeks=0, valid_hours=valid_hours, - effect_type="percent_discount", + effect_type='percent_discount', ) success = await self._send_expired_discount_notification( user, @@ -829,35 +816,29 @@ class MonitoringService: percent, offer.expires_at, offer.id, - "third", + 'third', trigger_days=trigger_days, ) if success: - await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + await record_notification(db, user.id, subscription.id, 'expired_discount_wave3') sent_wave3 += 1 if sent_day1 or sent_wave2 or sent_wave3: await self._log_monitoring_event( db, - "expired_followups_sent", - ( - "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( - sent_day1, - sent_wave2, - sent_wave3, - ) - ), + 'expired_followups_sent', + (f'Follow-ups: 1д={sent_day1}, скидка 2-3д={sent_wave2}, скидка N={sent_wave3}'), { - "day1": sent_day1, - "wave2": sent_wave2, - "wave3": sent_wave3, + 'day1': sent_day1, + 'wave2': sent_wave2, + 'wave3': sent_wave3, }, ) except Exception as e: - logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + logger.error(f'Ошибка проверки напоминаний об истекшей подписке: {e}') - async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> list[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -872,42 +853,41 @@ class MonitoringService: Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.is_trial == False, Subscription.end_date > current_time, - Subscription.end_date <= threshold_date + Subscription.end_date <= threshold_date, ) ) ) - logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней") - logger.debug(f"📅 Текущее время: {current_time}") - logger.debug(f"📅 Пороговая дата: {threshold_date}") + logger.debug(f'🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней') + logger.debug(f'📅 Текущее время: {current_time}') + logger.debug(f'📅 Пороговая дата: {threshold_date}') all_subscriptions = result.scalars().all() # Исключаем суточные тарифы - для них отдельная логика списания subscriptions = [ - sub for sub in all_subscriptions - if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) + sub for sub in all_subscriptions if not (sub.tariff and getattr(sub.tariff, 'is_daily', False)) ] excluded_count = len(all_subscriptions) - len(subscriptions) if excluded_count > 0: - logger.debug(f"🔄 Исключено {excluded_count} суточных подписок из уведомлений") + logger.debug(f'🔄 Исключено {excluded_count} суточных подписок из уведомлений') - logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений") + logger.info(f'📊 Найдено {len(subscriptions)} платных подписок для уведомлений') return subscriptions - + @staticmethod - def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int: + def _get_user_promo_offer_discount_percent(user: User | None) -> int: if not user: return 0 try: - percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): return 0 - expires_at = getattr(user, "promo_offer_discount_expires_at", None) + expires_at = getattr(user, 'promo_offer_discount_expires_at', None) if expires_at and expires_at <= datetime.utcnow(): return 0 @@ -919,29 +899,29 @@ class MonitoringService: if percent <= 0: return - source = getattr(user, "promo_offer_discount_source", None) + source = getattr(user, 'promo_offer_discount_source', None) log_payload = { - "offer_id": None, - "percent": percent, - "source": source, - "effect_type": None, + 'offer_id': None, + 'percent': percent, + 'source': source, + 'effect_type': None, } try: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception as lookup_error: # pragma: no cover - defensive logging logger.warning( - "Failed to resolve latest claimed promo offer for user %s: %s", + 'Failed to resolve latest claimed promo offer for user %s: %s', user.id, lookup_error, ) offer = None if offer: - log_payload["offer_id"] = offer.id - log_payload["effect_type"] = offer.effect_type - if not log_payload["percent"] and offer.discount_percent: - log_payload["percent"] = offer.discount_percent + log_payload['offer_id'] = offer.id + log_payload['effect_type'] = offer.effect_type + if not log_payload['percent'] and offer.discount_percent: + log_payload['percent'] = offer.discount_percent user.promo_offer_discount_percent = 0 user.promo_offer_discount_source = None @@ -955,16 +935,16 @@ class MonitoringService: await log_promo_offer_action( db, user_id=user.id, - offer_id=log_payload.get("offer_id"), - action="consumed", - source=log_payload.get("source"), - percent=log_payload.get("percent"), - effect_type=log_payload.get("effect_type"), - details={"reason": "autopay_consumed"}, + offer_id=log_payload.get('offer_id'), + action='consumed', + source=log_payload.get('source'), + percent=log_payload.get('percent'), + effect_type=log_payload.get('effect_type'), + details={'reason': 'autopay_consumed'}, ) except Exception as log_error: # pragma: no cover - defensive logging logger.warning( - "Failed to record promo offer autopay log for user %s: %s", + 'Failed to record promo offer autopay log for user %s: %s', user.id, log_error, ) @@ -972,14 +952,14 @@ class MonitoringService: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer autopay log failure: %s", + 'Failed to rollback session after promo offer autopay log failure: %s', rollback_error, ) async def _process_autopayments(self, db: AsyncSession): try: current_time = datetime.utcnow() - + result = await db.execute( select(Subscription) .options( @@ -992,32 +972,30 @@ class MonitoringService: and_( Subscription.status == SubscriptionStatus.ACTIVE.value, Subscription.autopay_enabled == True, - Subscription.is_trial == False + Subscription.is_trial == False, ) ) ) all_autopay_subscriptions = result.scalars().all() - + autopay_subscriptions = [] for sub in all_autopay_subscriptions: days_before_expiry = (sub.end_date - current_time).days if days_before_expiry <= min(sub.autopay_days_before, 3): autopay_subscriptions.append(sub) - + processed_count = 0 failed_count = 0 - + for subscription in autopay_subscriptions: user = subscription.user if not user: continue - user_identifier = user.telegram_id or f"email:{user.id}" + user_identifier = user.telegram_id or f'email:{user.id}' # Правильный расчет стоимости продления с учетом всех параметров подписки - renewal_cost = await self.subscription_service.calculate_renewal_price( - subscription, 30, db, user=user - ) + renewal_cost = await self.subscription_service.calculate_renewal_price(subscription, 30, db, user=user) promo_discount_percent = self._get_user_promo_offer_discount_percent(user) charge_amount = renewal_cost promo_discount_value = 0 @@ -1028,15 +1006,12 @@ class MonitoringService: promo_discount_percent, ) - autopay_key = f"autopay_{user.id}_{subscription.id}" + autopay_key = f'autopay_{user.id}_{subscription.id}' if autopay_key in self._notified_users: continue if user.balance_kopeks >= charge_amount: - success = await subtract_user_balance( - db, user, charge_amount, - "Автопродление подписки" - ) + success = await subtract_user_balance(db, user, charge_amount, 'Автопродление подписки') if success: await extend_subscription(db, subscription, 30) @@ -1044,7 +1019,7 @@ class MonitoringService: db, subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="автопродление подписки", + reset_reason='автопродление подписки', ) if promo_discount_value > 0: @@ -1064,7 +1039,7 @@ class MonitoringService: processed_count += 1 self._notified_users.add(autopay_key) logger.info( - "💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)", + '💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)', user_identifier, charge_amount, promo_discount_percent, @@ -1076,9 +1051,9 @@ class MonitoringService: elif not user.telegram_id: await notification_delivery_service.notify_autopay_failed( user=user, - reason="Ошибка списания средств", + reason='Ошибка списания средств', ) - logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user_identifier}") + logger.warning(f'💳 Ошибка списания средств для автопродления пользователя {user_identifier}') else: failed_count += 1 if user.telegram_id and self.bot: @@ -1086,20 +1061,21 @@ class MonitoringService: elif not user.telegram_id: await notification_delivery_service.notify_autopay_failed( user=user, - reason="Недостаточно средств на балансе", + reason='Недостаточно средств на балансе', ) - logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user_identifier}") - + logger.warning(f'💳 Недостаточно средств для автопродления у пользователя {user_identifier}') + if processed_count > 0 or failed_count > 0: await self._log_monitoring_event( - db, "autopayments_processed", - f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}", - {"processed": processed_count, "failed": failed_count} + db, + 'autopayments_processed', + f'Автоплатежи: успешно {processed_count}, неудачно {failed_count}', + {'processed': processed_count, 'failed': failed_count}, ) - + except Exception as e: - logger.error(f"Ошибка обработки автоплатежей: {e}") - + logger.error(f'Ошибка обработки автоплатежей: {e}') + async def _send_subscription_expired_notification(self, user: User) -> bool: try: message = """ @@ -1109,126 +1085,136 @@ class MonitoringService: 🔧 Доступ к серверам заблокирован до продления. """ - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")], - [build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")], - ]) + from aiogram.types import InlineKeyboardMarkup + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [build_miniapp_or_callback_button(text='💎 Купить подписку', callback_data='menu_buy')], + [build_miniapp_or_callback_button(text='💳 Пополнить баланс', callback_data='balance_topup')], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "уведомление об истечении подписки"): + if self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'): return True logger.error( - "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + 'Ошибка отправки уведомления об истечении подписки пользователю %s: %s', user.telegram_id, e, ) return False - + async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool: try: from app.utils.formatters import format_days_declension - + texts = get_texts(user.language) days_text = format_days_declension(days, user.language) - + if settings.ENABLE_AUTOPAY: if subscription.autopay_enabled: - autopay_status = "✅ Включен - подписка продлится автоматически" - action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}" + autopay_status = '✅ Включен - подписка продлится автоматически' + action_text = ( + f'💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}' + ) else: - autopay_status = "❌ Отключен - не забудьте продлить вручную!" - action_text = "💡 Включите автоплатеж или продлите подписку вручную" + autopay_status = '❌ Отключен - не забудьте продлить вручную!' + action_text = '💡 Включите автоплатеж или продлите подписку вручную' else: - autopay_status = "❌ Отключен - не забудьте продлить вручную!" - action_text = "💡 Продлите подписку вручную" - + autopay_status = '❌ Отключен - не забудьте продлить вручную!' + action_text = '💡 Продлите подписку вручную' + message = f""" ⚠️ Подписка истекает через {days_text}! -Ваша платная подписка истекает {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}. +Ваша платная подписка истекает {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}. 💳 Автоплатеж: {autopay_status} {action_text} """ - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button(text="⏰ Продлить подписку", callback_data="subscription_extend")], - [build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")], - [build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")], - ]) + from aiogram.types import InlineKeyboardMarkup + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text='⏰ Продлить подписку', callback_data='subscription_extend' + ) + ], + [build_miniapp_or_callback_button(text='💳 Пополнить баланс', callback_data='balance_topup')], + [build_miniapp_or_callback_button(text='📱 Моя подписка', callback_data='menu_subscription')], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "уведомление об истекающей подписке"): + if self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'): return True logger.error( - "Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки уведомления об истечении подписки пользователю %s: %s", + 'Ошибка отправки уведомления об истечении подписки пользователю %s: %s', user.telegram_id, e, ) return False - + async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: - texts = get_texts(user.language) + get_texts(user.language) # Рассчитываем минимальную цену за подписку с минимальной конфигурацией - from app.config import settings, PERIOD_PRICES + from app.config import PERIOD_PRICES, settings from app.utils.pricing_utils import apply_percentage_discount # Базовая цена за 30 дней base_price_original = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) - + # Применяем скидку промогруппы для категории "period" - promo_group_discount = user.get_promo_discount("period", 30) if user else 0 + promo_group_discount = user.get_promo_discount('period', 30) if user else 0 # Применяем пользовательскую промо-скидку (если есть) user_discount_percent = self._get_user_promo_offer_discount_percent(user) - + # Общая скидка - максимальная из промогруппы и пользовательской total_discount_percent = max(promo_group_discount, user_discount_percent) - + base_price, _ = apply_percentage_discount(base_price_original, total_discount_percent) # Добавляем цену за трафик (если фиксированный трафик включён) if settings.is_traffic_fixed(): traffic_price = settings.get_traffic_price(settings.get_fixed_traffic_limit()) # Применяем скидки на трафик - traffic_discount = user.get_promo_discount("traffic", 30) if user else 0 + traffic_discount = user.get_promo_discount('traffic', 30) if user else 0 traffic_price, _ = apply_percentage_discount(traffic_price, traffic_discount) else: traffic_price = 0 # Трафик не фиксирован, цена включена в базовую @@ -1241,17 +1227,17 @@ class MonitoringService: # Если нет явной минимальной цены, используем базовую цену # В реальных условиях цена сервера будет определяться в ходе оформления подписки min_server_price = 0 - + # Добавляем цену за устройства (если больше базового лимита) # В минимальной конфигурации - базовый лимит, без доп. устройств device_limit = settings.DEFAULT_DEVICE_LIMIT additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) - devices_price = additional_devices * settings.PRICE_PER_DEVICE + additional_devices * settings.PRICE_PER_DEVICE # Для простоты и правильной работы без обращения к БД, рассчитываем минимальную цену как: # базовая цена + минимальная цена за трафик (если есть фиксированный) min_server_price = 0 # для минимальной конфигурации с 1 сервером используем 0 или минимальную известную - + # Попробуем получить минимальную цену сервера из настроек или используем подходящее значение # Находим минимальную возможную цену из возможных цен серверов # В упрощенном варианте используем базовую конфигурацию: базовая цена + трафик @@ -1273,34 +1259,36 @@ class MonitoringService: ⚡️ Успейте оформить до окончания тестового периода! """ - - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button(text="💎 Купить подписку", callback_data="menu_buy")], - [build_miniapp_or_callback_button(text="💰 Пополнить баланс", callback_data="balance_topup")], - ]) + from aiogram.types import InlineKeyboardMarkup + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [build_miniapp_or_callback_button(text='💎 Купить подписку', callback_data='menu_buy')], + [build_miniapp_or_callback_button(text='💰 Пополнить баланс', callback_data='balance_topup')], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "уведомление о завершении тестовой подписки"): + if self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'): return True logger.error( - "Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s", + 'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s', user.telegram_id, e, ) @@ -1311,61 +1299,71 @@ class MonitoringService: texts = get_texts(user.language) if hours >= 24: template = texts.get( - "TRIAL_INACTIVE_24H", + 'TRIAL_INACTIVE_24H', ( - "⏳ Вы ещё не подключились к VPN\n\n" - "Прошли сутки с активации тестового периода, но трафик не зафиксирован." - "\n\nНажмите кнопку ниже, чтобы подключиться." + '⏳ Вы ещё не подключились к VPN\n\n' + 'Прошли сутки с активации тестового периода, но трафик не зафиксирован.' + '\n\nНажмите кнопку ниже, чтобы подключиться.' ), ) else: template = texts.get( - "TRIAL_INACTIVE_1H", + 'TRIAL_INACTIVE_1H', ( - "⏳ Прошёл час, а подключения нет\n\n" - "Если возникли сложности с запуском — воспользуйтесь инструкциями." + '⏳ Прошёл час, а подключения нет\n\n' + 'Если возникли сложности с запуском — воспользуйтесь инструкциями.' ), ) message = template.format( price=settings.format_price(settings.PRICE_30_DAYS), - end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), ) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button( - text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), - callback_data="subscription_connect", - )], - [build_miniapp_or_callback_button( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - )], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), + callback_data='subscription_connect', + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support' + ) + ], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "уведомление о бездействии на тесте"): + if self._handle_unreachable_user(user, exc, 'уведомление о бездействии на тесте'): return True logger.error( - "Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s", + 'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s', user.telegram_id, e, ) @@ -1375,25 +1373,25 @@ class MonitoringService: try: texts = get_texts(user.language) template = texts.get( - "TRIAL_CHANNEL_UNSUBSCRIBED", + 'TRIAL_CHANNEL_UNSUBSCRIBED', ( - "🚫 Доступ приостановлен\n\n" - "Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n" - "Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ." + '🚫 Доступ приостановлен\n\n' + 'Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n' + 'Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.' ), ) - check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался") + check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался') message = template.format(check_button=check_button) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup buttons = [] if settings.CHANNEL_LINK: buttons.append( [ InlineKeyboardButton( - text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"), + text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'), url=settings.CHANNEL_LINK, ) ] @@ -1402,7 +1400,7 @@ class MonitoringService: [ InlineKeyboardButton( text=check_button, - callback_data="sub_channel_check", + callback_data='sub_channel_check', ) ] ) @@ -1412,23 +1410,23 @@ class MonitoringService: await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"): + if self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'): return True logger.error( - "Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as error: logger.error( - "Ошибка отправки уведомления об отписке от канала пользователю %s: %s", + 'Ошибка отправки уведомления об отписке от канала пользователю %s: %s', user.telegram_id, error, ) @@ -1438,51 +1436,61 @@ class MonitoringService: try: texts = get_texts(user.language) template = texts.get( - "SUBSCRIPTION_EXPIRED_1D", + 'SUBSCRIPTION_EXPIRED_1D', ( - "⛔ Подписка закончилась\n\n" - "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + '⛔ Подписка закончилась\n\n' + 'Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис.' ), ) message = template.format( - end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"), + end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'), price=settings.format_price(settings.PRICE_30_DAYS), ) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", - )], - [build_miniapp_or_callback_button( - text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), - callback_data="balance_topup", - )], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('BALANCE_TOPUP', '💳 Пополнить баланс'), + callback_data='balance_topup', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support' + ) + ], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "напоминание об истекшей подписке"): + if self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'): return True logger.error( - "Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s", + 'Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки напоминания об истекшей подписке пользователю %s: %s", + 'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s', user.telegram_id, e, ) @@ -1501,66 +1509,80 @@ class MonitoringService: try: texts = get_texts(user.language) - if wave == "second": + if wave == 'second': template = texts.get( - "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + 'SUBSCRIPTION_EXPIRED_SECOND_WAVE', ( - "🔥 Скидка {percent}% на продление\n\n" - "Активируйте предложение, чтобы получить дополнительную скидку. " - "Она суммируется с вашей промогруппой и действует до {expires_at}." + '🔥 Скидка {percent}% на продление\n\n' + 'Активируйте предложение, чтобы получить дополнительную скидку. ' + 'Она суммируется с вашей промогруппой и действует до {expires_at}.' ), ) else: template = texts.get( - "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + 'SUBSCRIPTION_EXPIRED_THIRD_WAVE', ( - "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. " - "Она суммируется с промогруппой и действует до {expires_at}." + '🎁 Индивидуальная скидка {percent}%\n\n' + 'Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. ' + 'Она суммируется с промогруппой и действует до {expires_at}.' ), ) message = template.format( percent=percent, - expires_at=format_local_datetime(expires_at, "%d.%m.%Y %H:%M"), - trigger_days=trigger_days or "", + expires_at=format_local_datetime(expires_at, '%d.%m.%Y %H:%M'), + trigger_days=trigger_days or '', ) - from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], - [build_miniapp_or_callback_button( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", - )], - [build_miniapp_or_callback_button( - text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), - callback_data="balance_topup", - )], - [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], - ]) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text='🎁 Получить скидку', callback_data=f'claim_discount_{offer_id}' + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', + ) + ], + [ + build_miniapp_or_callback_button( + text=texts.t('BALANCE_TOPUP', '💳 Пополнить баланс'), + callback_data='balance_topup', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support' + ) + ], + ] + ) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) return True except (TelegramForbiddenError, TelegramBadRequest) as exc: - if self._handle_unreachable_user(user, exc, "скидочное уведомление"): + if self._handle_unreachable_user(user, exc, 'скидочное уведомление'): return True logger.error( - "Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s", + 'Ошибка Telegram API при отправке скидочного уведомления пользователю %s: %s', user.telegram_id, exc, ) return False except Exception as e: logger.error( - "Ошибка отправки скидочного уведомления пользователю %s: %s", + 'Ошибка отправки скидочного уведомления пользователю %s: %s', user.telegram_id, e, ) @@ -1569,25 +1591,22 @@ class MonitoringService: async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) - message = texts.AUTOPAY_SUCCESS.format( - days=days, - amount=settings.format_price(amount) - ) + message = texts.AUTOPAY_SUCCESS.format(days=days, amount=settings.format_price(amount)) await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', ) except (TelegramForbiddenError, TelegramBadRequest) as exc: - if not self._handle_unreachable_user(user, exc, "уведомление об успешном автоплатеже"): + if not self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'): logger.error( - "Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления об автоплатеже пользователю %s: %s', user.telegram_id, exc, ) except Exception as e: logger.error( - "Ошибка отправки уведомления об автоплатеже пользователю %s: %s", + 'Ошибка отправки уведомления об автоплатеже пользователю %s: %s', user.telegram_id, e, ) @@ -1596,98 +1615,100 @@ class MonitoringService: try: texts = get_texts(user.language) message = texts.AUTOPAY_FAILED.format( - balance=settings.format_price(balance), - required=settings.format_price(required) + balance=settings.format_price(balance), required=settings.format_price(required) ) - + from aiogram.types import InlineKeyboardMarkup - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [build_miniapp_or_callback_button(text="💳 Пополнить баланс", callback_data="balance_topup")], - [build_miniapp_or_callback_button(text="📱 Моя подписка", callback_data="menu_subscription")], - ]) - + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [build_miniapp_or_callback_button(text='💳 Пополнить баланс', callback_data='balance_topup')], + [build_miniapp_or_callback_button(text='📱 Моя подписка', callback_data='menu_subscription')], + ] + ) + await self._send_message_with_logo( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except (TelegramForbiddenError, TelegramBadRequest) as exc: - if not self._handle_unreachable_user(user, exc, "уведомление о неудачном автоплатеже"): + if not self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'): logger.error( - "Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s", + 'Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю %s: %s', user.telegram_id, exc, ) except Exception as e: logger.error( - "Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s", + 'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s', user.telegram_id, e, ) - + async def _cleanup_inactive_users(self, db: AsyncSession): try: now = datetime.utcnow() - if now.hour != 3: + if now.hour != 3: return - + inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS) deleted_count = 0 - + for user in inactive_users: if not user.subscription or not user.subscription.is_active: success = await delete_user(db, user) if success: deleted_count += 1 - + if deleted_count > 0: await self._log_monitoring_event( - db, "inactive_users_cleanup", - f"Удалено {deleted_count} неактивных пользователей", - {"deleted_count": deleted_count} + db, + 'inactive_users_cleanup', + f'Удалено {deleted_count} неактивных пользователей', + {'deleted_count': deleted_count}, ) - logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей") - + logger.info(f'🗑️ Удалено {deleted_count} неактивных пользователей') + except Exception as e: - logger.error(f"Ошибка очистки неактивных пользователей: {e}") - + logger.error(f'Ошибка очистки неактивных пользователей: {e}') + async def _sync_with_remnawave(self, db: AsyncSession): try: now = datetime.utcnow() if now.minute != 0: return - + if not self.subscription_service.is_configured: - logger.warning("RemnaWave API не настроен. Пропускаем синхронизацию") + logger.warning('RemnaWave API не настроен. Пропускаем синхронизацию') return async with self.subscription_service.get_api_client() as api: system_stats = await api.get_system_stats() - + await self._log_monitoring_event( - db, "remnawave_sync", - "Синхронизация с RemnaWave завершена", - {"stats": system_stats} + db, 'remnawave_sync', 'Синхронизация с RemnaWave завершена', {'stats': system_stats} ) - + except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave: {e}") + logger.error(f'Ошибка синхронизации с RemnaWave: {e}') await self._log_monitoring_event( - db, "remnawave_sync_error", - f"Ошибка синхронизации с RemnaWave: {str(e)}", - {"error": str(e)}, - is_success=False + db, + 'remnawave_sync_error', + f'Ошибка синхронизации с RemnaWave: {e!s}', + {'error': str(e)}, + is_success=False, ) - + async def _check_ticket_sla(self, db: AsyncSession): try: # Quick guards # Allow runtime toggle from SupportSettingsService try: from app.services.support_settings_service import SupportSettingsService + sla_enabled_runtime = SupportSettingsService.get_sla_enabled() except Exception: sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True) @@ -1699,8 +1720,10 @@ class MonitoringService: return from datetime import datetime, timedelta + try: from app.services.support_settings_service import SupportSettingsService + sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes())) except Exception: sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5))) @@ -1739,18 +1762,18 @@ class MonitoringService: 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 "отсутствует" + 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"⏰ Ожидание ответа на тикет превышено\n\n" - f"🆔 ID: {ticket.id}\n" - f"👤 Пользователь: {full_name}\n" - f"🆔 Telegram ID: {telegram_id_display}\n" - f"📱 Username: @{username_display}\n" - f"📝 Заголовок: {title or '—'}\n" - f"⏱️ Ожидает ответа: {waited_minutes} мин\n" + f'⏰ Ожидание ответа на тикет превышено\n\n' + f'🆔 ID: {ticket.id}\n' + f'👤 Пользователь: {full_name}\n' + f'🆔 Telegram ID: {telegram_id_display}\n' + f'📱 Username: @{username_display}\n' + f'📝 Заголовок: {title or "—"}\n' + f'⏱️ Ожидает ответа: {waited_minutes} мин\n' ) sent = await service.send_ticket_event_notification(text) @@ -1760,17 +1783,17 @@ class MonitoringService: # commit after each to persist timestamp and avoid duplicate reminders on crash await db.commit() except Exception as notify_error: - logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}") + logger.error(f'Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}') if reminders_sent > 0: await self._log_monitoring_event( db, - "ticket_sla_reminders_sent", - f"Отправлено {reminders_sent} SLA-напоминаний по тикетам", - {"count": reminders_sent}, + 'ticket_sla_reminders_sent', + f'Отправлено {reminders_sent} SLA-напоминаний по тикетам', + {'count': reminders_sent}, ) except Exception as e: - logger.error(f"Ошибка проверки SLA тикетов: {e}") + logger.error(f'Ошибка проверки SLA тикетов: {e}') async def _sla_loop(self): try: @@ -1784,180 +1807,144 @@ class MonitoringService: await self._check_ticket_sla(db) await db.commit() except Exception as e: - logger.error(f"Ошибка в SLA-проверке: {e}") + logger.error(f'Ошибка в SLA-проверке: {e}') await db.rollback() except asyncio.CancelledError: break except Exception as e: - logger.error(f"Ошибка в SLA-цикле: {e}") + logger.error(f'Ошибка в SLA-цикле: {e}') await asyncio.sleep(interval_seconds) async def _log_monitoring_event( - self, - db: AsyncSession, - event_type: str, - message: str, - data: Dict[str, Any] = None, - is_success: bool = True + self, db: AsyncSession, event_type: str, message: str, data: dict[str, Any] = None, is_success: bool = True ): try: - log_entry = MonitoringLog( - event_type=event_type, - message=message, - data=data or {}, - is_success=is_success - ) - + log_entry = MonitoringLog(event_type=event_type, message=message, data=data or {}, is_success=is_success) + db.add(log_entry) await db.commit() - - except Exception as e: - logger.error(f"Ошибка логирования события мониторинга: {e}") - async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]: + except Exception as e: + logger.error(f'Ошибка логирования события мониторинга: {e}') + + async def get_monitoring_status(self, db: AsyncSession) -> dict[str, Any]: try: - from sqlalchemy import select, desc - + from sqlalchemy import desc, select + recent_events_result = await db.execute( - select(MonitoringLog) - .order_by(desc(MonitoringLog.created_at)) - .limit(10) + select(MonitoringLog).order_by(desc(MonitoringLog.created_at)).limit(10) ) recent_events = recent_events_result.scalars().all() - + yesterday = datetime.utcnow() - timedelta(days=1) - - events_24h_result = await db.execute( - select(MonitoringLog) - .where(MonitoringLog.created_at >= yesterday) - ) + + events_24h_result = await db.execute(select(MonitoringLog).where(MonitoringLog.created_at >= yesterday)) events_24h = events_24h_result.scalars().all() - + successful_events = sum(1 for event in events_24h if event.is_success) failed_events = sum(1 for event in events_24h if not event.is_success) - + return { - "is_running": self.is_running, - "last_update": datetime.utcnow(), - "recent_events": [ + 'is_running': self.is_running, + 'last_update': datetime.utcnow(), + 'recent_events': [ { - "type": event.event_type, - "message": event.message, - "success": event.is_success, - "created_at": event.created_at + 'type': event.event_type, + 'message': event.message, + 'success': event.is_success, + 'created_at': event.created_at, } for event in recent_events ], - "stats_24h": { - "total_events": len(events_24h), - "successful": successful_events, - "failed": failed_events, - "success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0 - } + 'stats_24h': { + 'total_events': len(events_24h), + 'successful': successful_events, + 'failed': failed_events, + 'success_rate': round(successful_events / len(events_24h) * 100, 1) if events_24h else 0, + }, } - + except Exception as e: - logger.error(f"Ошибка получения статуса мониторинга: {e}") + logger.error(f'Ошибка получения статуса мониторинга: {e}') return { - "is_running": self.is_running, - "last_update": datetime.utcnow(), - "recent_events": [], - "stats_24h": { - "total_events": 0, - "successful": 0, - "failed": 0, - "success_rate": 0 - } + 'is_running': self.is_running, + 'last_update': datetime.utcnow(), + 'recent_events': [], + 'stats_24h': {'total_events': 0, 'successful': 0, 'failed': 0, 'success_rate': 0}, } - - async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]: + + async def force_check_subscriptions(self, db: AsyncSession) -> dict[str, int]: try: expired_subscriptions = await get_expired_subscriptions(db) expired_count = 0 - + for subscription in expired_subscriptions: await deactivate_subscription(db, subscription) expired_count += 1 - + expiring_subscriptions = await get_expiring_subscriptions(db, 1) expiring_count = len(expiring_subscriptions) - + autopay_subscriptions = await get_subscriptions_for_autopay(db) autopay_processed = 0 - + for subscription in autopay_subscriptions: user = await get_user_by_id(db, subscription.user_id) if user and user.balance_kopeks >= settings.PRICE_30_DAYS: autopay_processed += 1 - + await self._log_monitoring_event( - db, "manual_check_subscriptions", - f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}", - { - "expired": expired_count, - "expiring": expiring_count, - "autopay_ready": autopay_processed - } + db, + 'manual_check_subscriptions', + f'Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}', + {'expired': expired_count, 'expiring': expiring_count, 'autopay_ready': autopay_processed}, ) - - return { - "expired": expired_count, - "expiring": expiring_count, - "autopay_ready": autopay_processed - } - + + return {'expired': expired_count, 'expiring': expiring_count, 'autopay_ready': autopay_processed} + except Exception as e: - logger.error(f"Ошибка принудительной проверки подписок: {e}") - return {"expired": 0, "expiring": 0, "autopay_ready": 0} - + logger.error(f'Ошибка принудительной проверки подписок: {e}') + return {'expired': 0, 'expiring': 0, 'autopay_ready': 0} + async def get_monitoring_logs( - self, - db: AsyncSession, - limit: int = 50, - event_type: Optional[str] = None, - page: int = 1, - per_page: int = 20 - ) -> List[Dict[str, Any]]: + self, db: AsyncSession, limit: int = 50, event_type: str | None = None, page: int = 1, per_page: int = 20 + ) -> list[dict[str, Any]]: try: - from sqlalchemy import select, desc - + from sqlalchemy import desc, select + query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at)) - + if event_type: query = query.where(MonitoringLog.event_type == event_type) - + if page > 1 or per_page != 20: offset = (page - 1) * per_page query = query.offset(offset).limit(per_page) else: query = query.limit(limit) - + result = await db.execute(query) logs = result.scalars().all() - + return [ { - "id": log.id, - "event_type": log.event_type, - "message": log.message, - "data": log.data, - "is_success": log.is_success, - "created_at": log.created_at + 'id': log.id, + 'event_type': log.event_type, + 'message': log.message, + 'data': log.data, + 'is_success': log.is_success, + 'created_at': log.created_at, } for log in logs ] - + except Exception as e: - logger.error(f"Ошибка получения логов мониторинга: {e}") + logger.error(f'Ошибка получения логов мониторинга: {e}') return [] - async def get_monitoring_logs_count( - self, - db: AsyncSession, - event_type: Optional[str] = None - ) -> int: + async def get_monitoring_logs_count(self, db: AsyncSession, event_type: str | None = None) -> int: try: - from sqlalchemy import select, func + from sqlalchemy import func, select query = select(func.count(MonitoringLog.id)) @@ -1970,10 +1957,10 @@ class MonitoringService: return count or 0 except Exception as e: - logger.error(f"Ошибка получения количества логов: {e}") + logger.error(f'Ошибка получения количества логов: {e}') return 0 - async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]: + async def get_monitoring_event_types(self, db: AsyncSession) -> list[str]: try: from sqlalchemy import select @@ -1987,33 +1974,31 @@ class MonitoringService: return [row[0] for row in result.fetchall() if row[0]] except Exception as e: - logger.error(f"Ошибка получения списка типов событий мониторинга: {e}") + logger.error(f'Ошибка получения списка типов событий мониторинга: {e}') return [] - + async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int: try: - from sqlalchemy import delete, select - + from sqlalchemy import delete + if days == 0: result = await db.execute(delete(MonitoringLog)) else: cutoff_date = datetime.utcnow() - timedelta(days=days) - result = await db.execute( - delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date) - ) - + result = await db.execute(delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)) + deleted_count = result.rowcount await db.commit() - + if days == 0: - logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)") + logger.info(f'🗑️ Удалены все логи мониторинга ({deleted_count} записей)') else: - logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)") - + logger.info(f'🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)') + return deleted_count - + except Exception as e: - logger.error(f"Ошибка очистки логов: {e}") + logger.error(f'Ошибка очистки логов: {e}') await db.rollback() return 0 diff --git a/app/services/mulenpay_service.py b/app/services/mulenpay_service.py index bbd3901c..87a66cbe 100644 --- a/app/services/mulenpay_service.py +++ b/app/services/mulenpay_service.py @@ -2,12 +2,13 @@ import asyncio import hashlib import json import logging -from typing import Optional, Dict, Any, Tuple +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @@ -18,7 +19,7 @@ class MulenPayService: self.api_key = settings.MULENPAY_API_KEY self.shop_id = settings.MULENPAY_SHOP_ID self.secret_key = settings.MULENPAY_SECRET_KEY - self.base_url = settings.MULENPAY_BASE_URL.rstrip("/") + self.base_url = settings.MULENPAY_BASE_URL.rstrip('/') self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25) self._max_retries = 3 self._retry_delay = 0.5 @@ -26,77 +27,71 @@ class MulenPayService: @property def is_configured(self) -> bool: - return bool( - settings.is_mulenpay_enabled() - and self.api_key - and self.shop_id - and self.secret_key - ) + return bool(settings.is_mulenpay_enabled() and self.api_key and self.shop_id and self.secret_key) async def _request( self, method: str, endpoint: str, *, - json_data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + json_data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: if not self.is_configured: - logger.error("MulenPay service is not configured") + logger.error('MulenPay service is not configured') return None - url = f"{self.base_url}{endpoint}" + url = f'{self.base_url}{endpoint}' headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', } - last_error: Optional[BaseException] = None + last_error: BaseException | None = None for attempt in range(1, self._max_retries + 1): try: - async with aiohttp.ClientSession(timeout=self._timeout) as session: - async with session.request( + async with ( + aiohttp.ClientSession(timeout=self._timeout) as session, + session.request( method, url, headers=headers, json=json_data, params=params, - ) as response: - data, raw_text = await self._deserialize_response(response) + ) as response, + ): + data, raw_text = await self._deserialize_response(response) - if response.status >= 400: - logger.error( - "MulenPay API error %s %s: %s", - response.status, + if response.status >= 400: + logger.error( + 'MulenPay API error %s %s: %s', + response.status, + endpoint, + raw_text, + ) + if response.status in self._retryable_statuses and attempt < self._max_retries: + await self._sleep_with_backoff(attempt) + continue + return None + + if data is None: + if raw_text: + logger.warning( + 'MulenPay returned unexpected payload for %s: %s', endpoint, raw_text, ) - if ( - response.status in self._retryable_statuses - and attempt < self._max_retries - ): - await self._sleep_with_backoff(attempt) - continue - return None + return None - if data is None: - if raw_text: - logger.warning( - "MulenPay returned unexpected payload for %s: %s", - endpoint, - raw_text, - ) - return None - - return data + return data except asyncio.CancelledError: - logger.debug("MulenPay request cancelled: %s %s", method, endpoint) + logger.debug('MulenPay request cancelled: %s %s', method, endpoint) raise - except asyncio.TimeoutError as error: + except TimeoutError as error: last_error = error logger.warning( - "MulenPay request timeout (%s %s) attempt %s/%s", + 'MulenPay request timeout (%s %s) attempt %s/%s', method, endpoint, attempt, @@ -105,7 +100,7 @@ class MulenPayService: except aiohttp.ClientError as error: last_error = error logger.warning( - "MulenPay client error (%s %s) attempt %s/%s: %s", + 'MulenPay client error (%s %s) attempt %s/%s: %s', method, endpoint, attempt, @@ -113,7 +108,7 @@ class MulenPayService: error, ) except Exception as error: # pragma: no cover - safety - logger.error("Unexpected MulenPay error: %s", error, exc_info=True) + logger.error('Unexpected MulenPay error: %s', error, exc_info=True) return None if attempt < self._max_retries: @@ -121,14 +116,14 @@ class MulenPayService: if isinstance(last_error, asyncio.TimeoutError): logger.error( - "MulenPay request timed out after %s attempts: %s %s", + 'MulenPay request timed out after %s attempts: %s %s', self._max_retries, method, endpoint, ) elif last_error is not None: logger.error( - "MulenPay request failed after %s attempts (%s %s): %s", + 'MulenPay request failed after %s attempts (%s %s): %s', self._max_retries, method, endpoint, @@ -140,20 +135,18 @@ class MulenPayService: async def _sleep_with_backoff(self, attempt: int) -> None: await asyncio.sleep(self._retry_delay * attempt) - async def _deserialize_response( - self, response: aiohttp.ClientResponse - ) -> Tuple[Optional[Dict[str, Any]], str]: + async def _deserialize_response(self, response: aiohttp.ClientResponse) -> tuple[dict[str, Any] | None, str]: raw_text = await response.text() if not raw_text: - return None, "" + return None, '' - content_type = response.headers.get("Content-Type", "") - if "json" in content_type.lower() or not content_type: + content_type = response.headers.get('Content-Type', '') + if 'json' in content_type.lower() or not content_type: try: return json.loads(raw_text), raw_text except json.JSONDecodeError as error: logger.error( - "Failed to decode MulenPay JSON response %s: %s", + 'Failed to decode MulenPay JSON response %s: %s', response.url, error, ) @@ -163,10 +156,10 @@ class MulenPayService: @staticmethod def _format_amount(amount_kopeks: int) -> str: - return f"{amount_kopeks / 100:.2f}" + return f'{amount_kopeks / 100:.2f}' def _build_signature(self, currency: str, amount_str: str) -> str: - raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode() + raw_string = f'{currency}{amount_str}{self.shop_id}{self.secret_key}'.encode() return hashlib.sha1(raw_string).hexdigest() async def create_payment( @@ -176,59 +169,59 @@ class MulenPayService: description: str, uuid: str, items: list, - language: str = "ru", - subscribe: Optional[str] = None, - hold_time: Optional[int] = None, - website_url: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + language: str = 'ru', + subscribe: str | None = None, + hold_time: int | None = None, + website_url: str | None = None, + ) -> dict[str, Any] | None: if not self.is_configured: - logger.error("MulenPay service is not configured") + logger.error('MulenPay service is not configured') return None amount_str = self._format_amount(amount_kopeks) - currency = "rub" + currency = 'rub' payload = { - "currency": currency, - "amount": amount_str, - "uuid": uuid, - "shopId": self.shop_id, - "description": description, - "items": items, - "language": language, - "sign": self._build_signature(currency, amount_str), + 'currency': currency, + 'amount': amount_str, + 'uuid': uuid, + 'shopId': self.shop_id, + 'description': description, + 'items': items, + 'language': language, + 'sign': self._build_signature(currency, amount_str), } if subscribe: - payload["subscribe"] = subscribe + payload['subscribe'] = subscribe if hold_time is not None: - payload["holdTime"] = hold_time + payload['holdTime'] = hold_time if website_url: - payload["website_url"] = website_url + payload['website_url'] = website_url - response = await self._request("POST", "/v2/payments", json_data=payload) - if not response or not response.get("success"): - logger.error("Failed to create MulenPay payment: %s", response) + response = await self._request('POST', '/v2/payments', json_data=payload) + if not response or not response.get('success'): + logger.error('Failed to create MulenPay payment: %s', response) return None return response - async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]: - return await self._request("GET", f"/v2/payments/{payment_id}") + async def get_payment(self, payment_id: int) -> dict[str, Any] | None: + return await self._request('GET', f'/v2/payments/{payment_id}') async def list_payments( self, *, offset: int = 0, limit: int = 100, - uuid: Optional[str] = None, - status: Optional[int] = None, - ) -> Optional[Dict[str, Any]]: + uuid: str | None = None, + status: int | None = None, + ) -> dict[str, Any] | None: params = { - "offset": max(0, offset), - "limit": max(1, min(limit, 1000)), + 'offset': max(0, offset), + 'limit': max(1, min(limit, 1000)), } if uuid: - params["uuid"] = uuid + params['uuid'] = uuid if status is not None: - params["status"] = status - return await self._request("GET", "/v2/payments", params=params) + params['status'] = status + return await self._request('GET', '/v2/payments', params=params) diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py index 6a11529a..9ba07563 100644 --- a/app/services/nalogo_queue_service.py +++ b/app/services/nalogo_queue_service.py @@ -7,28 +7,27 @@ import asyncio import logging from datetime import datetime, timedelta -from typing import Optional - -from dateutil.parser import isoparse from aiogram import Bot +from dateutil.parser import isoparse from app.config import settings from app.services.nalogo_service import NaloGoService from app.utils.cache import cache + logger = logging.getLogger(__name__) class NalogoQueueService: """Сервис фоновой обработки очереди чеков NaloGO.""" - def __init__(self, nalogo_service: Optional[NaloGoService] = None): + def __init__(self, nalogo_service: NaloGoService | None = None): self._nalogo_service = nalogo_service - self._bot: Optional[Bot] = None - self._task: Optional[asyncio.Task] = None + self._bot: Bot | None = None + self._task: asyncio.Task | None = None self._running = False - self._last_notification_time: Optional[datetime] = None + self._last_notification_time: datetime | None = None self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки @@ -47,33 +46,33 @@ class NalogoQueueService: @property def _check_interval(self) -> int: """Интервал проверки очереди в секундах.""" - return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300) + return getattr(settings, 'NALOGO_QUEUE_CHECK_INTERVAL', 300) @property def _receipt_delay(self) -> int: """Задержка между отправкой чеков в секундах.""" - return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3) + return getattr(settings, 'NALOGO_QUEUE_RECEIPT_DELAY', 3) @property def _max_attempts(self) -> int: """Максимальное количество попыток отправки чека.""" - return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10) + return getattr(settings, 'NALOGO_QUEUE_MAX_ATTEMPTS', 10) async def start(self) -> None: """Запустить фоновую обработку очереди.""" if not self._nalogo_service or not self._nalogo_service.configured: - logger.info("NaloGO не настроен, сервис очереди чеков не запущен") + logger.info('NaloGO не настроен, сервис очереди чеков не запущен') return if self.is_running(): - logger.warning("Сервис очереди чеков уже запущен") + logger.warning('Сервис очереди чеков уже запущен') return self._running = True self._task = asyncio.create_task(self._process_queue_loop()) logger.info( - f"Сервис очереди чеков NaloGO запущен " - f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)" + f'Сервис очереди чеков NaloGO запущен ' + f'(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)' ) async def stop(self) -> None: @@ -86,7 +85,7 @@ class NalogoQueueService: except asyncio.CancelledError: pass self._task = None - logger.info("Сервис очереди чеков NaloGO остановлен") + logger.info('Сервис очереди чеков NaloGO остановлен') async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None: """Отправить уведомление админам о чеках.""" @@ -104,7 +103,7 @@ class NalogoQueueService: now = datetime.now() if self._last_notification_time: if now - self._last_notification_time < self._notification_cooldown: - logger.debug("Уведомление о чеках пропущено (cooldown)") + logger.debug('Уведомление о чеках пропущено (cooldown)') return try: @@ -112,12 +111,12 @@ class NalogoQueueService: chat_id=chat_id, message_thread_id=topic_id, text=message, - parse_mode="HTML", + parse_mode='HTML', ) self._last_notification_time = datetime.now() - logger.info("Отправлено уведомление о чеках NaloGO") + logger.info('Отправлено уведомление о чеках NaloGO') except Exception as error: - logger.error(f"Ошибка отправки уведомления о чеках: {error}") + logger.error(f'Ошибка отправки уведомления о чеках: {error}') async def _process_queue_loop(self) -> None: """Основной цикл обработки очереди.""" @@ -125,7 +124,7 @@ class NalogoQueueService: try: await self._process_pending_receipts() except Exception as error: - logger.error(f"Ошибка в цикле обработки очереди чеков: {error}") + logger.error(f'Ошибка в цикле обработки очереди чеков: {error}') await asyncio.sleep(self._check_interval) @@ -138,7 +137,7 @@ class NalogoQueueService: if queue_length == 0: return - logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.") + logger.info(f'Начинаем обработку очереди чеков: {queue_length} шт.') self._had_pending_receipts = True processed = 0 @@ -152,50 +151,43 @@ class NalogoQueueService: if not receipt_data: break - attempts = receipt_data.get("attempts", 0) - payment_id = receipt_data.get("payment_id", "unknown") - amount = receipt_data.get("amount", 0) + attempts = receipt_data.get('attempts', 0) + payment_id = receipt_data.get('payment_id', 'unknown') + amount = receipt_data.get('amount', 0) # Логируем количество попыток (чек никогда не удаляется из очереди) if attempts >= 10: - logger.warning( - f"Чек {payment_id} уже {attempts} попыток, продолжаем пытаться..." - ) + logger.warning(f'Чек {payment_id} уже {attempts} попыток, продолжаем пытаться...') # Пытаемся отправить чек try: # Восстанавливаем описание из сохранённых данных - telegram_user_id = receipt_data.get("telegram_user_id") - amount_kopeks = receipt_data.get("amount_kopeks") + telegram_user_id = receipt_data.get('telegram_user_id') + amount_kopeks = receipt_data.get('amount_kopeks') # Извлекаем время оплаты из очереди (чтобы чек был с правильным временем) operation_time = None - created_at_str = receipt_data.get("created_at") + created_at_str = receipt_data.get('created_at') if created_at_str: try: operation_time = isoparse(created_at_str) except (ValueError, TypeError) as parse_error: - logger.warning( - f"Не удалось распарсить created_at '{created_at_str}': {parse_error}" - ) + logger.warning(f"Не удалось распарсить created_at '{created_at_str}': {parse_error}") # Формируем описание заново из настроек (если есть данные) if amount_kopeks is not None: - receipt_name = settings.get_balance_payment_description( - amount_kopeks, telegram_user_id - ) + receipt_name = settings.get_balance_payment_description(amount_kopeks, telegram_user_id) else: # Fallback на сохранённое имя receipt_name = receipt_data.get( - "name", - settings.get_balance_payment_description(int(amount * 100), telegram_user_id) + 'name', settings.get_balance_payment_description(int(amount * 100), telegram_user_id) ) receipt_uuid = await self._nalogo_service.create_receipt( name=receipt_name, amount=amount, - quantity=receipt_data.get("quantity", 1), - client_info=receipt_data.get("client_info"), + quantity=receipt_data.get('quantity', 1), + client_info=receipt_data.get('client_info'), payment_id=payment_id, queue_on_failure=False, # Не добавлять в очередь повторно автоматически telegram_user_id=telegram_user_id, @@ -209,12 +201,12 @@ class NalogoQueueService: # Удаляем метку "в очереди" (чек создан успешно) if payment_id: - queued_key = f"nalogo:queued:{payment_id}" + queued_key = f'nalogo:queued:{payment_id}' await cache.delete(queued_key) logger.info( - f"Чек из очереди успешно создан: {receipt_uuid} " - f"(payment_id={payment_id}, попытка {attempts + 1})" + f'Чек из очереди успешно создан: {receipt_uuid} ' + f'(payment_id={payment_id}, попытка {attempts + 1})' ) else: # Вернуть в очередь с увеличенным счетчиком попыток @@ -222,8 +214,8 @@ class NalogoQueueService: failed += 1 service_unavailable = True logger.warning( - f"Не удалось создать чек из очереди (payment_id={payment_id}), " - f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})" + f'Не удалось создать чек из очереди (payment_id={payment_id}), ' + f'возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})' ) # Если сервис недоступен, прекращаем попытки до следующего цикла break @@ -231,9 +223,7 @@ class NalogoQueueService: except Exception as error: await self._nalogo_service.requeue_receipt(receipt_data) failed += 1 - logger.error( - f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}" - ) + logger.error(f'Ошибка при создании чека из очереди (payment_id={payment_id}): {error}') # Прекращаем попытки при ошибке break @@ -241,10 +231,7 @@ class NalogoQueueService: await asyncio.sleep(self._receipt_delay) if processed > 0 or failed > 0 or skipped > 0: - logger.info( - f"Обработка очереди завершена: " - f"успешно={processed}, неудачно={failed}, пропущено={skipped}" - ) + logger.info(f'Обработка очереди завершена: успешно={processed}, неудачно={failed}, пропущено={skipped}') # Проверяем остаток в очереди remaining = await self._nalogo_service.get_queue_length() @@ -253,14 +240,14 @@ class NalogoQueueService: if service_unavailable or failed > 0: if remaining > 0: queued = await self._nalogo_service.get_queued_receipts() - total_queued_amount = sum(r.get("amount", 0) for r in queued) + total_queued_amount = sum(r.get('amount', 0) for r in queued) message = ( - f"⚠️ Проблема с отправкой чеков NaloGO\n\n" - f"Сервис nalog.ru временно недоступен.\n\n" - f"📋 В очереди: {remaining} чек(ов)\n" - f"💰 На сумму: {total_queued_amount:,.2f} ₽\n\n" - f"Чеки будут отправлены автоматически когда сервис восстановится." + f'⚠️ Проблема с отправкой чеков NaloGO\n\n' + f'Сервис nalog.ru временно недоступен.\n\n' + f'📋 В очереди: {remaining} чек(ов)\n' + f'💰 На сумму: {total_queued_amount:,.2f} ₽\n\n' + f'Чеки будут отправлены автоматически когда сервис восстановится.' ) await self._send_admin_notification(message) @@ -268,30 +255,30 @@ class NalogoQueueService: elif remaining == 0 and self._had_pending_receipts and processed > 0: self._had_pending_receipts = False message = ( - f"✅ Очередь чеков NaloGO разгружена\n\n" - f"Все отложенные чеки успешно отправлены!\n\n" - f"📋 Отправлено: {processed} чек(ов)\n" - f"💰 На сумму: {total_processed_amount:,.2f} ₽" + f'✅ Очередь чеков NaloGO разгружена\n\n' + f'Все отложенные чеки успешно отправлены!\n\n' + f'📋 Отправлено: {processed} чек(ов)\n' + f'💰 На сумму: {total_processed_amount:,.2f} ₽' ) await self._send_admin_notification(message, skip_cooldown=True) async def force_process(self) -> dict: """Принудительно обработать очередь (для ручного запуска).""" if not self._nalogo_service: - return {"error": "NaloGO сервис не настроен"} + return {'error': 'NaloGO сервис не настроен'} queue_length = await self._nalogo_service.get_queue_length() if queue_length == 0: - return {"message": "Очередь пуста", "processed": 0} + return {'message': 'Очередь пуста', 'processed': 0} await self._process_pending_receipts() new_length = await self._nalogo_service.get_queue_length() return { - "message": "Обработка завершена", - "was_in_queue": queue_length, - "remaining": new_length, - "processed": queue_length - new_length, + 'message': 'Обработка завершена', + 'was_in_queue': queue_length, + 'remaining': new_length, + 'processed': queue_length - new_length, } async def get_status(self) -> dict: @@ -307,26 +294,26 @@ class NalogoQueueService: queue_length = await self._nalogo_service.get_queue_length() if queue_length > 0: queued_receipts = await self._nalogo_service.get_queued_receipts() - total_amount = sum(r.get("amount", 0) for r in queued_receipts) + total_amount = sum(r.get('amount', 0) for r in queued_receipts) # Чеки ожидающие ручной проверки pending_verification_count = await self._nalogo_service.get_pending_verification_count() if pending_verification_count > 0: pending_verification_receipts = await self._nalogo_service.get_pending_verification_receipts() - pending_verification_amount = sum(r.get("amount", 0) for r in pending_verification_receipts) + pending_verification_amount = sum(r.get('amount', 0) for r in pending_verification_receipts) return { - "running": self.is_running(), - "check_interval_seconds": self._check_interval, - "receipt_delay_seconds": self._receipt_delay, - "queue_length": queue_length, - "total_amount": total_amount, - "max_attempts": self._max_attempts, - "queued_receipts": queued_receipts[:10], + 'running': self.is_running(), + 'check_interval_seconds': self._check_interval, + 'receipt_delay_seconds': self._receipt_delay, + 'queue_length': queue_length, + 'total_amount': total_amount, + 'max_attempts': self._max_attempts, + 'queued_receipts': queued_receipts[:10], # Чеки требующие ручной проверки (таймаут после успешной авторизации) - "pending_verification_count": pending_verification_count, - "pending_verification_amount": pending_verification_amount, - "pending_verification_receipts": pending_verification_receipts[:10], + 'pending_verification_count': pending_verification_count, + 'pending_verification_amount': pending_verification_amount, + 'pending_verification_receipts': pending_verification_receipts[:10], } diff --git a/app/services/nalogo_service.py b/app/services/nalogo_service.py index c2856577..443e0348 100644 --- a/app/services/nalogo_service.py +++ b/app/services/nalogo_service.py @@ -1,30 +1,32 @@ import logging -from datetime import datetime, timezone, timedelta, date -from typing import Optional, Dict, Any, List +from datetime import date, datetime, timedelta from decimal import Decimal +from typing import Any + +from app.config import settings # Используем локальную исправленную версию библиотеки from app.lib.nalogo import Client -from app.lib.nalogo.dto.income import IncomeClient, IncomeType, MOSCOW_TZ - -from app.config import settings +from app.lib.nalogo.dto.income import IncomeClient, IncomeType from app.utils.cache import cache + logger = logging.getLogger(__name__) -NALOGO_QUEUE_KEY = "nalogo:receipt_queue" -NALOGO_PENDING_VERIFICATION_KEY = "nalogo:pending_verification" +NALOGO_QUEUE_KEY = 'nalogo:receipt_queue' +NALOGO_PENDING_VERIFICATION_KEY = 'nalogo:pending_verification' class NaloGoService: """Сервис для работы с API NaloGO (налоговая служба самозанятых).""" - def __init__(self, - inn: Optional[str] = None, - password: Optional[str] = None, - device_id: Optional[str] = None, - storage_path: Optional[str] = None): - + def __init__( + self, + inn: str | None = None, + password: str | None = None, + device_id: str | None = None, + storage_path: str | None = None, + ): inn = inn or getattr(settings, 'NALOGO_INN', None) password = password or getattr(settings, 'NALOGO_PASSWORD', None) device_id = device_id or getattr(settings, 'NALOGO_DEVICE_ID', None) @@ -33,26 +35,24 @@ class NaloGoService: self.configured = False if not inn or not password: - logger.warning( - "NaloGO INN или PASSWORD не настроены в settings. " - "Функционал чеков будет ОТКЛЮЧЕН.") + logger.warning('NaloGO INN или PASSWORD не настроены в settings. Функционал чеков будет ОТКЛЮЧЕН.') else: try: # Таймаут 30 секунд — nalog.ru иногда отвечает медленно timeout = getattr(settings, 'NALOGO_TIMEOUT', 30.0) self.client = Client( - base_url="https://lknpd.nalog.ru/api", + base_url='https://lknpd.nalog.ru/api', storage_path=storage_path, - device_id=device_id or "bot-device-123", + device_id=device_id or 'bot-device-123', timeout=timeout, ) self.inn = inn self.password = password self.configured = True - logger.info(f"NaloGO клиент инициализирован для ИНН: {inn[:5]}...") + logger.info(f'NaloGO клиент инициализирован для ИНН: {inn[:5]}...') except Exception as error: logger.error( - "Ошибка инициализации NaloGO клиента: %s", + 'Ошибка инициализации NaloGO клиента: %s', error, exc_info=True, ) @@ -64,21 +64,21 @@ class NaloGoService: error_str = str(error).lower() error_type = type(error).__name__.lower() return ( - "503" in error_str - or "500" in error_str - or "internal server error" in error_str - or "внутренняя ошибка" in error_str - or "service temporarily unavailable" in error_str - or "service unavailable" in error_str - or "ведутся работы" in error_str - or ("health" in error_str and "false" in error_str) + '503' in error_str + or '500' in error_str + or 'internal server error' in error_str + or 'внутренняя ошибка' in error_str + or 'service temporarily unavailable' in error_str + or 'service unavailable' in error_str + or 'ведутся работы' in error_str + or ('health' in error_str and 'false' in error_str) # Таймауты и сетевые ошибки — временные проблемы - or "timeout" in error_type - or "timeout" in error_str - or "readtimeout" in error_type - or "connecttimeout" in error_type - or "connectionerror" in error_type - or "connecterror" in error_type + or 'timeout' in error_type + or 'timeout' in error_str + or 'readtimeout' in error_type + or 'connecttimeout' in error_type + or 'connectionerror' in error_type + or 'connecterror' in error_type ) async def _queue_receipt( @@ -86,56 +86,47 @@ class NaloGoService: name: str, amount: float, quantity: int, - client_info: Optional[Dict[str, Any]], - payment_id: Optional[str] = None, - telegram_user_id: Optional[int] = None, - amount_kopeks: Optional[int] = None, + client_info: dict[str, Any] | None, + payment_id: str | None = None, + telegram_user_id: int | None = None, + amount_kopeks: int | None = None, ) -> bool: """Добавить чек в очередь для отложенной отправки.""" if payment_id: # Защита от дубликатов: проверяем не был ли чек уже создан - created_key = f"nalogo:created:{payment_id}" + created_key = f'nalogo:created:{payment_id}' already_created = await cache.get(created_key) if already_created: - logger.info( - f"Чек для payment_id={payment_id} уже создан ({already_created}), " - "не добавляем в очередь" - ) + logger.info(f'Чек для payment_id={payment_id} уже создан ({already_created}), не добавляем в очередь') return False # Атомарная проверка и установка флага "в очереди" (защита от race condition) - queued_key = f"nalogo:queued:{payment_id}" - lock_acquired = await cache.setnx(queued_key, "queued", expire=7 * 24 * 3600) + queued_key = f'nalogo:queued:{payment_id}' + lock_acquired = await cache.setnx(queued_key, 'queued', expire=7 * 24 * 3600) if not lock_acquired: # Ключ уже существует — чек уже в очереди - logger.info( - f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат" - ) + logger.info(f'Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат') return False receipt_data = { - "name": name, - "amount": amount, - "quantity": quantity, - "client_info": client_info, - "payment_id": payment_id, - "telegram_user_id": telegram_user_id, - "amount_kopeks": amount_kopeks, - "created_at": datetime.now().isoformat(), - "attempts": 0, + 'name': name, + 'amount': amount, + 'quantity': quantity, + 'client_info': client_info, + 'payment_id': payment_id, + 'telegram_user_id': telegram_user_id, + 'amount_kopeks': amount_kopeks, + 'created_at': datetime.now().isoformat(), + 'attempts': 0, } success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data) if success: queue_len = await cache.llen(NALOGO_QUEUE_KEY) - logger.info( - f"Чек добавлен в очередь (payment_id={payment_id}, " - f"сумма={amount}₽, в очереди: {queue_len})" - ) - else: - # Если не удалось добавить в очередь — удаляем флаг - if payment_id: - queued_key = f"nalogo:queued:{payment_id}" - await cache.delete(queued_key) + logger.info(f'Чек добавлен в очередь (payment_id={payment_id}, сумма={amount}₽, в очереди: {queue_len})') + # Если не удалось добавить в очередь — удаляем флаг + elif payment_id: + queued_key = f'nalogo:queued:{payment_id}' + await cache.delete(queued_key) return success async def _save_pending_verification( @@ -143,10 +134,10 @@ class NaloGoService: name: str, amount: float, quantity: int, - client_info: Optional[Dict[str, Any]], - payment_id: Optional[str], - telegram_user_id: Optional[int], - amount_kopeks: Optional[int], + client_info: dict[str, Any] | None, + payment_id: str | None, + telegram_user_id: int | None, + amount_kopeks: int | None, error_message: str, ) -> bool: """Сохранить чек в очередь ожидающих проверки. @@ -155,23 +146,23 @@ class NaloGoService: чек мог быть создан на сервере, но ответ не пришёл. """ receipt_data = { - "name": name, - "amount": amount, - "quantity": quantity, - "client_info": client_info, - "payment_id": payment_id, - "telegram_user_id": telegram_user_id, - "amount_kopeks": amount_kopeks, - "created_at": datetime.now().isoformat(), - "error": error_message, - "status": "pending_verification", + 'name': name, + 'amount': amount, + 'quantity': quantity, + 'client_info': client_info, + 'payment_id': payment_id, + 'telegram_user_id': telegram_user_id, + 'amount_kopeks': amount_kopeks, + 'created_at': datetime.now().isoformat(), + 'error': error_message, + 'status': 'pending_verification', } success = await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, receipt_data) if success: count = await cache.llen(NALOGO_PENDING_VERIFICATION_KEY) logger.warning( - f"Чек сохранён для ручной проверки (payment_id={payment_id}, " - f"сумма={amount}₽, всего ожидают проверки: {count})" + f'Чек сохранён для ручной проверки (payment_id={payment_id}, ' + f'сумма={amount}₽, всего ожидают проверки: {count})' ) return success @@ -186,9 +177,9 @@ class NaloGoService: async def mark_pending_as_verified( self, payment_id: str, - receipt_uuid: Optional[str] = None, + receipt_uuid: str | None = None, was_created: bool = True, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Пометить чек как проверенный и удалить из очереди. Args: @@ -204,15 +195,13 @@ class NaloGoService: removed_receipt = None for receipt in receipts: - if receipt.get("payment_id") == payment_id: + if receipt.get('payment_id') == payment_id: removed_receipt = receipt if was_created and receipt_uuid: # Сохраняем что чек создан - created_key = f"nalogo:created:{payment_id}" + created_key = f'nalogo:created:{payment_id}' await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600) - logger.info( - f"Чек {payment_id} помечен как созданный: {receipt_uuid}" - ) + logger.info(f'Чек {payment_id} помечен как созданный: {receipt_uuid}') else: updated_receipts.append(receipt) @@ -221,11 +210,11 @@ class NaloGoService: await cache.delete(NALOGO_PENDING_VERIFICATION_KEY) for r in reversed(updated_receipts): # reversed чтобы сохранить порядок await cache.lpush(NALOGO_PENDING_VERIFICATION_KEY, r) - logger.info(f"Чек {payment_id} удалён из очереди проверки") + logger.info(f'Чек {payment_id} удалён из очереди проверки') return removed_receipt - async def retry_pending_receipt(self, payment_id: str) -> Optional[str]: + async def retry_pending_receipt(self, payment_id: str) -> str | None: """Повторно отправить чек из очереди проверки. Используется когда проверили что чек НЕ был создан в налоговой. @@ -237,30 +226,30 @@ class NaloGoService: target_receipt = None for receipt in receipts: - if receipt.get("payment_id") == payment_id: + if receipt.get('payment_id') == payment_id: target_receipt = receipt break if not target_receipt: - logger.warning(f"Чек {payment_id} не найден в очереди проверки") + logger.warning(f'Чек {payment_id} не найден в очереди проверки') return None # Пытаемся создать чек receipt_uuid = await self.create_receipt( - name=target_receipt.get("name", ""), - amount=target_receipt.get("amount", 0), - quantity=target_receipt.get("quantity", 1), - client_info=target_receipt.get("client_info"), + name=target_receipt.get('name', ''), + amount=target_receipt.get('amount', 0), + quantity=target_receipt.get('quantity', 1), + client_info=target_receipt.get('client_info'), payment_id=payment_id, queue_on_failure=False, # Не добавлять обратно в очередь - telegram_user_id=target_receipt.get("telegram_user_id"), - amount_kopeks=target_receipt.get("amount_kopeks"), + telegram_user_id=target_receipt.get('telegram_user_id'), + amount_kopeks=target_receipt.get('amount_kopeks'), ) if receipt_uuid: # Удаляем из очереди проверки await self.mark_pending_as_verified(payment_id, receipt_uuid, was_created=True) - logger.info(f"Чек {payment_id} успешно создан после ручной проверки: {receipt_uuid}") + logger.info(f'Чек {payment_id} успешно создан после ручной проверки: {receipt_uuid}') return receipt_uuid @@ -269,7 +258,7 @@ class NaloGoService: count = await self.get_pending_verification_count() if count > 0: await cache.delete(NALOGO_PENDING_VERIFICATION_KEY) - logger.info(f"Очередь проверки очищена: удалено {count} чеков") + logger.info(f'Очередь проверки очищена: удалено {count} чеков') return count async def authenticate(self) -> bool: @@ -280,16 +269,13 @@ class NaloGoService: try: token = await self.client.create_new_access_token(self.inn, self.password) await self.client.authenticate(token) - logger.info("Успешная аутентификация в NaloGO") + logger.info('Успешная аутентификация в NaloGO') return True except Exception as error: if self._is_service_unavailable(error): - logger.warning( - "NaloGO временно недоступен (техработы): %s", - str(error)[:200] - ) + logger.warning('NaloGO временно недоступен (техработы): %s', str(error)[:200]) else: - logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True) + logger.error('Ошибка аутентификации в NaloGO: %s', error, exc_info=True) return False async def create_receipt( @@ -297,13 +283,13 @@ class NaloGoService: name: str, amount: float, quantity: int = 1, - client_info: Optional[Dict[str, Any]] = None, - payment_id: Optional[str] = None, + client_info: dict[str, Any] | None = None, + payment_id: str | None = None, queue_on_failure: bool = True, - telegram_user_id: Optional[int] = None, - amount_kopeks: Optional[int] = None, - operation_time: Optional[datetime] = None, - ) -> Optional[str]: + telegram_user_id: int | None = None, + amount_kopeks: int | None = None, + operation_time: datetime | None = None, + ) -> str | None: """Создание чека о доходе. Args: @@ -321,23 +307,21 @@ class NaloGoService: UUID чека или None при ошибке """ if not self.configured: - logger.warning("NaloGO не настроен, чек не создан") + logger.warning('NaloGO не настроен, чек не создан') return None # Защита от дублей: проверяем не был ли уже создан чек для этого payment_id if payment_id: - created_key = f"nalogo:created:{payment_id}" + created_key = f'nalogo:created:{payment_id}' already_created = await cache.get(created_key) if already_created: logger.info( - f"Чек для payment_id={payment_id} уже был создан ({already_created}), " - "пропускаем повторное создание" + f'Чек для payment_id={payment_id} уже был создан ({already_created}), пропускаем повторное создание' ) return already_created # Возвращаем ранее созданный uuid # ЭТАП 1: Аутентификация # Если не прошла — чек точно не создавался, безопасно добавить в очередь - auth_was_successful = False try: if not hasattr(self.client, '_access_token') or not self.client._access_token: auth_success = await self.authenticate() @@ -345,25 +329,21 @@ class NaloGoService: # Аутентификация не прошла — чек не создавался, безопасно в очередь if queue_on_failure: await self._queue_receipt( - name, amount, quantity, client_info, payment_id, - telegram_user_id, amount_kopeks + name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks ) return None - auth_was_successful = True except Exception as auth_error: # Ошибка аутентификации — чек не создавался, безопасно в очередь if self._is_service_unavailable(auth_error): logger.warning( - f"NaloGO недоступен при аутентификации, чек в очередь " - f"(payment_id={payment_id}, сумма={amount}₽)" + f'NaloGO недоступен при аутентификации, чек в очередь (payment_id={payment_id}, сумма={amount}₽)' ) if queue_on_failure: await self._queue_receipt( - name, amount, quantity, client_info, payment_id, - telegram_user_id, amount_kopeks + name, amount, quantity, client_info, payment_id, telegram_user_id, amount_kopeks ) else: - logger.error("Ошибка аутентификации NaloGO: %s", auth_error, exc_info=True) + logger.error('Ошибка аутентификации NaloGO: %s', auth_error, exc_info=True) return None # ЭТАП 2: Создание чека @@ -376,10 +356,10 @@ class NaloGoService: income_client = None if client_info: income_client = IncomeClient( - contact_phone=client_info.get("phone"), - display_name=client_info.get("name"), - income_type=client_info.get("income_type", IncomeType.FROM_INDIVIDUAL), - inn=client_info.get("inn") + contact_phone=client_info.get('phone'), + display_name=client_info.get('name'), + income_type=client_info.get('income_type', IncomeType.FROM_INDIVIDUAL), + inn=client_info.get('inn'), ) # Используем переданное время операции или текущее @@ -391,19 +371,18 @@ class NaloGoService: client=income_client, ) - receipt_uuid = result.get("approvedReceiptUuid") + receipt_uuid = result.get('approvedReceiptUuid') if receipt_uuid: - logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}₽") + logger.info(f'Чек создан успешно: {receipt_uuid} на сумму {amount}₽') # Сохраняем в Redis чтобы предотвратить дубли (TTL 30 дней) if payment_id: - created_key = f"nalogo:created:{payment_id}" + created_key = f'nalogo:created:{payment_id}' await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600) return receipt_uuid - else: - logger.error(f"Ошибка создания чека: {result}") - return None + logger.error(f'Ошибка создания чека: {result}') + return None except Exception as error: # ВАЖНО: Аутентификация была успешной, запрос на создание чека УШЁЛ @@ -411,9 +390,9 @@ class NaloGoService: if self._is_service_unavailable(error): error_msg = str(error)[:200] logger.error( - f"⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! " - f"(payment_id={payment_id}, сумма={amount}₽). " - f"Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru" + f'⚠️ ТАЙМАУТ после успешной аутентификации! Чек МОГ быть создан! ' + f'(payment_id={payment_id}, сумма={amount}₽). ' + f'Сохраняем в очередь проверки. Проверьте lknpd.nalog.ru' ) # Сохраняем в очередь для ручной проверки await self._save_pending_verification( @@ -427,7 +406,7 @@ class NaloGoService: error_message=error_msg, ) else: - logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True) + logger.error('Ошибка создания чека в NaloGO: %s', error, exc_info=True) return None async def get_queue_length(self) -> int: @@ -438,13 +417,13 @@ class NaloGoService: """Получить список чеков в очереди (без удаления).""" return await cache.lrange(NALOGO_QUEUE_KEY) - async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]: + async def pop_receipt_from_queue(self) -> dict[str, Any] | None: """Извлечь следующий чек из очереди.""" return await cache.rpop(NALOGO_QUEUE_KEY) - async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool: + async def requeue_receipt(self, receipt_data: dict[str, Any]) -> bool: """Вернуть чек обратно в очередь (при неудачной отправке).""" - receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1 + receipt_data['attempts'] = receipt_data.get('attempts', 0) + 1 return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data) async def find_duplicate_receipt( @@ -452,7 +431,7 @@ class NaloGoService: amount: float, created_at: datetime, time_window_minutes: int = 10, - ) -> Optional[str]: + ) -> str | None: """Проверяет, не был ли уже создан чек с такой суммой в заданном временном окне. Используется для защиты от дублей при таймаутах — когда сервер создал чек, @@ -485,17 +464,18 @@ class NaloGoService: # Ищем чек с такой же суммой в пределах временного окна for income in incomes: - income_amount = float(income.get("totalAmount", income.get("amount", 0))) + income_amount = float(income.get('totalAmount', income.get('amount', 0))) # Проверяем сумму (с погрешностью 0.01) if abs(income_amount - amount) > 0.01: continue # Проверяем время - operation_time_str = income.get("operationTime") + operation_time_str = income.get('operationTime') if operation_time_str: try: from dateutil.parser import isoparse + operation_time = isoparse(operation_time_str) # Убираем timezone для сравнения @@ -505,30 +485,30 @@ class NaloGoService: time_diff = abs((operation_time - created_at_naive).total_seconds()) if time_diff <= time_window_minutes * 60: - receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid")) + receipt_uuid = income.get('approvedReceiptUuid', income.get('receiptUuid')) if receipt_uuid: logger.info( - f"Найден дубликат чека: {receipt_uuid} " - f"(сумма={income_amount}₽, время={operation_time}, " - f"разница={time_diff:.0f}с)" + f'Найден дубликат чека: {receipt_uuid} ' + f'(сумма={income_amount}₽, время={operation_time}, ' + f'разница={time_diff:.0f}с)' ) return receipt_uuid except Exception as parse_error: - logger.debug(f"Ошибка парсинга времени чека: {parse_error}") + logger.debug(f'Ошибка парсинга времени чека: {parse_error}') continue return None except Exception as error: - logger.warning(f"Ошибка проверки дубликата чека: {error}") + logger.warning(f'Ошибка проверки дубликата чека: {error}') return None async def get_incomes( self, - from_date: Optional[date] = None, - to_date: Optional[date] = None, + from_date: date | None = None, + to_date: date | None = None, limit: int = 100, - ) -> Optional[List[Dict[str, Any]]]: + ) -> list[dict[str, Any]] | None: """Получить список доходов (чеков) за период. Args: @@ -540,7 +520,7 @@ class NaloGoService: Список чеков с информацией, или None при ошибке """ if not self.configured: - logger.warning("NaloGO не настроен, невозможно получить список доходов") + logger.warning('NaloGO не настроен, невозможно получить список доходов') return None try: @@ -558,13 +538,13 @@ class NaloGoService: ) # API возвращает структуру с полем content или items - incomes = result.get("content", result.get("items", [])) - logger.info(f"Получено {len(incomes)} доходов из NaloGO") + incomes = result.get('content', result.get('items', [])) + logger.info(f'Получено {len(incomes)} доходов из NaloGO') return incomes except Exception as error: if self._is_service_unavailable(error): - logger.warning(f"NaloGO временно недоступен: {error}") + logger.warning(f'NaloGO временно недоступен: {error}') else: - logger.error(f"Ошибка получения списка доходов: {error}", exc_info=True) + logger.error(f'Ошибка получения списка доходов: {error}', exc_info=True) return None # None = ошибка, [] = нет чеков diff --git a/app/services/notification_delivery_service.py b/app/services/notification_delivery_service.py index 0ee183e9..45637cab 100644 --- a/app/services/notification_delivery_service.py +++ b/app/services/notification_delivery_service.py @@ -9,13 +9,14 @@ This service handles notification delivery through appropriate channels: import asyncio import logging from enum import Enum -from typing import Any, Dict, Optional +from typing import Any from aiogram import Bot from app.config import settings from app.database.models import User + logger = logging.getLogger(__name__) @@ -23,38 +24,38 @@ class NotificationType(Enum): """Types of notifications that can be sent to users.""" # Balance notifications - BALANCE_TOPUP = "balance_topup" - BALANCE_CHANGE = "balance_change" - BALANCE_LOW = "balance_low" + BALANCE_TOPUP = 'balance_topup' + BALANCE_CHANGE = 'balance_change' + BALANCE_LOW = 'balance_low' # Subscription notifications - SUBSCRIPTION_ACTIVATED = "subscription_activated" - SUBSCRIPTION_EXPIRING = "subscription_expiring" - SUBSCRIPTION_EXPIRED = "subscription_expired" - SUBSCRIPTION_RENEWED = "subscription_renewed" + SUBSCRIPTION_ACTIVATED = 'subscription_activated' + SUBSCRIPTION_EXPIRING = 'subscription_expiring' + SUBSCRIPTION_EXPIRED = 'subscription_expired' + SUBSCRIPTION_RENEWED = 'subscription_renewed' # Autopay notifications - AUTOPAY_SUCCESS = "autopay_success" - AUTOPAY_FAILED = "autopay_failed" - AUTOPAY_INSUFFICIENT_FUNDS = "autopay_insufficient_funds" + AUTOPAY_SUCCESS = 'autopay_success' + AUTOPAY_FAILED = 'autopay_failed' + AUTOPAY_INSUFFICIENT_FUNDS = 'autopay_insufficient_funds' # Daily subscription notifications - DAILY_DEBIT = "daily_debit" - DAILY_INSUFFICIENT_FUNDS = "daily_insufficient_funds" - TRAFFIC_RESET = "traffic_reset" + DAILY_DEBIT = 'daily_debit' + DAILY_INSUFFICIENT_FUNDS = 'daily_insufficient_funds' + TRAFFIC_RESET = 'traffic_reset' # Account notifications - BAN_NOTIFICATION = "ban_notification" - UNBAN_NOTIFICATION = "unban_notification" - WARNING_NOTIFICATION = "warning_notification" + BAN_NOTIFICATION = 'ban_notification' + UNBAN_NOTIFICATION = 'unban_notification' + WARNING_NOTIFICATION = 'warning_notification' # Referral notifications - REFERRAL_BONUS = "referral_bonus" - REFERRAL_REGISTERED = "referral_registered" + REFERRAL_BONUS = 'referral_bonus' + REFERRAL_REGISTERED = 'referral_registered' # Other - BROADCAST = "broadcast" - PAYMENT_RECEIVED = "payment_received" + BROADCAST = 'broadcast' + PAYMENT_RECEIVED = 'payment_received' class NotificationDeliveryService: @@ -75,6 +76,7 @@ class NotificationDeliveryService: """Lazy load email service.""" if self._email_service is None: from app.cabinet.services.email_service import email_service + self._email_service = email_service return self._email_service @@ -83,6 +85,7 @@ class NotificationDeliveryService: """Lazy load email templates.""" if self._email_templates is None: from app.cabinet.services.email_templates import EmailNotificationTemplates + self._email_templates = EmailNotificationTemplates() return self._email_templates @@ -91,6 +94,7 @@ class NotificationDeliveryService: """Lazy load WebSocket manager.""" if self._ws_manager is None: from app.cabinet.routes.websocket import cabinet_ws_manager + self._ws_manager = cabinet_ws_manager return self._ws_manager @@ -98,10 +102,10 @@ class NotificationDeliveryService: self, user: User, notification_type: NotificationType, - context: Dict[str, Any], - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + context: dict[str, Any], + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """ Send notification to user through appropriate channel. @@ -127,7 +131,7 @@ class NotificationDeliveryService: message=telegram_message, markup=telegram_markup, ) - elif user.email and user.email_verified: + if user.email and user.email_verified: # Email-only user - send via email and WebSocket results = await asyncio.gather( self._send_email_notification(user, notification_type, context), @@ -140,47 +144,45 @@ class NotificationDeliveryService: if email_sent or ws_sent: logger.info( - "Уведомление %s отправлено email-пользователю %s (email=%s, ws=%s)", + 'Уведомление %s отправлено email-пользователю %s (email=%s, ws=%s)', notification_type.value, user.id, email_sent, ws_sent, ) return True - else: - logger.warning( - "Не удалось отправить уведомление %s email-пользователю %s", - notification_type.value, - user.id, - ) - return False - else: - logger.debug( - "Пользователь %s не имеет telegram_id или verified email, пропускаем уведомление", + logger.warning( + 'Не удалось отправить уведомление %s email-пользователю %s', + notification_type.value, user.id, ) return False + logger.debug( + 'Пользователь %s не имеет telegram_id или verified email, пропускаем уведомление', + user.id, + ) + return False async def _send_telegram_notification( self, user: User, notification_type: NotificationType, - context: Dict[str, Any], - bot: Optional[Bot], - message: Optional[str], - markup: Optional[Any], + context: dict[str, Any], + bot: Bot | None, + message: str | None, + markup: Any | None, ) -> bool: """Send notification via Telegram bot.""" if not bot: logger.warning( - "Bot instance not provided for Telegram notification to user %s", + 'Bot instance not provided for Telegram notification to user %s', user.telegram_id, ) return False if not message: logger.warning( - "No Telegram message provided for notification %s to user %s", + 'No Telegram message provided for notification %s to user %s', notification_type.value, user.telegram_id, ) @@ -193,20 +195,20 @@ class NotificationDeliveryService: chat_id=user.telegram_id, text=message, reply_markup=markup, - parse_mode="HTML", + parse_mode='HTML', ) return True except TelegramForbiddenError: logger.warning( - "Telegram user %s заблокировал бота", + 'Telegram user %s заблокировал бота', user.telegram_id, ) return False except TelegramBadRequest as e: logger.warning( - "Ошибка отправки Telegram уведомления пользователю %s: %s", + 'Ошибка отправки Telegram уведомления пользователю %s: %s', user.telegram_id, e, ) @@ -214,7 +216,7 @@ class NotificationDeliveryService: except Exception as e: logger.error( - "Неожиданная ошибка при отправке Telegram уведомления: %s", + 'Неожиданная ошибка при отправке Telegram уведомления: %s', e, ) return False @@ -223,25 +225,25 @@ class NotificationDeliveryService: self, user: User, notification_type: NotificationType, - context: Dict[str, Any], + context: dict[str, Any], ) -> bool: """Send notification via email.""" if not self.email_service.is_configured(): - logger.debug("SMTP не настроен, пропускаем email уведомление") + logger.debug('SMTP не настроен, пропускаем email уведомление') return False if not user.email or not user.email_verified: - logger.debug("У пользователя %s нет подтверждённого email", user.id) + logger.debug('У пользователя %s нет подтверждённого email', user.id) return False try: # Get email template - language = user.language or "ru" + language = user.language or 'ru' template = self.email_templates.get_template(notification_type, language, context) if not template: logger.warning( - "Не найден email шаблон для %s", + 'Не найден email шаблон для %s', notification_type.value, ) return False @@ -249,14 +251,14 @@ class NotificationDeliveryService: # Send email success = self.email_service.send_email( to_email=user.email, - subject=template["subject"], - body_html=template["body_html"], - body_text=template.get("body_text"), + subject=template['subject'], + body_html=template['body_html'], + body_text=template.get('body_text'), ) if success: logger.info( - "Email уведомление %s отправлено пользователю %s (%s)", + 'Email уведомление %s отправлено пользователю %s (%s)', notification_type.value, user.id, user.email, @@ -266,7 +268,7 @@ class NotificationDeliveryService: except Exception as e: logger.error( - "Ошибка отправки email уведомления пользователю %s: %s", + 'Ошибка отправки email уведомления пользователю %s: %s', user.id, e, ) @@ -276,12 +278,12 @@ class NotificationDeliveryService: self, user: User, notification_type: NotificationType, - context: Dict[str, Any], + context: dict[str, Any], ) -> bool: """Send notification via WebSocket to cabinet.""" try: message = { - "type": f"notification.{notification_type.value}", + 'type': f'notification.{notification_type.value}', **context, } @@ -290,7 +292,7 @@ class NotificationDeliveryService: except Exception as e: logger.debug( - "WebSocket уведомление не отправлено пользователю %s: %s", + 'WebSocket уведомление не отправлено пользователю %s: %s', user.id, e, ) @@ -305,18 +307,18 @@ class NotificationDeliveryService: user: User, amount_kopeks: int, new_balance_kopeks: int, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about balance top-up.""" context = { - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "new_balance_kopeks": new_balance_kopeks, - "new_balance_rubles": new_balance_kopeks / 100, - "formatted_amount": settings.format_price(amount_kopeks), - "formatted_balance": settings.format_price(new_balance_kopeks), + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'new_balance_kopeks': new_balance_kopeks, + 'new_balance_rubles': new_balance_kopeks / 100, + 'formatted_amount': settings.format_price(amount_kopeks), + 'formatted_balance': settings.format_price(new_balance_kopeks), } return await self.send_notification( @@ -333,14 +335,14 @@ class NotificationDeliveryService: user: User, days_left: int, expires_at: Any, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about expiring subscription.""" context = { - "days_left": days_left, - "expires_at": str(expires_at), + 'days_left': days_left, + 'expires_at': str(expires_at), } return await self.send_notification( @@ -355,9 +357,9 @@ class NotificationDeliveryService: async def notify_subscription_expired( self, user: User, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about expired subscription.""" return await self.send_notification( @@ -374,16 +376,16 @@ class NotificationDeliveryService: user: User, amount_kopeks: int, new_expires_at: Any, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about successful autopay.""" context = { - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "formatted_amount": settings.format_price(amount_kopeks), - "new_expires_at": str(new_expires_at), + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'formatted_amount': settings.format_price(amount_kopeks), + 'new_expires_at': str(new_expires_at), } return await self.send_notification( @@ -399,13 +401,13 @@ class NotificationDeliveryService: self, user: User, reason: str, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about failed autopay.""" context = { - "reason": reason, + 'reason': reason, } return await self.send_notification( @@ -420,14 +422,14 @@ class NotificationDeliveryService: async def notify_ban( self, user: User, - reason: Optional[str] = None, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + reason: str | None = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about account ban.""" context = { - "reason": reason or "Нарушение правил использования", + 'reason': reason or 'Нарушение правил использования', } return await self.send_notification( @@ -442,9 +444,9 @@ class NotificationDeliveryService: async def notify_unban( self, user: User, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about account unban.""" return await self.send_notification( @@ -461,16 +463,16 @@ class NotificationDeliveryService: user: User, bonus_kopeks: int, referral_name: str, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about referral bonus.""" context = { - "bonus_kopeks": bonus_kopeks, - "bonus_rubles": bonus_kopeks / 100, - "formatted_bonus": settings.format_price(bonus_kopeks), - "referral_name": referral_name, + 'bonus_kopeks': bonus_kopeks, + 'bonus_rubles': bonus_kopeks / 100, + 'formatted_bonus': settings.format_price(bonus_kopeks), + 'referral_name': referral_name, } return await self.send_notification( @@ -487,18 +489,18 @@ class NotificationDeliveryService: user: User, amount_kopeks: int, new_balance_kopeks: int, - bot: Optional[Bot] = None, - telegram_message: Optional[str] = None, - telegram_markup: Optional[Any] = None, + bot: Bot | None = None, + telegram_message: str | None = None, + telegram_markup: Any | None = None, ) -> bool: """Notify user about daily subscription debit.""" context = { - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_kopeks / 100, - "formatted_amount": settings.format_price(amount_kopeks), - "new_balance_kopeks": new_balance_kopeks, - "new_balance_rubles": new_balance_kopeks / 100, - "formatted_balance": settings.format_price(new_balance_kopeks), + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_kopeks / 100, + 'formatted_amount': settings.format_price(amount_kopeks), + 'new_balance_kopeks': new_balance_kopeks, + 'new_balance_rubles': new_balance_kopeks / 100, + 'formatted_balance': settings.format_price(new_balance_kopeks), } return await self.send_notification( diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py index 457b1c00..3df4a45d 100644 --- a/app/services/notification_settings_service.py +++ b/app/services/notification_settings_service.py @@ -2,7 +2,7 @@ import json import logging from copy import deepcopy from pathlib import Path -from typing import Any, Dict +from typing import Any from app.config import settings @@ -13,25 +13,25 @@ logger = logging.getLogger(__name__) class NotificationSettingsService: """Runtime-editable notification settings stored on disk.""" - _storage_path: Path = Path("data/notification_settings.json") - _data: Dict[str, Dict[str, Any]] = {} + _storage_path: Path = Path('data/notification_settings.json') + _data: dict[str, dict[str, Any]] = {} _loaded: bool = False - _DEFAULTS: Dict[str, Dict[str, Any]] = { - "trial_inactive_1h": {"enabled": True}, - "trial_inactive_24h": {"enabled": True}, - "trial_channel_unsubscribed": {"enabled": True}, - "expired_1d": {"enabled": True}, - "expired_second_wave": { - "enabled": True, - "discount_percent": 10, - "valid_hours": 24, + _DEFAULTS: dict[str, dict[str, Any]] = { + 'trial_inactive_1h': {'enabled': True}, + 'trial_inactive_24h': {'enabled': True}, + 'trial_channel_unsubscribed': {'enabled': True}, + 'expired_1d': {'enabled': True}, + 'expired_second_wave': { + 'enabled': True, + 'discount_percent': 10, + 'valid_hours': 24, }, - "expired_third_wave": { - "enabled": True, - "discount_percent": 20, - "valid_hours": 24, - "trigger_days": 5, + 'expired_third_wave': { + 'enabled': True, + 'discount_percent': 20, + 'valid_hours': 24, + 'trigger_days': 5, }, } @@ -40,7 +40,7 @@ class NotificationSettingsService: try: cls._storage_path.parent.mkdir(parents=True, exist_ok=True) except Exception as exc: # pragma: no cover - filesystem guard - logger.error("Failed to create notification settings dir: %s", exc) + logger.error('Failed to create notification settings dir: %s', exc) @classmethod def _load(cls) -> None: @@ -50,12 +50,12 @@ class NotificationSettingsService: cls._ensure_dir() try: if cls._storage_path.exists(): - raw = cls._storage_path.read_text(encoding="utf-8") + raw = cls._storage_path.read_text(encoding='utf-8') cls._data = json.loads(raw) if raw.strip() else {} else: cls._data = {} except Exception as exc: - logger.error("Failed to load notification settings: %s", exc) + logger.error('Failed to load notification settings: %s', exc) cls._data = {} changed = cls._apply_defaults() @@ -85,15 +85,15 @@ class NotificationSettingsService: try: cls._storage_path.write_text( json.dumps(cls._data, ensure_ascii=False, indent=2), - encoding="utf-8", + encoding='utf-8', ) return True except Exception as exc: - logger.error("Failed to save notification settings: %s", exc) + logger.error('Failed to save notification settings: %s', exc) return False @classmethod - def _get(cls, key: str) -> Dict[str, Any]: + def _get(cls, key: str) -> dict[str, Any]: cls._load() value = cls._data.get(key) if not isinstance(value, dict): @@ -102,7 +102,7 @@ class NotificationSettingsService: return value @classmethod - def get_config(cls) -> Dict[str, Dict[str, Any]]: + def get_config(cls) -> dict[str, dict[str, Any]]: cls._load() return deepcopy(cls._data) @@ -116,57 +116,57 @@ class NotificationSettingsService: @classmethod def set_enabled(cls, key: str, enabled: bool) -> bool: - return cls._set_field(key, "enabled", bool(enabled)) + return cls._set_field(key, 'enabled', bool(enabled)) @classmethod def is_enabled(cls, key: str) -> bool: - return bool(cls._get(key).get("enabled", True)) + return bool(cls._get(key).get('enabled', True)) # Trial inactivity helpers @classmethod def is_trial_inactive_1h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_1h") + return cls.is_enabled('trial_inactive_1h') @classmethod def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_1h", enabled) + return cls.set_enabled('trial_inactive_1h', enabled) @classmethod def is_trial_inactive_24h_enabled(cls) -> bool: - return cls.is_enabled("trial_inactive_24h") + return cls.is_enabled('trial_inactive_24h') @classmethod def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_inactive_24h", enabled) + return cls.set_enabled('trial_inactive_24h', enabled) @classmethod def is_trial_channel_unsubscribed_enabled(cls) -> bool: - return cls.is_enabled("trial_channel_unsubscribed") + return cls.is_enabled('trial_channel_unsubscribed') @classmethod def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("trial_channel_unsubscribed", enabled) + return cls.set_enabled('trial_channel_unsubscribed', enabled) # Expired subscription notifications @classmethod def is_expired_1d_enabled(cls) -> bool: - return cls.is_enabled("expired_1d") + return cls.is_enabled('expired_1d') @classmethod def set_expired_1d_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_1d", enabled) + return cls.set_enabled('expired_1d', enabled) @classmethod def is_second_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_second_wave") + return cls.is_enabled('expired_second_wave') @classmethod def set_second_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_second_wave", enabled) + return cls.set_enabled('expired_second_wave', enabled) @classmethod def get_second_wave_discount_percent(cls) -> int: - value = cls._get("expired_second_wave").get("discount_percent", 10) + value = cls._get('expired_second_wave').get('discount_percent', 10) try: return max(0, min(100, int(value))) except (TypeError, ValueError): @@ -178,11 +178,11 @@ class NotificationSettingsService: percent_int = max(0, min(100, int(percent))) except (TypeError, ValueError): return False - return cls._set_field("expired_second_wave", "discount_percent", percent_int) + return cls._set_field('expired_second_wave', 'discount_percent', percent_int) @classmethod def get_second_wave_valid_hours(cls) -> int: - value = cls._get("expired_second_wave").get("valid_hours", 24) + value = cls._get('expired_second_wave').get('valid_hours', 24) try: return max(1, min(168, int(value))) except (TypeError, ValueError): @@ -194,19 +194,19 @@ class NotificationSettingsService: hours_int = max(1, min(168, int(hours))) except (TypeError, ValueError): return False - return cls._set_field("expired_second_wave", "valid_hours", hours_int) + return cls._set_field('expired_second_wave', 'valid_hours', hours_int) @classmethod def is_third_wave_enabled(cls) -> bool: - return cls.is_enabled("expired_third_wave") + return cls.is_enabled('expired_third_wave') @classmethod def set_third_wave_enabled(cls, enabled: bool) -> bool: - return cls.set_enabled("expired_third_wave", enabled) + return cls.set_enabled('expired_third_wave', enabled) @classmethod def get_third_wave_discount_percent(cls) -> int: - value = cls._get("expired_third_wave").get("discount_percent", 20) + value = cls._get('expired_third_wave').get('discount_percent', 20) try: return max(0, min(100, int(value))) except (TypeError, ValueError): @@ -218,11 +218,11 @@ class NotificationSettingsService: percent_int = max(0, min(100, int(percent))) except (TypeError, ValueError): return False - return cls._set_field("expired_third_wave", "discount_percent", percent_int) + return cls._set_field('expired_third_wave', 'discount_percent', percent_int) @classmethod def get_third_wave_valid_hours(cls) -> int: - value = cls._get("expired_third_wave").get("valid_hours", 24) + value = cls._get('expired_third_wave').get('valid_hours', 24) try: return max(1, min(168, int(value))) except (TypeError, ValueError): @@ -234,11 +234,11 @@ class NotificationSettingsService: hours_int = max(1, min(168, int(hours))) except (TypeError, ValueError): return False - return cls._set_field("expired_third_wave", "valid_hours", hours_int) + return cls._set_field('expired_third_wave', 'valid_hours', hours_int) @classmethod def get_third_wave_trigger_days(cls) -> int: - value = cls._get("expired_third_wave").get("trigger_days", 5) + value = cls._get('expired_third_wave').get('trigger_days', 5) try: return max(2, min(60, int(value))) except (TypeError, ValueError): @@ -250,8 +250,8 @@ class NotificationSettingsService: days_int = max(2, min(60, int(days))) except (TypeError, ValueError): return False - return cls._set_field("expired_third_wave", "trigger_days", days_int) + return cls._set_field('expired_third_wave', 'trigger_days', days_int) @classmethod def are_notifications_globally_enabled(cls) -> bool: - return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) + return bool(getattr(settings, 'ENABLE_NOTIFICATIONS', True)) diff --git a/app/services/pal24_service.py b/app/services/pal24_service.py index c565d93f..67a63417 100644 --- a/app/services/pal24_service.py +++ b/app/services/pal24_service.py @@ -5,10 +5,11 @@ from __future__ import annotations import logging from datetime import datetime, timedelta from decimal import Decimal -from typing import Any, Dict, Optional +from typing import Any from app.config import settings -from app.external.pal24_client import Pal24Client, Pal24APIError +from app.external.pal24_client import Pal24APIError, Pal24Client + logger = logging.getLogger(__name__) @@ -16,11 +17,11 @@ logger = logging.getLogger(__name__) class Pal24Service: """Wrapper around :class:`Pal24Client` providing domain helpers.""" - BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"} - BILL_FAILED_STATES = {"FAIL", "CANCELLED"} - BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"} + BILL_SUCCESS_STATES = {'SUCCESS', 'OVERPAID'} + BILL_FAILED_STATES = {'FAIL', 'CANCELLED'} + BILL_PENDING_STATES = {'NEW', 'PROCESS', 'UNDERPAID'} - def __init__(self, client: Optional[Pal24Client] = None) -> None: + def __init__(self, client: Pal24Client | None = None) -> None: self.client = client or Pal24Client() @property @@ -34,29 +35,29 @@ class Pal24Service: user_id: int, order_id: str, description: str, - 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]: + ttl_seconds: int | None = None, + custom_payload: dict[str, Any] | None = None, + payer_email: str | None = None, + payment_method: str | None = None, + ) -> dict[str, Any]: if not self.is_configured: - raise Pal24APIError("Pal24 service is not configured") + raise Pal24APIError('Pal24 service is not configured') amount_decimal = Pal24Client.normalize_amount(amount_kopeks) - extra_payload: Dict[str, Any] = { - "custom": custom_payload or {}, - "ttl": ttl_seconds, + extra_payload: dict[str, Any] = { + 'custom': custom_payload or {}, + 'ttl': ttl_seconds, } if payer_email: - extra_payload["payer_email"] = payer_email + extra_payload['payer_email'] = payer_email if payment_method: - extra_payload["payment_method"] = payment_method + extra_payload['payment_method'] = payment_method filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})} logger.info( - "Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s", + 'Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s', user_id, order_id, amount_decimal, @@ -68,46 +69,46 @@ class Pal24Service: shop_id=settings.PAL24_SHOP_ID, order_id=order_id, description=description, - type_="normal", + type_='normal', **filtered_payload, ) - logger.info("Pal24 счет создан: %s", response) + logger.info('Pal24 счет создан: %s', response) return response - async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: - logger.debug("Запрашиваем статус Pal24 счета %s", bill_id) + async def get_bill_status(self, bill_id: str) -> dict[str, Any]: + logger.debug('Запрашиваем статус Pal24 счета %s', bill_id) return await self.client.get_bill_status(bill_id) - async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: - logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id) + async def get_payment_status(self, payment_id: str) -> dict[str, Any]: + logger.debug('Запрашиваем статус Pal24 платежа %s', payment_id) return await self.client.get_payment_status(payment_id) - async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]: + async def get_bill_payments(self, bill_id: str) -> dict[str, Any]: """Возвращает список платежей, связанных со счетом.""" - logger.debug("Запрашиваем платежи Pal24 счёта %s", bill_id) + logger.debug('Запрашиваем платежи Pal24 счёта %s', bill_id) return await self.client.get_bill_payments(bill_id) @staticmethod - def parse_callback(payload: Dict[str, Any]) -> Dict[str, Any]: - required_fields = ["InvId", "OutSum", "Status", "SignatureValue"] + def parse_callback(payload: dict[str, Any]) -> dict[str, Any]: + required_fields = ['InvId', 'OutSum', 'Status', 'SignatureValue'] missing = [field for field in required_fields if field not in payload] if missing: - raise Pal24APIError(f"Pal24 callback missing fields: {', '.join(missing)}") + raise Pal24APIError(f'Pal24 callback missing fields: {", ".join(missing)}') - inv_id = str(payload["InvId"]) - out_sum = str(payload["OutSum"]) - signature = str(payload["SignatureValue"]) + inv_id = str(payload['InvId']) + out_sum = str(payload['OutSum']) + signature = str(payload['SignatureValue']) if not Pal24Client.verify_signature(out_sum, inv_id, signature): - raise Pal24APIError("Pal24 callback signature mismatch") + raise Pal24APIError('Pal24 callback signature mismatch') logger.info( - "Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s", + 'Получен Pal24 callback: InvId=%s, Status=%s, TrsId=%s', inv_id, - payload.get("Status"), - payload.get("TrsId"), + payload.get('Status'), + payload.get('TrsId'), ) return payload @@ -115,11 +116,10 @@ class Pal24Service: @staticmethod def convert_to_kopeks(amount: str) -> int: decimal_amount = Decimal(str(amount)) - return int((decimal_amount * Decimal("100")).quantize(Decimal("1"))) + return int((decimal_amount * Decimal(100)).quantize(Decimal(1))) @staticmethod - def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]: + def get_expiration(ttl_seconds: int | None) -> datetime | None: if not ttl_seconds: return None return datetime.utcnow() + timedelta(seconds=ttl_seconds) - diff --git a/app/services/partner_stats_service.py b/app/services/partner_stats_service.py index abf5c2a1..51e91001 100644 --- a/app/services/partner_stats_service.py +++ b/app/services/partner_stats_service.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any from sqlalchemy import and_, case, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +16,7 @@ from app.database.models import ( User, ) + logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ class PartnerStatsService: cls, db: AsyncSession, user_id: int, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Получить детальную статистику реферера.""" now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) @@ -66,19 +67,20 @@ class PartnerStatsService: # Заработки по периодам - один запрос с CASE WHEN earnings_result = await db.execute( select( - func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("today"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("week"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("month"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("year"), + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('all_time'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)), + 0, + ).label('today'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('week'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('month'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('year'), ).where(ReferralEarning.user_id == user_id) ) earnings_row = earnings_result.one() @@ -102,28 +104,28 @@ class PartnerStatsService: avg_earnings_per_referral = round(earnings_all_time / paid_referrals, 2) if paid_referrals > 0 else 0 return { - "user_id": user_id, - "summary": { - "total_referrals": total_referrals, - "paid_referrals": paid_referrals, - "active_referrals": active_referrals, - "conversion_to_paid_percent": conversion_to_paid, - "conversion_to_active_percent": conversion_to_active, - "avg_earnings_per_referral_kopeks": avg_earnings_per_referral, + 'user_id': user_id, + 'summary': { + 'total_referrals': total_referrals, + 'paid_referrals': paid_referrals, + 'active_referrals': active_referrals, + 'conversion_to_paid_percent': conversion_to_paid, + 'conversion_to_active_percent': conversion_to_active, + 'avg_earnings_per_referral_kopeks': avg_earnings_per_referral, }, - "earnings": { - "all_time_kopeks": earnings_all_time, - "year_kopeks": earnings_year, - "month_kopeks": earnings_month, - "week_kopeks": earnings_week, - "today_kopeks": earnings_today, + 'earnings': { + 'all_time_kopeks': earnings_all_time, + 'year_kopeks': earnings_year, + 'month_kopeks': earnings_month, + 'week_kopeks': earnings_week, + 'today_kopeks': earnings_today, }, - "referrals_count": { - "all_time": total_referrals, - "year": referrals_year, - "month": referrals_month, - "week": referrals_week, - "today": referrals_today, + 'referrals_count': { + 'all_time': total_referrals, + 'year': referrals_year, + 'month': referrals_month, + 'week': referrals_week, + 'today': referrals_today, }, } @@ -133,7 +135,7 @@ class PartnerStatsService: db: AsyncSession, user_id: int, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить статистику реферера по дням.""" now = datetime.utcnow() start_date = now - timedelta(days=days) @@ -141,8 +143,8 @@ class PartnerStatsService: # Рефералы по дням referrals_by_day = await db.execute( select( - func.date(User.created_at).label("date"), - func.count(User.id).label("referrals_count"), + func.date(User.created_at).label('date'), + func.count(User.id).label('referrals_count'), ) .where( and_( @@ -158,8 +160,8 @@ class PartnerStatsService: # Заработки по дням (из ReferralEarning) earnings_by_day = await db.execute( select( - func.date(ReferralEarning.created_at).label("date"), - func.sum(ReferralEarning.amount_kopeks).label("earnings"), + func.date(ReferralEarning.created_at).label('date'), + func.sum(ReferralEarning.amount_kopeks).label('earnings'), ) .where( and_( @@ -176,11 +178,13 @@ class PartnerStatsService: for i in range(days): date = (start_date + timedelta(days=i)).date() date_str = str(date) - result.append({ - "date": date_str, - "referrals_count": referrals_dict.get(date_str, 0), - "earnings_kopeks": earnings_dict.get(date_str, 0), - }) + result.append( + { + 'date': date_str, + 'referrals_count': referrals_dict.get(date_str, 0), + 'earnings_kopeks': earnings_dict.get(date_str, 0), + } + ) return result @@ -190,7 +194,7 @@ class PartnerStatsService: db: AsyncSession, user_id: int, limit: int = 10, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Получить топ рефералов по доходу для реферера.""" now = datetime.utcnow() @@ -204,7 +208,7 @@ class PartnerStatsService: User.last_name, User.created_at, User.has_made_first_topup, - func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("total_earnings"), + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earnings'), ) .outerjoin(ReferralEarning, ReferralEarning.referral_id == User.id) .where(User.referred_by_id == user_id) @@ -222,8 +226,7 @@ class PartnerStatsService: # Получаем все активные подписки для этих пользователей одним запросом active_subs_result = await db.execute( - select(Subscription.user_id) - .where( + select(Subscription.user_id).where( and_( Subscription.user_id.in_(user_ids), Subscription.status == SubscriptionStatus.ACTIVE.value, @@ -235,18 +238,22 @@ class PartnerStatsService: referrals = [] for row in rows: - referrals.append({ - "id": row.id, - "telegram_id": row.telegram_id, - "username": row.username, - "first_name": row.first_name, - "last_name": row.last_name, - "full_name": f"{row.first_name or ''} {row.last_name or ''}".strip() or (row.telegram_id and f"User {row.telegram_id}") or f"User #{row.id}", - "created_at": row.created_at, - "has_made_first_topup": row.has_made_first_topup, - "is_active": row.id in active_user_ids, - "total_earnings_kopeks": int(row.total_earnings), - }) + referrals.append( + { + 'id': row.id, + 'telegram_id': row.telegram_id, + 'username': row.username, + 'first_name': row.first_name, + 'last_name': row.last_name, + 'full_name': f'{row.first_name or ""} {row.last_name or ""}'.strip() + or (row.telegram_id and f'User {row.telegram_id}') + or f'User #{row.id}', + 'created_at': row.created_at, + 'has_made_first_topup': row.has_made_first_topup, + 'is_active': row.id in active_user_ids, + 'total_earnings_kopeks': int(row.total_earnings), + } + ) return referrals @@ -257,7 +264,7 @@ class PartnerStatsService: user_id: int, current_days: int = 7, previous_days: int = 7, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Сравнить текущий и предыдущий период.""" now = datetime.utcnow() current_start = now - timedelta(days=current_days) @@ -266,8 +273,7 @@ class PartnerStatsService: # Рефералы за текущий период current_referrals = await db.execute( - select(func.count(User.id)) - .where( + select(func.count(User.id)).where( and_( User.referred_by_id == user_id, User.created_at >= current_start, @@ -278,8 +284,7 @@ class PartnerStatsService: # Рефералы за предыдущий период previous_referrals = await db.execute( - select(func.count(User.id)) - .where( + select(func.count(User.id)).where( and_( User.referred_by_id == user_id, User.created_at >= previous_start, @@ -293,50 +298,42 @@ class PartnerStatsService: current_earnings = await cls._get_earnings_for_period(db, user_id, current_start) # Заработки за предыдущий период - previous_earnings = await cls._get_earnings_for_period( - db, user_id, previous_start, previous_end - ) + previous_earnings = await cls._get_earnings_for_period(db, user_id, previous_start, previous_end) # Расчёт изменений referrals_change = current_referrals_count - previous_referrals_count referrals_change_percent = ( - round((referrals_change / previous_referrals_count * 100), 2) - if previous_referrals_count > 0 - else 0 + round((referrals_change / previous_referrals_count * 100), 2) if previous_referrals_count > 0 else 0 ) earnings_change = current_earnings - previous_earnings - earnings_change_percent = ( - round((earnings_change / previous_earnings * 100), 2) - if previous_earnings > 0 - else 0 - ) + earnings_change_percent = round((earnings_change / previous_earnings * 100), 2) if previous_earnings > 0 else 0 return { - "current_period": { - "days": current_days, - "start": current_start.isoformat(), - "end": now.isoformat(), - "referrals_count": current_referrals_count, - "earnings_kopeks": current_earnings, + 'current_period': { + 'days': current_days, + 'start': current_start.isoformat(), + 'end': now.isoformat(), + 'referrals_count': current_referrals_count, + 'earnings_kopeks': current_earnings, }, - "previous_period": { - "days": previous_days, - "start": previous_start.isoformat(), - "end": previous_end.isoformat(), - "referrals_count": previous_referrals_count, - "earnings_kopeks": previous_earnings, + 'previous_period': { + 'days': previous_days, + 'start': previous_start.isoformat(), + 'end': previous_end.isoformat(), + 'referrals_count': previous_referrals_count, + 'earnings_kopeks': previous_earnings, }, - "change": { - "referrals": { - "absolute": referrals_change, - "percent": referrals_change_percent, - "trend": "up" if referrals_change > 0 else "down" if referrals_change < 0 else "stable", + 'change': { + 'referrals': { + 'absolute': referrals_change, + 'percent': referrals_change_percent, + 'trend': 'up' if referrals_change > 0 else 'down' if referrals_change < 0 else 'stable', }, - "earnings": { - "absolute": earnings_change, - "percent": earnings_change_percent, - "trend": "up" if earnings_change > 0 else "down" if earnings_change < 0 else "stable", + 'earnings': { + 'absolute': earnings_change, + 'percent': earnings_change_percent, + 'trend': 'up' if earnings_change > 0 else 'down' if earnings_change < 0 else 'stable', }, }, } @@ -346,33 +343,28 @@ class PartnerStatsService: cls, db: AsyncSession, days: int = 30, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Глобальная статистика партнёрской программы.""" now = datetime.utcnow() today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_ago = now - timedelta(days=7) month_ago = now - timedelta(days=30) year_ago = now - timedelta(days=365) - start_date = now - timedelta(days=days) + now - timedelta(days=days) # Всего рефереров (у кого есть рефералы) total_referrers = await db.execute( - select(func.count(func.distinct(User.referred_by_id))) - .where(User.referred_by_id.isnot(None)) + select(func.count(func.distinct(User.referred_by_id))).where(User.referred_by_id.isnot(None)) ) total_referrers_count = total_referrers.scalar() or 0 # Всего рефералов - total_referrals = await db.execute( - select(func.count(User.id)) - .where(User.referred_by_id.isnot(None)) - ) + total_referrals = await db.execute(select(func.count(User.id)).where(User.referred_by_id.isnot(None))) total_referrals_count = total_referrals.scalar() or 0 # Рефералы которые заплатили paid_referrals = await db.execute( - select(func.count(User.id)) - .where( + select(func.count(User.id)).where( and_( User.referred_by_id.isnot(None), User.has_made_first_topup.is_(True), @@ -384,19 +376,20 @@ class PartnerStatsService: # Всего выплачено - один запрос с CASE WHEN payouts_result = await db.execute( select( - func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label("all_time"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("today"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("week"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("month"), - func.coalesce(func.sum( - case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0) - ), 0).label("year"), + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('all_time'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= today_start, ReferralEarning.amount_kopeks), else_=0)), + 0, + ).label('today'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= week_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('week'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= month_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('month'), + func.coalesce( + func.sum(case((ReferralEarning.created_at >= year_ago, ReferralEarning.amount_kopeks), else_=0)), 0 + ).label('year'), ) ) payouts_row = payouts_result.one() @@ -409,9 +402,9 @@ class PartnerStatsService: # Новые рефералы по периодам - один запрос с CASE WHEN new_referrals_result = await db.execute( select( - func.sum(case((User.created_at >= today_start, 1), else_=0)).label("today"), - func.sum(case((User.created_at >= week_ago, 1), else_=0)).label("week"), - func.sum(case((User.created_at >= month_ago, 1), else_=0)).label("month"), + func.sum(case((User.created_at >= today_start, 1), else_=0)).label('today'), + func.sum(case((User.created_at >= week_ago, 1), else_=0)).label('week'), + func.sum(case((User.created_at >= month_ago, 1), else_=0)).label('month'), ).where(User.referred_by_id.isnot(None)) ) new_referrals_row = new_referrals_result.one() @@ -421,37 +414,31 @@ class PartnerStatsService: # Конверсия conversion_rate = ( - round((paid_referrals_count / total_referrals_count * 100), 2) - if total_referrals_count > 0 - else 0 + round((paid_referrals_count / total_referrals_count * 100), 2) if total_referrals_count > 0 else 0 ) # Средний доход с реферала - avg_per_referral = ( - round(total_paid / paid_referrals_count, 2) - if paid_referrals_count > 0 - else 0 - ) + avg_per_referral = round(total_paid / paid_referrals_count, 2) if paid_referrals_count > 0 else 0 return { - "summary": { - "total_referrers": total_referrers_count, - "total_referrals": total_referrals_count, - "paid_referrals": paid_referrals_count, - "conversion_rate_percent": conversion_rate, - "avg_earnings_per_referral_kopeks": avg_per_referral, + 'summary': { + 'total_referrers': total_referrers_count, + 'total_referrals': total_referrals_count, + 'paid_referrals': paid_referrals_count, + 'conversion_rate_percent': conversion_rate, + 'avg_earnings_per_referral_kopeks': avg_per_referral, }, - "payouts": { - "all_time_kopeks": total_paid, - "year_kopeks": year_paid, - "month_kopeks": month_paid, - "week_kopeks": week_paid, - "today_kopeks": today_paid, + 'payouts': { + 'all_time_kopeks': total_paid, + 'year_kopeks': year_paid, + 'month_kopeks': month_paid, + 'week_kopeks': week_paid, + 'today_kopeks': today_paid, }, - "new_referrals": { - "today": new_referrals_today_count, - "week": new_referrals_week_count, - "month": new_referrals_month_count, + 'new_referrals': { + 'today': new_referrals_today_count, + 'week': new_referrals_week_count, + 'month': new_referrals_month_count, }, } @@ -460,7 +447,7 @@ class PartnerStatsService: cls, db: AsyncSession, days: int = 30, - ) -> List[Dict[str, Any]]: + ) -> list[dict[str, Any]]: """Глобальная статистика по дням.""" now = datetime.utcnow() start_date = now - timedelta(days=days) @@ -468,8 +455,8 @@ class PartnerStatsService: # Рефералы по дням referrals_by_day = await db.execute( select( - func.date(User.created_at).label("date"), - func.count(User.id).label("referrals_count"), + func.date(User.created_at).label('date'), + func.count(User.id).label('referrals_count'), ) .where( and_( @@ -484,8 +471,8 @@ class PartnerStatsService: # Выплаты по дням earnings_by_day = await db.execute( select( - func.date(ReferralEarning.created_at).label("date"), - func.sum(ReferralEarning.amount_kopeks).label("earnings"), + func.date(ReferralEarning.created_at).label('date'), + func.sum(ReferralEarning.amount_kopeks).label('earnings'), ) .where(ReferralEarning.created_at >= start_date) .group_by(func.date(ReferralEarning.created_at)) @@ -496,11 +483,13 @@ class PartnerStatsService: for i in range(days): date = (start_date + timedelta(days=i)).date() date_str = str(date) - result.append({ - "date": date_str, - "referrals_count": referrals_dict.get(date_str, 0), - "earnings_kopeks": earnings_dict.get(date_str, 0), - }) + result.append( + { + 'date': date_str, + 'referrals_count': referrals_dict.get(date_str, 0), + 'earnings_kopeks': earnings_dict.get(date_str, 0), + } + ) return result @@ -509,20 +498,17 @@ class PartnerStatsService: cls, db: AsyncSession, limit: int = 10, - days: Optional[int] = None, - ) -> List[Dict[str, Any]]: + days: int | None = None, + ) -> list[dict[str, Any]]: """Получить топ рефереров.""" now = datetime.utcnow() start_date = now - timedelta(days=days) if days else None # Подсчёт рефералов и заработков - earnings_query = ( - select( - ReferralEarning.user_id, - func.sum(ReferralEarning.amount_kopeks).label("total_earnings"), - ) - .group_by(ReferralEarning.user_id) - ) + earnings_query = select( + ReferralEarning.user_id, + func.sum(ReferralEarning.amount_kopeks).label('total_earnings'), + ).group_by(ReferralEarning.user_id) if start_date: earnings_query = earnings_query.where(ReferralEarning.created_at >= start_date) @@ -533,7 +519,7 @@ class PartnerStatsService: referrals_query = ( select( User.referred_by_id, - func.count(User.id).label("referrals_count"), + func.count(User.id).label('referrals_count'), ) .where(User.referred_by_id.isnot(None)) .group_by(User.referred_by_id) @@ -549,42 +535,47 @@ class PartnerStatsService: referrers_data = [] for referrer_id in all_referrer_ids: - referrers_data.append({ - "user_id": referrer_id, - "referrals_count": referrals_dict.get(referrer_id, 0), - "total_earnings": earnings_dict.get(referrer_id, 0), - }) + referrers_data.append( + { + 'user_id': referrer_id, + 'referrals_count': referrals_dict.get(referrer_id, 0), + 'total_earnings': earnings_dict.get(referrer_id, 0), + } + ) # Сортируем по заработку - referrers_data.sort(key=lambda x: x["total_earnings"], reverse=True) + referrers_data.sort(key=lambda x: x['total_earnings'], reverse=True) top_referrers = referrers_data[:limit] if not top_referrers: return [] # Получаем данные всех пользователей одним запросом - top_user_ids = [data["user_id"] for data in top_referrers] - users_result = await db.execute( - select(User).where(User.id.in_(top_user_ids)) - ) + top_user_ids = [data['user_id'] for data in top_referrers] + users_result = await db.execute(select(User).where(User.id.in_(top_user_ids))) users_dict = {user.id: user for user in users_result.scalars().all()} # Формируем результат с сохранением порядка сортировки result = [] for data in top_referrers: - user = users_dict.get(data["user_id"]) + user = users_dict.get(data['user_id']) if user: - result.append({ - "id": user.id, - "telegram_id": user.telegram_id, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "full_name": f"{user.first_name or ''} {user.last_name or ''}".strip() or (user.telegram_id and f"User {user.telegram_id}") or user.email or f"User #{user.id}", - "referral_code": user.referral_code, - "referrals_count": data["referrals_count"], - "total_earnings_kopeks": data["total_earnings"], - }) + result.append( + { + 'id': user.id, + 'telegram_id': user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'full_name': f'{user.first_name or ""} {user.last_name or ""}'.strip() + or (user.telegram_id and f'User {user.telegram_id}') + or user.email + or f'User #{user.id}', + 'referral_code': user.referral_code, + 'referrals_count': data['referrals_count'], + 'total_earnings_kopeks': data['total_earnings'], + } + ) return result @@ -593,8 +584,8 @@ class PartnerStatsService: cls, db: AsyncSession, user_id: int, - start_date: Optional[datetime], - end_date: Optional[datetime] = None, + start_date: datetime | None, + end_date: datetime | None = None, ) -> int: """Получить заработки за период.""" query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( @@ -613,7 +604,7 @@ class PartnerStatsService: async def _get_total_earnings( cls, db: AsyncSession, - start_date: Optional[datetime], + start_date: datetime | None, ) -> int: """Получить общие выплаты за период.""" query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index ce0aa62f..f832d417 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -4,32 +4,33 @@ оставался компактным и импортировал только нужные компоненты. """ +from .cloudpayments import CloudPaymentsPaymentMixin from .common import PaymentCommonMixin -from .stars import TelegramStarsMixin -from .yookassa import YooKassaPaymentMixin -from .tribute import TributePaymentMixin from .cryptobot import CryptoBotPaymentMixin +from .freekassa import FreekassaPaymentMixin from .heleket import HeleketPaymentMixin +from .kassa_ai import KassaAiPaymentMixin from .mulenpay import MulenPayPaymentMixin from .pal24 import Pal24PaymentMixin from .platega import PlategaPaymentMixin +from .stars import TelegramStarsMixin +from .tribute import TributePaymentMixin from .wata import WataPaymentMixin -from .cloudpayments import CloudPaymentsPaymentMixin -from .freekassa import FreekassaPaymentMixin -from .kassa_ai import KassaAiPaymentMixin +from .yookassa import YooKassaPaymentMixin + __all__ = [ - "PaymentCommonMixin", - "TelegramStarsMixin", - "YooKassaPaymentMixin", - "TributePaymentMixin", - "CryptoBotPaymentMixin", - "HeleketPaymentMixin", - "MulenPayPaymentMixin", - "Pal24PaymentMixin", - "PlategaPaymentMixin", - "WataPaymentMixin", - "CloudPaymentsPaymentMixin", - "FreekassaPaymentMixin", - "KassaAiPaymentMixin", + 'CloudPaymentsPaymentMixin', + 'CryptoBotPaymentMixin', + 'FreekassaPaymentMixin', + 'HeleketPaymentMixin', + 'KassaAiPaymentMixin', + 'MulenPayPaymentMixin', + 'Pal24PaymentMixin', + 'PaymentCommonMixin', + 'PlategaPaymentMixin', + 'TelegramStarsMixin', + 'TributePaymentMixin', + 'WataPaymentMixin', + 'YooKassaPaymentMixin', ] diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index e771d86d..b5c5980a 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -2,22 +2,21 @@ from __future__ import annotations -import json from datetime import datetime from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType +from app.services.cloudpayments_service import CloudPaymentsAPIError from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class CloudPaymentsPaymentMixin: @@ -30,10 +29,10 @@ class CloudPaymentsPaymentMixin: amount_kopeks: int, description: str, *, - telegram_id: Optional[int] = None, - language: Optional[str] = None, - email: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + telegram_id: int | None = None, + language: str | None = None, + email: str | None = None, + ) -> dict[str, Any] | None: """ Create a CloudPayments payment and return payment link info. @@ -49,13 +48,13 @@ class CloudPaymentsPaymentMixin: Returns: Dict with payment_url and invoice_id, or None on error """ - if not getattr(self, "cloudpayments_service", None): - logger.error("CloudPayments service is not initialised") + if not getattr(self, 'cloudpayments_service', None): + logger.error('CloudPayments service is not initialised') return None if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: logger.warning( - "Сумма CloudPayments меньше минимальной: %s < %s", + 'Сумма CloudPayments меньше минимальной: %s < %s', amount_kopeks, settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, ) @@ -63,13 +62,13 @@ class CloudPaymentsPaymentMixin: if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма CloudPayments больше максимальной: %s > %s", + 'Сумма CloudPayments больше максимальной: %s > %s', amount_kopeks, settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, ) return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') # Generate unique invoice ID (use user_id for uniqueness, works for email-only users too) invoice_id = self.cloudpayments_service.generate_invoice_id(user_id) @@ -85,15 +84,15 @@ class CloudPaymentsPaymentMixin: email=email, ) except CloudPaymentsAPIError as error: - logger.error("Ошибка создания CloudPayments платежа: %s", error) + logger.error('Ошибка создания CloudPayments платежа: %s', error) return None except Exception as error: - logger.exception("Непредвиденная ошибка при создании CloudPayments платежа: %s", error) + logger.exception('Непредвиденная ошибка при создании CloudPayments платежа: %s', error) return None metadata = { - "language": language or settings.DEFAULT_LANGUAGE, - "telegram_id": telegram_id, + 'language': language or settings.DEFAULT_LANGUAGE, + 'telegram_id': telegram_id, } # Create local payment record @@ -109,26 +108,26 @@ class CloudPaymentsPaymentMixin: ) if not local_payment: - logger.error("Не удалось создать локальную запись CloudPayments платежа") + logger.error('Не удалось создать локальную запись CloudPayments платежа') return None logger.info( - "Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s", + 'Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s', invoice_id, amount_kopeks / 100, user_id, ) return { - "payment_url": payment_url, - "invoice_id": invoice_id, - "payment_id": local_payment.id, + 'payment_url': payment_url, + 'invoice_id': invoice_id, + 'payment_id': local_payment.id, } async def process_cloudpayments_pay_webhook( self, db: AsyncSession, - webhook_data: Dict[str, Any], + webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Pay webhook (successful payment). @@ -140,26 +139,26 @@ class CloudPaymentsPaymentMixin: Returns: True if payment was processed successfully """ - invoice_id = webhook_data.get("invoice_id") - transaction_id_cp = webhook_data.get("transaction_id") - amount = webhook_data.get("amount", 0) + invoice_id = webhook_data.get('invoice_id') + transaction_id_cp = webhook_data.get('transaction_id') + amount = webhook_data.get('amount', 0) amount_kopeks = int(amount * 100) - account_id = webhook_data.get("account_id", "") - token = webhook_data.get("token") - test_mode = webhook_data.get("test_mode", False) + account_id = webhook_data.get('account_id', '') + token = webhook_data.get('token') + test_mode = webhook_data.get('test_mode', False) if not invoice_id: - logger.error("CloudPayments webhook без invoice_id") + logger.error('CloudPayments webhook без invoice_id') return False - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') # Find existing payment record payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id) if not payment: logger.warning( - "CloudPayments платёж не найден: invoice=%s, создаём новый", + 'CloudPayments платёж не найден: invoice=%s, создаём новый', invoice_id, ) # Try to extract user_id from account_id (we now use user_id as AccountId) @@ -169,14 +168,15 @@ class CloudPaymentsPaymentMixin: user_id = None if not user_id: - logger.error("Не удалось определить user_id из account_id: %s", account_id) + logger.error('Не удалось определить user_id из account_id: %s', account_id) return False # Get user by ID from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) if not user: - logger.error("Пользователь не найден: id=%s", user_id) + logger.error('Пользователь не найден: id=%s', user_id) return False # Create payment record @@ -190,36 +190,37 @@ class CloudPaymentsPaymentMixin: ) if not payment: - logger.error("Не удалось создать запись платежа") + logger.error('Не удалось создать запись платежа') return False # Check if already processed if payment.is_paid: - logger.info("CloudPayments платёж уже обработан: invoice=%s", invoice_id) + logger.info('CloudPayments платёж уже обработан: invoice=%s', invoice_id) return True # Update payment record payment.transaction_id_cp = transaction_id_cp - payment.status = "completed" + payment.status = 'completed' payment.is_paid = True payment.paid_at = datetime.utcnow() payment.token = token - payment.card_first_six = webhook_data.get("card_first_six") - payment.card_last_four = webhook_data.get("card_last_four") - payment.card_type = webhook_data.get("card_type") - payment.card_exp_date = webhook_data.get("card_exp_date") - payment.email = webhook_data.get("email") + payment.card_first_six = webhook_data.get('card_first_six') + payment.card_last_four = webhook_data.get('card_last_four') + payment.card_type = webhook_data.get('card_type') + payment.card_exp_date = webhook_data.get('card_exp_date') + payment.email = webhook_data.get('email') payment.test_mode = test_mode payment.callback_payload = webhook_data await db.flush() # Get user - from app.database.crud.user import get_user_by_id, add_user_balance + from app.database.crud.user import add_user_balance, get_user_by_id + user = await get_user_by_id(db, payment.user_id) if not user: - logger.error("Пользователь не найден: id=%s", payment.user_id) + logger.error('Пользователь не найден: id=%s', payment.user_id) return False # Add balance @@ -227,6 +228,7 @@ class CloudPaymentsPaymentMixin: # Create transaction record from app.database.crud.transaction import create_transaction + transaction = await create_transaction( db=db, user_id=user.id, @@ -241,9 +243,9 @@ class CloudPaymentsPaymentMixin: payment.transaction_id = transaction.id await db.commit() - user_id_display = user.telegram_id or user.email or f"#{user.id}" + user_id_display = user.telegram_id or user.email or f'#{user.id}' logger.info( - "CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s", + 'CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s', invoice_id, amount_kopeks / 100, user_id_display, @@ -257,33 +259,31 @@ class CloudPaymentsPaymentMixin: transaction=transaction, ) except Exception as error: - logger.exception("Ошибка отправки уведомления CloudPayments: %s", error) + logger.exception('Ошибка отправки уведомления CloudPayments: %s', error) # Auto-purchase if enabled auto_purchase_success = False try: - auto_purchase_success = await auto_purchase_saved_cart_after_topup( - db, user, bot=getattr(self, "bot", None) - ) + auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None)) except Exception as error: - logger.exception("Ошибка автопокупки после CloudPayments: %s", error) + logger.exception('Ошибка автопокупки после CloudPayments: %s', error) # Умная автоактивация если автопокупка не сработала if not auto_purchase_success: try: # Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks ) except Exception as error: - logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error) + logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error) return True async def process_cloudpayments_fail_webhook( self, db: AsyncSession, - webhook_data: Dict[str, Any], + webhook_data: dict[str, Any], ) -> bool: """ Process CloudPayments Fail webhook (failed payment). @@ -295,28 +295,28 @@ class CloudPaymentsPaymentMixin: Returns: True if processed successfully """ - invoice_id = webhook_data.get("invoice_id") - reason = webhook_data.get("reason", "Unknown") - reason_code = webhook_data.get("reason_code") - card_holder_message = webhook_data.get("card_holder_message", reason) - account_id = webhook_data.get("account_id", "") + invoice_id = webhook_data.get('invoice_id') + reason = webhook_data.get('reason', 'Unknown') + reason_code = webhook_data.get('reason_code') + card_holder_message = webhook_data.get('card_holder_message', reason) + account_id = webhook_data.get('account_id', '') if not invoice_id: - logger.warning("CloudPayments fail webhook без invoice_id") + logger.warning('CloudPayments fail webhook без invoice_id') return True - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') # Find payment record payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id) if payment: - payment.status = "failed" + payment.status = 'failed' payment.callback_payload = webhook_data await db.commit() logger.info( - "CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)", + 'CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)', invoice_id, reason, reason_code, @@ -327,8 +327,10 @@ class CloudPaymentsPaymentMixin: user_id = int(account_id) if account_id else None if user_id: from app.database.crud.user import get_user_by_id + # Need a new session for this query since we're outside the main flow from app.database.session import async_session_factory + async with async_session_factory() as session: user = await get_user_by_id(session, user_id) if user and user.telegram_id: @@ -337,7 +339,7 @@ class CloudPaymentsPaymentMixin: message=card_holder_message, ) except Exception as error: - logger.exception("Ошибка отправки уведомления о неуспешном платеже: %s", error) + logger.exception('Ошибка отправки уведомления о неуспешном платеже: %s', error) return True @@ -356,7 +358,7 @@ class CloudPaymentsPaymentMixin: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug("Skipping CloudPayments notification for email-only user %s", user.id) + logger.debug('Skipping CloudPayments notification for email-only user %s', user.id) return texts = get_texts(user.language) @@ -368,29 +370,29 @@ class CloudPaymentsPaymentMixin: new_balance = user.balance_kopeks / 100 message = texts.t( - "PAYMENT_SUCCESS_CLOUDPAYMENTS", - "✅ Оплата получена!\n\n" - "💰 Сумма: {amount}₽\n" - "💳 Способ: CloudPayments\n" - "💵 Баланс: {balance}₽\n\n" - "Спасибо за пополнение!", + 'PAYMENT_SUCCESS_CLOUDPAYMENTS', + '✅ Оплата получена!\n\n' + '💰 Сумма: {amount}₽\n' + '💳 Способ: CloudPayments\n' + '💵 Баланс: {balance}₽\n\n' + 'Спасибо за пополнение!', ).format( - amount=f"{amount_rub:.2f}", - balance=f"{new_balance:.2f}", + amount=f'{amount_rub:.2f}', + balance=f'{new_balance:.2f}', ) if referrer_info: - message += f"\n\n{referrer_info}" + message += f'\n\n{referrer_info}' try: await bot.send_message( chat_id=user.telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.warning("Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю %s: %s', user.telegram_id, error) async def _send_cloudpayments_fail_notification( self, @@ -403,22 +405,22 @@ class CloudPaymentsPaymentMixin: if not bot: return - text = f"❌ Оплата не прошла\n\n{message}" + text = f'❌ Оплата не прошла\n\n{message}' try: await bot.send_message( chat_id=telegram_id, text=text, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: - logger.warning("Не удалось отправить уведомление пользователю %s: %s", telegram_id, error) + logger.warning('Не удалось отправить уведомление пользователю %s: %s', telegram_id, error) async def get_cloudpayments_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Check CloudPayments payment status via API. @@ -429,69 +431,69 @@ class CloudPaymentsPaymentMixin: Returns: Dict with payment info or None if not found """ - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') # Get local payment record payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id) if not payment: - logger.warning("CloudPayments payment not found: id=%s", local_payment_id) + logger.warning('CloudPayments payment not found: id=%s', local_payment_id) return None # If already paid, return current state if payment.is_paid: - return {"payment": payment, "status": "completed"} + return {'payment': payment, 'status': 'completed'} # Check with CloudPayments API - if not getattr(self, "cloudpayments_service", None): - logger.warning("CloudPayments service not initialized") - return {"payment": payment, "status": payment.status} + if not getattr(self, 'cloudpayments_service', None): + logger.warning('CloudPayments service not initialized') + return {'payment': payment, 'status': payment.status} try: # Try to find payment by invoice_id api_response = await self.cloudpayments_service.find_payment(payment.invoice_id) - if not api_response.get("Success"): + if not api_response.get('Success'): logger.debug( - "CloudPayments API: payment not found or error for invoice=%s", + 'CloudPayments API: payment not found or error for invoice=%s', payment.invoice_id, ) - return {"payment": payment, "status": payment.status} + return {'payment': payment, 'status': payment.status} - model = api_response.get("Model", {}) - api_status = model.get("Status", "") - transaction_id_cp = model.get("TransactionId") + model = api_response.get('Model', {}) + api_status = model.get('Status', '') + transaction_id_cp = model.get('TransactionId') # Update local record if status changed - if api_status == "Completed" and not payment.is_paid: + if api_status == 'Completed' and not payment.is_paid: # Payment completed - process it webhook_data = { - "invoice_id": payment.invoice_id, - "transaction_id": transaction_id_cp, - "amount": model.get("Amount", 0), - "account_id": model.get("AccountId", ""), - "token": model.get("Token"), - "card_first_six": model.get("CardFirstSix"), - "card_last_four": model.get("CardLastFour"), - "card_type": model.get("CardType"), - "card_exp_date": model.get("CardExpDate"), - "email": model.get("Email"), - "test_mode": model.get("TestMode", False), - "status": api_status, + 'invoice_id': payment.invoice_id, + 'transaction_id': transaction_id_cp, + 'amount': model.get('Amount', 0), + 'account_id': model.get('AccountId', ''), + 'token': model.get('Token'), + 'card_first_six': model.get('CardFirstSix'), + 'card_last_four': model.get('CardLastFour'), + 'card_type': model.get('CardType'), + 'card_exp_date': model.get('CardExpDate'), + 'email': model.get('Email'), + 'test_mode': model.get('TestMode', False), + 'status': api_status, } await self.process_cloudpayments_pay_webhook(db, webhook_data) await db.refresh(payment) - elif api_status in ("Declined", "Cancelled") and payment.status not in ("failed", "cancelled"): - payment.status = "failed" + elif api_status in ('Declined', 'Cancelled') and payment.status not in ('failed', 'cancelled'): + payment.status = 'failed' await db.flush() await db.refresh(payment) - return {"payment": payment, "status": payment.status} + return {'payment': payment, 'status': payment.status} except Exception as error: logger.error( - "Error checking CloudPayments payment status: id=%s, error=%s", + 'Error checking CloudPayments payment status: id=%s, error=%s', local_payment_id, error, ) - return {"payment": payment, "status": payment.status} + return {'payment': payment, 'status': payment.status} diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 9d1a806d..ce819f62 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -33,7 +33,7 @@ class PaymentCommonMixin: async def build_topup_success_keyboard(self, user: Any) -> InlineKeyboardMarkup: """Формирует клавиатуру по завершении платежа, подстраиваясь под пользователя.""" # Загружаем нужные тексты с учётом выбранного языка пользователя. - texts = get_texts(user.language if user else "ru") + texts = get_texts(user.language if user else 'ru') # Определяем статус подписки, чтобы показать подходящую кнопку. has_active_subscription = False @@ -43,32 +43,26 @@ class PaymentCommonMixin: subscription = user.subscription has_active_subscription = bool( subscription - and not getattr(subscription, "is_trial", False) - and getattr(subscription, "is_active", False) + and not getattr(subscription, 'is_trial', False) + and getattr(subscription, 'is_active', False) ) except MissingGreenlet as error: logger.warning( - "Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s", - getattr(user, "id", None), + 'Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s', + getattr(user, 'id', None), error, ) except Exception as error: # pragma: no cover - защитный код logger.error( - "Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s", - getattr(user, "id", None), + 'Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s', + getattr(user, 'id', None), error, ) # Создаем основную кнопку: если есть активная подписка - продлить, иначе купить first_button = build_miniapp_or_callback_button( - text=( - texts.MENU_EXTEND_SUBSCRIPTION - if has_active_subscription - else texts.MENU_BUY_SUBSCRIPTION - ), - callback_data=( - "subscription_extend" if has_active_subscription else "menu_buy" - ), + text=(texts.MENU_EXTEND_SUBSCRIPTION if has_active_subscription else texts.MENU_BUY_SUBSCRIPTION), + callback_data=('subscription_extend' if has_active_subscription else 'menu_buy'), ) keyboard_rows: list[list[InlineKeyboardButton]] = [ @@ -81,42 +75,50 @@ class PaymentCommonMixin: has_saved_cart = await user_cart_service.has_user_cart(user.id) except Exception as cart_error: logger.warning( - "Не удалось проверить наличие сохраненной корзины у пользователя %s: %s", + 'Не удалось проверить наличие сохраненной корзины у пользователя %s: %s', user.id, cart_error, ) has_saved_cart = False if has_saved_cart: - keyboard_rows.append([ - build_miniapp_or_callback_button( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", - ) - ]) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data='return_to_saved_cart', + ) + ] + ) else: draft_exists = await has_subscription_checkout_draft(user.id) if should_offer_checkout_resume(user, draft_exists, subscription=subscription): - keyboard_rows.append([ - build_miniapp_or_callback_button( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout", - ) - ]) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, + callback_data='subscription_resume_checkout', + ) + ] + ) # Стандартные кнопки быстрого доступа к балансу и главному меню. - keyboard_rows.append([ - build_miniapp_or_callback_button( - text="💰 Мой баланс", - callback_data="menu_balance", - ) - ]) - keyboard_rows.append([ - InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", - ) - ]) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text='💰 Мой баланс', + callback_data='menu_balance', + ) + ] + ) + keyboard_rows.append( + [ + InlineKeyboardButton( + text='🏠 Главное меню', + callback_data='back_to_menu', + ) + ] + ) return InlineKeyboardMarkup(inline_keyboard=keyboard_rows) @@ -130,7 +132,7 @@ class PaymentCommonMixin: payment_method_title: str | None = None, ) -> None: """Отправляет пользователю уведомление об успешном платеже.""" - if not getattr(self, "bot", None): + if not getattr(self, 'bot', None): # Если бот не передан (например, внутри фоновых задач), уведомление пропускаем. return @@ -145,7 +147,7 @@ class PaymentCommonMixin: ) try: - payment_method = payment_method_title or "Банковская карта (YooKassa)" + payment_method = payment_method_title or 'Банковская карта (YooKassa)' # Проверяем, нужно ли показывать яркое предупреждение об активации if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: @@ -156,21 +158,21 @@ class PaymentCommonMixin: subscription = user_snapshot.subscription has_active_subscription = bool( subscription - and not getattr(subscription, "is_trial", False) - and getattr(subscription, "is_active", False) + and not getattr(subscription, 'is_trial', False) + and getattr(subscription, 'is_active', False) ) except Exception: pass # Яркое сообщение с восклицательными знаками message = ( - "✅ Платеж успешно завершен!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"💳 Способ: {payment_method}\n\n" - "💎 Средства зачислены на ваш баланс!\n\n" - "‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n" - "⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n" - "👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇" + '✅ Платеж успешно завершен!\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'💳 Способ: {payment_method}\n\n' + '💎 Средства зачислены на ваш баланс!\n\n' + '‼️ ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ! ‼️\n\n' + '⚠️ Пополнение баланса НЕ АКТИВИРУЕТ подписку автоматически!\n\n' + '👇 НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ 👇' ) # Формируем клавиатуру с кнопками действий @@ -179,51 +181,57 @@ class PaymentCommonMixin: # Кнопка активации или продления в зависимости от статуса if has_active_subscription: # Активная платная подписка - показываем продление и изменение устройств - keyboard_rows.append([ - build_miniapp_or_callback_button( - text="🔄 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend", - ) - ]) - keyboard_rows.append([ - build_miniapp_or_callback_button( - text="📱 Изменить количество устройств", - callback_data="subscription_change_devices", - ) - ]) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text='🔄 ПРОДЛИТЬ ПОДПИСКУ', + callback_data='subscription_extend', + ) + ] + ) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text='📱 Изменить количество устройств', + callback_data='subscription_change_devices', + ) + ] + ) else: # Нет подписки или истекла - показываем только активацию - keyboard_rows.append([ - build_miniapp_or_callback_button( - text="🔥 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="menu_buy", - ) - ]) + keyboard_rows.append( + [ + build_miniapp_or_callback_button( + text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', + callback_data='menu_buy', + ) + ] + ) keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) else: # Стандартное сообщение с полной клавиатурой keyboard = await self.build_topup_success_keyboard(user_snapshot) message = ( - "✅ Платеж успешно завершен!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"💳 Способ: {payment_method}\n\n" - "Средства зачислены на ваш баланс!\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса." + '✅ Платеж успешно завершен!\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'💳 Способ: {payment_method}\n\n' + 'Средства зачислены на ваш баланс!\n\n' + '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + 'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.' ) await self.bot.send_message( chat_id=telegram_id, text=message, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка отправки уведомления пользователю %s: %s", + 'Ошибка отправки уведомления пользователю %s: %s', telegram_id, error, ) @@ -241,20 +249,20 @@ class PaymentCommonMixin: if source is None: return None - subscription = getattr(source, "subscription", None) + subscription = getattr(source, 'subscription', None) subscription_snapshot = None if subscription is not None: subscription_snapshot = SimpleNamespace( - is_trial=getattr(subscription, "is_trial", False), - is_active=getattr(subscription, "is_active", False), - actual_status=getattr(subscription, "actual_status", None), + is_trial=getattr(subscription, 'is_trial', False), + is_active=getattr(subscription, 'is_active', False), + actual_status=getattr(subscription, 'actual_status', None), ) return SimpleNamespace( - id=getattr(source, "id", None), - telegram_id=getattr(source, "telegram_id", None), - language=getattr(source, "language", "ru"), + id=getattr(source, 'id', None), + telegram_id=getattr(source, 'telegram_id', None), + language=getattr(source, 'language', 'ru'), subscription=subscription_snapshot, ) @@ -274,7 +282,7 @@ class PaymentCommonMixin: return _build_snapshot(fetched_user) except Exception as fetch_error: logger.warning( - "Не удалось обновить пользователя %s из переданной сессии: %s", + 'Не удалось обновить пользователя %s из переданной сессии: %s', telegram_id, fetch_error, ) @@ -285,7 +293,7 @@ class PaymentCommonMixin: return _build_snapshot(fetched_user) except Exception as fetch_error: logger.warning( - "Не удалось получить пользователя %s для уведомления: %s", + 'Не удалось получить пользователя %s для уведомления: %s', telegram_id, fetch_error, ) @@ -302,7 +310,7 @@ class PaymentCommonMixin: """Общая точка учёта успешных платежей (используется провайдерами при необходимости).""" try: logger.info( - "Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s", + 'Обработан успешный платеж: %s, %s₽, пользователь %s, метод %s', payment_id, amount_kopeks / 100, user_id, @@ -310,5 +318,5 @@ class PaymentCommonMixin: ) return True except Exception as error: - logger.error("Ошибка обработки платежа %s: %s", payment_id, error) + logger.error('Ошибка обработки платежа %s: %s', payment_id, error) return False diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 6ae2525f..93975cf7 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -6,7 +6,7 @@ import math from dataclasses import dataclass from datetime import datetime from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -18,17 +18,17 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.services.subscription_renewal_service import ( + RenewalPaymentDescriptor, SubscriptionRenewalChargeError, SubscriptionRenewalPricing, SubscriptionRenewalService, - RenewalPaymentDescriptor, build_renewal_period_id, decode_payment_payload, parse_payment_metadata, ) from app.utils.currency_converter import currency_converter -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info renewal_service = SubscriptionRenewalService() @@ -47,7 +47,7 @@ class _AdminNotificationContext: class _UserNotificationPayload: telegram_id: int text: str - parse_mode: Optional[str] + parse_mode: str | None reply_markup: Any amount_rubles: float asset: str @@ -69,129 +69,119 @@ class CryptoBotPaymentMixin: db: AsyncSession, user_id: int, amount_usd: float, - asset: str = "USDT", - description: str = "Пополнение баланса", - payload: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + asset: str = 'USDT', + description: str = 'Пополнение баланса', + payload: str | None = None, + ) -> dict[str, Any] | None: """Создаёт invoice в CryptoBot и сохраняет локальную запись.""" - if not getattr(self, "cryptobot_service", None): - logger.error("CryptoBot сервис не инициализирован") + if not getattr(self, 'cryptobot_service', None): + logger.error('CryptoBot сервис не инициализирован') return None try: - amount_str = f"{amount_usd:.2f}" + amount_str = f'{amount_usd:.2f}' invoice_data = await self.cryptobot_service.create_invoice( amount=amount_str, asset=asset, description=description, - payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}", + payload=payload or f'balance_topup_{user_id}_{int(amount_usd * 100)}', expires_in=settings.get_cryptobot_invoice_expires_seconds(), ) if not invoice_data: - logger.error("Ошибка создания CryptoBot invoice") + logger.error('Ошибка создания CryptoBot invoice') return None - cryptobot_crud = import_module("app.database.crud.cryptobot") + cryptobot_crud = import_module('app.database.crud.cryptobot') local_payment = await cryptobot_crud.create_cryptobot_payment( db=db, user_id=user_id, - invoice_id=str(invoice_data["invoice_id"]), + invoice_id=str(invoice_data['invoice_id']), amount=amount_str, asset=asset, - status="active", + status='active', description=description, payload=payload, - bot_invoice_url=invoice_data.get("bot_invoice_url"), - mini_app_invoice_url=invoice_data.get("mini_app_invoice_url"), - web_app_invoice_url=invoice_data.get("web_app_invoice_url"), + bot_invoice_url=invoice_data.get('bot_invoice_url'), + mini_app_invoice_url=invoice_data.get('mini_app_invoice_url'), + web_app_invoice_url=invoice_data.get('web_app_invoice_url'), ) logger.info( - "Создан CryptoBot платеж %s на %s %s для пользователя %s", - invoice_data["invoice_id"], + 'Создан CryptoBot платеж %s на %s %s для пользователя %s', + invoice_data['invoice_id'], amount_str, asset, user_id, ) return { - "local_payment_id": local_payment.id, - "invoice_id": str(invoice_data["invoice_id"]), - "amount": amount_str, - "asset": asset, - "bot_invoice_url": invoice_data.get("bot_invoice_url"), - "mini_app_invoice_url": invoice_data.get("mini_app_invoice_url"), - "web_app_invoice_url": invoice_data.get("web_app_invoice_url"), - "status": "active", - "created_at": ( - local_payment.created_at.isoformat() - if local_payment.created_at - else None - ), + 'local_payment_id': local_payment.id, + 'invoice_id': str(invoice_data['invoice_id']), + 'amount': amount_str, + 'asset': asset, + 'bot_invoice_url': invoice_data.get('bot_invoice_url'), + 'mini_app_invoice_url': invoice_data.get('mini_app_invoice_url'), + 'web_app_invoice_url': invoice_data.get('web_app_invoice_url'), + 'status': 'active', + 'created_at': (local_payment.created_at.isoformat() if local_payment.created_at else None), } except Exception as error: - logger.error("Ошибка создания CryptoBot платежа: %s", error) + logger.error('Ошибка создания CryptoBot платежа: %s', error) return None async def process_cryptobot_webhook( self, db: AsyncSession, - webhook_data: Dict[str, Any], + webhook_data: dict[str, Any], ) -> bool: """Обрабатывает webhook от CryptoBot и начисляет средства пользователю.""" try: - update_type = webhook_data.get("update_type") + update_type = webhook_data.get('update_type') - if update_type != "invoice_paid": - logger.info("Пропуск CryptoBot webhook с типом: %s", update_type) + if update_type != 'invoice_paid': + logger.info('Пропуск CryptoBot webhook с типом: %s', update_type) return True - payload = webhook_data.get("payload", {}) - invoice_id = str(payload.get("invoice_id")) - status = "paid" + payload = webhook_data.get('payload', {}) + invoice_id = str(payload.get('invoice_id')) + status = 'paid' if not invoice_id: - logger.error("CryptoBot webhook без invoice_id") + logger.error('CryptoBot webhook без invoice_id') return False - cryptobot_crud = import_module("app.database.crud.cryptobot") - payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id( - db, invoice_id - ) + cryptobot_crud = import_module('app.database.crud.cryptobot') + payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(db, invoice_id) if not payment: - logger.error("CryptoBot платеж не найден в БД: %s", invoice_id) + logger.error('CryptoBot платеж не найден в БД: %s', invoice_id) return False - if payment.status == "paid": - logger.info("CryptoBot платеж %s уже обработан", invoice_id) + if payment.status == 'paid': + logger.info('CryptoBot платеж %s уже обработан', invoice_id) return True - paid_at_str = payload.get("paid_at") + paid_at_str = payload.get('paid_at') if paid_at_str: try: - paid_at = datetime.fromisoformat( - paid_at_str.replace("Z", "+00:00") - ).replace(tzinfo=None) + paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None) except Exception: paid_at = datetime.utcnow() else: paid_at = datetime.utcnow() - updated_payment = await cryptobot_crud.update_cryptobot_payment_status( - db, invoice_id, status, paid_at - ) + updated_payment = await cryptobot_crud.update_cryptobot_payment_status(db, invoice_id, status, paid_at) descriptor = decode_payment_payload( - getattr(updated_payment, "payload", "") or "", + getattr(updated_payment, 'payload', '') or '', expected_user_id=updated_payment.user_id, ) if descriptor is None: - inline_payload = payload.get("payload") + inline_payload = payload.get('payload') if isinstance(inline_payload, str) and inline_payload: descriptor = decode_payment_payload( inline_payload, @@ -199,7 +189,7 @@ class CryptoBotPaymentMixin: ) if descriptor is None: - metadata = payload.get("metadata") + metadata = payload.get('metadata') if isinstance(metadata, dict) and metadata: descriptor = parse_payment_metadata( metadata, @@ -222,11 +212,9 @@ class CryptoBotPaymentMixin: amount_rubles = await currency_converter.usd_to_rub(amount_usd) amount_rubles_rounded = math.ceil(amount_rubles) amount_kopeks = int(amount_rubles_rounded * 100) - conversion_rate = ( - amount_rubles / amount_usd if amount_usd > 0 else 0 - ) + conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0 logger.info( - "Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)", + 'Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)', amount_usd, amount_rubles, amount_rubles_rounded, @@ -234,7 +222,7 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.warning( - "Ошибка конвертации валют для платежа %s, используем курс 1:1: %s", + 'Ошибка конвертации валют для платежа %s, используем курс 1:1: %s', invoice_id, error, ) @@ -245,36 +233,34 @@ class CryptoBotPaymentMixin: if amount_kopeks <= 0: logger.error( - "Некорректная сумма после конвертации: %s копеек для платежа %s", + 'Некорректная сумма после конвертации: %s копеек для платежа %s', amount_kopeks, invoice_id, ) return False - payment_service_module = import_module("app.services.payment_service") + payment_service_module = import_module('app.services.payment_service') transaction = await payment_service_module.create_transaction( db, user_id=updated_payment.user_id, type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, description=( - "Пополнение через CryptoBot " - f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)" + 'Пополнение через CryptoBot ' + f'({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)' ), payment_method=PaymentMethod.CRYPTOBOT, external_id=invoice_id, is_completed=True, ) - await cryptobot_crud.link_cryptobot_payment_to_transaction( - db, invoice_id, transaction.id - ) + await cryptobot_crud.link_cryptobot_payment_to_transaction(db, invoice_id, transaction.id) get_user_by_id = payment_service_module.get_user_by_id user = await get_user_by_id(db, updated_payment.user_id) if not user: logger.error( - "Пользователь с ID %s не найден при пополнении баланса", + 'Пользователь с ID %s не найден при пополнении баланса', updated_payment.user_id, ) return False @@ -286,9 +272,7 @@ class CryptoBotPaymentMixin: user.updated_at = datetime.utcnow() referrer_info = format_referrer_info(user) - topup_status = ( - "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - ) + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' await db.commit() @@ -299,11 +283,11 @@ class CryptoBotPaymentMixin: db, user.id, amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: logger.error( - "Ошибка обработки реферального пополнения CryptoBot: %s", + 'Ошибка обработки реферального пополнения CryptoBot: %s', error, ) @@ -313,11 +297,11 @@ class CryptoBotPaymentMixin: await db.refresh(user) - admin_notification: Optional[_AdminNotificationContext] = None - user_notification: Optional[_UserNotificationPayload] = None - saved_cart_notification: Optional[_SavedCartNotificationPayload] = None + admin_notification: _AdminNotificationContext | None = None + user_notification: _UserNotificationPayload | None = None + saved_cart_notification: _SavedCartNotificationPayload | None = None - bot_instance = getattr(self, "bot", None) + bot_instance = getattr(self, 'bot', None) if bot_instance: admin_notification = _AdminNotificationContext( user_id=user.id, @@ -330,32 +314,33 @@ class CryptoBotPaymentMixin: try: keyboard = await self.build_topup_success_keyboard(user) message_text = ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(amount_kopeks)}\n" - f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n" - f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n" - f"🆔 Транзакция: {invoice_id[:8]}...\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(amount_kopeks)}\n' + f'🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n' + f'💱 Курс: 1 USD = {conversion_rate:.2f}₽\n' + f'🆔 Транзакция: {invoice_id[:8]}...\n\n' + 'Баланс пополнен автоматически!' ) user_notification = _UserNotificationPayload( telegram_id=user.telegram_id, text=message_text, - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, amount_rubles=amount_rubles_rounded, asset=updated_payment.asset, ) except Exception as error: logger.error( - "Ошибка подготовки уведомления о пополнении CryptoBot: %s", + 'Ошибка подготовки уведомления о пополнении CryptoBot: %s', error, ) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -367,7 +352,7 @@ class CryptoBotPaymentMixin: ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -388,7 +373,7 @@ class CryptoBotPaymentMixin: ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, @@ -403,36 +388,33 @@ class CryptoBotPaymentMixin: total_amount=settings.format_price(amount_kopeks) ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data='return_to_saved_cart' + ) + ], + [types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')], + ] + ) saved_cart_notification = _SavedCartNotificationPayload( telegram_id=user.telegram_id, text=( - f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}" + f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}' ), reply_markup=keyboard, user_id=user.id, ) except Exception as error: logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', user.id, error, exc_info=True, @@ -450,9 +432,7 @@ class CryptoBotPaymentMixin: return True except Exception as error: - logger.error( - "Ошибка обработки CryptoBot webhook: %s", error, exc_info=True - ) + logger.error('Ошибка обработки CryptoBot webhook: %s', error, exc_info=True) return False async def _process_subscription_renewal_payment( @@ -463,41 +443,39 @@ class CryptoBotPaymentMixin: cryptobot_crud: Any, ) -> bool: try: - payment_service_module = import_module("app.services.payment_service") + payment_service_module = import_module('app.services.payment_service') user = await payment_service_module.get_user_by_id(db, payment.user_id) except Exception as error: logger.error( - "Не удалось загрузить пользователя %s для продления через CryptoBot: %s", - getattr(payment, "user_id", None), + 'Не удалось загрузить пользователя %s для продления через CryptoBot: %s', + getattr(payment, 'user_id', None), error, ) return False if not user: logger.error( - "Пользователь %s не найден при обработке продления через CryptoBot", - getattr(payment, "user_id", None), + 'Пользователь %s не найден при обработке продления через CryptoBot', + getattr(payment, 'user_id', None), ) return False - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if not subscription or subscription.id != descriptor.subscription_id: logger.warning( - "Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s", - getattr(subscription, "id", None), + 'Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s', + getattr(subscription, 'id', None), descriptor.subscription_id, ) return False - pricing_model: Optional[SubscriptionRenewalPricing] = None + pricing_model: SubscriptionRenewalPricing | None = None if descriptor.pricing_snapshot: try: - pricing_model = SubscriptionRenewalPricing.from_payload( - descriptor.pricing_snapshot - ) + pricing_model = SubscriptionRenewalPricing.from_payload(descriptor.pricing_snapshot) except Exception as error: logger.warning( - "Не удалось восстановить сохраненную стоимость продления из payload %s: %s", + 'Не удалось восстановить сохраненную стоимость продления из payload %s: %s', payment.invoice_id, error, ) @@ -512,7 +490,7 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.error( - "Не удалось пересчитать стоимость продления для CryptoBot %s: %s", + 'Не удалось пересчитать стоимость продления для CryptoBot %s: %s', payment.invoice_id, error, ) @@ -520,7 +498,7 @@ class CryptoBotPaymentMixin: if pricing_model.final_total != descriptor.total_amount_kopeks: logger.warning( - "Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)", + 'Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)', payment.invoice_id, descriptor.total_amount_kopeks, pricing_model.final_total, @@ -543,17 +521,17 @@ class CryptoBotPaymentMixin: ), ) - current_balance = getattr(user, "balance_kopeks", 0) + current_balance = getattr(user, 'balance_kopeks', 0) if current_balance < required_balance: logger.warning( - "Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s", + 'Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s', user.id, required_balance, current_balance, ) return False - description = f"Продление подписки на {descriptor.period_days} дней" + description = f'Продление подписки на {descriptor.period_days} дней' try: result = await renewal_service.finalize( @@ -567,14 +545,14 @@ class CryptoBotPaymentMixin: ) except SubscriptionRenewalChargeError as error: logger.error( - "Списание баланса не выполнено при продлении через CryptoBot %s: %s", + 'Списание баланса не выполнено при продлении через CryptoBot %s: %s', payment.invoice_id, error, ) return False except Exception as error: logger.error( - "Ошибка завершения продления через CryptoBot %s: %s", + 'Ошибка завершения продления через CryptoBot %s: %s', payment.invoice_id, error, exc_info=True, @@ -591,7 +569,7 @@ class CryptoBotPaymentMixin: ) except Exception as error: logger.warning( - "Не удалось связать платеж CryptoBot %s с транзакцией %s: %s", + 'Не удалось связать платеж CryptoBot %s с транзакцией %s: %s', payment.invoice_id, transaction.id, error, @@ -601,7 +579,7 @@ class CryptoBotPaymentMixin: balance_amount_label = settings.format_price(required_balance) logger.info( - "Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)", + 'Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)', subscription.id, payment.invoice_id, external_amount_label, @@ -610,20 +588,18 @@ class CryptoBotPaymentMixin: return True - async def _deliver_admin_topup_notification( - self, context: _AdminNotificationContext - ) -> None: - bot_instance = getattr(self, "bot", None) + async def _deliver_admin_topup_notification(self, context: _AdminNotificationContext) -> None: + bot_instance = getattr(self, 'bot', None) if not bot_instance: return try: - from app.services.admin_notification_service import AdminNotificationService - from app.database.crud.user import get_user_by_id from app.database.crud.transaction import get_transaction_by_id + from app.database.crud.user import get_user_by_id + from app.services.admin_notification_service import AdminNotificationService except Exception as error: logger.error( - "Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s", + 'Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s', error, exc_info=True, ) @@ -635,7 +611,7 @@ class CryptoBotPaymentMixin: transaction = await get_transaction_by_id(session, context.transaction_id) except Exception as error: logger.error( - "Ошибка загрузки данных для админ-уведомления CryptoBot: %s", + 'Ошибка загрузки данных для админ-уведомления CryptoBot: %s', error, exc_info=True, ) @@ -644,7 +620,7 @@ class CryptoBotPaymentMixin: if not user or not transaction: logger.warning( - "Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s", + 'Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s', bool(user), bool(transaction), ) @@ -658,27 +634,25 @@ class CryptoBotPaymentMixin: context.old_balance, topup_status=context.topup_status, referrer_info=context.referrer_info, - subscription=getattr(user, "subscription", None), - promo_group=getattr(user, "promo_group", None), + subscription=getattr(user, 'subscription', None), + promo_group=getattr(user, 'promo_group', None), db=session, ) except Exception as error: logger.error( - "Ошибка отправки админ-уведомления о пополнении CryptoBot: %s", + 'Ошибка отправки админ-уведомления о пополнении CryptoBot: %s', error, exc_info=True, ) - async def _deliver_user_topup_notification( - self, payload: _UserNotificationPayload - ) -> None: - bot_instance = getattr(self, "bot", None) + async def _deliver_user_topup_notification(self, payload: _UserNotificationPayload) -> None: + bot_instance = getattr(self, 'bot', None) if not bot_instance: return # Skip email-only users (no telegram_id) if not payload.telegram_id: - logger.info("Пропуск Telegram-уведомления о пополнении CryptoBot для email-пользователя") + logger.info('Пропуск Telegram-уведомления о пополнении CryptoBot для email-пользователя') return try: @@ -689,27 +663,25 @@ class CryptoBotPaymentMixin: reply_markup=payload.reply_markup, ) logger.info( - "✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)", + '✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)', payload.telegram_id, - f"{payload.amount_rubles:.2f}", + f'{payload.amount_rubles:.2f}', payload.asset, ) except Exception as error: logger.error( - "Ошибка отправки уведомления о пополнении CryptoBot: %s", + 'Ошибка отправки уведомления о пополнении CryptoBot: %s', error, ) - async def _deliver_saved_cart_reminder( - self, payload: _SavedCartNotificationPayload - ) -> None: - bot_instance = getattr(self, "bot", None) + async def _deliver_saved_cart_reminder(self, payload: _SavedCartNotificationPayload) -> None: + bot_instance = getattr(self, 'bot', None) if not bot_instance: return # Skip email-only users (no telegram_id) if not payload.telegram_id: - logger.debug("Пропуск напоминания о корзине для email-пользователя") + logger.debug('Пропуск напоминания о корзине для email-пользователя') return try: @@ -719,12 +691,12 @@ class CryptoBotPaymentMixin: reply_markup=payload.reply_markup, ) logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', payload.user_id, ) except Exception as error: logger.error( - "Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s", + 'Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s', payload.user_id, error, exc_info=True, @@ -734,78 +706,73 @@ class CryptoBotPaymentMixin: self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Запрашивает актуальный статус CryptoBot invoice и синхронизирует его.""" - cryptobot_crud = import_module("app.database.crud.cryptobot") + cryptobot_crud = import_module('app.database.crud.cryptobot') payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) if not payment: - logger.warning("CryptoBot платеж %s не найден", local_payment_id) + logger.warning('CryptoBot платеж %s не найден', local_payment_id) return None if not self.cryptobot_service: - logger.warning("CryptoBot сервис не инициализирован для ручной проверки") - return {"payment": payment} + logger.warning('CryptoBot сервис не инициализирован для ручной проверки') + return {'payment': payment} invoice_id = payment.invoice_id try: - invoices = await self.cryptobot_service.get_invoices( - invoice_ids=[invoice_id] - ) + invoices = await self.cryptobot_service.get_invoices(invoice_ids=[invoice_id]) except Exception as error: # pragma: no cover - network errors logger.error( - "Ошибка запроса статуса CryptoBot invoice %s: %s", + 'Ошибка запроса статуса CryptoBot invoice %s: %s', invoice_id, error, ) - return {"payment": payment} + return {'payment': payment} - remote_invoice: Optional[Dict[str, Any]] = None + remote_invoice: dict[str, Any] | None = None if invoices: for item in invoices: - if str(item.get("invoice_id")) == str(invoice_id): + if str(item.get('invoice_id')) == str(invoice_id): remote_invoice = item break if not remote_invoice: logger.info( - "CryptoBot invoice %s не найден через API при ручной проверке", + 'CryptoBot invoice %s не найден через API при ручной проверке', invoice_id, ) refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) - return {"payment": refreshed or payment} + return {'payment': refreshed or payment} - status = (remote_invoice.get("status") or "").lower() - paid_at_str = remote_invoice.get("paid_at") + status = (remote_invoice.get('status') or '').lower() + paid_at_str = remote_invoice.get('paid_at') paid_at = None if paid_at_str: try: - paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace( - tzinfo=None - ) + paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None) except Exception: # pragma: no cover - defensive parsing paid_at = None - if status == "paid": + if status == 'paid': webhook_payload = { - "update_type": "invoice_paid", - "payload": { - "invoice_id": remote_invoice.get("invoice_id") or invoice_id, - "amount": remote_invoice.get("amount") or payment.amount, - "asset": remote_invoice.get("asset") or payment.asset, - "paid_at": paid_at_str, - "payload": remote_invoice.get("payload") or payment.payload, + 'update_type': 'invoice_paid', + 'payload': { + 'invoice_id': remote_invoice.get('invoice_id') or invoice_id, + 'amount': remote_invoice.get('amount') or payment.amount, + 'asset': remote_invoice.get('asset') or payment.asset, + 'paid_at': paid_at_str, + 'payload': remote_invoice.get('payload') or payment.payload, }, } await self.process_cryptobot_webhook(db, webhook_payload) - else: - if status and status != (payment.status or "").lower(): - await cryptobot_crud.update_cryptobot_payment_status( - db, - invoice_id, - status, - paid_at, - ) + elif status and status != (payment.status or '').lower(): + await cryptobot_crud.update_cryptobot_payment_status( + db, + invoice_id, + status, + paid_at, + ) refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id) - return {"payment": refreshed or payment} + return {'payment': refreshed or payment} diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 074efc7e..4533cce7 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -4,10 +4,9 @@ from __future__ import annotations import json import uuid -import logging from datetime import datetime, timedelta from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -18,8 +17,8 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class FreekassaPaymentMixin: @@ -31,10 +30,10 @@ class FreekassaPaymentMixin: *, user_id: int, amount_kopeks: int, - description: str = "Пополнение баланса", - email: Optional[str] = None, - language: str = "ru", - ) -> Optional[Dict[str, Any]]: + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + ) -> dict[str, Any] | None: """ Создает платеж Freekassa. @@ -50,13 +49,13 @@ class FreekassaPaymentMixin: Словарь с данными платежа или None при ошибке """ if not settings.is_freekassa_enabled(): - logger.error("Freekassa не настроен") + logger.error('Freekassa не настроен') return None # Валидация лимитов if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS: logger.warning( - "Freekassa: сумма %s меньше минимальной %s", + 'Freekassa: сумма %s меньше минимальной %s', amount_kopeks, settings.FREEKASSA_MIN_AMOUNT_KOPEKS, ) @@ -64,29 +63,27 @@ class FreekassaPaymentMixin: if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS: logger.warning( - "Freekassa: сумма %s больше максимальной %s", + 'Freekassa: сумма %s больше максимальной %s', amount_kopeks, settings.FREEKASSA_MAX_AMOUNT_KOPEKS, ) return None # Генерируем уникальный order_id - order_id = f"fk_{user_id}_{uuid.uuid4().hex[:12]}" + order_id = f'fk_{user_id}_{uuid.uuid4().hex[:12]}' amount_rubles = amount_kopeks / 100 currency = settings.FREEKASSA_CURRENCY # Срок действия платежа - expires_at = datetime.utcnow() + timedelta( - seconds=settings.FREEKASSA_PAYMENT_TIMEOUT_SECONDS - ) + expires_at = datetime.utcnow() + timedelta(seconds=settings.FREEKASSA_PAYMENT_TIMEOUT_SECONDS) # Метаданные metadata = { - "user_id": user_id, - "amount_kopeks": amount_kopeks, - "description": description, - "language": language, - "type": "balance_topup", + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', } try: @@ -101,7 +98,7 @@ class FreekassaPaymentMixin: payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID, ) logger.info( - "Freekassa API: создан заказ order_id=%s, url=%s", + 'Freekassa API: создан заказ order_id=%s, url=%s', order_id, payment_url, ) @@ -116,7 +113,7 @@ class FreekassaPaymentMixin: ) # Импортируем CRUD модуль - freekassa_crud = import_module("app.database.crud.freekassa") + freekassa_crud = import_module('app.database.crud.freekassa') # Сохраняем в БД local_payment = await freekassa_crud.create_freekassa_payment( @@ -132,7 +129,7 @@ class FreekassaPaymentMixin: ) logger.info( - "Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s", + 'Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s', order_id, user_id, amount_rubles, @@ -141,17 +138,17 @@ class FreekassaPaymentMixin: ) return { - "order_id": order_id, - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_rubles, - "currency": currency, - "payment_url": payment_url, - "expires_at": expires_at.isoformat(), - "local_payment_id": local_payment.id, + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, } except Exception as e: - logger.exception("Freekassa: ошибка создания платежа: %s", e) + logger.exception('Freekassa: ошибка создания платежа: %s', e) return None async def process_freekassa_webhook( @@ -163,7 +160,7 @@ class FreekassaPaymentMixin: order_id: str, sign: str, intid: str, - cur_id: Optional[int] = None, + cur_id: int | None = None, client_ip: str, ) -> bool: """ @@ -185,43 +182,33 @@ class FreekassaPaymentMixin: try: # Проверка IP if not freekassa_service.verify_webhook_ip(client_ip): - logger.warning("Freekassa webhook: недоверенный IP %s", client_ip) + logger.warning('Freekassa webhook: недоверенный IP %s', client_ip) return False # Проверка подписи - if not freekassa_service.verify_webhook_signature( - merchant_id, amount, order_id, sign - ): - logger.warning( - "Freekassa webhook: неверная подпись для order_id=%s", order_id - ) + if not freekassa_service.verify_webhook_signature(merchant_id, amount, order_id, sign): + logger.warning('Freekassa webhook: неверная подпись для order_id=%s', order_id) return False # Импортируем CRUD модуль - freekassa_crud = import_module("app.database.crud.freekassa") + freekassa_crud = import_module('app.database.crud.freekassa') # Получаем платеж из БД - payment = await freekassa_crud.get_freekassa_payment_by_order_id( - db, order_id - ) + payment = await freekassa_crud.get_freekassa_payment_by_order_id(db, order_id) if not payment: - logger.warning( - "Freekassa webhook: платеж не найден order_id=%s", order_id - ) + logger.warning('Freekassa webhook: платеж не найден order_id=%s', order_id) return False # Проверка дублирования if payment.is_paid: - logger.info( - "Freekassa webhook: платеж уже обработан order_id=%s", order_id - ) + logger.info('Freekassa webhook: платеж уже обработан order_id=%s', order_id) return True # Проверка суммы expected_amount = payment.amount_kopeks / 100 if abs(amount - expected_amount) > 0.01: logger.warning( - "Freekassa webhook: несоответствие суммы ожидалось=%s, получено=%s", + 'Freekassa webhook: несоответствие суммы ожидалось=%s, получено=%s', expected_amount, amount, ) @@ -229,17 +216,17 @@ class FreekassaPaymentMixin: # Обновляем статус платежа callback_payload = { - "merchant_id": merchant_id, - "amount": amount, - "order_id": order_id, - "intid": intid, - "cur_id": cur_id, + 'merchant_id': merchant_id, + 'amount': amount, + 'order_id': order_id, + 'intid': intid, + 'cur_id': cur_id, } payment = await freekassa_crud.update_freekassa_payment_status( db=db, payment=payment, - status="success", + status='success', is_paid=True, freekassa_order_id=intid, payment_system_id=cur_id, @@ -247,12 +234,10 @@ class FreekassaPaymentMixin: ) # Финализируем платеж (начисляем баланс, создаем транзакцию) - return await self._finalize_freekassa_payment( - db, payment, intid=intid, trigger="webhook" - ) + return await self._finalize_freekassa_payment(db, payment, intid=intid, trigger='webhook') except Exception as e: - logger.exception("Freekassa webhook: ошибка обработки: %s", e) + logger.exception('Freekassa webhook: ошибка обработки: %s', e) return False async def _finalize_freekassa_payment( @@ -260,15 +245,15 @@ class FreekassaPaymentMixin: db: AsyncSession, payment: Any, *, - intid: Optional[str], + intid: str | None, trigger: str, ) -> bool: """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') if payment.transaction_id: logger.info( - "Freekassa платеж %s уже привязан к транзакции (trigger=%s)", + 'Freekassa платеж %s уже привязан к транзакции (trigger=%s)', payment.order_id, trigger, ) @@ -278,7 +263,7 @@ class FreekassaPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - "Пользователь %s не найден для Freekassa платежа %s (trigger=%s)", + 'Пользователь %s не найден для Freekassa платежа %s (trigger=%s)', payment.user_id, payment.order_id, trigger, @@ -291,14 +276,14 @@ class FreekassaPaymentMixin: user_id=payment.user_id, type=TransactionType.DEPOSIT, amount_kopeks=payment.amount_kopeks, - description=f"Пополнение через Freekassa (#{intid or payment.order_id})", + description=f'Пополнение через Freekassa (#{intid or payment.order_id})', payment_method=PaymentMethod.FREEKASSA, external_id=str(intid) if intid else payment.order_id, is_completed=True, ) # Связываем платеж с транзакцией - freekassa_crud = import_module("app.database.crud.freekassa") + freekassa_crud = import_module('app.database.crud.freekassa') await freekassa_crud.update_freekassa_payment_status( db=db, payment=payment, @@ -314,9 +299,9 @@ class FreekassaPaymentMixin: user.updated_at = datetime.utcnow() promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "Первое пополнение" if was_first_topup else "Пополнение" + topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение' await db.commit() @@ -324,13 +309,9 @@ class FreekassaPaymentMixin: try: from app.services.referral_service import process_referral_topup - await process_referral_topup( - db, user.id, payment.amount_kopeks, getattr(self, "bot", None) - ) + await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: - logger.error( - "Ошибка обработки реферального пополнения Freekassa: %s", error - ) + logger.error('Ошибка обработки реферального пополнения Freekassa: %s', error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -340,7 +321,7 @@ class FreekassaPaymentMixin: await db.refresh(payment) # Отправка уведомления админам - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, @@ -358,37 +339,34 @@ class FreekassaPaymentMixin: db=db, ) except Exception as error: - logger.error( - "Ошибка отправки админ уведомления Freekassa: %s", error - ) + logger.error('Ошибка отправки админ уведомления Freekassa: %s', error) # Отправка уведомления пользователю - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) display_name = settings.get_freekassa_display_name() await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - f"💳 Способ: {display_name}\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'💳 Способ: {display_name}\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.error( - "Ошибка отправки уведомления пользователю Freekassa: %s", error - ) + logger.error('Ошибка отправки уведомления пользователю Freekassa: %s', error) # Автопокупка подписки try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False @@ -397,11 +375,11 @@ class FreekassaPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -415,24 +393,24 @@ class FreekassaPaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER", - "У вас есть незавершенное оформление подписки. Вернуться?", + 'BALANCE_TOPUP_CART_REMINDER', + 'У вас есть незавершенное оформление подписки. Вернуться?', ) keyboard = types.InlineKeyboardMarkup( @@ -440,16 +418,16 @@ class FreekassaPaymentMixin: [ types.InlineKeyboardButton( text=texts.t( - "BALANCE_TOPUP_CART_BUTTON", - "🛒 Продолжить оформление", + 'BALANCE_TOPUP_CART_BUTTON', + '🛒 Продолжить оформление', ), - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -457,23 +435,19 @@ class FreekassaPaymentMixin: await self.bot.send_message( chat_id=user.telegram_id, - text=( - "✅ Баланс пополнен на " - f"{settings.format_price(payment.amount_kopeks)}!\n\n" - f"{cart_message}" - ), + text=(f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}'), reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', user.id, error, exc_info=True, ) logger.info( - "✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)", + '✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)', payment.order_id, payment.user_id, trigger, @@ -485,7 +459,7 @@ class FreekassaPaymentMixin: self, db: AsyncSession, order_id: str, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Проверяет статус платежа через API. @@ -500,36 +474,36 @@ class FreekassaPaymentMixin: status_data = await freekassa_service.get_order_status(order_id) return status_data except Exception as e: - logger.exception("Freekassa: ошибка проверки статуса: %s", e) + logger.exception('Freekassa: ошибка проверки статуса: %s', e) return None async def get_freekassa_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Проверяет статус платежа Freekassa по локальному ID через API. """ - freekassa_crud = import_module("app.database.crud.freekassa") + freekassa_crud = import_module('app.database.crud.freekassa') payment = await freekassa_crud.get_freekassa_payment_by_id(db, local_payment_id) if not payment: - logger.warning("Freekassa payment not found: id=%s", local_payment_id) + logger.warning('Freekassa payment not found: id=%s', local_payment_id) return None if payment.is_paid: return { - "payment": payment, - "status": "success", - "is_paid": True, + 'payment': payment, + 'status': 'success', + 'is_paid': True, } if not settings.FREEKASSA_API_KEY: return { - "payment": payment, - "status": payment.status or "pending", - "is_paid": payment.is_paid, + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, } try: @@ -537,41 +511,41 @@ class FreekassaPaymentMixin: response = await freekassa_service.get_order_status(payment.order_id) # Freekassa возвращает список заказов - orders = response.get("orders", []) + orders = response.get('orders', []) target_order = None # Ищем наш заказ в списке for order in orders: # В ответе API поле называется merchant_order_id, а не paymentId # Поддерживаем оба варианта на всякий случай - order_key = str(order.get("merchant_order_id") or order.get("paymentId")) + order_key = str(order.get('merchant_order_id') or order.get('paymentId')) if order_key == str(payment.order_id): target_order = order break if target_order: # Статус 1 = Оплачен - fk_status = int(target_order.get("status", 0)) + fk_status = int(target_order.get('status', 0)) if fk_status == 1: - logger.info("Freekassa payment %s confirmed via API", payment.order_id) + logger.info('Freekassa payment %s confirmed via API', payment.order_id) callback_payload = { - "check_source": "api", - "fk_order_data": target_order, + 'check_source': 'api', + 'fk_order_data': target_order, } # ID заказа на стороне FK (fk_order_id или id) - fk_intid = str(target_order.get("fk_order_id") or target_order.get("id")) + fk_intid = str(target_order.get('fk_order_id') or target_order.get('id')) # Обновляем статус payment = await freekassa_crud.update_freekassa_payment_status( db=db, payment=payment, - status="success", + status='success', is_paid=True, freekassa_order_id=fk_intid, - payment_system_id=int(target_order.get("curID")) if target_order.get("curID") else None, + payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None, callback_payload=callback_payload, ) @@ -580,13 +554,13 @@ class FreekassaPaymentMixin: db, payment, intid=fk_intid, - trigger="api_check", + trigger='api_check', ) except Exception as e: - logger.error("Error checking Freekassa payment status: %s", e) + logger.error('Error checking Freekassa payment status: %s', e) return { - "payment": payment, - "status": payment.status or "pending", - "is_paid": payment.is_paid, + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, } diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index d656589b..ffff7b90 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -4,16 +4,16 @@ from __future__ import annotations import secrets import time -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class HeleketPaymentMixin: @@ -26,88 +26,88 @@ class HeleketPaymentMixin: amount_kopeks: int, description: str, *, - language: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - if not getattr(self, "heleket_service", None): - logger.error("Heleket сервис не инициализирован") + language: str | None = None, + ) -> dict[str, Any] | None: + if not getattr(self, 'heleket_service', None): + logger.error('Heleket сервис не инициализирован') return None if amount_kopeks <= 0: - logger.error("Сумма Heleket должна быть положительной: %s", amount_kopeks) + logger.error('Сумма Heleket должна быть положительной: %s', amount_kopeks) return None amount_rubles = amount_kopeks / 100 - amount_str = f"{amount_rubles:.2f}" + amount_str = f'{amount_rubles:.2f}' - order_id = f"heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}" + order_id = f'heleket_{user_id}_{int(time.time())}_{secrets.token_hex(3)}' markup_percent = settings.get_heleket_markup_percent() - discount_percent: Optional[int] = None + discount_percent: int | None = None if markup_percent: try: rounded = int(round(markup_percent)) if rounded != 0: discount_percent = -rounded except (TypeError, ValueError): - logger.warning("Некорректная наценка Heleket: %s", markup_percent) + logger.warning('Некорректная наценка Heleket: %s', markup_percent) - payload: Dict[str, Any] = { - "amount": amount_str, - "currency": "RUB", - "order_id": order_id, - "lifetime": settings.get_heleket_lifetime(), + payload: dict[str, Any] = { + 'amount': amount_str, + 'currency': 'RUB', + 'order_id': order_id, + 'lifetime': settings.get_heleket_lifetime(), } - to_currency = (settings.HELEKET_DEFAULT_CURRENCY or "").strip() + to_currency = (settings.HELEKET_DEFAULT_CURRENCY or '').strip() if to_currency: - payload["to_currency"] = to_currency + payload['to_currency'] = to_currency - network = (settings.HELEKET_DEFAULT_NETWORK or "").strip() + network = (settings.HELEKET_DEFAULT_NETWORK or '').strip() if network: - payload["network"] = network + payload['network'] = network callback_url = settings.get_heleket_callback_url() if callback_url: - payload["url_callback"] = callback_url + payload['url_callback'] = callback_url if settings.HELEKET_RETURN_URL: - payload["url_return"] = settings.HELEKET_RETURN_URL + payload['url_return'] = settings.HELEKET_RETURN_URL if settings.HELEKET_SUCCESS_URL: - payload["url_success"] = settings.HELEKET_SUCCESS_URL + payload['url_success'] = settings.HELEKET_SUCCESS_URL if discount_percent is not None: - payload["discount_percent"] = discount_percent + payload['discount_percent'] = discount_percent - metadata: Dict[str, Any] = { - "language": language or settings.DEFAULT_LANGUAGE, - "created_at": datetime.utcnow().isoformat(), + metadata: dict[str, Any] = { + 'language': language or settings.DEFAULT_LANGUAGE, + 'created_at': datetime.utcnow().isoformat(), } try: response = await self.heleket_service.create_payment(payload) # type: ignore[union-attr] except Exception as error: # pragma: no cover - safety net - logger.exception("Ошибка создания Heleket платежа: %s", error) + logger.exception('Ошибка создания Heleket платежа: %s', error) return None if not response: - logger.error("Heleket API вернул пустой ответ при создании платежа") + logger.error('Heleket API вернул пустой ответ при создании платежа') return None - payment_result = response.get("result") if isinstance(response, dict) else None + payment_result = response.get('result') if isinstance(response, dict) else None if not payment_result: - logger.error("Некорректный ответ Heleket API: %s", response) + logger.error('Некорректный ответ Heleket API: %s', response) return None - uuid = str(payment_result.get("uuid")) - response_order_id = payment_result.get("order_id") + uuid = str(payment_result.get('uuid')) + response_order_id = payment_result.get('order_id') if response_order_id: order_id = str(response_order_id) - url = payment_result.get("url") - status = payment_result.get("status") or payment_result.get("payment_status") or "check" - payer_amount = payment_result.get("payer_amount") - payer_currency = payment_result.get("payer_currency") - exchange_rate = payment_result.get("payer_amount_exchange_rate") + url = payment_result.get('url') + status = payment_result.get('status') or payment_result.get('payment_status') or 'check' + payer_amount = payment_result.get('payer_amount') + payer_currency = payment_result.get('payer_currency') + exchange_rate = payment_result.get('payer_amount_exchange_rate') try: exchange_rate_value = float(exchange_rate) if exchange_rate is not None else None @@ -120,15 +120,15 @@ class HeleketPaymentMixin: except (TypeError, ValueError, ZeroDivisionError): exchange_rate_value = None - expires_at_raw = payment_result.get("expired_at") - expires_at: Optional[datetime] = None + expires_at_raw = payment_result.get('expired_at') + expires_at: datetime | None = None if expires_at_raw: try: expires_at = datetime.fromtimestamp(int(expires_at_raw)) except (TypeError, ValueError, OSError): expires_at = None - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') local_payment = await heleket_crud.create_heleket_payment( db=db, @@ -136,7 +136,7 @@ class HeleketPaymentMixin: uuid=uuid, order_id=order_id, amount=amount_str, - currency="RUB", + currency='RUB', status=status, payer_amount=payer_amount, payer_currency=payer_currency, @@ -144,50 +144,50 @@ class HeleketPaymentMixin: discount_percent=discount_percent, payment_url=url, expires_at=expires_at, - metadata={"raw_response": payment_result, **metadata}, + metadata={'raw_response': payment_result, **metadata}, ) logger.info( - "Создан Heleket платеж %s на %s₽ для пользователя %s", + 'Создан Heleket платеж %s на %s₽ для пользователя %s', uuid, amount_str, user_id, ) return { - "local_payment_id": local_payment.id, - "uuid": uuid, - "order_id": order_id, - "amount": amount_str, - "amount_kopeks": amount_kopeks, - "payment_url": url, - "status": status, - "payer_amount": payer_amount, - "payer_currency": payer_currency, - "exchange_rate": exchange_rate_value, - "discount_percent": discount_percent, + 'local_payment_id': local_payment.id, + 'uuid': uuid, + 'order_id': order_id, + 'amount': amount_str, + 'amount_kopeks': amount_kopeks, + 'payment_url': url, + 'status': status, + 'payer_amount': payer_amount, + 'payer_currency': payer_currency, + 'exchange_rate': exchange_rate_value, + 'discount_percent': discount_percent, } async def _process_heleket_payload( self, db: AsyncSession, - payload: Dict[str, Any], + payload: dict[str, Any], *, metadata_key: str, - ) -> Optional["HeleketPayment"]: + ) -> HeleketPayment | None: if not isinstance(payload, dict): - logger.error("Heleket webhook payload не является словарём: %s", payload) + logger.error('Heleket webhook payload не является словарём: %s', payload) return None - heleket_crud = import_module("app.database.crud.heleket") - payment_module = import_module("app.services.payment_service") + heleket_crud = import_module('app.database.crud.heleket') + payment_module = import_module('app.services.payment_service') - uuid = str(payload.get("uuid") or "").strip() - order_id = str(payload.get("order_id") or "").strip() - status = payload.get("status") or payload.get("payment_status") + uuid = str(payload.get('uuid') or '').strip() + order_id = str(payload.get('order_id') or '').strip() + status = payload.get('status') or payload.get('payment_status') if not uuid and not order_id: - logger.error("Heleket webhook без uuid/order_id: %s", payload) + logger.error('Heleket webhook без uuid/order_id: %s', payload) return None payment = None @@ -198,19 +198,19 @@ class HeleketPaymentMixin: if not payment: logger.error( - "Heleket платеж не найден (uuid=%s order_id=%s)", + 'Heleket платеж не найден (uuid=%s order_id=%s)', uuid, order_id, ) return None - payer_amount = payload.get("payer_amount") or payload.get("payment_amount") - payer_currency = payload.get("payer_currency") or payload.get("currency") - discount_percent = payload.get("discount_percent") - exchange_rate_raw = payload.get("payer_amount_exchange_rate") - payment_url = payload.get("url") + payer_amount = payload.get('payer_amount') or payload.get('payment_amount') + payer_currency = payload.get('payer_currency') or payload.get('currency') + discount_percent = payload.get('discount_percent') + exchange_rate_raw = payload.get('payer_amount_exchange_rate') + payment_url = payload.get('url') - exchange_rate: Optional[float] = None + exchange_rate: float | None = None if exchange_rate_raw is not None: try: exchange_rate = float(exchange_rate_raw) @@ -223,19 +223,19 @@ class HeleketPaymentMixin: except (TypeError, ValueError, ZeroDivisionError): exchange_rate = None - paid_at: Optional[datetime] = None - paid_at_raw = payload.get("paid_at") or payload.get("updated_at") + paid_at: datetime | None = None + paid_at_raw = payload.get('paid_at') or payload.get('updated_at') if paid_at_raw: try: if isinstance(paid_at_raw, (int, float)): paid_at = datetime.utcfromtimestamp(float(paid_at_raw)) else: - paid_at = datetime.fromisoformat(str(paid_at_raw).replace("Z", "+00:00")) + paid_at = datetime.fromisoformat(str(paid_at_raw).replace('Z', '+00:00')) except (ValueError, TypeError): paid_at = None if paid_at and paid_at.tzinfo is not None: - paid_at = paid_at.astimezone(timezone.utc).replace(tzinfo=None) + paid_at = paid_at.astimezone(UTC).replace(tzinfo=None) updated_payment = await heleket_crud.update_heleket_payment( db, @@ -253,24 +253,24 @@ class HeleketPaymentMixin: if updated_payment is None: return None - metadata = dict(getattr(updated_payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} + metadata = dict(getattr(updated_payment, 'metadata_json', {}) or {}) + invoice_message = metadata.get('invoice_message') or {} invoice_message_removed = False - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + if getattr(self, 'bot', None) and invoice_message: + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights logger.warning( - "Не удалось удалить счёт Heleket %s: %s", + 'Не удалось удалить счёт Heleket %s: %s', message_id, delete_error, ) else: - metadata.pop("invoice_message", None) + metadata.pop('invoice_message', None) invoice_message_removed = True if invoice_message_removed: @@ -285,26 +285,26 @@ class HeleketPaymentMixin: updated_payment.metadata_json = metadata except Exception as error: # pragma: no cover - diagnostics logger.warning( - "Не удалось обновить метаданные Heleket после удаления счёта: %s", + 'Не удалось обновить метаданные Heleket после удаления счёта: %s', error, ) if updated_payment.transaction_id: logger.info( - "Heleket платеж %s уже связан с транзакцией %s", + 'Heleket платеж %s уже связан с транзакцией %s', updated_payment.uuid, updated_payment.transaction_id, ) return updated_payment - status_normalized = (status or "").lower() - if status_normalized not in {"paid", "paid_over"}: - logger.info("Heleket платеж %s в статусе %s, зачисление не требуется", updated_payment.uuid, status) + status_normalized = (status or '').lower() + if status_normalized not in {'paid', 'paid_over'}: + logger.info('Heleket платеж %s в статусе %s, зачисление не требуется', updated_payment.uuid, status) return updated_payment amount_kopeks = updated_payment.amount_kopeks if amount_kopeks <= 0: - logger.error("Heleket платеж %s имеет некорректную сумму: %s", updated_payment.uuid, updated_payment.amount) + logger.error('Heleket платеж %s имеет некорректную сумму: %s', updated_payment.uuid, updated_payment.amount) return None transaction = await payment_module.create_transaction( @@ -313,12 +313,9 @@ class HeleketPaymentMixin: type=TransactionType.DEPOSIT, amount_kopeks=amount_kopeks, description=( - "Пополнение через Heleket" + 'Пополнение через Heleket' if not updated_payment.payer_currency - else ( - "Пополнение через Heleket " - f"({updated_payment.payer_amount} {updated_payment.payer_currency})" - ) + else (f'Пополнение через Heleket ({updated_payment.payer_amount} {updated_payment.payer_currency})') ), payment_method=PaymentMethod.HELEKET, external_id=updated_payment.uuid, @@ -336,7 +333,7 @@ class HeleketPaymentMixin: get_user_by_id = payment_module.get_user_by_id user = await get_user_by_id(db, updated_payment.user_id) if not user: - logger.error("Пользователь %s не найден для Heleket платежа", updated_payment.user_id) + logger.error('Пользователь %s не найден для Heleket платежа', updated_payment.user_id) return None old_balance = user.balance_kopeks @@ -355,20 +352,20 @@ class HeleketPaymentMixin: db, user.id, amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: # pragma: no cover - defensive - logger.error("Ошибка реферального начисления Heleket: %s", error) + logger.error('Ошибка реферального начисления Heleket: %s', error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True await db.commit() await db.refresh(user) - if getattr(self, "bot", None): - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + if getattr(self, 'bot', None): + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' referrer_info = format_referrer_info(user) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) promo_group = user.get_primary_promo_group() try: @@ -386,7 +383,7 @@ class HeleketPaymentMixin: db=db, ) except Exception as error: # pragma: no cover - logger.error("Ошибка отправки админ-уведомления Heleket: %s", error) + logger.error('Ошибка отправки админ-уведомления Heleket: %s', error) # Отправляем уведомление только Telegram-пользователям if user.telegram_id: @@ -395,45 +392,45 @@ class HeleketPaymentMixin: exchange_rate_value = updated_payment.exchange_rate or 0 rate_text = ( - f"💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}" + f'💱 Курс: 1 RUB = {1 / exchange_rate_value:.4f} {updated_payment.payer_currency}' if exchange_rate_value and updated_payment.payer_currency else None ) message_lines = [ - "✅ Пополнение успешно!", - f"💰 Сумма: {settings.format_price(amount_kopeks)}", - "💳 Способ: Heleket", + '✅ Пополнение успешно!', + f'💰 Сумма: {settings.format_price(amount_kopeks)}', + '💳 Способ: Heleket', ] if updated_payment.payer_amount and updated_payment.payer_currency: message_lines.append( - f"🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}" + f'🪙 Оплата: {updated_payment.payer_amount} {updated_payment.payer_currency}' ) if rate_text: message_lines.append(rate_text) await self.bot.send_message( chat_id=user.telegram_id, - text="\n".join(message_lines), - parse_mode="HTML", + text='\n'.join(message_lines), + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: # pragma: no cover - logger.error("Ошибка отправки уведомления пользователю Heleket: %s", error) + logger.error('Ошибка отправки уведомления пользователю Heleket: %s', error) else: - logger.info(f"Пропуск Telegram-уведомления Heleket для email-пользователя {user.id}") + logger.info(f'Пропуск Telegram-уведомления Heleket для email-пользователя {user.id}') return updated_payment async def process_heleket_webhook( self, db: AsyncSession, - payload: Dict[str, Any], + payload: dict[str, Any], ) -> bool: result = await self._process_heleket_payload( db, payload, - metadata_key="last_webhook", + metadata_key='last_webhook', ) return result is not None @@ -443,19 +440,19 @@ class HeleketPaymentMixin: db: AsyncSession, *, local_payment_id: int, - ) -> Optional["HeleketPayment"]: - if not getattr(self, "heleket_service", None): - logger.error("Heleket сервис не инициализирован") + ) -> HeleketPayment | None: + if not getattr(self, 'heleket_service', None): + logger.error('Heleket сервис не инициализирован') return None - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') payment = await heleket_crud.get_heleket_payment_by_id(db, local_payment_id) if not payment: - logger.error("Heleket платеж с id=%s не найден", local_payment_id) + logger.error('Heleket платеж с id=%s не найден', local_payment_id) return None - payload: Optional[Dict[str, Any]] = None + payload: dict[str, Any] | None = None try: response = await self.heleket_service.get_payment_info( # type: ignore[union-attr] uuid=payment.uuid, @@ -463,18 +460,18 @@ class HeleketPaymentMixin: ) except Exception as error: # pragma: no cover - defensive logger.exception( - "Ошибка получения статуса Heleket платежа %s: %s", + 'Ошибка получения статуса Heleket платежа %s: %s', payment.uuid, error, ) else: if response: - result = response.get("result") if isinstance(response, dict) else None + result = response.get('result') if isinstance(response, dict) else None if isinstance(result, dict): payload = dict(result) else: logger.error( - "Некорректный ответ Heleket API при проверке платежа %s: %s", + 'Некорректный ответ Heleket API при проверке платежа %s: %s', payment.uuid, response, ) @@ -483,41 +480,41 @@ class HeleketPaymentMixin: fallback = await self._lookup_heleket_payment_history(payment) if not fallback: logger.warning( - "Heleket API не вернул информацию по платежу %s", + 'Heleket API не вернул информацию по платежу %s', payment.uuid, ) return payment payload = dict(fallback) - payload.setdefault("uuid", payment.uuid) - payload.setdefault("order_id", payment.order_id) + payload.setdefault('uuid', payment.uuid) + payload.setdefault('order_id', payment.order_id) updated_payment = await self._process_heleket_payload( db, payload, - metadata_key="last_status_check", + metadata_key='last_status_check', ) return updated_payment or payment async def _lookup_heleket_payment_history( self, - payment: "HeleketPayment", - ) -> Optional[Dict[str, Any]]: - service = getattr(self, "heleket_service", None) + payment: HeleketPayment, + ) -> dict[str, Any] | None: + service = getattr(self, 'heleket_service', None) if not service: return None - created_at = getattr(payment, "created_at", None) - date_from_str: Optional[str] = None - date_to_str: Optional[str] = None + created_at = getattr(payment, 'created_at', None) + date_from_str: str | None = None + date_to_str: str | None = None if isinstance(created_at, datetime): start = created_at - timedelta(days=2) end = created_at + timedelta(days=2) - date_from_str = start.strftime("%Y-%m-%d %H:%M:%S") - date_to_str = end.strftime("%Y-%m-%d %H:%M:%S") + date_from_str = start.strftime('%Y-%m-%d %H:%M:%S') + date_to_str = end.strftime('%Y-%m-%d %H:%M:%S') - cursor: Optional[str] = None + cursor: str | None = None for _ in range(10): response = await service.list_payments( date_from=date_from_str, @@ -527,26 +524,26 @@ class HeleketPaymentMixin: if not response or not isinstance(response, dict): return None - result = response.get("result") + result = response.get('result') if not isinstance(result, dict): return None - items = result.get("items") + items = result.get('items') if isinstance(items, list): for item in items: if not isinstance(item, dict): continue - uuid = str(item.get("uuid") or "").strip() - order_id = str(item.get("order_id") or "").strip() + uuid = str(item.get('uuid') or '').strip() + order_id = str(item.get('order_id') or '').strip() if uuid and uuid == str(payment.uuid): return item if order_id and order_id == str(payment.order_id): return item - paginate = result.get("paginate") + paginate = result.get('paginate') cursor = None if isinstance(paginate, dict): - next_cursor = paginate.get("nextCursor") + next_cursor = paginate.get('nextCursor') if isinstance(next_cursor, str) and next_cursor: cursor = next_cursor diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index 42dd8cb3..7d2ee04f 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -4,10 +4,9 @@ from __future__ import annotations import json import uuid -import logging from datetime import datetime, timedelta from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -18,8 +17,8 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class KassaAiPaymentMixin: @@ -31,10 +30,10 @@ class KassaAiPaymentMixin: *, user_id: int, amount_kopeks: int, - description: str = "Пополнение баланса", - email: Optional[str] = None, - language: str = "ru", - ) -> Optional[Dict[str, Any]]: + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + ) -> dict[str, Any] | None: """ Создает платеж KassaAI. @@ -50,13 +49,13 @@ class KassaAiPaymentMixin: Словарь с данными платежа или None при ошибке """ if not settings.is_kassa_ai_enabled(): - logger.error("KassaAI не настроен") + logger.error('KassaAI не настроен') return None # Валидация лимитов if amount_kopeks < settings.KASSA_AI_MIN_AMOUNT_KOPEKS: logger.warning( - "KassaAI: сумма %s меньше минимальной %s", + 'KassaAI: сумма %s меньше минимальной %s', amount_kopeks, settings.KASSA_AI_MIN_AMOUNT_KOPEKS, ) @@ -64,14 +63,14 @@ class KassaAiPaymentMixin: if amount_kopeks > settings.KASSA_AI_MAX_AMOUNT_KOPEKS: logger.warning( - "KassaAI: сумма %s больше максимальной %s", + 'KassaAI: сумма %s больше максимальной %s', amount_kopeks, settings.KASSA_AI_MAX_AMOUNT_KOPEKS, ) return None # Генерируем уникальный order_id - order_id = f"kai_{user_id}_{uuid.uuid4().hex[:12]}" + order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}' amount_rubles = amount_kopeks / 100 currency = settings.KASSA_AI_CURRENCY @@ -80,11 +79,11 @@ class KassaAiPaymentMixin: # Метаданные metadata = { - "user_id": user_id, - "amount_kopeks": amount_kopeks, - "description": description, - "language": language, - "type": "balance_topup", + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', } try: @@ -97,19 +96,19 @@ class KassaAiPaymentMixin: payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID, ) - payment_url = result.get("location") + payment_url = result.get('location') if not payment_url: - logger.error("KassaAI API не вернул URL платежа") + logger.error('KassaAI API не вернул URL платежа') return None logger.info( - "KassaAI API: создан заказ order_id=%s, url=%s", + 'KassaAI API: создан заказ order_id=%s, url=%s', order_id, payment_url, ) # Импортируем CRUD модуль - kassa_ai_crud = import_module("app.database.crud.kassa_ai") + kassa_ai_crud = import_module('app.database.crud.kassa_ai') # Сохраняем в БД local_payment = await kassa_ai_crud.create_kassa_ai_payment( @@ -126,7 +125,7 @@ class KassaAiPaymentMixin: ) logger.info( - "KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s", + 'KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s', order_id, user_id, amount_rubles, @@ -134,17 +133,17 @@ class KassaAiPaymentMixin: ) return { - "order_id": order_id, - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_rubles, - "currency": currency, - "payment_url": payment_url, - "expires_at": expires_at.isoformat(), - "local_payment_id": local_payment.id, + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, } except Exception as e: - logger.exception("KassaAI: ошибка создания платежа: %s", e) + logger.exception('KassaAI: ошибка создания платежа: %s', e) return None async def process_kassa_ai_webhook( @@ -156,7 +155,7 @@ class KassaAiPaymentMixin: order_id: str, sign: str, intid: str, - cur_id: Optional[int] = None, + cur_id: int | None = None, ) -> bool: """ Обрабатывает webhook от KassaAI. @@ -175,39 +174,29 @@ class KassaAiPaymentMixin: """ try: # Проверка подписи - if not kassa_ai_service.verify_webhook_signature( - merchant_id, amount, order_id, sign - ): - logger.warning( - "KassaAI webhook: неверная подпись для order_id=%s", order_id - ) + if not kassa_ai_service.verify_webhook_signature(merchant_id, amount, order_id, sign): + logger.warning('KassaAI webhook: неверная подпись для order_id=%s', order_id) return False # Импортируем CRUD модуль - kassa_ai_crud = import_module("app.database.crud.kassa_ai") + kassa_ai_crud = import_module('app.database.crud.kassa_ai') # Получаем платеж из БД - payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id( - db, order_id - ) + payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id(db, order_id) if not payment: - logger.warning( - "KassaAI webhook: платеж не найден order_id=%s", order_id - ) + logger.warning('KassaAI webhook: платеж не найден order_id=%s', order_id) return False # Проверка дублирования if payment.is_paid: - logger.info( - "KassaAI webhook: платеж уже обработан order_id=%s", order_id - ) + logger.info('KassaAI webhook: платеж уже обработан order_id=%s', order_id) return True # Проверка суммы expected_amount = payment.amount_kopeks / 100 if abs(amount - expected_amount) > 0.01: logger.warning( - "KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s", + 'KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s', expected_amount, amount, ) @@ -215,17 +204,17 @@ class KassaAiPaymentMixin: # Обновляем статус платежа callback_payload = { - "merchant_id": merchant_id, - "amount": amount, - "order_id": order_id, - "intid": intid, - "cur_id": cur_id, + 'merchant_id': merchant_id, + 'amount': amount, + 'order_id': order_id, + 'intid': intid, + 'cur_id': cur_id, } payment = await kassa_ai_crud.update_kassa_ai_payment_status( db=db, payment=payment, - status="success", + status='success', is_paid=True, kassa_ai_order_id=intid, payment_system_id=cur_id, @@ -233,12 +222,10 @@ class KassaAiPaymentMixin: ) # Финализируем платеж (начисляем баланс, создаем транзакцию) - return await self._finalize_kassa_ai_payment( - db, payment, intid=intid, trigger="webhook" - ) + return await self._finalize_kassa_ai_payment(db, payment, intid=intid, trigger='webhook') except Exception as e: - logger.exception("KassaAI webhook: ошибка обработки: %s", e) + logger.exception('KassaAI webhook: ошибка обработки: %s', e) return False async def _finalize_kassa_ai_payment( @@ -246,15 +233,15 @@ class KassaAiPaymentMixin: db: AsyncSession, payment: Any, *, - intid: Optional[str], + intid: str | None, trigger: str, ) -> bool: """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') if payment.transaction_id: logger.info( - "KassaAI платеж %s уже привязан к транзакции (trigger=%s)", + 'KassaAI платеж %s уже привязан к транзакции (trigger=%s)', payment.order_id, trigger, ) @@ -264,7 +251,7 @@ class KassaAiPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - "Пользователь %s не найден для KassaAI платежа %s (trigger=%s)", + 'Пользователь %s не найден для KassaAI платежа %s (trigger=%s)', payment.user_id, payment.order_id, trigger, @@ -277,14 +264,14 @@ class KassaAiPaymentMixin: user_id=payment.user_id, type=TransactionType.DEPOSIT, amount_kopeks=payment.amount_kopeks, - description=f"Пополнение через KassaAI (#{intid or payment.order_id})", + description=f'Пополнение через KassaAI (#{intid or payment.order_id})', payment_method=PaymentMethod.KASSA_AI, external_id=str(intid) if intid else payment.order_id, is_completed=True, ) # Связываем платеж с транзакцией - kassa_ai_crud = import_module("app.database.crud.kassa_ai") + kassa_ai_crud = import_module('app.database.crud.kassa_ai') await kassa_ai_crud.update_kassa_ai_payment_status( db=db, payment=payment, @@ -300,9 +287,9 @@ class KassaAiPaymentMixin: user.updated_at = datetime.utcnow() promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "Первое пополнение" if was_first_topup else "Пополнение" + topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение' await db.commit() @@ -310,13 +297,9 @@ class KassaAiPaymentMixin: try: from app.services.referral_service import process_referral_topup - await process_referral_topup( - db, user.id, payment.amount_kopeks, getattr(self, "bot", None) - ) + await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: - logger.error( - "Ошибка обработки реферального пополнения KassaAI: %s", error - ) + logger.error('Ошибка обработки реферального пополнения KassaAI: %s', error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -326,7 +309,7 @@ class KassaAiPaymentMixin: await db.refresh(payment) # Отправка уведомления админам - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, @@ -344,37 +327,34 @@ class KassaAiPaymentMixin: db=db, ) except Exception as error: - logger.error( - "Ошибка отправки админ уведомления KassaAI: %s", error - ) + logger.error('Ошибка отправки админ уведомления KassaAI: %s', error) # Отправка уведомления пользователю (только Telegram-пользователям) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) display_name = settings.get_kassa_ai_display_name() await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - f"💳 Способ: {display_name}\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'💳 Способ: {display_name}\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.error( - "Ошибка отправки уведомления пользователю KassaAI: %s", error - ) + logger.error('Ошибка отправки уведомления пользователю KassaAI: %s', error) # Автопокупка подписки try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False @@ -383,11 +363,11 @@ class KassaAiPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -401,24 +381,24 @@ class KassaAiPaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER", - "У вас есть незавершенное оформление подписки. Вернуться?", + 'BALANCE_TOPUP_CART_REMINDER', + 'У вас есть незавершенное оформление подписки. Вернуться?', ) keyboard = types.InlineKeyboardMarkup( @@ -426,16 +406,16 @@ class KassaAiPaymentMixin: [ types.InlineKeyboardButton( text=texts.t( - "BALANCE_TOPUP_CART_BUTTON", - "🛒 Продолжить оформление", + 'BALANCE_TOPUP_CART_BUTTON', + '🛒 Продолжить оформление', ), - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -443,23 +423,19 @@ class KassaAiPaymentMixin: await self.bot.send_message( chat_id=user.telegram_id, - text=( - "✅ Баланс пополнен на " - f"{settings.format_price(payment.amount_kopeks)}!\n\n" - f"{cart_message}" - ), + text=(f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n{cart_message}'), reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', user.id, error, exc_info=True, ) logger.info( - "✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)", + '✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)', payment.order_id, payment.user_id, trigger, @@ -471,7 +447,7 @@ class KassaAiPaymentMixin: self, db: AsyncSession, order_id: str, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """ Проверяет статус платежа через API. @@ -486,5 +462,5 @@ class KassaAiPaymentMixin: status_data = await kassa_ai_service.get_order_status(order_id) return status_data except Exception as e: - logger.exception("KassaAI: ошибка проверки статуса: %s", e) + logger.exception('KassaAI: ошибка проверки статуса: %s', e) return None diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index 62bffce0..f41befcf 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -4,7 +4,7 @@ from __future__ import annotations import uuid from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -14,8 +14,8 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class MulenPayPaymentMixin: @@ -27,18 +27,18 @@ class MulenPayPaymentMixin: user_id: int, amount_kopeks: int, description: str, - language: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + language: str | None = None, + ) -> dict[str, Any] | None: """Создаёт локальный платеж и инициализирует сессию в MulenPay.""" display_name = settings.get_mulenpay_display_name() - display_name_html = settings.get_mulenpay_display_name_html() - if not getattr(self, "mulenpay_service", None): - logger.error("%s сервис не инициализирован", display_name) + settings.get_mulenpay_display_name_html() + if not getattr(self, 'mulenpay_service', None): + logger.error('%s сервис не инициализирован', display_name) return None if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: logger.warning( - "Сумма %s меньше минимальной: %s < %s", + 'Сумма %s меньше минимальной: %s < %s', display_name, amount_kopeks, settings.MULENPAY_MIN_AMOUNT_KOPEKS, @@ -47,26 +47,26 @@ class MulenPayPaymentMixin: if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма %s больше максимальной: %s > %s", + 'Сумма %s больше максимальной: %s > %s', display_name, amount_kopeks, settings.MULENPAY_MAX_AMOUNT_KOPEKS, ) return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') try: - payment_uuid = f"mulen_{user_id}_{uuid.uuid4().hex}" + payment_uuid = f'mulen_{user_id}_{uuid.uuid4().hex}' amount_rubles = amount_kopeks / 100 items = [ { - "description": description[:128], - "quantity": 1, - "price": round(amount_rubles, 2), - "vat_code": settings.MULENPAY_VAT_CODE, - "payment_subject": settings.MULENPAY_PAYMENT_SUBJECT, - "payment_mode": settings.MULENPAY_PAYMENT_MODE, + 'description': description[:128], + 'quantity': 1, + 'price': round(amount_rubles, 2), + 'vat_code': settings.MULENPAY_VAT_CODE, + 'payment_subject': settings.MULENPAY_PAYMENT_SUBJECT, + 'payment_mode': settings.MULENPAY_PAYMENT_MODE, } ] @@ -80,16 +80,16 @@ class MulenPayPaymentMixin: ) if not response: - logger.error("Ошибка создания %s платежа", display_name) + logger.error('Ошибка создания %s платежа', display_name) return None - mulen_payment_id = response.get("id") - payment_url = response.get("paymentUrl") + mulen_payment_id = response.get('id') + payment_url = response.get('paymentUrl') metadata = { - "user_id": user_id, - "amount_kopeks": amount_kopeks, - "description": description, + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, } local_payment = await payment_module.create_mulenpay_payment( @@ -100,13 +100,13 @@ class MulenPayPaymentMixin: description=description, payment_url=payment_url, mulen_payment_id=mulen_payment_id, - currency="RUB", - status="created", + currency='RUB', + status='created', metadata=metadata, ) logger.info( - "Создан %s платеж %s на %s₽ для пользователя %s", + 'Создан %s платеж %s на %s₽ для пользователя %s', display_name, mulen_payment_id, amount_rubles, @@ -114,45 +114,43 @@ class MulenPayPaymentMixin: ) return { - "local_payment_id": local_payment.id, - "mulen_payment_id": mulen_payment_id, - "payment_url": payment_url, - "amount_kopeks": amount_kopeks, - "uuid": payment_uuid, - "status": "created", + 'local_payment_id': local_payment.id, + 'mulen_payment_id': mulen_payment_id, + 'payment_url': payment_url, + 'amount_kopeks': amount_kopeks, + 'uuid': payment_uuid, + 'status': 'created', } except Exception as error: - logger.error("Ошибка создания %s платежа: %s", display_name, error) + logger.error('Ошибка создания %s платежа: %s', display_name, error) return None async def process_mulenpay_callback( self, db: AsyncSession, - callback_data: Dict[str, Any], + callback_data: dict[str, Any], ) -> bool: """Обрабатывает callback от MulenPay, обновляет статус и начисляет баланс.""" display_name = settings.get_mulenpay_display_name() display_name_html = settings.get_mulenpay_display_name_html() try: - payment_module = import_module("app.services.payment_service") - uuid_value = callback_data.get("uuid") + payment_module = import_module('app.services.payment_service') + uuid_value = callback_data.get('uuid') payment_status_raw = ( - callback_data.get("payment_status") - or callback_data.get("status") - or callback_data.get("paymentStatus") + callback_data.get('payment_status') or callback_data.get('status') or callback_data.get('paymentStatus') ) - payment_status = (payment_status_raw or "").lower() - mulen_payment_id_raw = callback_data.get("id") - mulen_payment_id_int: Optional[int] = None + payment_status = (payment_status_raw or '').lower() + mulen_payment_id_raw = callback_data.get('id') + mulen_payment_id_int: int | None = None if mulen_payment_id_raw is not None: try: mulen_payment_id_int = int(mulen_payment_id_raw) except (TypeError, ValueError): mulen_payment_id_int = None - amount_value = callback_data.get("amount") + amount_value = callback_data.get('amount') logger.debug( - "%s callback: uuid=%s, status=%s, amount=%s", + '%s callback: uuid=%s, status=%s, amount=%s', display_name, uuid_value, payment_status, @@ -160,7 +158,7 @@ class MulenPayPaymentMixin: ) if not uuid_value and mulen_payment_id_raw is None: - logger.error("%s callback без uuid и id", display_name) + logger.error('%s callback без uuid и id', display_name) return False payment = None @@ -168,39 +166,37 @@ class MulenPayPaymentMixin: payment = await payment_module.get_mulenpay_payment_by_uuid(db, uuid_value) if not payment and mulen_payment_id_int is not None: - payment = await payment_module.get_mulenpay_payment_by_mulen_id( - db, mulen_payment_id_int - ) + payment = await payment_module.get_mulenpay_payment_by_mulen_id(db, mulen_payment_id_int) if not payment: logger.error( - "%s платеж не найден (uuid=%s, id=%s)", + '%s платеж не найден (uuid=%s, id=%s)', display_name, uuid_value, mulen_payment_id_raw, ) return False - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + invoice_message = metadata.get('invoice_message') or {} invoice_message_removed = False - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + if getattr(self, 'bot', None): + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - "Не удалось удалить %s счёт %s: %s", + 'Не удалось удалить %s счёт %s: %s', display_name, message_id, delete_error, ) else: - metadata.pop("invoice_message", None) + metadata.pop('invoice_message', None) invoice_message_removed = True if payment.is_paid: @@ -213,23 +209,23 @@ class MulenPayPaymentMixin: ) except Exception as error: # pragma: no cover - diagnostics logger.warning( - "Не удалось обновить метаданные %s после удаления счёта: %s", + 'Не удалось обновить метаданные %s после удаления счёта: %s', display_name, error, ) logger.info( - "%s платеж %s уже обработан, игнорируем повторный callback", + '%s платеж %s уже обработан, игнорируем повторный callback', display_name, payment.uuid, ) return True - if payment_status == "success": + if payment_status == 'success': await payment_module.update_mulenpay_payment_status( db, payment=payment, - status="success", + status='success', callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, metadata=metadata, @@ -237,7 +233,7 @@ class MulenPayPaymentMixin: if payment.transaction_id: logger.info( - "Для %s платежа %s уже создана транзакция", + 'Для %s платежа %s уже создана транзакция', display_name, payment.uuid, ) @@ -245,8 +241,8 @@ class MulenPayPaymentMixin: payment_description = getattr( payment, - "description", - f"платеж {payment.uuid}", + 'description', + f'платеж {payment.uuid}', ) transaction = await payment_module.create_transaction( @@ -254,7 +250,7 @@ class MulenPayPaymentMixin: user_id=payment.user_id, type=TransactionType.DEPOSIT, amount_kopeks=payment.amount_kopeks, - description=f"Пополнение через {display_name}: {payment_description}", + description=f'Пополнение через {display_name}: {payment_description}', payment_method=PaymentMethod.MULENPAY, external_id=payment.uuid, is_completed=True, @@ -269,7 +265,7 @@ class MulenPayPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - "Пользователь %s не найден при обработке %s", + 'Пользователь %s не найден при обработке %s', payment.user_id, display_name, ) @@ -282,7 +278,7 @@ class MulenPayPaymentMixin: db, user, payment.amount_kopeks, - f"Пополнение {display_name}: {payment.amount_kopeks // 100}₽", + f'Пополнение {display_name}: {payment.amount_kopeks // 100}₽', create_transaction=False, ) @@ -293,11 +289,11 @@ class MulenPayPaymentMixin: db, user.id, payment.amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: logger.error( - "Ошибка обработки реферального пополнения %s: %s", + 'Ошибка обработки реферального пополнения %s: %s', display_name, error, ) @@ -311,20 +307,18 @@ class MulenPayPaymentMixin: user = await payment_module.get_user_by_id(db, user.id) if not user: logger.error( - "Пользователь %s не найден при повторной загрузке после %s", + 'Пользователь %s не найден при повторной загрузке после %s', payment.user_id, display_name, ) return False promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = ( - "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - ) + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, @@ -343,38 +337,39 @@ class MulenPayPaymentMixin: ) except Exception as error: logger.error( - "Ошибка отправки уведомления о пополнении %s: %s", + 'Ошибка отправки уведомления о пополнении %s: %s', display_name, error, ) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - f"🦊 Способ: {display_name_html}\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'🦊 Способ: {display_name_html}\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка отправки уведомления пользователю %s: %s", + 'Ошибка отправки уведомления пользователю %s: %s', display_name, error, ) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -382,11 +377,11 @@ class MulenPayPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -400,89 +395,93 @@ class MulenPayPaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили и есть telegram_id - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if ( + has_saved_cart + and getattr(self, 'bot', None) + and not activation_notification_sent + and user.telegram_id + ): # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами." + 'BALANCE_TOPUP_CART_REMINDER_DETAILED', + '🛒 У вас есть неоформленный заказ.\n\n' + 'Вы можете продолжить оформление с теми же параметрами.', ) - + # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu" - )] - ]) - + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data='return_to_saved_cart' + ) + ], + [types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')], + ] + ) + await self.bot.send_message( chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", - reply_markup=keyboard + text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}', + reply_markup=keyboard, ) logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', user.id, ) except Exception as e: - logger.error(f"Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", exc_info=True) + logger.error( + f'Ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}', exc_info=True + ) logger.info( - "✅ Обработан %s платеж %s для пользователя %s", + '✅ Обработан %s платеж %s для пользователя %s', display_name, payment.uuid, payment.user_id, ) return True - if payment_status == "cancel": + if payment_status == 'cancel': await payment_module.update_mulenpay_payment_status( db, payment=payment, - status="canceled", + status='canceled', callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, ) - logger.info("%s платеж %s отменен", display_name, payment.uuid) + logger.info('%s платеж %s отменен', display_name, payment.uuid) return True await payment_module.update_mulenpay_payment_status( db, payment=payment, - status=payment_status or "unknown", + status=payment_status or 'unknown', callback_payload=callback_data, mulen_payment_id=mulen_payment_id_int, ) logger.info( - "Получен %s callback со статусом %s для платежа %s", + 'Получен %s callback со статусом %s для платежа %s', display_name, payment_status, payment.uuid, @@ -491,35 +490,35 @@ class MulenPayPaymentMixin: except Exception as error: logger.error( - "Ошибка обработки %s callback: %s", + 'Ошибка обработки %s callback: %s', display_name, error, exc_info=True, ) return False - def _map_mulenpay_status(self, status_code: Optional[int]) -> str: + def _map_mulenpay_status(self, status_code: int | None) -> str: """Приводит числовой статус MulenPay к строковому значению.""" mapping = { - 0: "created", - 1: "processing", - 2: "canceled", - 3: "success", - 4: "error", - 5: "hold", - 6: "hold", + 0: 'created', + 1: 'processing', + 2: 'canceled', + 3: 'success', + 4: 'error', + 5: 'hold', + 6: 'hold', } - return mapping.get(status_code, "unknown") + return mapping.get(status_code, 'unknown') async def get_mulenpay_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Возвращает текущее состояние платежа и при необходимости синхронизирует его.""" display_name = settings.get_mulenpay_display_name() try: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id) if not payment: @@ -528,75 +527,68 @@ class MulenPayPaymentMixin: remote_status_code = None remote_data = None - if getattr(self, "mulenpay_service", None) and payment.mulen_payment_id is not None: - response = await self.mulenpay_service.get_payment( - payment.mulen_payment_id - ) + if getattr(self, 'mulenpay_service', None) and payment.mulen_payment_id is not None: + response = await self.mulenpay_service.get_payment(payment.mulen_payment_id) if response: - if isinstance(response, dict) and response.get("success"): - remote_data = response.get("payment") - elif isinstance(response, dict) and "status" in response and "id" in response: + if isinstance(response, dict) and response.get('success'): + remote_data = response.get('payment') + elif isinstance(response, dict) and 'status' in response and 'id' in response: remote_data = response - if not remote_data and getattr(self, "mulenpay_service", None): + if not remote_data and getattr(self, 'mulenpay_service', None): list_response = await self.mulenpay_service.list_payments( limit=100, uuid=payment.uuid, ) items = [] if isinstance(list_response, dict): - items = list_response.get("items") or [] + items = list_response.get('items') or [] if items: for candidate in items: if not isinstance(candidate, dict): continue - candidate_id = candidate.get("id") - candidate_uuid = candidate.get("uuid") - if ( - (candidate_id is not None and candidate_id == payment.mulen_payment_id) - or (candidate_uuid and candidate_uuid == payment.uuid) + candidate_id = candidate.get('id') + candidate_uuid = candidate.get('uuid') + if (candidate_id is not None and candidate_id == payment.mulen_payment_id) or ( + candidate_uuid and candidate_uuid == payment.uuid ): remote_data = candidate break if isinstance(remote_data, dict): - remote_status_code = remote_data.get("status") + remote_status_code = remote_data.get('status') mapped_status = self._map_mulenpay_status(remote_status_code) - if mapped_status == "success" and not payment.is_paid: + if mapped_status == 'success' and not payment.is_paid: await self.process_mulenpay_callback( db, { - "uuid": payment.uuid, - "payment_status": "success", - "id": remote_data.get("id"), - "amount": remote_data.get("amount"), + 'uuid': payment.uuid, + 'payment_status': 'success', + 'id': remote_data.get('id'), + 'amount': remote_data.get('amount'), }, ) - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id - ) + payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id) elif mapped_status and mapped_status != payment.status: await payment_module.update_mulenpay_payment_status( db, payment=payment, status=mapped_status, - mulen_payment_id=remote_data.get("id"), - ) - payment = await payment_module.get_mulenpay_payment_by_local_id( - db, local_payment_id + mulen_payment_id=remote_data.get('id'), ) + payment = await payment_module.get_mulenpay_payment_by_local_id(db, local_payment_id) return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "remote_status_code": remote_status_code, - "remote_data": remote_data, + 'payment': payment, + 'status': payment.status, + 'is_paid': payment.is_paid, + 'remote_status_code': remote_status_code, + 'remote_data': remote_data, } except Exception as error: logger.error( - "Ошибка получения статуса %s: %s", + 'Ошибка получения статуса %s: %s', display_name, error, exc_info=True, diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 76b4103b..9834bb8a 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -2,10 +2,10 @@ from __future__ import annotations +import uuid from datetime import datetime from importlib import import_module -import uuid -from typing import Any, Dict, List, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -16,8 +16,8 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class Pal24PaymentMixin: @@ -31,19 +31,19 @@ class Pal24PaymentMixin: amount_kopeks: int, description: str, language: str, - ttl_seconds: Optional[int] = None, - payer_email: Optional[str] = None, - payment_method: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + ttl_seconds: int | None = None, + payer_email: str | None = None, + payment_method: str | None = None, + ) -> dict[str, Any] | None: """Создаёт счёт в Pal24 и сохраняет локальную запись.""" - service = getattr(self, "pal24_service", None) + service = getattr(self, 'pal24_service', None) if not service or not service.is_configured: - logger.error("Pal24 сервис не инициализирован") + logger.error('Pal24 сервис не инициализирован') return None if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: logger.warning( - "Сумма Pal24 меньше минимальной: %s < %s", + 'Сумма Pal24 меньше минимальной: %s < %s', amount_kopeks, settings.PAL24_MIN_AMOUNT_KOPEKS, ) @@ -51,24 +51,24 @@ class Pal24PaymentMixin: if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма Pal24 больше максимальной: %s > %s", + 'Сумма Pal24 больше максимальной: %s > %s', amount_kopeks, settings.PAL24_MAX_AMOUNT_KOPEKS, ) return None - order_id = f"pal24_{user_id}_{uuid.uuid4().hex}" + order_id = f'pal24_{user_id}_{uuid.uuid4().hex}' custom_payload = { - "user_id": user_id, - "amount_kopeks": amount_kopeks, - "language": language, + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'language': language, } normalized_payment_method = self._normalize_payment_method(payment_method) api_payment_method = self._map_api_payment_method(normalized_payment_method) - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') try: response = await service.create_bill( @@ -82,19 +82,19 @@ class Pal24PaymentMixin: payment_method=api_payment_method, ) except Pal24APIError as error: - logger.error("Ошибка Pal24 API при создании счета: %s", error) + logger.error('Ошибка Pal24 API при создании счета: %s', error) return None - if not response.get("success", True): - logger.error("Pal24 вернул ошибку при создании счета: %s", response) + if not response.get('success', True): + logger.error('Pal24 вернул ошибку при создании счета: %s', response) return None - bill_id = response.get("bill_id") + bill_id = response.get('bill_id') if not bill_id: - logger.error("Pal24 не вернул bill_id: %s", response) + logger.error('Pal24 не вернул bill_id: %s', response) return None - def _pick_url(*keys: str) -> Optional[str]: + def _pick_url(*keys: str) -> str | None: for key in keys: value = response.get(key) if value: @@ -102,33 +102,33 @@ class Pal24PaymentMixin: return None transfer_url = _pick_url( - "transfer_url", - "transferUrl", - "transfer_link", - "transferLink", - "transfer", - "sbp_url", - "sbpUrl", - "sbp_link", - "sbpLink", + 'transfer_url', + 'transferUrl', + 'transfer_link', + 'transferLink', + 'transfer', + 'sbp_url', + 'sbpUrl', + 'sbp_link', + 'sbpLink', ) card_url = _pick_url( - "link_url", - "linkUrl", - "link", - "card_url", - "cardUrl", - "card_link", - "cardLink", - "payment_url", - "paymentUrl", - "url", + 'link_url', + 'linkUrl', + 'link', + 'card_url', + 'cardUrl', + 'card_link', + 'cardLink', + 'payment_url', + 'paymentUrl', + 'url', ) link_page_url = _pick_url( - "link_page_url", - "linkPageUrl", - "page_url", - "pageUrl", + 'link_page_url', + 'linkPageUrl', + 'page_url', + 'pageUrl', ) primary_link = transfer_url or link_page_url or card_url @@ -137,20 +137,20 @@ class Pal24PaymentMixin: metadata_links = { key: value for key, value in { - "sbp": transfer_url, - "card": card_url, - "page": link_page_url, + 'sbp': transfer_url, + 'card': card_url, + 'page': link_page_url, }.items() if value } metadata_payload = { - "user_id": user_id, - "amount_kopeks": amount_kopeks, - "description": description, - "links": metadata_links, - "raw_response": response, - "selected_method": normalized_payment_method, + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'links': metadata_links, + 'raw_response': response, + 'selected_method': normalized_payment_method, } payment = await payment_module.create_pal24_payment( @@ -159,9 +159,9 @@ class Pal24PaymentMixin: bill_id=bill_id, amount_kopeks=amount_kopeks, description=description, - status=response.get("status", "NEW"), - type_=response.get("type", "normal"), - currency=response.get("currency", "RUB"), + status=response.get('status', 'NEW'), + type_=response.get('type', 'normal'), + currency=response.get('currency', 'RUB'), link_url=transfer_url or card_url, link_page_url=link_page_url or primary_link, order_id=order_id, @@ -170,68 +170,68 @@ class Pal24PaymentMixin: ) logger.info( - "Создан Pal24 счет %s для пользователя %s (%s₽)", + 'Создан Pal24 счет %s для пользователя %s (%s₽)', bill_id, user_id, amount_kopeks / 100, ) - payment_status = getattr(payment, "status", response.get("status", "NEW")) + payment_status = getattr(payment, 'status', response.get('status', 'NEW')) return { - "local_payment_id": payment.id, - "bill_id": bill_id, - "order_id": order_id, - "amount_kopeks": amount_kopeks, - "primary_url": primary_link, - "secondary_url": secondary_link, - "link_url": transfer_url, - "card_url": card_url, - "payment_method": normalized_payment_method, - "metadata_links": metadata_links, - "status": payment_status, - "sbp_url": transfer_url, - "transfer_url": transfer_url, - "link_page_url": link_page_url, - "payment_url": primary_link, + 'local_payment_id': payment.id, + 'bill_id': bill_id, + 'order_id': order_id, + 'amount_kopeks': amount_kopeks, + 'primary_url': primary_link, + 'secondary_url': secondary_link, + 'link_url': transfer_url, + 'card_url': card_url, + 'payment_method': normalized_payment_method, + 'metadata_links': metadata_links, + 'status': payment_status, + 'sbp_url': transfer_url, + 'transfer_url': transfer_url, + 'link_page_url': link_page_url, + 'payment_url': primary_link, } async def process_pal24_callback( self, db: AsyncSession, - callback: Dict[str, Any], + callback: dict[str, Any], ) -> bool: """Обрабатывает callback от Pal24 и начисляет баланс при успехе.""" try: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - def _first_non_empty(*values: Optional[str]) -> Optional[str]: + def _first_non_empty(*values: str | None) -> str | None: for value in values: if value: return value return None payment_id = _first_non_empty( - callback.get("id"), - callback.get("TrsId"), - callback.get("TrsID"), + callback.get('id'), + callback.get('TrsId'), + callback.get('TrsID'), ) bill_id = _first_non_empty( - callback.get("bill_id"), - callback.get("billId"), - callback.get("BillId"), - callback.get("BillID"), + callback.get('bill_id'), + callback.get('billId'), + callback.get('BillId'), + callback.get('BillID'), ) order_id = _first_non_empty( - callback.get("order_id"), - callback.get("orderId"), - callback.get("InvId"), - callback.get("InvID"), + callback.get('order_id'), + callback.get('orderId'), + callback.get('InvId'), + callback.get('InvID'), ) - status = (callback.get("status") or callback.get("Status") or "").upper() + status = (callback.get('status') or callback.get('Status') or '').upper() if not bill_id and not order_id: - logger.error("Pal24 callback без идентификаторов: %s", callback) + logger.error('Pal24 callback без идентификаторов: %s', callback) return False payment = None @@ -241,15 +241,15 @@ class Pal24PaymentMixin: payment = await payment_module.get_pal24_payment_by_order_id(db, order_id) if not payment: - logger.error("Pal24 платеж не найден: %s / %s", bill_id, order_id) + logger.error('Pal24 платеж не найден: %s / %s', bill_id, order_id) return False if payment.is_paid: - logger.info("Pal24 платеж %s уже обработан", payment.bill_id) + logger.info('Pal24 платеж %s уже обработан', payment.bill_id) return True - if status in {"PAID", "SUCCESS", "OVERPAID"}: - metadata = getattr(payment, "metadata_json", {}) or {} + if status in {'PAID', 'SUCCESS', 'OVERPAID'}: + metadata = getattr(payment, 'metadata_json', {}) or {} if not isinstance(metadata, dict): metadata = {} @@ -261,63 +261,55 @@ class Pal24PaymentMixin: paid_at=datetime.utcnow(), callback_payload=callback, payment_id=payment_id, - payment_status=callback.get("Status") or status, + payment_status=callback.get('Status') or status, payment_method=( - callback.get("payment_method") - or callback.get("PaymentMethod") - or metadata.get("selected_method") - or getattr(payment, "payment_method", None) + callback.get('payment_method') + or callback.get('PaymentMethod') + or metadata.get('selected_method') + or getattr(payment, 'payment_method', None) ), - balance_amount=callback.get("BalanceAmount") - or callback.get("balance_amount"), - balance_currency=callback.get("BalanceCurrency") - or callback.get("balance_currency"), - payer_account=callback.get("AccountNumber") - or callback.get("account") - or callback.get("Account"), + balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'), + balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'), + payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'), ) return await self._finalize_pal24_payment( db, payment, payment_id=payment_id, - trigger="callback", + trigger='callback', ) - metadata = getattr(payment, "metadata_json", {}) or {} + metadata = getattr(payment, 'metadata_json', {}) or {} if not isinstance(metadata, dict): metadata = {} await payment_module.update_pal24_payment_status( db, payment, - status=status or "UNKNOWN", + status=status or 'UNKNOWN', is_paid=False, callback_payload=callback, payment_id=payment_id, - payment_status=callback.get("Status") or status, + payment_status=callback.get('Status') or status, payment_method=( - callback.get("payment_method") - or callback.get("PaymentMethod") - or getattr(payment, "payment_method", None) + callback.get('payment_method') + or callback.get('PaymentMethod') + or getattr(payment, 'payment_method', None) ), - balance_amount=callback.get("BalanceAmount") - or callback.get("balance_amount"), - balance_currency=callback.get("BalanceCurrency") - or callback.get("balance_currency"), - payer_account=callback.get("AccountNumber") - or callback.get("account") - or callback.get("Account"), + balance_amount=callback.get('BalanceAmount') or callback.get('balance_amount'), + balance_currency=callback.get('BalanceCurrency') or callback.get('balance_currency'), + payer_account=callback.get('AccountNumber') or callback.get('account') or callback.get('Account'), ) logger.info( - "Обновили Pal24 платеж %s до статуса %s", + 'Обновили Pal24 платеж %s до статуса %s', payment.bill_id, status, ) return True except Exception as error: - logger.error("Ошибка обработки Pal24 callback: %s", error, exc_info=True) + logger.error('Ошибка обработки Pal24 callback: %s', error, exc_info=True) return False async def _finalize_pal24_payment( @@ -325,31 +317,31 @@ class Pal24PaymentMixin: db: AsyncSession, payment: Any, *, - payment_id: Optional[str], + payment_id: str | None, trigger: str, ) -> bool: """Создаёт транзакцию, начисляет баланс и отправляет уведомления.""" - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - invoice_message = metadata.get("invoice_message") or {} + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + invoice_message = metadata.get('invoice_message') or {} invoice_message_removed = False - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + if getattr(self, 'bot', None) and invoice_message: + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights logger.warning( - "Не удалось удалить счёт PayPalych %s: %s", + 'Не удалось удалить счёт PayPalych %s: %s', message_id, delete_error, ) else: - metadata.pop("invoice_message", None) + metadata.pop('invoice_message', None) invoice_message_removed = True if invoice_message_removed: @@ -363,13 +355,13 @@ class Pal24PaymentMixin: payment.metadata_json = metadata except Exception as error: # pragma: no cover - diagnostics logger.warning( - "Не удалось обновить метаданные PayPalych после удаления счёта: %s", + 'Не удалось обновить метаданные PayPalych после удаления счёта: %s', error, ) if payment.transaction_id: logger.info( - "Pal24 платеж %s уже привязан к транзакции (trigger=%s)", + 'Pal24 платеж %s уже привязан к транзакции (trigger=%s)', payment.bill_id, trigger, ) @@ -378,7 +370,7 @@ class Pal24PaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: logger.error( - "Пользователь %s не найден для Pal24 платежа %s (trigger=%s)", + 'Пользователь %s не найден для Pal24 платежа %s (trigger=%s)', payment.user_id, payment.bill_id, trigger, @@ -390,7 +382,7 @@ class Pal24PaymentMixin: user_id=payment.user_id, type=TransactionType.DEPOSIT, amount_kopeks=payment.amount_kopeks, - description=f"Пополнение через Pal24 ({payment_id or payment.bill_id})", + description=f'Пополнение через Pal24 ({payment_id or payment.bill_id})', payment_method=PaymentMethod.PAL24, external_id=str(payment_id) if payment_id else payment.bill_id, is_completed=True, @@ -405,21 +397,19 @@ class Pal24PaymentMixin: user.updated_at = datetime.utcnow() promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' await db.commit() try: from app.services.referral_service import process_referral_topup - await process_referral_topup( - db, user.id, payment.amount_kopeks, getattr(self, "bot", None) - ) + await process_referral_topup(db, user.id, payment.amount_kopeks, getattr(self, 'bot', None)) except Exception as error: logger.error( - "Ошибка обработки реферального пополнения Pal24: %s", + 'Ошибка обработки реферального пополнения Pal24: %s', error, ) @@ -430,7 +420,7 @@ class Pal24PaymentMixin: await db.refresh(user) await db.refresh(payment) - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, @@ -449,35 +439,36 @@ class Pal24PaymentMixin: ) except Exception as error: logger.error( - "Ошибка отправки админ уведомления Pal24: %s", + 'Ошибка отправки админ уведомления Pal24: %s', error, ) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - "🦊 Способ: PayPalych\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + '🦊 Способ: PayPalych\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка отправки уведомления пользователю Pal24: %s", + 'Ошибка отправки уведомления пользователю Pal24: %s', error, ) try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -485,11 +476,11 @@ class Pal24PaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -503,24 +494,24 @@ class Pal24PaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER", - "У вас есть незавершенное оформление подписки. Вернуться?", + 'BALANCE_TOPUP_CART_REMINDER', + 'У вас есть незавершенное оформление подписки. Вернуться?', ) keyboard = types.InlineKeyboardMarkup( @@ -528,16 +519,16 @@ class Pal24PaymentMixin: [ types.InlineKeyboardButton( text=texts.t( - "BALANCE_TOPUP_CART_BUTTON", - "🛒 Продолжить оформление", + 'BALANCE_TOPUP_CART_BUTTON', + '🛒 Продолжить оформление', ), - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -546,34 +537,34 @@ class Pal24PaymentMixin: await self.bot.send_message( chat_id=user.telegram_id, text=( - "✅ Баланс пополнен на " - f"{settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}" + '✅ Баланс пополнен на ' + f'{settings.format_price(payment.amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}' ), reply_markup=keyboard, ) logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', user.id, ) else: logger.info( - "У пользователя %s нет сохраненной корзины или автопокупка выполнена", + 'У пользователя %s нет сохраненной корзины или автопокупка выполнена', user.id, ) except Exception as error: logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', user.id, error, exc_info=True, ) logger.info( - "✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)", + '✅ Обработан Pal24 платеж %s для пользователя %s (trigger=%s)', payment.bill_id, payment.user_id, trigger, @@ -581,35 +572,34 @@ class Pal24PaymentMixin: return True - async def get_pal24_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Запрашивает актуальный статус платежа у Pal24 и синхронизирует локальную запись.""" try: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) if not payment: return None - remote_status: Optional[str] = None - remote_payloads: Dict[str, Any] = {} - payment_info_candidates: List[Dict[str, Optional[str]]] = [] + remote_status: str | None = None + remote_payloads: dict[str, Any] = {} + payment_info_candidates: list[dict[str, str | None]] = [] - service = getattr(self, "pal24_service", None) + service = getattr(self, 'pal24_service', None) if service and payment.bill_id: bill_id_str = str(payment.bill_id) try: response = await service.get_bill_status(bill_id_str) except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении статуса счёта: %s", error) + logger.error('Ошибка Pal24 API при получении статуса счёта: %s', error) else: if response: - remote_payloads["bill_status"] = response - status_value = response.get("status") or (response.get("bill") or {}).get("status") + remote_payloads['bill_status'] = response + status_value = response.get('status') or (response.get('bill') or {}).get('status') if status_value: remote_status = str(status_value).upper() extracted = self._extract_remote_payment_info(response) @@ -621,10 +611,10 @@ class Pal24PaymentMixin: try: payment_response = await service.get_payment_status(payment_id_str) except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении статуса платежа: %s", error) + logger.error('Ошибка Pal24 API при получении статуса платежа: %s', error) else: if payment_response: - remote_payloads["payment_status"] = payment_response + remote_payloads['payment_status'] = payment_response extracted = self._extract_remote_payment_info(payment_response) if extracted: payment_info_candidates.append(extracted) @@ -632,10 +622,10 @@ class Pal24PaymentMixin: try: payments_response = await service.get_bill_payments(bill_id_str) except Pal24APIError as error: - logger.error("Ошибка Pal24 API при получении списка платежей: %s", error) + logger.error('Ошибка Pal24 API при получении списка платежей: %s', error) else: if payments_response: - remote_payloads["bill_payments"] = payments_response + remote_payloads['bill_payments'] = payments_response for candidate in self._collect_payment_candidates(payments_response): extracted = self._extract_remote_payment_info(candidate) if extracted: @@ -643,76 +633,76 @@ class Pal24PaymentMixin: payment_info = self._select_best_payment_info(payment, payment_info_candidates) if payment_info: - remote_payloads.setdefault("selected_payment", payment_info) + remote_payloads.setdefault('selected_payment', payment_info) - bill_success = getattr(service, "BILL_SUCCESS_STATES", {"SUCCESS"}) if service else {"SUCCESS"} - bill_failed = getattr(service, "BILL_FAILED_STATES", {"FAIL"}) if service else {"FAIL"} - bill_pending = getattr(service, "BILL_PENDING_STATES", {"NEW", "PROCESS"}) if service else {"NEW", "PROCESS"} + bill_success = getattr(service, 'BILL_SUCCESS_STATES', {'SUCCESS'}) if service else {'SUCCESS'} + bill_failed = getattr(service, 'BILL_FAILED_STATES', {'FAIL'}) if service else {'FAIL'} + bill_pending = ( + getattr(service, 'BILL_PENDING_STATES', {'NEW', 'PROCESS'}) if service else {'NEW', 'PROCESS'} + ) - update_status = payment.status or "NEW" - update_kwargs: Dict[str, Any] = {} - is_paid_update: Optional[bool] = None + update_status = payment.status or 'NEW' + update_kwargs: dict[str, Any] = {} + is_paid_update: bool | None = None if remote_status: update_status = remote_status if remote_status in bill_success: is_paid_update = True - elif remote_status in bill_failed: - is_paid_update = False - elif remote_status in bill_pending and is_paid_update is None: + elif remote_status in bill_failed or (remote_status in bill_pending and is_paid_update is None): is_paid_update = False - payment_status_code: Optional[str] = None + payment_status_code: str | None = None if payment_info: - payment_status_code = (payment_info.get("status") or "").upper() or None + payment_status_code = (payment_info.get('status') or '').upper() or None if payment_status_code: - existing_status = (getattr(payment, "payment_status", "") or "").upper() + existing_status = (getattr(payment, 'payment_status', '') or '').upper() if payment_status_code != existing_status: - update_kwargs["payment_status"] = payment_status_code + update_kwargs['payment_status'] = payment_status_code - payment_id_value = payment_info.get("id") - if payment_id_value and payment_id_value != (payment.payment_id or ""): - update_kwargs["payment_id"] = payment_id_value + payment_id_value = payment_info.get('id') + if payment_id_value and payment_id_value != (payment.payment_id or ''): + update_kwargs['payment_id'] = payment_id_value - method_value = payment_info.get("method") + method_value = payment_info.get('method') if method_value: normalized_method = self._normalize_payment_method(method_value) - if normalized_method != (payment.payment_method or ""): - update_kwargs["payment_method"] = normalized_method + if normalized_method != (payment.payment_method or ''): + update_kwargs['payment_method'] = normalized_method - balance_amount = payment_info.get("balance_amount") - if balance_amount and balance_amount != (payment.balance_amount or ""): - update_kwargs["balance_amount"] = balance_amount + balance_amount = payment_info.get('balance_amount') + if balance_amount and balance_amount != (payment.balance_amount or ''): + update_kwargs['balance_amount'] = balance_amount - balance_currency = payment_info.get("balance_currency") - if balance_currency and balance_currency != (payment.balance_currency or ""): - update_kwargs["balance_currency"] = balance_currency + balance_currency = payment_info.get('balance_currency') + if balance_currency and balance_currency != (payment.balance_currency or ''): + update_kwargs['balance_currency'] = balance_currency - payer_account = payment_info.get("account") - if payer_account and payer_account != (payment.payer_account or ""): - update_kwargs["payer_account"] = payer_account + payer_account = payment_info.get('account') + if payer_account and payer_account != (payment.payer_account or ''): + update_kwargs['payer_account'] = payer_account if payment_status_code: - success_states = {"SUCCESS", "OVERPAID"} - failed_states = {"FAIL"} - pending_states = {"NEW", "PROCESS", "UNDERPAID"} + success_states = {'SUCCESS', 'OVERPAID'} + failed_states = {'FAIL'} + pending_states = {'NEW', 'PROCESS', 'UNDERPAID'} if payment_status_code in success_states: is_paid_update = True - elif payment_status_code in failed_states and is_paid_update is not True: - is_paid_update = False - elif payment_status_code in pending_states and is_paid_update is None: + elif (payment_status_code in failed_states and is_paid_update is not True) or ( + payment_status_code in pending_states and is_paid_update is None + ): is_paid_update = False if not remote_status and payment_status_code: update_status = payment_status_code if is_paid_update is not None and is_paid_update != bool(payment.is_paid): - update_kwargs["is_paid"] = is_paid_update + update_kwargs['is_paid'] = is_paid_update if is_paid_update and not payment.paid_at: - update_kwargs.setdefault("paid_at", datetime.utcnow()) + update_kwargs.setdefault('paid_at', datetime.utcnow()) - current_status = payment.status or "" - effective_status = update_status or current_status or "NEW" + current_status = payment.status or '' + effective_status = update_status or current_status or 'NEW' needs_update = bool(update_kwargs) or effective_status != current_status if needs_update: @@ -731,58 +721,49 @@ class Pal24PaymentMixin: finalized = await self._finalize_pal24_payment( db, payment, - payment_id=getattr(payment, "payment_id", None), - trigger="status_check", + payment_id=getattr(payment, 'payment_id', None), + trigger='status_check', ) if finalized: payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id) except Exception as error: logger.error( - "Ошибка автоматического начисления по Pal24 статусу: %s", + 'Ошибка автоматического начисления по Pal24 статусу: %s', error, exc_info=True, ) links_map, selected_method = self._build_links_map(payment, remote_payloads) primary_url = ( - links_map.get(selected_method) - or links_map.get("sbp") - or links_map.get("page") - or links_map.get("card") - ) - secondary_url = ( - links_map.get("page") - or links_map.get("card") - or links_map.get("sbp") + links_map.get(selected_method) or links_map.get('sbp') or links_map.get('page') or links_map.get('card') ) + secondary_url = links_map.get('page') or links_map.get('card') or links_map.get('sbp') return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "remote_status": remote_status_for_return, - "remote_data": remote_data, - "links": links_map or None, - "primary_url": primary_url, - "secondary_url": secondary_url, - "sbp_url": links_map.get("sbp"), - "card_url": links_map.get("card"), - "link_page_url": links_map.get("page") - or getattr(payment, "link_page_url", None), - "link_url": getattr(payment, "link_url", None), - "selected_method": selected_method, + 'payment': payment, + 'status': payment.status, + 'is_paid': payment.is_paid, + 'remote_status': remote_status_for_return, + 'remote_data': remote_data, + 'links': links_map or None, + 'primary_url': primary_url, + 'secondary_url': secondary_url, + 'sbp_url': links_map.get('sbp'), + 'card_url': links_map.get('card'), + 'link_page_url': links_map.get('page') or getattr(payment, 'link_page_url', None), + 'link_url': getattr(payment, 'link_url', None), + 'selected_method': selected_method, } except Exception as error: - logger.error("Ошибка получения статуса Pal24: %s", error, exc_info=True) + logger.error('Ошибка получения статуса Pal24: %s', error, exc_info=True) return None - @staticmethod - def _extract_remote_payment_info(remote_data: Any) -> Dict[str, Optional[str]]: + def _extract_remote_payment_info(remote_data: Any) -> dict[str, str | None]: """Извлекает данные о платеже из ответа Pal24.""" - def _pick_candidate(value: Any) -> Optional[Dict[str, Any]]: + def _pick_candidate(value: Any) -> dict[str, Any] | None: if isinstance(value, dict): return value if isinstance(value, list): @@ -791,65 +772,55 @@ class Pal24PaymentMixin: return item return None - def _normalize(candidate: Dict[str, Any]) -> Dict[str, Optional[str]]: - def _stringify(value: Any) -> Optional[str]: + def _normalize(candidate: dict[str, Any]) -> dict[str, str | None]: + def _stringify(value: Any) -> str | None: if value is None: return None return str(value) return { - "id": _stringify(candidate.get("id") or candidate.get("payment_id")), - "status": _stringify(candidate.get("status")), - "method": _stringify(candidate.get("method") or candidate.get("payment_method")), - "balance_amount": _stringify( - candidate.get("balance_amount") - or candidate.get("amount") - or candidate.get("BalanceAmount") + 'id': _stringify(candidate.get('id') or candidate.get('payment_id')), + 'status': _stringify(candidate.get('status')), + 'method': _stringify(candidate.get('method') or candidate.get('payment_method')), + 'balance_amount': _stringify( + candidate.get('balance_amount') or candidate.get('amount') or candidate.get('BalanceAmount') ), - "balance_currency": _stringify( - candidate.get("balance_currency") or candidate.get("BalanceCurrency") - ), - "account": _stringify( - candidate.get("account") - or candidate.get("payer_account") - or candidate.get("AccountNumber") - ), - "bill_id": _stringify( - candidate.get("bill_id") - or candidate.get("BillId") - or candidate.get("billId") + 'balance_currency': _stringify(candidate.get('balance_currency') or candidate.get('BalanceCurrency')), + 'account': _stringify( + candidate.get('account') or candidate.get('payer_account') or candidate.get('AccountNumber') ), + 'bill_id': _stringify(candidate.get('bill_id') or candidate.get('BillId') or candidate.get('billId')), } if not isinstance(remote_data, dict): return {} lower_keys = {str(key).lower() for key in remote_data.keys()} - has_status = any(key in lower_keys for key in ("status", "payment_status")) - has_identifier = any( - key in lower_keys - for key in ("payment_id", "from_card", "account_amount", "id") - ) or "bill_id" in lower_keys + has_status = any(key in lower_keys for key in ('status', 'payment_status')) + has_identifier = ( + any(key in lower_keys for key in ('payment_id', 'from_card', 'account_amount', 'id')) + or 'bill_id' in lower_keys + ) - if has_status and has_identifier and "bill" not in lower_keys: + if has_status and has_identifier and 'bill' not in lower_keys: return _normalize(remote_data) search_spaces = [remote_data] - bill_section = remote_data.get("bill") or remote_data.get("Bill") + bill_section = remote_data.get('bill') or remote_data.get('Bill') if isinstance(bill_section, dict): search_spaces.append(bill_section) for space in search_spaces: - for key in ("payment", "Payment", "payment_info", "PaymentInfo"): + for key in ('payment', 'Payment', 'payment_info', 'PaymentInfo'): candidate = _pick_candidate(space.get(key)) if candidate: return _normalize(candidate) - for key in ("payments", "Payments"): + for key in ('payments', 'Payments'): candidate = _pick_candidate(space.get(key)) if candidate: return _normalize(candidate) - data_section = remote_data.get("data") or remote_data.get("Data") + data_section = remote_data.get('data') or remote_data.get('Data') candidate = _pick_candidate(data_section) if candidate: return _normalize(candidate) @@ -857,16 +828,15 @@ class Pal24PaymentMixin: return {} @staticmethod - def _collect_payment_candidates(remote_data: Any) -> List[Dict[str, Any]]: - candidates: List[Dict[str, Any]] = [] + def _collect_payment_candidates(remote_data: Any) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] def _visit(value: Any) -> None: if isinstance(value, dict): lower_keys = {str(key).lower() for key in value.keys()} - has_status = any(key in lower_keys for key in ("status", "payment_status")) + has_status = any(key in lower_keys for key in ('status', 'payment_status')) has_identifier = any( - key in lower_keys - for key in ("id", "payment_id", "bill_id", "from_card", "account_amount") + key in lower_keys for key in ('id', 'payment_id', 'bill_id', 'from_card', 'account_amount') ) if has_status and has_identifier and value not in candidates: candidates.append(value) @@ -882,47 +852,47 @@ class Pal24PaymentMixin: @staticmethod def _select_best_payment_info( payment: Any, - candidates: List[Dict[str, Optional[str]]], - ) -> Dict[str, Optional[str]]: + candidates: list[dict[str, str | None]], + ) -> dict[str, str | None]: if not candidates: return {} - payment_id = str(getattr(payment, "payment_id", "") or "") - bill_id = str(getattr(payment, "bill_id", "") or "") + payment_id = str(getattr(payment, 'payment_id', '') or '') + bill_id = str(getattr(payment, 'bill_id', '') or '') for candidate in candidates: - candidate_id = str(candidate.get("id") or "") + candidate_id = str(candidate.get('id') or '') if payment_id and candidate_id == payment_id: return candidate for candidate in candidates: - candidate_bill = str(candidate.get("bill_id") or "") + candidate_bill = str(candidate.get('bill_id') or '') if bill_id and candidate_bill == bill_id: return candidate return candidates[0] @staticmethod - def _normalize_payment_method(payment_method: Optional[str]) -> str: + def _normalize_payment_method(payment_method: str | None) -> str: mapping = { - "sbp": "sbp", - "fast": "sbp", - "fastpay": "sbp", - "fast_payment": "sbp", - "card": "card", - "bank_card": "card", - "bankcard": "card", - "bank-card": "card", + 'sbp': 'sbp', + 'fast': 'sbp', + 'fastpay': 'sbp', + 'fast_payment': 'sbp', + 'card': 'card', + 'bank_card': 'card', + 'bankcard': 'card', + 'bank-card': 'card', } if not payment_method: - return "sbp" + return 'sbp' normalized = payment_method.strip().lower() - return mapping.get(normalized, "sbp") + return mapping.get(normalized, 'sbp') @staticmethod - def _pick_first(mapping: Dict[str, Any], *keys: str) -> Optional[str]: + def _pick_first(mapping: dict[str, Any], *keys: str) -> str | None: for key in keys: value = mapping.get(key) if value: @@ -933,29 +903,29 @@ class Pal24PaymentMixin: def _build_links_map( cls, payment: Any, - remote_payloads: Dict[str, Any], - ) -> tuple[Dict[str, str], str]: - links: Dict[str, str] = {} + remote_payloads: dict[str, Any], + ) -> tuple[dict[str, str], str]: + links: dict[str, str] = {} - metadata = getattr(payment, "metadata_json", {}) or {} + metadata = getattr(payment, 'metadata_json', {}) or {} if not isinstance(metadata, dict): metadata = {} if metadata: - links_meta = metadata.get("links") + links_meta = metadata.get('links') if isinstance(links_meta, dict): for key, value in links_meta.items(): if value: links[key] = str(value) selected_method = cls._normalize_payment_method( - (metadata.get("selected_method") if isinstance(metadata, dict) else None) - or getattr(payment, "payment_method", None) + (metadata.get('selected_method') if isinstance(metadata, dict) else None) + or getattr(payment, 'payment_method', None) ) - def _visit(value: Any) -> List[Dict[str, Any]]: - stack: List[Any] = [value] - result: List[Dict[str, Any]] = [] + def _visit(value: Any) -> list[dict[str, Any]]: + stack: list[Any] = [value] + result: list[dict[str, Any]] = [] while stack: current = stack.pop() if isinstance(current, dict): @@ -965,40 +935,40 @@ class Pal24PaymentMixin: stack.extend(current) return result - payload_sources: List[Any] = [] + payload_sources: list[Any] = [] if metadata: - payload_sources.append(metadata.get("raw_response")) - payload_sources.append(getattr(payment, "callback_payload", None)) + payload_sources.append(metadata.get('raw_response')) + payload_sources.append(getattr(payment, 'callback_payload', None)) payload_sources.extend(remote_payloads.values()) sbp_keys = ( - "transfer_url", - "transferUrl", - "transfer_link", - "transferLink", - "transfer", - "sbp_url", - "sbpUrl", - "sbp_link", - "sbpLink", + 'transfer_url', + 'transferUrl', + 'transfer_link', + 'transferLink', + 'transfer', + 'sbp_url', + 'sbpUrl', + 'sbp_link', + 'sbpLink', ) card_keys = ( - "link_url", - "linkUrl", - "link", - "card_url", - "cardUrl", - "card_link", - "cardLink", - "payment_url", - "paymentUrl", - "url", + 'link_url', + 'linkUrl', + 'link', + 'card_url', + 'cardUrl', + 'card_link', + 'cardLink', + 'payment_url', + 'paymentUrl', + 'url', ) page_keys = ( - "link_page_url", - "linkPageUrl", - "page_url", - "pageUrl", + 'link_page_url', + 'linkPageUrl', + 'page_url', + 'pageUrl', ) for source in payload_sources: @@ -1006,34 +976,34 @@ class Pal24PaymentMixin: continue for candidate in _visit(source): sbp_url = cls._pick_first(candidate, *sbp_keys) - if sbp_url and "sbp" not in links: - links["sbp"] = sbp_url + if sbp_url and 'sbp' not in links: + links['sbp'] = sbp_url card_url = cls._pick_first(candidate, *card_keys) - if card_url and "card" not in links: - links["card"] = card_url + if card_url and 'card' not in links: + links['card'] = card_url page_url = cls._pick_first(candidate, *page_keys) - if page_url and "page" not in links: - links["page"] = page_url + if page_url and 'page' not in links: + links['page'] = page_url - if getattr(payment, "link_page_url", None): - links.setdefault("page", str(payment.link_page_url)) + if getattr(payment, 'link_page_url', None): + links.setdefault('page', str(payment.link_page_url)) - if getattr(payment, "link_url", None): + if getattr(payment, 'link_url', None): link_url_value = str(payment.link_url) - if selected_method == "card": - links.setdefault("card", link_url_value) + if selected_method == 'card': + links.setdefault('card', link_url_value) else: - links.setdefault("sbp", link_url_value) + links.setdefault('sbp', link_url_value) return links, selected_method @staticmethod - def _map_api_payment_method(normalized_payment_method: str) -> Optional[str]: + def _map_api_payment_method(normalized_payment_method: str) -> str | None: """Преобразует нормализованный метод оплаты в значение для Pal24 API.""" api_mapping = { - "sbp": "SBP", - "card": "BANK_CARD", + 'sbp': 'SBP', + 'card': 'BANK_CARD', } return api_mapping.get(normalized_payment_method) diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 1067fafa..e83bd31b 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -16,16 +16,16 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info class PlategaPaymentMixin: """Логика создания и обработки платежей Platega.""" - _SUCCESS_STATUSES = {"CONFIRMED"} - _FAILED_STATUSES = {"FAILED", "CANCELED", "EXPIRED"} - _PENDING_STATUSES = {"PENDING", "INPROGRESS"} + _SUCCESS_STATUSES = {'CONFIRMED'} + _FAILED_STATUSES = {'FAILED', 'CANCELED', 'EXPIRED'} + _PENDING_STATUSES = {'PENDING', 'INPROGRESS'} async def create_platega_payment( self, @@ -36,15 +36,15 @@ class PlategaPaymentMixin: description: str, language: str, payment_method_code: int, - ) -> Optional[Dict[str, Any]]: - service: Optional[PlategaService] = getattr(self, "platega_service", None) + ) -> dict[str, Any] | None: + service: PlategaService | None = getattr(self, 'platega_service', None) if not service or not service.is_configured: - logger.error("Platega сервис не инициализирован") + logger.error('Platega сервис не инициализирован') return None if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS: logger.warning( - "Сумма Platega меньше минимальной: %s < %s", + 'Сумма Platega меньше минимальной: %s < %s', amount_kopeks, settings.PLATEGA_MIN_AMOUNT_KOPEKS, ) @@ -52,14 +52,14 @@ class PlategaPaymentMixin: if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма Platega больше максимальной: %s > %s", + 'Сумма Platega больше максимальной: %s > %s', amount_kopeks, settings.PLATEGA_MAX_AMOUNT_KOPEKS, ) return None correlation_id = uuid.uuid4().hex - payload_token = f"platega:{correlation_id}" + payload_token = f'platega:{correlation_id}' amount_value = amount_kopeks / 100 @@ -74,25 +74,25 @@ class PlategaPaymentMixin: payload=payload_token, ) except Exception as error: # pragma: no cover - network errors - logger.exception("Ошибка Platega при создании платежа: %s", error) + logger.exception('Ошибка Platega при создании платежа: %s', error) return None if not response: - logger.error("Platega вернул пустой ответ при создании платежа") + logger.error('Platega вернул пустой ответ при создании платежа') return None - transaction_id = response.get("transactionId") or response.get("id") - redirect_url = response.get("redirect") - status = str(response.get("status") or "PENDING").upper() - expires_at = PlategaService.parse_expires_at(response.get("expiresIn")) + transaction_id = response.get('transactionId') or response.get('id') + redirect_url = response.get('redirect') + status = str(response.get('status') or 'PENDING').upper() + expires_at = PlategaService.parse_expires_at(response.get('expiresIn')) metadata = { - "raw_response": response, - "language": language, - "selected_method": payment_method_code, + 'raw_response': response, + 'language': language, + 'selected_method': payment_method_code, } - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') payment = await payment_module.create_platega_payment( db, @@ -113,7 +113,7 @@ class PlategaPaymentMixin: ) logger.info( - "Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)", + 'Создан Platega платеж %s для пользователя %s (метод %s, сумма %s₽)', transaction_id or payment.id, user_id, payment_method_code, @@ -121,57 +121,53 @@ class PlategaPaymentMixin: ) return { - "local_payment_id": payment.id, - "transaction_id": transaction_id, - "redirect_url": redirect_url, - "status": status, - "expires_at": expires_at, - "correlation_id": correlation_id, - "payload": payload_token, + 'local_payment_id': payment.id, + 'transaction_id': transaction_id, + 'redirect_url': redirect_url, + 'status': status, + 'expires_at': expires_at, + 'correlation_id': correlation_id, + 'payload': payload_token, } async def process_platega_webhook( self, db: AsyncSession, - payload: Dict[str, Any], + payload: dict[str, Any], ) -> bool: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - transaction_id = str(payload.get("id") or "").strip() - payload_token = payload.get("payload") + transaction_id = str(payload.get('id') or '').strip() + payload_token = payload.get('payload') payment = None if transaction_id: - payment = await payment_module.get_platega_payment_by_transaction_id( - db, transaction_id - ) + payment = await payment_module.get_platega_payment_by_transaction_id(db, transaction_id) if not payment and payload_token: payment = await payment_module.get_platega_payment_by_correlation_id( - db, str(payload_token).replace("platega:", "") + db, str(payload_token).replace('platega:', '') ) if not payment: - logger.warning("Platega webhook: платеж не найден (id=%s)", transaction_id) + logger.warning('Platega webhook: платеж не найден (id=%s)', transaction_id) return False - status_raw = str(payload.get("status") or "").upper() + status_raw = str(payload.get('status') or '').upper() if not status_raw: - logger.warning("Platega webhook без статуса для платежа %s", payment.id) + logger.warning('Platega webhook без статуса для платежа %s', payment.id) return False update_kwargs = { - "status": status_raw, - "callback_payload": payload, + 'status': status_raw, + 'callback_payload': payload, } if transaction_id: - update_kwargs["platega_transaction_id"] = transaction_id + update_kwargs['platega_transaction_id'] = transaction_id if status_raw in self._SUCCESS_STATUSES: if payment.is_paid: - logger.info( - "Platega платеж %s уже помечен как оплачен", payment.correlation_id - ) + logger.info('Platega платеж %s уже помечен как оплачен', payment.correlation_id) await payment_module.update_platega_payment( db, payment=payment, @@ -195,9 +191,7 @@ class PlategaPaymentMixin: **update_kwargs, is_paid=False, ) - logger.info( - "Platega платеж %s перешёл в статус %s", payment.correlation_id, status_raw - ) + logger.info('Platega платеж %s перешёл в статус %s', payment.correlation_id, status_raw) return True await payment_module.update_platega_payment( @@ -211,46 +205,41 @@ class PlategaPaymentMixin: self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: - payment_module = import_module("app.services.payment_service") + ) -> dict[str, Any] | None: + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) if not payment: return None - service: Optional[PlategaService] = getattr(self, "platega_service", None) - remote_status: Optional[str] = None - remote_payload: Optional[Dict[str, Any]] = None + service: PlategaService | None = getattr(self, 'platega_service', None) + remote_status: str | None = None + remote_payload: dict[str, Any] | None = None if service and payment.platega_transaction_id: try: - remote_payload = await service.get_transaction( - payment.platega_transaction_id - ) + remote_payload = await service.get_transaction(payment.platega_transaction_id) except Exception as error: # pragma: no cover - network errors logger.error( - "Ошибка Platega при получении транзакции %s: %s", + 'Ошибка Platega при получении транзакции %s: %s', payment.platega_transaction_id, error, ) if remote_payload: - remote_status = str(remote_payload.get("status") or "").upper() + remote_status = str(remote_payload.get('status') or '').upper() if remote_status and remote_status != payment.status: await payment_module.update_platega_payment( db, payment=payment, status=remote_status, metadata={ - **(getattr(payment, "metadata_json", {}) or {}), - "remote_status": remote_payload, + **(getattr(payment, 'metadata_json', {}) or {}), + 'remote_status': remote_payload, }, ) payment = await payment_module.get_platega_payment_by_id(db, local_payment_id) - if ( - remote_status in self._SUCCESS_STATUSES - and not payment.is_paid - ): + if remote_status in self._SUCCESS_STATUSES and not payment.is_paid: payment = await payment_module.update_platega_payment( db, payment=payment, @@ -260,27 +249,27 @@ class PlategaPaymentMixin: await self._finalize_platega_payment(db, payment, remote_payload) return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "remote": remote_payload, + 'payment': payment, + 'status': payment.status, + 'is_paid': payment.is_paid, + 'remote': remote_payload, } async def _finalize_platega_payment( self, db: AsyncSession, payment: Any, - payload: Optional[Dict[str, Any]], + payload: dict[str, Any] | None, ) -> Any: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - metadata = dict(getattr(payment, "metadata_json", {}) or {}) + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) if payload is not None: - metadata["webhook"] = payload + metadata['webhook'] = payload paid_at = None if isinstance(payload, dict): - paid_at_raw = payload.get("paidAt") or payload.get("confirmedAt") + paid_at_raw = payload.get('paidAt') or payload.get('confirmedAt') if paid_at_raw: try: paid_at = datetime.fromisoformat(str(paid_at_raw)) @@ -290,41 +279,39 @@ class PlategaPaymentMixin: payment = await payment_module.update_platega_payment( db, payment=payment, - status="CONFIRMED", + status='CONFIRMED', is_paid=True, paid_at=paid_at, metadata=metadata, callback_payload=payload, ) - locked_payment = await payment_module.get_platega_payment_by_id_for_update( - db, payment.id - ) + locked_payment = await payment_module.get_platega_payment_by_id_for_update(db, payment.id) if locked_payment: payment = locked_payment - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - balance_already_credited = bool(metadata.get("balance_credited")) + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + balance_already_credited = bool(metadata.get('balance_credited')) - invoice_message = metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + invoice_message = metadata.get('invoice_message') or {} + if getattr(self, 'bot', None): + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - "Не удалось удалить Platega счёт %s: %s", + 'Не удалось удалить Platega счёт %s: %s', message_id, delete_error, ) else: - metadata.pop("invoice_message", None) + metadata.pop('invoice_message', None) if payment.transaction_id: logger.info( - "Platega платеж %s уже связан с транзакцией %s", + 'Platega платеж %s уже связан с транзакцией %s', payment.correlation_id, payment.transaction_id, ) @@ -332,22 +319,22 @@ class PlategaPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: - logger.error("Пользователь %s не найден для Platega", payment.user_id) + logger.error('Пользователь %s не найден для Platega', payment.user_id) return payment # Убеждаемся, что промогруппы загружены в асинхронном контексте, # чтобы избежать попыток ленивой загрузки без greenlet - await db.refresh(user, attribute_names=["promo_group", "user_promo_groups"]) - for user_promo_group in getattr(user, "user_promo_groups", []): - await db.refresh(user_promo_group, attribute_names=["promo_group"]) + await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups']) + for user_promo_group in getattr(user, 'user_promo_groups', []): + await db.refresh(user_promo_group, attribute_names=['promo_group']) promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) transaction_external_id = ( - str(payload.get("id")) - if isinstance(payload, dict) and payload.get("id") + str(payload.get('id')) + if isinstance(payload, dict) and payload.get('id') else payment.platega_transaction_id ) @@ -362,9 +349,9 @@ class PlategaPaymentMixin: platega_name = settings.get_platega_display_name() method_display = settings.get_platega_method_display_name(payment.payment_method_code) description = ( - f"Пополнение через {platega_name} ({method_display})" + f'Пополнение через {platega_name} ({method_display})' if method_display - else f"Пополнение через {platega_name}" + else f'Пополнение через {platega_name}' ) transaction = existing_transaction @@ -383,15 +370,13 @@ class PlategaPaymentMixin: ) created_transaction = True - await payment_module.link_platega_payment_to_transaction( - db, payment=payment, transaction_id=transaction.id - ) + await payment_module.link_platega_payment_to_transaction(db, payment=payment, transaction_id=transaction.id) should_credit_balance = created_transaction or not balance_already_credited if not should_credit_balance: logger.info( - "Platega платеж %s уже зачислил баланс ранее", + 'Platega платеж %s уже зачислил баланс ранее', payment.correlation_id, ) return payment @@ -403,7 +388,7 @@ class PlategaPaymentMixin: user.updated_at = datetime.utcnow() await db.commit() await db.refresh(user) - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' try: from app.services.referral_service import process_referral_topup @@ -412,17 +397,17 @@ class PlategaPaymentMixin: db, user.id, payment.amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: - logger.error("Ошибка обработки реферального пополнения Platega: %s", error) + logger.error('Ошибка обработки реферального пополнения Platega: %s', error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True await db.commit() await db.refresh(user) - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import AdminNotificationService @@ -438,32 +423,33 @@ class PlategaPaymentMixin: db=db, ) except Exception as error: - logger.error("Ошибка отправки админ уведомления Platega: %s", error) + logger.error('Ошибка отправки админ уведомления Platega: %s', error) method_title = settings.get_platega_method_display_title(payment.payment_method_code) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - f"🦊 Способ: {method_title}\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + f'🦊 Способ: {method_title}\n' + f'🆔 Транзакция: {transaction.id}\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.error("Ошибка отправки уведомления пользователю Platega: %s", error) + logger.error('Ошибка отправки уведомления пользователю Platega: %s', error) try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -471,11 +457,11 @@ class PlategaPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -489,25 +475,24 @@ class PlategaPaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами.", + 'BALANCE_TOPUP_CART_REMINDER_DETAILED', + '🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.', ) keyboard = types.InlineKeyboardMarkup( @@ -515,19 +500,19 @@ class PlategaPaymentMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", + text='💰 Мой баланс', + callback_data='menu_balance', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -536,29 +521,29 @@ class PlategaPaymentMixin: await self.bot.send_message( chat_id=user.telegram_id, text=( - f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n" - f"{cart_message}" + f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n' + f'{cart_message}' ), reply_markup=keyboard, ) except Exception as error: logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', payment.user_id, error, exc_info=True, ) - metadata["balance_change"] = { - "old_balance": old_balance, - "new_balance": user.balance_kopeks, - "credited_at": datetime.utcnow().isoformat(), + metadata['balance_change'] = { + 'old_balance': old_balance, + 'new_balance': user.balance_kopeks, + 'credited_at': datetime.utcnow().isoformat(), } - metadata["balance_credited"] = True + metadata['balance_credited'] = True await payment_module.update_platega_payment( db, @@ -567,7 +552,7 @@ class PlategaPaymentMixin: ) logger.info( - "✅ Обработан Platega платеж %s для пользователя %s", + '✅ Обработан Platega платеж %s для пользователя %s', payment.correlation_id, payment.user_id, ) diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index ef756a7a..5dd89c8c 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -8,8 +8,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime -from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP -from typing import Optional +from decimal import ROUND_HALF_UP, Decimal from aiogram.types import LabeledPrice from sqlalchemy.ext.asyncio import AsyncSession @@ -23,16 +22,16 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info @dataclass(slots=True) class _SimpleSubscriptionPayload: """Данные для простой подписки, извлечённые из payload звёздного платежа.""" - subscription_id: Optional[int] - period_days: Optional[int] + subscription_id: int | None + period_days: int | None class TelegramStarsMixin: @@ -42,13 +41,13 @@ class TelegramStarsMixin: self, amount_kopeks: int, description: str, - payload: Optional[str] = None, + payload: str | None = None, *, - stars_amount: Optional[int] = None, + stars_amount: int | None = None, ) -> str: """Создаёт invoice в Telegram Stars, автоматически рассчитывая количество звёзд.""" - if not self.bot or not getattr(self, "stars_service", None): - raise ValueError("Bot instance required for Stars payments") + if not self.bot or not getattr(self, 'stars_service', None): + raise ValueError('Bot instance required for Stars payments') try: amount_rubles = Decimal(amount_kopeks) / Decimal(100) @@ -58,26 +57,26 @@ class TelegramStarsMixin: stars_amount = settings.rubles_to_stars(float(amount_rubles)) if stars_amount <= 0: - raise ValueError("Stars amount must be positive") + raise ValueError('Stars amount must be positive') invoice_link = await self.bot.create_invoice_link( - title="Пополнение баланса VPN", - description=f"{description} (≈{stars_amount} ⭐)", - payload=payload or f"balance_topup_{amount_kopeks}", - provider_token="", - currency="XTR", - prices=[LabeledPrice(label="Пополнение", amount=stars_amount)], + title='Пополнение баланса VPN', + description=f'{description} (≈{stars_amount} ⭐)', + payload=payload or f'balance_topup_{amount_kopeks}', + provider_token='', + currency='XTR', + prices=[LabeledPrice(label='Пополнение', amount=stars_amount)], ) logger.info( - "Создан Stars invoice на %s звезд (~%s)", + 'Создан Stars invoice на %s звезд (~%s)', stars_amount, settings.format_price(amount_kopeks), ) return invoice_link except Exception as error: - logger.error("Ошибка создания Stars invoice: %s", error) + logger.error('Ошибка создания Stars invoice: %s', error) raise async def process_stars_payment( @@ -90,14 +89,8 @@ class TelegramStarsMixin: ) -> bool: """Финализирует платеж, пришедший из Telegram Stars, и обновляет баланс пользователя.""" try: - rubles_amount = TelegramStarsService.calculate_rubles_from_stars( - stars_amount - ) - amount_kopeks = int( - (rubles_amount * Decimal(100)).to_integral_value( - rounding=ROUND_HALF_UP - ) - ) + rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount) + amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP)) simple_payload = self._parse_simple_subscription_payload( payload, @@ -105,15 +98,11 @@ class TelegramStarsMixin: ) transaction_description = ( - f"Оплата подписки через Telegram Stars ({stars_amount} ⭐)" + f'Оплата подписки через Telegram Stars ({stars_amount} ⭐)' if simple_payload - else f"Пополнение через Telegram Stars ({stars_amount} ⭐)" - ) - transaction_type = ( - TransactionType.SUBSCRIPTION_PAYMENT - if simple_payload - else TransactionType.DEPOSIT + else f'Пополнение через Telegram Stars ({stars_amount} ⭐)' ) + transaction_type = TransactionType.SUBSCRIPTION_PAYMENT if simple_payload else TransactionType.DEPOSIT transaction = await create_transaction( db=db, @@ -129,7 +118,7 @@ class TelegramStarsMixin: user = await get_user_by_id(db, user_id) if not user: logger.error( - "Пользователь с ID %s не найден при обработке Stars платежа", + 'Пользователь с ID %s не найден при обработке Stars платежа', user_id, ) return False @@ -155,25 +144,25 @@ class TelegramStarsMixin: ) except Exception as error: - logger.error("Ошибка обработки Stars платежа: %s", error, exc_info=True) + logger.error('Ошибка обработки Stars платежа: %s', error, exc_info=True) return False @staticmethod def _parse_simple_subscription_payload( payload: str, expected_user_id: int, - ) -> Optional[_SimpleSubscriptionPayload]: + ) -> _SimpleSubscriptionPayload | None: """Пытается извлечь параметры простой подписки из payload звёздного платежа.""" - prefix = "simple_sub_" + prefix = 'simple_sub_' if not payload or not payload.startswith(prefix): return None tail = payload[len(prefix) :] - parts = tail.split("_", 2) + parts = tail.split('_', 2) if len(parts) < 3: logger.warning( - "Payload Stars simple subscription имеет некорректный формат: %s", + 'Payload Stars simple subscription имеет некорректный формат: %s', payload, ) return None @@ -184,14 +173,14 @@ class TelegramStarsMixin: payload_user_id = int(user_part) except ValueError: logger.warning( - "Не удалось разобрать user_id в payload Stars simple subscription: %s", + 'Не удалось разобрать user_id в payload Stars simple subscription: %s', payload, ) return None if payload_user_id != expected_user_id: logger.warning( - "Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)", + 'Получен payload Stars simple subscription с чужим user_id: %s (ожидался %s)', payload_user_id, expected_user_id, ) @@ -201,17 +190,17 @@ class TelegramStarsMixin: subscription_id = int(subscription_part) except ValueError: logger.warning( - "Не удалось разобрать subscription_id в payload Stars simple subscription: %s", + 'Не удалось разобрать subscription_id в payload Stars simple subscription: %s', payload, ) return None - period_days: Optional[int] = None + period_days: int | None = None try: period_days = int(period_part) except ValueError: logger.warning( - "Не удалось разобрать период в payload Stars simple subscription: %s", + 'Не удалось разобрать период в payload Stars simple subscription: %s', payload, ) @@ -238,6 +227,7 @@ class TelegramStarsMixin: if payload_data.subscription_id is not None: try: from sqlalchemy import select + from app.database.models import Subscription result = await db.execute( @@ -249,7 +239,7 @@ class TelegramStarsMixin: pending_subscription = result.scalar_one_or_none() except Exception as lookup_error: # pragma: no cover - диагностический лог logger.error( - "Ошибка поиска pending подписки %s для пользователя %s: %s", + 'Ошибка поиска pending подписки %s для пользователя %s: %s', payload_data.subscription_id, user.id, lookup_error, @@ -259,7 +249,7 @@ class TelegramStarsMixin: if not pending_subscription: logger.error( - "Не найдена pending подписка %s для пользователя %s", + 'Не найдена pending подписка %s для пользователя %s', payload_data.subscription_id, user.id, ) @@ -281,7 +271,7 @@ class TelegramStarsMixin: ) except Exception as error: logger.error( - "Ошибка активации pending подписки для пользователя %s: %s", + 'Ошибка активации pending подписки для пользователя %s: %s', user.id, error, exc_info=True, @@ -290,7 +280,7 @@ class TelegramStarsMixin: if not subscription: logger.error( - "Не удалось активировать pending подписку пользователя %s", + 'Не удалось активировать pending подписку пользователя %s', user.id, ) return False @@ -307,38 +297,35 @@ class TelegramStarsMixin: await db.refresh(subscription) except Exception as sync_error: # pragma: no cover - диагностический лог logger.error( - "Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s", + 'Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s', user.id, sync_error, exc_info=True, ) period_display = period_days - if not period_display and getattr(subscription, "start_date", None) and getattr( - subscription, "end_date", None - ): + if not period_display and getattr(subscription, 'start_date', None) and getattr(subscription, 'end_date', None): period_display = max(1, (subscription.end_date - subscription.start_date).days or 0) if not period_display: period_display = settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS # Отправляем уведомление только Telegram-пользователям (Stars требует Telegram) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: from aiogram import types + from app.localization.texts import get_texts - texts = get_texts(user.language) - traffic_limit = getattr(subscription, "traffic_limit_gb", 0) or 0 - traffic_label = ( - "Безлимит" if traffic_limit == 0 else f"{int(traffic_limit)} ГБ" - ) + get_texts(user.language) + traffic_limit = getattr(subscription, 'traffic_limit_gb', 0) or 0 + traffic_label = 'Безлимит' if traffic_limit == 0 else f'{int(traffic_limit)} ГБ' success_message = ( - "✅ Подписка успешно активирована!\n\n" - f"📅 Период: {period_display} дней\n" - f"📱 Устройства: {getattr(subscription, 'device_limit', 1)}\n" - f"📊 Трафик: {traffic_label}\n" - f"⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n" + '✅ Подписка успешно активирована!\n\n' + f'📅 Период: {period_display} дней\n' + f'📱 Устройства: {getattr(subscription, "device_limit", 1)}\n' + f'📊 Трафик: {traffic_label}\n' + f'⭐ Оплата: {stars_amount} ⭐ ({settings.format_price(amount_kopeks)})\n\n' "🔗 Для подключения перейдите в раздел 'Моя подписка'" ) @@ -346,14 +333,14 @@ class TelegramStarsMixin: inline_keyboard=[ [ types.InlineKeyboardButton( - text="📱 Моя подписка", - callback_data="menu_subscription", + text='📱 Моя подписка', + callback_data='menu_subscription', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -363,20 +350,20 @@ class TelegramStarsMixin: chat_id=user.telegram_id, text=success_message, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) logger.info( - "✅ Пользователь %s получил уведомление об оплате подписки через Stars", + '✅ Пользователь %s получил уведомление об оплате подписки через Stars', user.telegram_id, ) except Exception as error: # pragma: no cover - диагностический лог logger.error( - "Ошибка отправки уведомления о подписке через Stars: %s", + 'Ошибка отправки уведомления о подписке через Stars: %s', error, exc_info=True, ) - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import AdminNotificationService @@ -391,13 +378,13 @@ class TelegramStarsMixin: ) except Exception as admin_error: # pragma: no cover - диагностический лог logger.error( - "Ошибка уведомления администраторов о подписке через Stars: %s", + 'Ошибка уведомления администраторов о подписке через Stars: %s', admin_error, exc_info=True, ) logger.info( - "✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s", + '✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s', user.id, stars_amount, settings.format_price(amount_kopeks), @@ -424,15 +411,13 @@ class TelegramStarsMixin: user.updated_at = datetime.utcnow() promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' await db.commit() - description_for_referral = ( - f"Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)" - ) + description_for_referral = f'Пополнение Stars: {settings.format_price(amount_kopeks)} ({stars_amount} ⭐)' logger.info( "🔍 Проверка реферальной логики для описания: '%s'", description_for_referral, @@ -440,16 +425,14 @@ class TelegramStarsMixin: lower_description = description_for_referral.lower() contains_allowed_keywords = any( - word in lower_description for word in ["пополнение", "stars", "yookassa", "topup"] - ) - contains_forbidden_keywords = any( - word in lower_description for word in ["комиссия", "бонус"] + word in lower_description for word in ['пополнение', 'stars', 'yookassa', 'topup'] ) + contains_forbidden_keywords = any(word in lower_description for word in ['комиссия', 'бонус']) allow_referral = contains_allowed_keywords and not contains_forbidden_keywords if allow_referral: logger.info( - "🔞 Вызов process_referral_topup для пользователя %s", + '🔞 Вызов process_referral_topup для пользователя %s', user.id, ) try: @@ -459,11 +442,11 @@ class TelegramStarsMixin: db, user.id, amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: # pragma: no cover - диагностический лог logger.error( - "Ошибка обработки реферального пополнения: %s", + 'Ошибка обработки реферального пополнения: %s', error, ) else: @@ -479,14 +462,14 @@ class TelegramStarsMixin: await db.refresh(user) logger.info( - "💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)", + '💰 Баланс пользователя %s изменен: %s → %s (Δ +%s)', user.telegram_id, old_balance, user.balance_kopeks, amount_kopeks, ) - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import AdminNotificationService @@ -503,7 +486,7 @@ class TelegramStarsMixin: ) except Exception as error: # pragma: no cover - диагностический лог logger.error( - "Ошибка отправки уведомления о пополнении Stars: %s", + 'Ошибка отправки уведомления о пополнении Stars: %s', error, exc_info=True, ) @@ -511,6 +494,7 @@ class TelegramStarsMixin: # Проверяем наличие сохраненной корзины для возврата к оформлению подписки try: from aiogram import types + from app.localization.texts import get_texts from app.services.user_cart_service import user_cart_service @@ -521,11 +505,11 @@ class TelegramStarsMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: # pragma: no cover - диагностический лог logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -541,24 +525,23 @@ class TelegramStarsMixin: _, activation_notification_sent = await auto_activate_subscription_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), topup_amount=amount_kopeks, ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами.", + 'BALANCE_TOPUP_CART_REMINDER_DETAILED', + '🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.', ) keyboard = types.InlineKeyboardMarkup( @@ -566,19 +549,19 @@ class TelegramStarsMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", + text='💰 Мой баланс', + callback_data='menu_balance', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -586,27 +569,27 @@ class TelegramStarsMixin: await self.bot.send_message( chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", + text=f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}', reply_markup=keyboard, ) logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', user.id, ) except Exception as error: # pragma: no cover - диагностический лог logger.error( - "Ошибка при работе с сохраненной корзиной для пользователя %s: %s", + 'Ошибка при работе с сохраненной корзиной для пользователя %s: %s', user.id, error, exc_info=True, ) logger.info( - "✅ Обработан Stars платеж: пользователь %s, %s звезд → %s", + '✅ Обработан Stars платеж: пользователь %s, %s звезд → %s', user.id, stars_amount, settings.format_price(amount_kopeks), diff --git a/app/services/payment/tribute.py b/app/services/payment/tribute.py index 1cb09d32..f4a4d85f 100644 --- a/app/services/payment/tribute.py +++ b/app/services/payment/tribute.py @@ -4,7 +4,6 @@ from __future__ import annotations import hashlib import hmac -from typing import Dict from app.config import settings from app.utils.payment_logger import payment_logger as logger @@ -21,35 +20,33 @@ class TributePaymentMixin: ) -> str: """Формирует URL оплаты для Tribute и логирует результат.""" if not settings.TRIBUTE_ENABLED: - raise ValueError("Tribute payments are disabled") + raise ValueError('Tribute payments are disabled') try: # Сохраняем полезную информацию для метрик и отладки. payment_data = { - "amount": amount_kopeks, - "currency": "RUB", - "description": description, - "user_id": user_id, - "callback_url": f"{settings.WEBHOOK_URL}/tribute/callback", + 'amount': amount_kopeks, + 'currency': 'RUB', + 'description': description, + 'user_id': user_id, + 'callback_url': f'{settings.WEBHOOK_URL}/tribute/callback', } del payment_data # данные пока не отправляются вовне, но оставляем структуру для будущего API. - payment_url = ( - f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}" - ) + payment_url = f'https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}' logger.info( - "Создан Tribute платеж на %s₽ для пользователя %s", + 'Создан Tribute платеж на %s₽ для пользователя %s', amount_kopeks / 100, user_id, ) return payment_url except Exception as error: - logger.error("Ошибка создания Tribute платежа: %s", error) + logger.error('Ошибка создания Tribute платежа: %s', error) raise - def verify_tribute_webhook(self, data: Dict[str, object], signature: str) -> bool: + def verify_tribute_webhook(self, data: dict[str, object], signature: str) -> bool: """Проверяет подпись запроса, присланного Tribute.""" if not settings.TRIBUTE_API_KEY: return False @@ -65,5 +62,5 @@ class TributePaymentMixin: return hmac.compare_digest(signature, expected_signature) except Exception as error: - logger.error("Ошибка проверки Tribute webhook: %s", error) + logger.error('Ошибка проверки Tribute webhook: %s', error) return False diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 8089e663..3507034a 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from datetime import datetime from importlib import import_module -from typing import Any, Dict, Optional +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -16,14 +16,14 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.services.wata_service import WataAPIError, WataService -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info -def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]: +def _extract_transaction_id(payment: Any, remote_link: dict[str, Any] | None = None) -> str | None: """Try to find the remote WATA transaction identifier from stored payloads.""" - def _from_mapping(mapping: Any) -> Optional[str]: + def _from_mapping(mapping: Any) -> str | None: if isinstance(mapping, str): try: import json @@ -33,26 +33,26 @@ def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] return None if not isinstance(mapping, dict): return None - for key in ("id", "transaction_id", "transactionId"): + for key in ('id', 'transaction_id', 'transactionId'): value = mapping.get(key) if not value: continue value_str = str(value) - if "-" in value_str: + if '-' in value_str: return value_str return None candidate = None - if hasattr(payment, "callback_payload"): - candidate = _from_mapping(getattr(payment, "callback_payload")) + if hasattr(payment, 'callback_payload'): + candidate = _from_mapping(payment.callback_payload) if candidate: return candidate - metadata = getattr(payment, "metadata_json", None) + metadata = getattr(payment, 'metadata_json', None) if isinstance(metadata, dict): - if "transaction" in metadata: - candidate = _from_mapping(metadata.get("transaction")) + if 'transaction' in metadata: + candidate = _from_mapping(metadata.get('transaction')) if candidate: return candidate candidate = _from_mapping(metadata) @@ -76,15 +76,15 @@ class WataPaymentMixin: amount_kopeks: int, description: str, *, - language: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - if not getattr(self, "wata_service", None): - logger.error("WATA service is not initialised") + language: str | None = None, + ) -> dict[str, Any] | None: + if not getattr(self, 'wata_service', None): + logger.error('WATA service is not initialised') return None if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: logger.warning( - "Сумма WATA меньше минимальной: %s < %s", + 'Сумма WATA меньше минимальной: %s < %s', amount_kopeks, settings.WATA_MIN_AMOUNT_KOPEKS, ) @@ -92,47 +92,47 @@ class WataPaymentMixin: if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: logger.warning( - "Сумма WATA больше максимальной: %s > %s", + 'Сумма WATA больше максимальной: %s > %s', amount_kopeks, settings.WATA_MAX_AMOUNT_KOPEKS, ) return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') - order_id = f"wata_{user_id}_{uuid.uuid4().hex[:12]}" + order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}' try: response = await self.wata_service.create_payment_link( # type: ignore[union-attr] amount_kopeks=amount_kopeks, - currency="RUB", + currency='RUB', description=description, order_id=order_id, ) except WataAPIError as error: - logger.error("Ошибка создания WATA платежа: %s", error) + logger.error('Ошибка создания WATA платежа: %s', error) return None except Exception as error: # pragma: no cover - safety net - logger.exception("Непредвиденная ошибка при создании WATA платежа: %s", error) + logger.exception('Непредвиденная ошибка при создании WATA платежа: %s', error) return None - payment_link_id = response.get("id") - payment_url = response.get("url") or response.get("paymentUrl") - status = response.get("status") or "Opened" - terminal_public_id = response.get("terminalPublicId") - success_url = response.get("successRedirectUrl") - fail_url = response.get("failRedirectUrl") + payment_link_id = response.get('id') + payment_url = response.get('url') or response.get('paymentUrl') + status = response.get('status') or 'Opened' + terminal_public_id = response.get('terminalPublicId') + success_url = response.get('successRedirectUrl') + fail_url = response.get('failRedirectUrl') if not payment_link_id: - logger.error("WATA API не вернула идентификатор платежной ссылки: %s", response) + logger.error('WATA API не вернула идентификатор платежной ссылки: %s', response) return None - expiration_raw = response.get("expirationDateTime") + expiration_raw = response.get('expirationDateTime') expires_at = WataService._parse_datetime(expiration_raw) metadata = { - "response": response, - "language": language or settings.DEFAULT_LANGUAGE, + 'response': response, + 'language': language or settings.DEFAULT_LANGUAGE, } local_payment = await payment_module.create_wata_payment( @@ -140,10 +140,10 @@ class WataPaymentMixin: user_id=user_id, payment_link_id=payment_link_id, amount_kopeks=amount_kopeks, - currency="RUB", + currency='RUB', description=description, status=status, - type_=response.get("type"), + type_=response.get('type'), url=payment_url, order_id=order_id, metadata=metadata, @@ -154,50 +154,50 @@ class WataPaymentMixin: ) logger.info( - "Создан WATA платеж %s на %s₽ для пользователя %s", + 'Создан WATA платеж %s на %s₽ для пользователя %s', payment_link_id, amount_kopeks / 100, user_id, ) return { - "local_payment_id": local_payment.id, - "payment_link_id": payment_link_id, - "payment_url": payment_url, - "status": status, - "order_id": order_id, + 'local_payment_id': local_payment.id, + 'payment_link_id': payment_link_id, + 'payment_url': payment_url, + 'status': status, + 'order_id': order_id, } async def process_wata_webhook( self, db: AsyncSession, - payload: Dict[str, Any], + payload: dict[str, Any], ) -> bool: """Handles asynchronous webhook notifications from WATA.""" - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') if not isinstance(payload, dict): - logger.error("WATA webhook payload не является словарём: %s", payload) + logger.error('WATA webhook payload не является словарём: %s', payload) return False - order_id_raw = payload.get("orderId") - payment_link_raw = payload.get("paymentLinkId") or payload.get("id") - transaction_status_raw = payload.get("transactionStatus") + order_id_raw = payload.get('orderId') + payment_link_raw = payload.get('paymentLinkId') or payload.get('id') + transaction_status_raw = payload.get('transactionStatus') order_id = str(order_id_raw) if order_id_raw else None payment_link_id = str(payment_link_raw) if payment_link_raw else None - transaction_status = (transaction_status_raw or "").strip() + transaction_status = (transaction_status_raw or '').strip() if not order_id and not payment_link_id: logger.error( - "WATA webhook без orderId и paymentLinkId: %s", + 'WATA webhook без orderId и paymentLinkId: %s', payload, ) return False if not transaction_status: - logger.error("WATA webhook без статуса транзакции: %s", payload) + logger.error('WATA webhook без статуса транзакции: %s', payload) return False payment = None @@ -208,33 +208,31 @@ class WataPaymentMixin: if not payment: logger.error( - "WATA платеж не найден (order_id=%s, payment_link_id=%s)", + 'WATA платеж не найден (order_id=%s, payment_link_id=%s)', order_id, payment_link_id, ) return False status_lower = transaction_status.lower() - metadata = dict(getattr(payment, "metadata_json", {}) or {}) - metadata["last_webhook"] = payload + metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + metadata['last_webhook'] = payload terminal_public_id = ( - payload.get("terminalPublicId") - or payload.get("terminal_public_id") - or payload.get("terminalPublicID") + payload.get('terminalPublicId') or payload.get('terminal_public_id') or payload.get('terminalPublicID') ) - update_kwargs: Dict[str, Any] = { - "metadata": metadata, - "callback_payload": payload, - "terminal_public_id": terminal_public_id, + update_kwargs: dict[str, Any] = { + 'metadata': metadata, + 'callback_payload': payload, + 'terminal_public_id': terminal_public_id, } if transaction_status: - update_kwargs["status"] = transaction_status - update_kwargs["last_status"] = transaction_status + update_kwargs['status'] = transaction_status + update_kwargs['last_status'] = transaction_status - if status_lower != "paid" and not payment.is_paid: - update_kwargs["is_paid"] = False + if status_lower != 'paid' and not payment.is_paid: + update_kwargs['is_paid'] = False payment = await payment_module.update_wata_payment_status( db, @@ -242,10 +240,10 @@ class WataPaymentMixin: **update_kwargs, ) - if status_lower == "paid": + if status_lower == 'paid': if payment.is_paid: logger.info( - "WATA платеж %s уже помечен как оплачен", + 'WATA платеж %s уже помечен как оплачен', payment.payment_link_id, ) return True @@ -253,9 +251,9 @@ class WataPaymentMixin: await self._finalize_wata_payment(db, payment, payload) return True - if status_lower == "declined": + if status_lower == 'declined': logger.info( - "WATA платеж %s отклонён", + 'WATA платеж %s отклонён', payment.payment_link_id, ) @@ -265,43 +263,43 @@ class WataPaymentMixin: self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: - payment_module = import_module("app.services.payment_service") + ) -> dict[str, Any] | None: + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_wata_payment_by_id(db, local_payment_id) if not payment: return None - remote_link: Optional[Dict[str, Any]] = None - transaction_payload: Optional[Dict[str, Any]] = None - transaction_id: Optional[str] = None + remote_link: dict[str, Any] | None = None + transaction_payload: dict[str, Any] | None = None + transaction_id: str | None = None - if getattr(self, "wata_service", None) and payment.payment_link_id: + if getattr(self, 'wata_service', None) and payment.payment_link_id: try: remote_link = await self.wata_service.get_payment_link(payment.payment_link_id) # type: ignore[union-attr] except WataAPIError as error: - logger.error("Ошибка получения WATA ссылки %s: %s", payment.payment_link_id, error) + logger.error('Ошибка получения WATA ссылки %s: %s', payment.payment_link_id, error) except Exception as error: # pragma: no cover - safety net - logger.exception("Непредвиденная ошибка при запросе WATA ссылки: %s", error) + logger.exception('Непредвиденная ошибка при запросе WATA ссылки: %s', error) if remote_link: - remote_status = remote_link.get("status") or payment.status + remote_status = remote_link.get('status') or payment.status if remote_status != payment.status: - existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) - existing_metadata["link"] = remote_link + existing_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) + existing_metadata['link'] = remote_link await payment_module.update_wata_payment_status( db, payment=payment, status=remote_status, last_status=remote_status, - url=remote_link.get("url") or remote_link.get("paymentUrl"), + url=remote_link.get('url') or remote_link.get('paymentUrl'), metadata=existing_metadata, - terminal_public_id=remote_link.get("terminalPublicId"), + terminal_public_id=remote_link.get('terminalPublicId'), ) payment = await payment_module.get_wata_payment_by_id(db, local_payment_id) - remote_status_normalized = (remote_status or "").lower() - if remote_status_normalized in {"closed", "paid"} and not payment.is_paid: + remote_status_normalized = (remote_status or '').lower() + if remote_status_normalized in {'closed', 'paid'} and not payment.is_paid: transaction_id = _extract_transaction_id(payment, remote_link) if transaction_id: try: @@ -310,13 +308,13 @@ class WataPaymentMixin: ) except WataAPIError as error: logger.error( - "Ошибка получения WATA транзакции %s: %s", + 'Ошибка получения WATA транзакции %s: %s', transaction_id, error, ) except Exception as error: # pragma: no cover - safety net logger.exception( - "Непредвиденная ошибка при запросе WATA транзакции %s: %s", + 'Непредвиденная ошибка при запросе WATA транзакции %s: %s', transaction_id, error, ) @@ -325,28 +323,24 @@ class WataPaymentMixin: tx_response = await self.wata_service.search_transactions( # type: ignore[union-attr] order_id=payment.order_id, payment_link_id=payment.payment_link_id, - status="Paid", + status='Paid', limit=5, ) - items = tx_response.get("items") or [] + items = tx_response.get('items') or [] for item in items: - if (item or {}).get("status") == "Paid": + if (item or {}).get('status') == 'Paid': transaction_payload = item break except WataAPIError as error: logger.error( - "Ошибка поиска WATA транзакций для %s: %s", + 'Ошибка поиска WATA транзакций для %s: %s', payment.payment_link_id, error, ) except Exception as error: # pragma: no cover - safety net - logger.exception("Непредвиденная ошибка при поиске WATA транзакции: %s", error) + logger.exception('Непредвиденная ошибка при поиске WATA транзакции: %s', error) - if ( - not transaction_payload - and not payment.is_paid - and getattr(self, "wata_service", None) - ): + if not transaction_payload and not payment.is_paid and getattr(self, 'wata_service', None): fallback_transaction_id = transaction_id or _extract_transaction_id(payment) if fallback_transaction_id: try: @@ -355,13 +349,13 @@ class WataPaymentMixin: ) except WataAPIError as error: logger.error( - "Ошибка повторного запроса WATA транзакции %s: %s", + 'Ошибка повторного запроса WATA транзакции %s: %s', fallback_transaction_id, error, ) except Exception as error: # pragma: no cover - safety net logger.exception( - "Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s", + 'Непредвиденная ошибка при повторном запросе WATA транзакции %s: %s', fallback_transaction_id, error, ) @@ -369,76 +363,74 @@ class WataPaymentMixin: if transaction_payload and not payment.is_paid: normalized_status = None if isinstance(transaction_payload, dict): - raw_status = transaction_payload.get("status") or transaction_payload.get("statusName") + raw_status = transaction_payload.get('status') or transaction_payload.get('statusName') if raw_status: normalized_status = str(raw_status).lower() - if normalized_status == "paid": + if normalized_status == 'paid': payment = await self._finalize_wata_payment(db, payment, transaction_payload) else: logger.debug( - "WATA транзакция %s в статусе %s, повторная обработка не требуется", - transaction_id or getattr(payment, "payment_link_id", ""), - normalized_status or "unknown", + 'WATA транзакция %s в статусе %s, повторная обработка не требуется', + transaction_id or getattr(payment, 'payment_link_id', ''), + normalized_status or 'unknown', ) return { - "payment": payment, - "status": payment.status, - "is_paid": payment.is_paid, - "remote_link": remote_link, - "transaction": transaction_payload, + 'payment': payment, + 'status': payment.status, + 'is_paid': payment.is_paid, + 'remote_link': remote_link, + 'transaction': transaction_payload, } async def _finalize_wata_payment( self, db: AsyncSession, payment: Any, - transaction_payload: Dict[str, Any], + transaction_payload: dict[str, Any], ) -> Any: - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') if isinstance(transaction_payload, dict): - paid_status = transaction_payload.get("status") or transaction_payload.get("statusName") + paid_status = transaction_payload.get('status') or transaction_payload.get('statusName') else: paid_status = None - if paid_status and str(paid_status).lower() not in {"paid", "declined", "pending"}: + if paid_status and str(paid_status).lower() not in {'paid', 'declined', 'pending'}: logger.debug( - "Неизвестный статус WATA транзакции %s: %s", - getattr(payment, "payment_link_id", ""), + 'Неизвестный статус WATA транзакции %s: %s', + getattr(payment, 'payment_link_id', ''), paid_status, ) paid_at = None if isinstance(transaction_payload, dict): - paid_at = WataService._parse_datetime(transaction_payload.get("paymentTime")) - if not paid_at and getattr(payment, "paid_at", None): + paid_at = WataService._parse_datetime(transaction_payload.get('paymentTime')) + if not paid_at and getattr(payment, 'paid_at', None): paid_at = payment.paid_at - existing_metadata = dict(getattr(payment, "metadata_json", {}) or {}) + existing_metadata = dict(getattr(payment, 'metadata_json', {}) or {}) - invoice_message = existing_metadata.get("invoice_message") or {} - invoice_message_removed = False - if getattr(self, "bot", None) and invoice_message: - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + invoice_message = existing_metadata.get('invoice_message') or {} + if getattr(self, 'bot', None) and invoice_message: + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on rights logger.warning( - "Не удалось удалить счёт WATA %s: %s", + 'Не удалось удалить счёт WATA %s: %s', message_id, delete_error, ) else: - invoice_message_removed = True - existing_metadata.pop("invoice_message", None) + existing_metadata.pop('invoice_message', None) - existing_metadata["transaction"] = transaction_payload + existing_metadata['transaction'] = transaction_payload await payment_module.update_wata_payment_status( db, payment=payment, - status="Paid", + status='Paid', is_paid=True, paid_at=paid_at, callback_payload=transaction_payload, @@ -447,7 +439,7 @@ class WataPaymentMixin: if payment.transaction_id: logger.info( - "WATA платеж %s уже привязан к транзакции %s", + 'WATA платеж %s уже привязан к транзакции %s', payment.payment_link_id, payment.transaction_id, ) @@ -455,11 +447,11 @@ class WataPaymentMixin: user = await payment_module.get_user_by_id(db, payment.user_id) if not user: - logger.error("Пользователь %s не найден при обработке WATA", payment.user_id) + logger.error('Пользователь %s не найден при обработке WATA', payment.user_id) return payment - transaction_external_id = str(transaction_payload.get("id") or transaction_payload.get("transactionId") or "") - description = f"Пополнение через WATA ({payment.payment_link_id})" + transaction_external_id = str(transaction_payload.get('id') or transaction_payload.get('transactionId') or '') + description = f'Пополнение через WATA ({payment.payment_link_id})' transaction = await payment_module.create_transaction( db, @@ -483,9 +475,9 @@ class WataPaymentMixin: await db.refresh(user) promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' try: from app.services.referral_service import process_referral_topup @@ -494,17 +486,17 @@ class WataPaymentMixin: db, user.id, payment.amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: - logger.error("Ошибка обработки реферального пополнения WATA: %s", error) + logger.error('Ошибка обработки реферального пополнения WATA: %s', error) if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True await db.commit() await db.refresh(user) - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import AdminNotificationService @@ -520,34 +512,35 @@ class WataPaymentMixin: db=db, ) except Exception as error: - logger.error("Ошибка отправки админ уведомления WATA: %s", error) + logger.error('Ошибка отправки админ уведомления WATA: %s', error) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: keyboard = await self.build_topup_success_keyboard(user) await self.bot.send_message( user.telegram_id, ( - "✅ Пополнение успешно!\n\n" - f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n" - "🦊 Способ: WATA\n" - f"🆔 Транзакция: {transaction.id}\n\n" - "⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - "Обязательно активируйте подписку отдельно!\n\n" - "🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - "подписка будет приобретена автоматически после пополнения баланса.\n\n" - "Баланс пополнен автоматически!" + '✅ Пополнение успешно!\n\n' + f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n' + '🦊 Способ: WATA\n' + f'🆔 Транзакция: {transaction.id}\n\n' + '⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + 'Обязательно активируйте подписку отдельно!\n\n' + '🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + 'подписка будет приобретена автоматически после пополнения баланса.\n\n' + 'Баланс пополнен автоматически!' ), - parse_mode="HTML", + parse_mode='HTML', reply_markup=keyboard, ) except Exception as error: - logger.error("Ошибка отправки уведомления пользователю WATA: %s", error) + logger.error('Ошибка отправки уведомления пользователю WATA: %s', error) try: - from app.services.user_cart_service import user_cart_service from aiogram import types + from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -555,11 +548,11 @@ class WataPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -573,25 +566,24 @@ class WataPaymentMixin: if not auto_purchase_success: try: _, activation_notification_sent = await auto_activate_subscription_after_topup( - db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks + db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, ) # Отправляем уведомление только если его ещё не отправили - if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id: from app.localization.texts import get_texts texts = get_texts(user.language) cart_message = texts.t( - "BALANCE_TOPUP_CART_REMINDER_DETAILED", - "🛒 У вас есть неоформленный заказ.\n\n" - "Вы можете продолжить оформление с теми же параметрами.", + 'BALANCE_TOPUP_CART_REMINDER_DETAILED', + '🛒 У вас есть неоформленный заказ.\n\nВы можете продолжить оформление с теми же параметрами.', ) keyboard = types.InlineKeyboardMarkup( @@ -599,19 +591,19 @@ class WataPaymentMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", + text='💰 Мой баланс', + callback_data='menu_balance', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -623,6 +615,6 @@ class WataPaymentMixin: reply_markup=keyboard, ) except Exception as error: - logger.debug("Не удалось отправить напоминание о корзине после WATA: %s", error) + logger.debug('Не удалось отправить напоминание о корзине после WATA: %s', error) return payment diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index ae31f88e..4cb15962 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -9,7 +9,7 @@ from __future__ import annotations from datetime import datetime from decimal import Decimal, InvalidOperation from importlib import import_module -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from sqlalchemy.ext.asyncio import AsyncSession @@ -19,11 +19,12 @@ from app.services.subscription_auto_purchase_service import ( auto_activate_subscription_after_topup, auto_purchase_saved_cart_after_topup, ) -from app.utils.user_utils import format_referrer_info from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + if TYPE_CHECKING: - from app.database.models import YooKassaPayment, Transaction + from app.database.models import Transaction, YooKassaPayment class YooKassaPaymentMixin: @@ -34,58 +35,58 @@ class YooKassaPaymentMixin: """Форматирует сумму для хранения в webhook-объекте.""" try: - quantized = Decimal(str(value)).quantize(Decimal("0.00")) - return format(quantized, "f") + quantized = Decimal(str(value)).quantize(Decimal('0.00')) + return format(quantized, 'f') except (InvalidOperation, ValueError, TypeError): return str(value) @classmethod def _merge_remote_yookassa_payload( cls, - event_object: Dict[str, Any], - remote_data: Dict[str, Any], - ) -> Dict[str, Any]: + event_object: dict[str, Any], + remote_data: dict[str, Any], + ) -> dict[str, Any]: """Объединяет локальные данные вебхука с ответом API YooKassa.""" - merged: Dict[str, Any] = dict(event_object) + merged: dict[str, Any] = dict(event_object) - status = remote_data.get("status") + status = remote_data.get('status') if status: - merged["status"] = status + merged['status'] = status - if "paid" in remote_data: - merged["paid"] = bool(remote_data.get("paid")) + if 'paid' in remote_data: + merged['paid'] = bool(remote_data.get('paid')) - if "refundable" in remote_data: - merged["refundable"] = bool(remote_data.get("refundable")) + if 'refundable' in remote_data: + merged['refundable'] = bool(remote_data.get('refundable')) - payment_method_type = remote_data.get("payment_method_type") + payment_method_type = remote_data.get('payment_method_type') if payment_method_type: - payment_method = dict(merged.get("payment_method") or {}) - payment_method["type"] = payment_method_type - merged["payment_method"] = payment_method + payment_method = dict(merged.get('payment_method') or {}) + payment_method['type'] = payment_method_type + merged['payment_method'] = payment_method - amount_value = remote_data.get("amount_value") - amount_currency = remote_data.get("amount_currency") + amount_value = remote_data.get('amount_value') + amount_currency = remote_data.get('amount_currency') if amount_value is not None or amount_currency: - merged_amount = dict(merged.get("amount") or {}) + merged_amount = dict(merged.get('amount') or {}) if amount_value is not None: - merged_amount["value"] = cls._format_amount_value(amount_value) + merged_amount['value'] = cls._format_amount_value(amount_value) if amount_currency: - merged_amount["currency"] = str(amount_currency).upper() - merged["amount"] = merged_amount + merged_amount['currency'] = str(amount_currency).upper() + merged['amount'] = merged_amount - for datetime_field in ("captured_at", "created_at"): + for datetime_field in ('captured_at', 'created_at'): value = remote_data.get(datetime_field) if value: merged[datetime_field] = value - metadata = remote_data.get("metadata") + metadata = remote_data.get('metadata') if metadata: try: - merged["metadata"] = dict(metadata) # type: ignore[arg-type] + merged['metadata'] = dict(metadata) # type: ignore[arg-type] except TypeError: - merged["metadata"] = metadata + merged['metadata'] = metadata return merged @@ -95,16 +96,16 @@ class YooKassaPaymentMixin: user_id: int, amount_kopeks: int, description: str, - receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + receipt_email: str | None = None, + receipt_phone: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: """Создаёт обычный платёж в YooKassa и сохраняет локальную запись.""" - if not getattr(self, "yookassa_service", None): - logger.error("YooKassa сервис не инициализирован") + if not getattr(self, 'yookassa_service', None): + logger.error('YooKassa сервис не инициализирован') return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') try: amount_rubles = amount_kopeks / 100 @@ -112,86 +113,83 @@ class YooKassaPaymentMixin: payment_metadata = metadata.copy() if metadata else {} # Всегда добавляем telegram_id в метаданные для возможности возврата платежа - if "user_telegram_id" not in payment_metadata: + if 'user_telegram_id' not in payment_metadata: try: from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) if user and user.telegram_id: - payment_metadata["user_telegram_id"] = str(user.telegram_id) - payment_metadata["user_username"] = user.username or "" + payment_metadata['user_telegram_id'] = str(user.telegram_id) + payment_metadata['user_username'] = user.username or '' except Exception as e: - logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}") + logger.warning(f'Не удалось получить telegram_id для user_id={user_id}: {e}') # Preserve existing type from metadata if passed (e.g., "trial") - existing_type = payment_metadata.get("type") + existing_type = payment_metadata.get('type') payment_metadata.update( { - "user_id": str(user_id), - "amount_kopeks": str(amount_kopeks), - "type": existing_type or "balance_topup", + 'user_id': str(user_id), + 'amount_kopeks': str(amount_kopeks), + 'type': existing_type or 'balance_topup', } ) yookassa_response = await self.yookassa_service.create_payment( amount=amount_rubles, - currency="RUB", + currency='RUB', description=description, metadata=payment_metadata, receipt_email=receipt_email, receipt_phone=receipt_phone, ) - if not yookassa_response or yookassa_response.get("error"): - logger.error( - "Ошибка создания платежа YooKassa: %s", yookassa_response - ) + if not yookassa_response or yookassa_response.get('error'): + logger.error('Ошибка создания платежа YooKassa: %s', yookassa_response) return None - yookassa_created_at: Optional[datetime] = None - if yookassa_response.get("created_at"): + yookassa_created_at: datetime | None = None + if yookassa_response.get('created_at'): try: - dt_with_tz = datetime.fromisoformat( - yookassa_response["created_at"].replace("Z", "+00:00") - ) + dt_with_tz = datetime.fromisoformat(yookassa_response['created_at'].replace('Z', '+00:00')) yookassa_created_at = dt_with_tz.replace(tzinfo=None) except Exception as error: - logger.warning("Не удалось распарсить created_at: %s", error) + logger.warning('Не удалось распарсить created_at: %s', error) yookassa_created_at = None local_payment = await payment_module.create_yookassa_payment( db=db, user_id=user_id, - yookassa_payment_id=yookassa_response["id"], + yookassa_payment_id=yookassa_response['id'], amount_kopeks=amount_kopeks, - currency="RUB", + currency='RUB', description=description, - status=yookassa_response["status"], - confirmation_url=yookassa_response.get("confirmation_url"), + status=yookassa_response['status'], + confirmation_url=yookassa_response.get('confirmation_url'), metadata_json=payment_metadata, payment_method_type=None, yookassa_created_at=yookassa_created_at, - test_mode=yookassa_response.get("test_mode", False), + test_mode=yookassa_response.get('test_mode', False), ) logger.info( - "Создан платеж YooKassa %s на %s₽ для пользователя %s", - yookassa_response["id"], + 'Создан платеж YooKassa %s на %s₽ для пользователя %s', + yookassa_response['id'], amount_rubles, user_id, ) return { - "local_payment_id": local_payment.id, - "yookassa_payment_id": yookassa_response["id"], - "confirmation_url": yookassa_response.get("confirmation_url"), - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_rubles, - "status": yookassa_response["status"], - "created_at": local_payment.created_at, + 'local_payment_id': local_payment.id, + 'yookassa_payment_id': yookassa_response['id'], + 'confirmation_url': yookassa_response.get('confirmation_url'), + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'status': yookassa_response['status'], + 'created_at': local_payment.created_at, } except Exception as error: - logger.error("Ошибка создания платежа YooKassa: %s", error) + logger.error('Ошибка создания платежа YooKassa: %s', error) return None async def create_yookassa_sbp_payment( @@ -200,16 +198,16 @@ class YooKassaPaymentMixin: user_id: int, amount_kopeks: int, description: str, - receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + receipt_email: str | None = None, + receipt_phone: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: """Создаёт платёж по СБП через YooKassa.""" - if not getattr(self, "yookassa_service", None): - logger.error("YooKassa сервис не инициализирован") + if not getattr(self, 'yookassa_service', None): + logger.error('YooKassa сервис не инициализирован') return None - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') try: amount_rubles = amount_kopeks / 100 @@ -217,40 +215,39 @@ class YooKassaPaymentMixin: payment_metadata = metadata.copy() if metadata else {} # Всегда добавляем telegram_id в метаданные для возможности возврата платежа - if "user_telegram_id" not in payment_metadata: + if 'user_telegram_id' not in payment_metadata: try: from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) if user and user.telegram_id: - payment_metadata["user_telegram_id"] = str(user.telegram_id) - payment_metadata["user_username"] = user.username or "" + payment_metadata['user_telegram_id'] = str(user.telegram_id) + payment_metadata['user_username'] = user.username or '' except Exception as e: - logger.warning(f"Не удалось получить telegram_id для user_id={user_id}: {e}") + logger.warning(f'Не удалось получить telegram_id для user_id={user_id}: {e}') # Preserve existing type from metadata if passed (e.g., "trial") - existing_type = payment_metadata.get("type") + existing_type = payment_metadata.get('type') payment_metadata.update( { - "user_id": str(user_id), - "amount_kopeks": str(amount_kopeks), - "type": existing_type or "balance_topup_sbp", + 'user_id': str(user_id), + 'amount_kopeks': str(amount_kopeks), + 'type': existing_type or 'balance_topup_sbp', } ) - yookassa_response = ( - await self.yookassa_service.create_sbp_payment( - amount=amount_rubles, - currency="RUB", - description=description, - metadata=payment_metadata, - receipt_email=receipt_email, - receipt_phone=receipt_phone, - ) + yookassa_response = await self.yookassa_service.create_sbp_payment( + amount=amount_rubles, + currency='RUB', + description=description, + metadata=payment_metadata, + receipt_email=receipt_email, + receipt_phone=receipt_phone, ) - if not yookassa_response or yookassa_response.get("error"): + if not yookassa_response or yookassa_response.get('error'): logger.error( - "Ошибка создания платежа YooKassa СБП: %s", + 'Ошибка создания платежа YooKassa СБП: %s', yookassa_response, ) return None @@ -258,98 +255,94 @@ class YooKassaPaymentMixin: local_payment = await payment_module.create_yookassa_payment( db=db, user_id=user_id, - yookassa_payment_id=yookassa_response["id"], + yookassa_payment_id=yookassa_response['id'], amount_kopeks=amount_kopeks, - currency="RUB", + currency='RUB', description=description, - status=yookassa_response["status"], - confirmation_url=yookassa_response.get("confirmation_url"), # Используем confirmation URL + status=yookassa_response['status'], + confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL metadata_json=payment_metadata, - payment_method_type="bank_card", + payment_method_type='bank_card', yookassa_created_at=None, - test_mode=yookassa_response.get("test_mode", False), + test_mode=yookassa_response.get('test_mode', False), ) logger.info( - "Создан платеж YooKassa СБП %s на %s₽ для пользователя %s", - yookassa_response["id"], + 'Создан платеж YooKassa СБП %s на %s₽ для пользователя %s', + yookassa_response['id'], amount_rubles, user_id, ) - confirmation_token = ( - yookassa_response.get("confirmation", {}) or {} - ).get("confirmation_token") + confirmation_token = (yookassa_response.get('confirmation', {}) or {}).get('confirmation_token') return { - "local_payment_id": local_payment.id, - "yookassa_payment_id": yookassa_response["id"], - "confirmation_url": yookassa_response.get("confirmation_url"), # URL для подтверждения - "qr_confirmation_data": yookassa_response.get("qr_confirmation_data"), # Данные для QR-кода - "confirmation_token": confirmation_token, - "amount_kopeks": amount_kopeks, - "amount_rubles": amount_rubles, - "status": yookassa_response["status"], - "created_at": local_payment.created_at, + 'local_payment_id': local_payment.id, + 'yookassa_payment_id': yookassa_response['id'], + 'confirmation_url': yookassa_response.get('confirmation_url'), # URL для подтверждения + 'qr_confirmation_data': yookassa_response.get('qr_confirmation_data'), # Данные для QR-кода + 'confirmation_token': confirmation_token, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'status': yookassa_response['status'], + 'created_at': local_payment.created_at, } except Exception as error: - logger.error("Ошибка создания платежа YooKassa СБП: %s", error) + logger.error('Ошибка создания платежа YooKassa СБП: %s', error) return None async def get_yookassa_payment_status( self, db: AsyncSession, local_payment_id: int, - ) -> Optional[Dict[str, Any]]: + ) -> dict[str, Any] | None: """Запрашивает статус платежа в YooKassa и синхронизирует локальные данные.""" - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_yookassa_payment_by_local_id(db, local_payment_id) if not payment: return None - remote_data: Optional[Dict[str, Any]] = None + remote_data: dict[str, Any] | None = None - if getattr(self, "yookassa_service", None): + if getattr(self, 'yookassa_service', None): try: remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] payment.yookassa_payment_id ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Ошибка получения статуса YooKassa %s: %s", + 'Ошибка получения статуса YooKassa %s: %s', payment.yookassa_payment_id, error, ) if remote_data: - status = remote_data.get("status") or payment.status - paid = bool(remote_data.get("paid", getattr(payment, "is_paid", False))) - captured_raw = remote_data.get("captured_at") + status = remote_data.get('status') or payment.status + paid = bool(remote_data.get('paid', getattr(payment, 'is_paid', False))) + captured_raw = remote_data.get('captured_at') captured_at = None if captured_raw: try: - captured_at = datetime.fromisoformat( - str(captured_raw).replace("Z", "+00:00") - ).replace(tzinfo=None) + captured_at = datetime.fromisoformat(str(captured_raw).replace('Z', '+00:00')).replace(tzinfo=None) except Exception as parse_error: # pragma: no cover - diagnostic log logger.debug( - "Не удалось распарсить captured_at %s: %s", + 'Не удалось распарсить captured_at %s: %s', captured_raw, parse_error, ) captured_at = None - payment_method_type = remote_data.get("payment_method_type") + payment_method_type = remote_data.get('payment_method_type') updated_payment = await payment_module.update_yookassa_payment_status( db, payment.yookassa_payment_id, status=status, is_paid=paid, - is_captured=paid and status == "succeeded", + is_captured=paid and status == 'succeeded', captured_at=captured_at, payment_method_type=payment_method_type, ) @@ -357,19 +350,16 @@ class YooKassaPaymentMixin: if updated_payment: payment = updated_payment - transaction_id = getattr(payment, "transaction_id", None) + transaction_id = getattr(payment, 'transaction_id', None) - if ( - payment.status == "succeeded" - and getattr(payment, "is_paid", False) - ): + if payment.status == 'succeeded' and getattr(payment, 'is_paid', False): if not transaction_id: try: await db.refresh(payment) - transaction_id = getattr(payment, "transaction_id", None) + transaction_id = getattr(payment, 'transaction_id', None) except Exception as refresh_error: # pragma: no cover - defensive logging logger.warning( - "Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s", + 'Не удалось обновить состояние платежа YooKassa %s перед повторной обработкой: %s', payment.yookassa_payment_id, refresh_error, exc_info=True, @@ -377,7 +367,7 @@ class YooKassaPaymentMixin: if transaction_id: logger.info( - "Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s", + 'Пропускаем повторную обработку платежа YooKassa %s: уже связан с транзакцией %s', payment.yookassa_payment_id, transaction_id, ) @@ -386,33 +376,32 @@ class YooKassaPaymentMixin: await self._process_successful_yookassa_payment(db, payment) except Exception as process_error: # pragma: no cover - defensive logging logger.error( - "Ошибка обработки успешного платежа YooKassa %s: %s", + 'Ошибка обработки успешного платежа YooKassa %s: %s', payment.yookassa_payment_id, process_error, exc_info=True, ) return { - "payment": payment, - "status": payment.status, - "is_paid": getattr(payment, "is_paid", False), - "remote_data": remote_data, + 'payment': payment, + 'status': payment.status, + 'is_paid': getattr(payment, 'is_paid', False), + 'remote_data': remote_data, } async def _process_successful_yookassa_payment( self, db: AsyncSession, - payment: "YooKassaPayment", + payment: YooKassaPayment, ) -> bool: """Переносит успешный платёж YooKassa в транзакции и начисляет баланс пользователю.""" try: from sqlalchemy import select - payment_module = import_module("app.services.payment_service") + + payment_module = import_module('app.services.payment_service') # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) - get_transaction_by_external_id = getattr( - payment_module, "get_transaction_by_external_id", None - ) + get_transaction_by_external_id = getattr(payment_module, 'get_transaction_by_external_id', None) existing_transaction = None if get_transaction_by_external_id: try: @@ -422,18 +411,18 @@ class YooKassaPaymentMixin: PaymentMethod.YOOKASSA, ) except AttributeError: - logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") - + logger.debug('get_transaction_by_external_id недоступен, пропускаем проверку дубликатов') + if existing_transaction: # Если транзакция уже существует, просто завершаем обработку logger.info( - "Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.", + 'Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.', payment.yookassa_payment_id, existing_transaction.id, ) - + # Убедимся, что платеж связан с транзакцией - if not getattr(payment, "transaction_id", None): + if not getattr(payment, 'transaction_id', None): try: linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined] db, @@ -443,56 +432,56 @@ class YooKassaPaymentMixin: if linked_payment: payment.transaction_id = getattr( linked_payment, - "transaction_id", + 'transaction_id', existing_transaction.id, ) - if hasattr(linked_payment, "transaction"): + if hasattr(linked_payment, 'transaction'): payment.transaction = linked_payment.transaction except Exception as link_error: # pragma: no cover - защитный лог logger.warning( - "Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s", + 'Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s', payment.yookassa_payment_id, existing_transaction.id, link_error, exc_info=True, ) - + return True - payment_metadata: Dict[str, Any] = {} + payment_metadata: dict[str, Any] = {} try: - if hasattr(payment, "metadata_json") and payment.metadata_json: + if hasattr(payment, 'metadata_json') and payment.metadata_json: import json if isinstance(payment.metadata_json, str): payment_metadata = json.loads(payment.metadata_json) elif isinstance(payment.metadata_json, dict): payment_metadata = payment.metadata_json - logger.info(f"Метаданные платежа: {payment_metadata}") + logger.info(f'Метаданные платежа: {payment_metadata}') except Exception as parse_error: - logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}") + logger.error(f'Ошибка парсинга метаданных платежа: {parse_error}') - invoice_message = payment_metadata.get("invoice_message") or {} - if getattr(self, "bot", None): - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + invoice_message = payment_metadata.get('invoice_message') or {} + if getattr(self, 'bot', None): + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if chat_id and message_id: try: await self.bot.delete_message(chat_id, message_id) except Exception as delete_error: # pragma: no cover - depends on bot rights logger.warning( - "Не удалось удалить сообщение YooKassa %s: %s", + 'Не удалось удалить сообщение YooKassa %s: %s', message_id, delete_error, ) else: - payment_metadata.pop("invoice_message", None) + payment_metadata.pop('invoice_message', None) - processing_completed = bool(payment_metadata.get("processing_completed")) + processing_completed = bool(payment_metadata.get('processing_completed')) transaction = None - existing_transaction_id = getattr(payment, "transaction_id", None) + existing_transaction_id = getattr(payment, 'transaction_id', None) if existing_transaction_id: try: from app.database.crud.transaction import get_transaction_by_id @@ -500,7 +489,7 @@ class YooKassaPaymentMixin: transaction = await get_transaction_by_id(db, existing_transaction_id) except Exception as fetch_error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось получить транзакцию %s для платежа YooKassa %s: %s", + 'Не удалось получить транзакцию %s для платежа YooKassa %s: %s', existing_transaction_id, payment.yookassa_payment_id, fetch_error, @@ -509,7 +498,7 @@ class YooKassaPaymentMixin: if transaction and processing_completed: logger.info( - "Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.", + 'Пропускаем повторную обработку платежа YooKassa %s: транзакция %s уже завершила начисление.', payment.yookassa_payment_id, existing_transaction_id, ) @@ -517,15 +506,13 @@ class YooKassaPaymentMixin: if transaction: logger.info( - "Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.", + 'Транзакция %s для платежа YooKassa %s найдена, но обработка ранее не была завершена — повторяем критические шаги.', existing_transaction_id, payment.yookassa_payment_id, ) if transaction is None: - get_transaction_by_external_id = getattr( - payment_module, "get_transaction_by_external_id", None - ) + get_transaction_by_external_id = getattr(payment_module, 'get_transaction_by_external_id', None) existing_transaction = None if get_transaction_by_external_id: @@ -536,18 +523,18 @@ class YooKassaPaymentMixin: PaymentMethod.YOOKASSA, ) except AttributeError: - logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов") - + logger.debug('get_transaction_by_external_id недоступен, пропускаем проверку дубликатов') + if existing_transaction: # Если транзакция уже существует, пропускаем обработку logger.info( - "Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.", + 'Платеж YooKassa %s уже был обработан транзакцией %s. Пропускаем повторную обработку.', payment.yookassa_payment_id, existing_transaction.id, ) - + # Убедимся, что платеж связан с транзакцией - if not getattr(payment, "transaction_id", None): + if not getattr(payment, 'transaction_id', None): try: linked_payment = await payment_module.link_yookassa_payment_to_transaction( # type: ignore[attr-defined] db, @@ -557,28 +544,28 @@ class YooKassaPaymentMixin: if linked_payment: payment.transaction_id = getattr( linked_payment, - "transaction_id", + 'transaction_id', existing_transaction.id, ) - if hasattr(linked_payment, "transaction"): + if hasattr(linked_payment, 'transaction'): payment.transaction = linked_payment.transaction except Exception as link_error: # pragma: no cover - защитный лог logger.warning( - "Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s", + 'Не удалось привязать платеж YooKassa %s к существующей транзакции %s: %s', payment.yookassa_payment_id, existing_transaction.id, link_error, exc_info=True, ) - + return True - payment_description = getattr(payment, "description", "YooKassa платеж") + payment_description = getattr(payment, 'description', 'YooKassa платеж') - payment_purpose = payment_metadata.get("payment_purpose", "") - payment_type = payment_metadata.get("type", "") - is_simple_subscription = payment_purpose == "simple_subscription_purchase" - is_trial_payment = payment_type == "trial" + payment_purpose = payment_metadata.get('payment_purpose', '') + payment_type = payment_metadata.get('type', '') + is_simple_subscription = payment_purpose == 'simple_subscription_purchase' + is_trial_payment = payment_type == 'trial' transaction_type = ( TransactionType.SUBSCRIPTION_PAYMENT @@ -586,11 +573,11 @@ class YooKassaPaymentMixin: else TransactionType.DEPOSIT ) transaction_description = ( - f"Оплата подписки через YooKassa: {payment_description}" + f'Оплата подписки через YooKassa: {payment_description}' if is_simple_subscription - else f"Оплата пробной подписки через YooKassa: {payment_description}" + else f'Оплата пробной подписки через YooKassa: {payment_description}' if is_trial_payment - else f"Пополнение через YooKassa: {payment_description}" + else f'Пополнение через YooKassa: {payment_description}' ) if transaction is None: @@ -605,7 +592,7 @@ class YooKassaPaymentMixin: is_completed=True, ) - if not getattr(payment, "transaction_id", None): + if not getattr(payment, 'transaction_id', None): linked_payment = await payment_module.link_yookassa_payment_to_transaction( db, payment.yookassa_payment_id, @@ -613,8 +600,8 @@ class YooKassaPaymentMixin: ) if linked_payment: - payment.transaction_id = getattr(linked_payment, "transaction_id", transaction.id) - if hasattr(linked_payment, "transaction"): + payment.transaction_id = getattr(linked_payment, 'transaction_id', transaction.id) + if hasattr(linked_payment, 'transaction'): payment.transaction = linked_payment.transaction critical_flow_completed = False @@ -625,16 +612,16 @@ class YooKassaPaymentMixin: if is_trial_payment: # Обработка платного триала logger.info( - "YooKassa платеж %s обработан как оплата триала. Баланс пользователя %s не изменяется.", + 'YooKassa платеж %s обработан как оплата триала. Баланс пользователя %s не изменяется.', payment.yookassa_payment_id, user.id, ) try: - subscription_id = payment_metadata.get("subscription_id") + subscription_id = payment_metadata.get('subscription_id') if subscription_id: from app.database.crud.subscription import activate_pending_trial_subscription - from app.services.subscription_service import SubscriptionService from app.services.admin_notification_service import AdminNotificationService + from app.services.subscription_service import SubscriptionService subscription = await activate_pending_trial_subscription( db=db, @@ -643,71 +630,75 @@ class YooKassaPaymentMixin: ) if subscription: - logger.info(f"Триальная подписка {subscription_id} активирована для пользователя {user.id}") + logger.info( + f'Триальная подписка {subscription_id} активирована для пользователя {user.id}' + ) # Создаем пользователя в RemnaWave subscription_service = SubscriptionService() try: await subscription_service.create_remnawave_user(db, subscription) except Exception as rw_error: - logger.error(f"Ошибка создания RemnaWave для триала: {rw_error}") + logger.error(f'Ошибка создания RemnaWave для триала: {rw_error}') # Уведомление админам - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: admin_notification_service = AdminNotificationService(self.bot) await admin_notification_service.send_trial_activation_notification( user=user, subscription=subscription, paid_amount=payment.amount_kopeks, - payment_method="YooKassa", + payment_method='YooKassa', ) except Exception as admin_error: - logger.warning(f"Ошибка уведомления админов о триале: {admin_error}") + logger.warning(f'Ошибка уведомления админов о триале: {admin_error}') # Уведомление пользователю (только для Telegram-пользователей) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: await self.bot.send_message( chat_id=user.telegram_id, text=( - f"🎉 Пробная подписка активирована!\n\n" - f"💳 Оплачено: {settings.format_price(payment.amount_kopeks)}\n" - f"📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n" - f"📱 Устройств: {subscription.device_limit}\n\n" - f"Используйте меню для подключения к VPN." + f'🎉 Пробная подписка активирована!\n\n' + f'💳 Оплачено: {settings.format_price(payment.amount_kopeks)}\n' + f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней\n' + f'📱 Устройств: {subscription.device_limit}\n\n' + f'Используйте меню для подключения к VPN.' ), - parse_mode="HTML", + parse_mode='HTML', ) except Exception as notify_error: - logger.warning(f"Ошибка уведомления пользователя о триале: {notify_error}") + logger.warning(f'Ошибка уведомления пользователя о триале: {notify_error}') elif not user.telegram_id: - logger.info(f"Пропуск Telegram-уведомления о триале для email-пользователя {user.id}") + logger.info( + f'Пропуск Telegram-уведомления о триале для email-пользователя {user.id}' + ) else: - logger.error(f"Не удалось активировать триал {subscription_id} для {user.id}") + logger.error(f'Не удалось активировать триал {subscription_id} для {user.id}') else: - logger.error(f"Отсутствует subscription_id в metadata триального платежа YooKassa") + logger.error('Отсутствует subscription_id в metadata триального платежа YooKassa') except Exception as trial_error: - logger.error(f"Ошибка обработки триального платежа YooKassa: {trial_error}", exc_info=True) + logger.error(f'Ошибка обработки триального платежа YooKassa: {trial_error}', exc_info=True) elif is_simple_subscription: logger.info( - "YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.", + 'YooKassa платеж %s обработан как покупка подписки. Баланс пользователя %s не изменяется.', payment.yookassa_payment_id, user.id, ) else: - old_balance = getattr(user, "balance_kopeks", 0) - was_first_topup = not getattr(user, "has_made_first_topup", False) + old_balance = getattr(user, 'balance_kopeks', 0) + was_first_topup = not getattr(user, 'has_made_first_topup', False) user.balance_kopeks += payment.amount_kopeks user.updated_at = datetime.utcnow() # Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой - from app.database.crud.user import get_user_by_id from sqlalchemy.orm import selectinload - from app.database.models import User, Subscription as SubscriptionModel - + + from app.database.models import Subscription as SubscriptionModel, User + # Загружаем пользователя с подпиской и промо-группой full_user_result = await db.execute( select(User) @@ -718,15 +709,17 @@ class YooKassaPaymentMixin: full_user = full_user_result.scalar_one_or_none() # Используем обновленные данные или исходные, если не удалось обновить - subscription = full_user.subscription if full_user else getattr(user, "subscription", None) - promo_group = full_user.get_primary_promo_group() if full_user else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None) - + subscription = full_user.subscription if full_user else getattr(user, 'subscription', None) + promo_group = ( + full_user.get_primary_promo_group() + if full_user + else (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None) + ) + # Используем full_user для форматирования реферальной информации, чтобы избежать проблем с ленивой загрузкой user_for_referrer = full_user if full_user else user referrer_info = format_referrer_info(user_for_referrer) - topup_status = ( - "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" - ) + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' payment_metadata = await self._mark_yookassa_payment_processing_completed( db, @@ -745,22 +738,22 @@ class YooKassaPaymentMixin: db, user.id, payment.amount_kopeks, - getattr(self, "bot", None), + getattr(self, 'bot', None), ) except Exception as error: logger.error( - "Ошибка обработки реферального пополнения YooKassa: %s", + 'Ошибка обработки реферального пополнения YooKassa: %s', error, ) - if was_first_topup and not getattr(user, "has_made_first_topup", False): + if was_first_topup and not getattr(user, 'has_made_first_topup', False): user.has_made_first_topup = True await db.commit() await db.refresh(user) # Отправляем уведомления админам - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, @@ -779,16 +772,16 @@ class YooKassaPaymentMixin: promo_group=promo_group, db=db, ) - logger.info("Уведомление админам о пополнении отправлено успешно") + logger.info('Уведомление админам о пополнении отправлено успешно') except Exception as error: logger.error( - "Ошибка отправки уведомления админам о YooKassa пополнении: %s", + 'Ошибка отправки уведомления админам о YooKassa пополнении: %s', error, exc_info=True, # Добавляем полный стек вызовов для отладки ) # Отправляем уведомление пользователю (только Telegram-пользователям) - if getattr(self, "bot", None) and user.telegram_id: + if getattr(self, 'bot', None) and user.telegram_id: try: # Передаем только простые данные, чтобы избежать проблем с ленивой загрузкой await self._send_payment_success_notification( @@ -796,24 +789,25 @@ class YooKassaPaymentMixin: payment.amount_kopeks, user=None, # Передаем None, чтобы _ensure_user_snapshot загрузил данные сам db=db, - payment_method_title="Банковская карта (YooKassa)", + payment_method_title='Банковская карта (YooKassa)', ) - logger.info("Уведомление пользователю о платеже отправлено успешно") + logger.info('Уведомление пользователю о платеже отправлено успешно') except Exception as error: logger.error( - "Ошибка отправки уведомления о платеже: %s", + 'Ошибка отправки уведомления о платеже: %s', error, exc_info=True, # Добавляем полный стек вызовов для отладки ) # Проверяем наличие сохраненной корзины для возврата к оформлению подписки # ВАЖНО: этот код должен выполняться даже при ошибках в уведомлениях - logger.info(f"Проверяем наличие сохраненной корзины для пользователя {user.id}") + logger.info(f'Проверяем наличие сохраненной корзины для пользователя {user.id}') from app.services.user_cart_service import user_cart_service + try: has_saved_cart = await user_cart_service.has_user_cart(user.id) logger.info( - "Результат проверки корзины для пользователя %s: %s", + 'Результат проверки корзины для пользователя %s: %s', user.id, has_saved_cart, ) @@ -824,11 +818,11 @@ class YooKassaPaymentMixin: auto_purchase_success = await auto_purchase_saved_cart_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -843,11 +837,11 @@ class YooKassaPaymentMixin: await auto_activate_subscription_after_topup( db, user, - bot=getattr(self, "bot", None), + bot=getattr(self, 'bot', None), ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, @@ -856,12 +850,13 @@ class YooKassaPaymentMixin: # Если включен яркий промпт активации, пропускаем старое уведомление # т.к. оно будет отправлено через _send_payment_success_notification if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: - if has_saved_cart and getattr(self, "bot", None) and user.telegram_id: + if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts from aiogram import types + from app.localization.texts import get_texts + texts = get_texts(user.language) cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( total_amount=settings.format_price(payment.amount_kopeks) @@ -873,19 +868,19 @@ class YooKassaPaymentMixin: [ types.InlineKeyboardButton( text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="return_to_saved_cart", + callback_data='return_to_saved_cart', ) ], [ types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance", + text='💰 Мой баланс', + callback_data='menu_balance', ) ], [ types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu", + text='🏠 Главное меню', + callback_data='back_to_menu', ) ], ] @@ -893,50 +888,50 @@ class YooKassaPaymentMixin: await self.bot.send_message( chat_id=user.telegram_id, - text=f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}", + text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}', reply_markup=keyboard, ) logger.info( - f"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}" + f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}' ) else: logger.info( - "У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена", + 'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена', user.id, ) except Exception as e: logger.error( - f"Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}", + f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}', exc_info=True, ) if is_simple_subscription: - logger.info(f"Обнаружен платеж простой покупки подписки для пользователя {user.id}") + logger.info(f'Обнаружен платеж простой покупки подписки для пользователя {user.id}') try: # Активируем подписку from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() - + # Получаем параметры подписки из метаданных - subscription_period = int(payment_metadata.get("subscription_period", 30)) - order_id = payment_metadata.get("order_id") - - logger.info(f"Активация подписки: период={subscription_period} дней, заказ={order_id}") - + subscription_period = int(payment_metadata.get('subscription_period', 30)) + order_id = payment_metadata.get('order_id') + + logger.info(f'Активация подписки: период={subscription_period} дней, заказ={order_id}') + # Активируем pending подписку пользователя from app.database.crud.subscription import activate_pending_subscription + subscription = await activate_pending_subscription( - db=db, - user_id=user.id, - period_days=subscription_period + db=db, user_id=user.id, period_days=subscription_period ) - + if subscription: - logger.info(f"Подписка успешно активирована для пользователя {user.id}") + logger.info(f'Подписка успешно активирована для пользователя {user.id}') # Обновляем данные подписки в RemnaWave, чтобы получить актуальные ссылки try: @@ -945,55 +940,67 @@ class YooKassaPaymentMixin: await db.refresh(subscription) except Exception as sync_error: logger.error( - "Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s", + 'Ошибка синхронизации подписки с RemnaWave для пользователя %s: %s', user.id, sync_error, exc_info=True, ) - + # Отправляем уведомление пользователю об активации подписки (только Telegram) - if getattr(self, "bot", None) and user.telegram_id: - from app.localization.texts import get_texts + if getattr(self, 'bot', None) and user.telegram_id: from aiogram import types + from app.localization.texts import get_texts + texts = get_texts(user.language) success_message = ( - f"✅ Подписка успешно активирована!\n\n" - f"📅 Период: {subscription_period} дней\n" - f"📱 Устройства: 1\n" - f"📊 Трафик: Безлимит\n" - f"💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n" + f'✅ Подписка успешно активирована!\n\n' + f'📅 Период: {subscription_period} дней\n' + f'📱 Устройства: 1\n' + f'📊 Трафик: Безлимит\n' + f'💳 Оплата: {settings.format_price(payment.amount_kopeks)} (YooKassa)\n\n' f"🔗 Для подключения перейдите в раздел 'Моя подписка'" ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")], - [types.InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text='📱 Моя подписка', callback_data='menu_subscription' + ) + ], + [ + types.InlineKeyboardButton( + text='🏠 Главное меню', callback_data='back_to_menu' + ) + ], + ] + ) await self.bot.send_message( chat_id=user.telegram_id, text=success_message, reply_markup=keyboard, - parse_mode="HTML" + parse_mode='HTML', ) elif not user.telegram_id: - logger.info(f"Пропуск Telegram-уведомления о подписке для email-пользователя {user.id}") + logger.info(f'Пропуск Telegram-уведомления о подписке для email-пользователя {user.id}') - if getattr(self, "bot", None): + if getattr(self, 'bot', None): try: from app.services.admin_notification_service import ( AdminNotificationService, ) notification_service = AdminNotificationService(self.bot) - + # Обновляем пользователя с нужными связями, чтобы избежать проблем с ленивой загрузкой from sqlalchemy import select from sqlalchemy.orm import selectinload - from app.database.models import User, Subscription as SubscriptionModel - + + from app.database.models import Subscription as SubscriptionModel, User + # Загружаем пользователя с подпиской и промо-группой full_user_result = await db.execute( select(User) @@ -1002,14 +1009,13 @@ class YooKassaPaymentMixin: .where(User.id == user.id) ) full_user = full_user_result.scalar_one_or_none() - + # Загружаем подписку отдельно, если нужно subscription_result = await db.execute( - select(SubscriptionModel) - .where(SubscriptionModel.user_id == user.id) + select(SubscriptionModel).where(SubscriptionModel.user_id == user.id) ) subscription_db = subscription_result.scalar_one_or_none() - + await notification_service.send_subscription_purchase_notification( db, full_user or user, @@ -1020,14 +1026,14 @@ class YooKassaPaymentMixin: ) except Exception as admin_error: logger.error( - "Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s", + 'Ошибка отправки уведомления админам о покупке подписки через YooKassa: %s', admin_error, exc_info=True, ) else: - logger.error(f"Ошибка активации подписки для пользователя {user.id}") + logger.error(f'Ошибка активации подписки для пользователя {user.id}') except Exception as e: - logger.error(f"Ошибка активации подписки для пользователя {user.id}: {e}", exc_info=True) + logger.error(f'Ошибка активации подписки для пользователя {user.id}: {e}', exc_info=True) if not processing_marked: payment_metadata = await self._mark_yookassa_payment_processing_completed( @@ -1041,7 +1047,7 @@ class YooKassaPaymentMixin: critical_flow_completed = True else: logger.warning( - "Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно", + 'Пользователь %s для платежа YooKassa %s не найден — начисление баланса невозможно', payment.user_id, payment.yookassa_payment_id, ) @@ -1056,21 +1062,21 @@ class YooKassaPaymentMixin: if is_simple_subscription: logger.info( - "Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽", + 'Успешно обработан платеж YooKassa %s как покупка подписки: пользователь %s, сумма %s₽', payment.yookassa_payment_id, payment.user_id, payment.amount_kopeks / 100, ) else: logger.info( - "Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽", + 'Успешно обработан платеж YooKassa %s: пользователь %s пополнил баланс на %s₽', payment.yookassa_payment_id, payment.user_id, payment.amount_kopeks / 100, ) # Создаем чек через NaloGO (если NALOGO_ENABLED=true) - if hasattr(self, "nalogo_service") and self.nalogo_service: + if hasattr(self, 'nalogo_service') and self.nalogo_service: await self._create_nalogo_receipt( db=db, payment=payment, @@ -1082,7 +1088,7 @@ class YooKassaPaymentMixin: except Exception as error: logger.error( - "Ошибка обработки успешного платежа YooKassa %s: %s", + 'Ошибка обработки успешного платежа YooKassa %s: %s', payment.yookassa_payment_id, error, ) @@ -1091,21 +1097,22 @@ class YooKassaPaymentMixin: async def _mark_yookassa_payment_processing_completed( self, db: AsyncSession, - payment: "YooKassaPayment", - payment_metadata: Dict[str, Any], + payment: YooKassaPayment, + payment_metadata: dict[str, Any], *, commit: bool = False, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Отмечает платёж как полностью обработанный, чтобы избежать повторного начисления.""" - if payment_metadata.get("processing_completed"): + if payment_metadata.get('processing_completed'): return payment_metadata updated_metadata = dict(payment_metadata) - updated_metadata["processing_completed"] = True + updated_metadata['processing_completed'] = True try: from sqlalchemy import update + from app.database.models import YooKassaPayment as YooKassaPaymentModel await db.execute( @@ -1120,7 +1127,7 @@ class YooKassaPaymentMixin: payment.metadata_json = updated_metadata except Exception as mark_error: # pragma: no cover - защитный лог logger.warning( - "Не удалось отметить платеж YooKassa %s как завершенный: %s", + 'Не удалось отметить платеж YooKassa %s как завершенный: %s', payment.yookassa_payment_id, mark_error, exc_info=True, @@ -1131,29 +1138,27 @@ class YooKassaPaymentMixin: async def _create_nalogo_receipt( self, db: AsyncSession, - payment: "YooKassaPayment", - transaction: Optional["Transaction"] = None, - telegram_user_id: Optional[int] = None, + payment: YooKassaPayment, + transaction: Transaction | None = None, + telegram_user_id: int | None = None, ) -> None: """Создание чека через NaloGO для успешного платежа.""" - if not hasattr(self, "nalogo_service") or not self.nalogo_service: - logger.debug("NaloGO сервис не инициализирован, чек не создан") + if not hasattr(self, 'nalogo_service') or not self.nalogo_service: + logger.debug('NaloGO сервис не инициализирован, чек не создан') return # Защита от дублей: если у транзакции уже есть чек — не создаём новый - if transaction and getattr(transaction, "receipt_uuid", None): + if transaction and getattr(transaction, 'receipt_uuid', None): logger.info( - f"Чек для платежа {payment.yookassa_payment_id} уже создан: {transaction.receipt_uuid}, " - "пропускаем повторное создание" + f'Чек для платежа {payment.yookassa_payment_id} уже создан: {transaction.receipt_uuid}, ' + 'пропускаем повторное создание' ) return try: amount_rubles = payment.amount_kopeks / 100 # Формируем описание из настроек (включает сумму и ID пользователя) - receipt_name = settings.get_balance_payment_description( - payment.amount_kopeks, telegram_user_id - ) + receipt_name = settings.get_balance_payment_description(payment.amount_kopeks, telegram_user_id) receipt_uuid = await self.nalogo_service.create_receipt( name=receipt_name, @@ -1165,7 +1170,7 @@ class YooKassaPaymentMixin: ) if receipt_uuid: - logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}") + logger.info(f'Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}') # Сохраняем receipt_uuid в транзакцию if transaction: @@ -1173,18 +1178,14 @@ class YooKassaPaymentMixin: transaction.receipt_uuid = receipt_uuid transaction.receipt_created_at = datetime.utcnow() await db.commit() - logger.debug( - f"Чек {receipt_uuid} привязан к транзакции {transaction.id}" - ) + logger.debug(f'Чек {receipt_uuid} привязан к транзакции {transaction.id}') except Exception as save_error: - logger.warning( - f"Не удалось сохранить receipt_uuid в транзакцию: {save_error}" - ) + logger.warning(f'Не удалось сохранить receipt_uuid в транзакцию: {save_error}') # При временной недоступности чек добавляется в очередь автоматически except Exception as error: logger.error( - "Ошибка создания чека NaloGO для платежа %s: %s", + 'Ошибка создания чека NaloGO для платежа %s: %s', payment.yookassa_payment_id, error, exc_info=True, @@ -1193,79 +1194,76 @@ class YooKassaPaymentMixin: async def process_yookassa_webhook( self, db: AsyncSession, - event: Dict[str, Any], + event: dict[str, Any], ) -> bool: """Обрабатывает входящий webhook YooKassa и синхронизирует состояние платежа.""" - event_object = event.get("object", {}) - yookassa_payment_id = event_object.get("id") + event_object = event.get('object', {}) + yookassa_payment_id = event_object.get('id') if not yookassa_payment_id: - logger.warning("Webhook без payment id: %s", event) + logger.warning('Webhook без payment id: %s', event) return False - remote_data: Optional[Dict[str, Any]] = None - if getattr(self, "yookassa_service", None): + remote_data: dict[str, Any] | None = None + if getattr(self, 'yookassa_service', None): try: remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] yookassa_payment_id ) except Exception as error: # pragma: no cover - диагностический лог logger.warning( - "Не удалось запросить актуальный статус платежа YooKassa %s: %s", + 'Не удалось запросить актуальный статус платежа YooKassa %s: %s', yookassa_payment_id, error, exc_info=True, ) if remote_data: - previous_status = event_object.get("status") + previous_status = event_object.get('status') event_object = self._merge_remote_yookassa_payload(event_object, remote_data) - if previous_status and event_object.get("status") != previous_status: + if previous_status and event_object.get('status') != previous_status: logger.info( - "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", + 'Статус платежа YooKassa %s скорректирован по данным API: %s → %s', yookassa_payment_id, previous_status, - event_object.get("status"), + event_object.get('status'), ) - event["object"] = event_object + event['object'] = event_object - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') payment = await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) if not payment: - logger.warning( - "Локальный платеж для YooKassa id %s не найден", yookassa_payment_id - ) + logger.warning('Локальный платеж для YooKassa id %s не найден', yookassa_payment_id) payment = await self._restore_missing_yookassa_payment(db, event_object) if not payment: logger.error( - "Не удалось восстановить локальную запись платежа YooKassa %s", + 'Не удалось восстановить локальную запись платежа YooKassa %s', yookassa_payment_id, ) return False - payment.status = event_object.get("status", payment.status) + payment.status = event_object.get('status', payment.status) payment.confirmation_url = self._extract_confirmation_url(event_object) - payment.payment_method_type = ( - (event_object.get("payment_method") or {}).get("type") - or payment.payment_method_type - ) - payment.refundable = event_object.get("refundable", getattr(payment, "refundable", False)) + payment.payment_method_type = (event_object.get('payment_method') or {}).get( + 'type' + ) or payment.payment_method_type + payment.refundable = event_object.get('refundable', getattr(payment, 'refundable', False)) - current_paid = bool(getattr(payment, "is_paid", getattr(payment, "paid", False))) - payment.is_paid = bool(event_object.get("paid", current_paid)) + current_paid = bool(getattr(payment, 'is_paid', getattr(payment, 'paid', False))) + payment.is_paid = bool(event_object.get('paid', current_paid)) - captured_at_raw = event_object.get("captured_at") + captured_at_raw = event_object.get('captured_at') if captured_at_raw: try: - payment.captured_at = datetime.fromisoformat( - captured_at_raw.replace("Z", "+00:00") - ).replace(tzinfo=None) + payment.captured_at = datetime.fromisoformat(captured_at_raw.replace('Z', '+00:00')).replace( + tzinfo=None + ) except Exception as error: logger.debug( - "Не удалось распарсить captured_at=%s: %s", + 'Не удалось распарсить captured_at=%s: %s', captured_at_raw, error, ) @@ -1273,11 +1271,11 @@ class YooKassaPaymentMixin: await db.commit() await db.refresh(payment) - if payment.status == "succeeded" and payment.is_paid: + if payment.status == 'succeeded' and payment.is_paid: return await self._process_successful_yookassa_payment(db, payment) logger.info( - "Webhook YooKassa обновил платеж %s до статуса %s", + 'Webhook YooKassa обновил платеж %s до статуса %s', yookassa_payment_id, payment.status, ) @@ -1286,20 +1284,20 @@ class YooKassaPaymentMixin: async def _restore_missing_yookassa_payment( self, db: AsyncSession, - event_object: Dict[str, Any], - ) -> Optional["YooKassaPayment"]: + event_object: dict[str, Any], + ) -> YooKassaPayment | None: """Создает локальную запись платежа на основе данных webhook, если она отсутствует.""" - yookassa_payment_id = event_object.get("id") + yookassa_payment_id = event_object.get('id') if not yookassa_payment_id: return None - metadata = self._normalise_yookassa_metadata(event_object.get("metadata")) - user_id_raw = metadata.get("user_id") or metadata.get("userId") + metadata = self._normalise_yookassa_metadata(event_object.get('metadata')) + user_id_raw = metadata.get('user_id') or metadata.get('userId') if user_id_raw is None: logger.error( - "Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.", + 'Webhook YooKassa %s не содержит user_id в metadata. Невозможно восстановить платеж.', yookassa_payment_id, ) return None @@ -1308,53 +1306,51 @@ class YooKassaPaymentMixin: user_id = int(user_id_raw) except (TypeError, ValueError): logger.error( - "Webhook YooKassa %s содержит некорректный user_id=%s", + 'Webhook YooKassa %s содержит некорректный user_id=%s', yookassa_payment_id, user_id_raw, ) return None - amount_info = event_object.get("amount") or {} - amount_value = amount_info.get("value") - currency = (amount_info.get("currency") or "RUB").upper() + amount_info = event_object.get('amount') or {} + amount_value = amount_info.get('value') + currency = (amount_info.get('currency') or 'RUB').upper() if amount_value is None: logger.error( - "Webhook YooKassa %s не содержит сумму платежа", + 'Webhook YooKassa %s не содержит сумму платежа', yookassa_payment_id, ) return None try: - amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal("1"))) + amount_kopeks = int((Decimal(str(amount_value)) * 100).quantize(Decimal(1))) except (InvalidOperation, ValueError) as error: logger.error( - "Некорректная сумма в webhook YooKassa %s: %s (%s)", + 'Некорректная сумма в webhook YooKassa %s: %s (%s)', yookassa_payment_id, amount_value, error, ) return None - description = event_object.get("description") or metadata.get("description") or "YooKassa платеж" - payment_method_type = (event_object.get("payment_method") or {}).get("type") + description = event_object.get('description') or metadata.get('description') or 'YooKassa платеж' + payment_method_type = (event_object.get('payment_method') or {}).get('type') yookassa_created_at = None - created_at_raw = event_object.get("created_at") + created_at_raw = event_object.get('created_at') if created_at_raw: try: - yookassa_created_at = datetime.fromisoformat( - created_at_raw.replace("Z", "+00:00") - ).replace(tzinfo=None) + yookassa_created_at = datetime.fromisoformat(created_at_raw.replace('Z', '+00:00')).replace(tzinfo=None) except Exception as error: # pragma: no cover - диагностический лог logger.debug( - "Не удалось распарсить created_at=%s для YooKassa %s: %s", + 'Не удалось распарсить created_at=%s для YooKassa %s: %s', created_at_raw, yookassa_payment_id, error, ) - payment_module = import_module("app.services.payment_service") + payment_module = import_module('app.services.payment_service') local_payment = await payment_module.create_yookassa_payment( db=db, @@ -1363,12 +1359,12 @@ class YooKassaPaymentMixin: amount_kopeks=amount_kopeks, currency=currency, description=description, - status=event_object.get("status", "pending"), + status=event_object.get('status', 'pending'), confirmation_url=self._extract_confirmation_url(event_object), metadata_json=metadata, payment_method_type=payment_method_type, yookassa_created_at=yookassa_created_at, - test_mode=bool(event_object.get("test") or event_object.get("test_mode")), + test_mode=bool(event_object.get('test') or event_object.get('test_mode')), ) if not local_payment: @@ -1377,26 +1373,26 @@ class YooKassaPaymentMixin: await payment_module.update_yookassa_payment_status( db=db, yookassa_payment_id=yookassa_payment_id, - status=event_object.get("status", local_payment.status), - is_paid=bool(event_object.get("paid")), - is_captured=event_object.get("status") == "succeeded", - captured_at=self._parse_datetime(event_object.get("captured_at")), + status=event_object.get('status', local_payment.status), + is_paid=bool(event_object.get('paid')), + is_captured=event_object.get('status') == 'succeeded', + captured_at=self._parse_datetime(event_object.get('captured_at')), payment_method_type=payment_method_type, ) return await payment_module.get_yookassa_payment_by_id(db, yookassa_payment_id) @staticmethod - def _normalise_yookassa_metadata(metadata: Any) -> Dict[str, Any]: + def _normalise_yookassa_metadata(metadata: Any) -> dict[str, Any]: if isinstance(metadata, dict): return metadata if isinstance(metadata, list): - normalised: Dict[str, Any] = {} + normalised: dict[str, Any] = {} for item in metadata: - key = item.get("key") if isinstance(item, dict) else None + key = item.get('key') if isinstance(item, dict) else None if key: - normalised[key] = item.get("value") + normalised[key] = item.get('value') return normalised if isinstance(metadata, str): @@ -1407,27 +1403,27 @@ class YooKassaPaymentMixin: if isinstance(parsed, dict): return parsed except json.JSONDecodeError: - logger.debug("Не удалось распарсить metadata webhook YooKassa: %s", metadata) + logger.debug('Не удалось распарсить metadata webhook YooKassa: %s', metadata) return {} @staticmethod - def _extract_confirmation_url(event_object: Dict[str, Any]) -> Optional[str]: - if "confirmation_url" in event_object: - return event_object.get("confirmation_url") + def _extract_confirmation_url(event_object: dict[str, Any]) -> str | None: + if 'confirmation_url' in event_object: + return event_object.get('confirmation_url') - confirmation = event_object.get("confirmation") + confirmation = event_object.get('confirmation') if isinstance(confirmation, dict): - return confirmation.get("confirmation_url") or confirmation.get("return_url") + return confirmation.get('confirmation_url') or confirmation.get('return_url') return None @staticmethod - def _parse_datetime(raw_value: Optional[str]) -> Optional[datetime]: + def _parse_datetime(raw_value: str | None) -> datetime | None: if not raw_value: return None try: - return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).replace(tzinfo=None) + return datetime.fromisoformat(raw_value.replace('Z', '+00:00')).replace(tzinfo=None) except Exception: return None diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 771a126b..29763c81 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -4,37 +4,37 @@ from __future__ import annotations import logging from importlib import import_module -from typing import Optional from aiogram import Bot from app.config import settings -from app.utils.currency_converter import currency_converter # noqa: F401 from app.external.cryptobot import CryptoBotService from app.external.heleket import HeleketService from app.external.telegram_stars import TelegramStarsService +from app.services.cloudpayments_service import CloudPaymentsService from app.services.mulenpay_service import MulenPayService +from app.services.nalogo_service import NaloGoService from app.services.pal24_service import Pal24Service -from app.services.platega_service import PlategaService from app.services.payment import ( CryptoBotPaymentMixin, HeleketPaymentMixin, MulenPayPaymentMixin, Pal24PaymentMixin, - PlategaPaymentMixin, PaymentCommonMixin, + PlategaPaymentMixin, TelegramStarsMixin, TributePaymentMixin, - YooKassaPaymentMixin, WataPaymentMixin, + YooKassaPaymentMixin, ) from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin from app.services.payment.freekassa import FreekassaPaymentMixin from app.services.payment.kassa_ai import KassaAiPaymentMixin -from app.services.yookassa_service import YooKassaService +from app.services.platega_service import PlategaService from app.services.wata_service import WataService -from app.services.cloudpayments_service import CloudPaymentsService -from app.services.nalogo_service import NaloGoService +from app.services.yookassa_service import YooKassaService +from app.utils.currency_converter import currency_converter # noqa: F401 + logger = logging.getLogger(__name__) @@ -43,132 +43,132 @@ logger = logging.getLogger(__name__) async def create_yookassa_payment(*args, **kwargs): - yk_crud = import_module("app.database.crud.yookassa") + yk_crud = import_module('app.database.crud.yookassa') return await yk_crud.create_yookassa_payment(*args, **kwargs) async def update_yookassa_payment_status(*args, **kwargs): - yk_crud = import_module("app.database.crud.yookassa") + yk_crud = import_module('app.database.crud.yookassa') return await yk_crud.update_yookassa_payment_status(*args, **kwargs) async def link_yookassa_payment_to_transaction(*args, **kwargs): - yk_crud = import_module("app.database.crud.yookassa") + yk_crud = import_module('app.database.crud.yookassa') return await yk_crud.link_yookassa_payment_to_transaction(*args, **kwargs) async def get_yookassa_payment_by_id(*args, **kwargs): - yk_crud = import_module("app.database.crud.yookassa") + yk_crud = import_module('app.database.crud.yookassa') return await yk_crud.get_yookassa_payment_by_id(*args, **kwargs) async def get_yookassa_payment_by_local_id(*args, **kwargs): - yk_crud = import_module("app.database.crud.yookassa") + yk_crud = import_module('app.database.crud.yookassa') return await yk_crud.get_yookassa_payment_by_local_id(*args, **kwargs) async def create_transaction(*args, **kwargs): - transaction_crud = import_module("app.database.crud.transaction") + transaction_crud = import_module('app.database.crud.transaction') return await transaction_crud.create_transaction(*args, **kwargs) async def get_transaction_by_external_id(*args, **kwargs): - transaction_crud = import_module("app.database.crud.transaction") + transaction_crud = import_module('app.database.crud.transaction') return await transaction_crud.get_transaction_by_external_id(*args, **kwargs) async def add_user_balance(*args, **kwargs): - user_crud = import_module("app.database.crud.user") + user_crud = import_module('app.database.crud.user') return await user_crud.add_user_balance(*args, **kwargs) async def get_user_by_id(*args, **kwargs): - user_crud = import_module("app.database.crud.user") + user_crud = import_module('app.database.crud.user') return await user_crud.get_user_by_id(*args, **kwargs) async def get_user_by_telegram_id(*args, **kwargs): - user_crud = import_module("app.database.crud.user") + user_crud = import_module('app.database.crud.user') return await user_crud.get_user_by_telegram_id(*args, **kwargs) async def create_mulenpay_payment(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.create_mulenpay_payment(*args, **kwargs) async def get_mulenpay_payment_by_uuid(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.get_mulenpay_payment_by_uuid(*args, **kwargs) async def get_mulenpay_payment_by_mulen_id(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.get_mulenpay_payment_by_mulen_id(*args, **kwargs) async def get_mulenpay_payment_by_local_id(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.get_mulenpay_payment_by_local_id(*args, **kwargs) async def update_mulenpay_payment_status(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs) async def update_mulenpay_payment_metadata(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs) async def link_mulenpay_payment_to_transaction(*args, **kwargs): - mulenpay_crud = import_module("app.database.crud.mulenpay") + mulenpay_crud = import_module('app.database.crud.mulenpay') return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs) async def create_pal24_payment(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.create_pal24_payment(*args, **kwargs) async def get_pal24_payment_by_bill_id(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.get_pal24_payment_by_bill_id(*args, **kwargs) async def get_pal24_payment_by_order_id(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.get_pal24_payment_by_order_id(*args, **kwargs) async def get_pal24_payment_by_id(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.get_pal24_payment_by_id(*args, **kwargs) async def update_pal24_payment_status(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.update_pal24_payment_status(*args, **kwargs) async def link_pal24_payment_to_transaction(*args, **kwargs): - pal_crud = import_module("app.database.crud.pal24") + pal_crud = import_module('app.database.crud.pal24') return await pal_crud.link_pal24_payment_to_transaction(*args, **kwargs) async def create_wata_payment(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.create_wata_payment(*args, **kwargs) async def get_wata_payment_by_link_id(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.get_wata_payment_by_link_id(*args, **kwargs) async def get_wata_payment_by_id(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.get_wata_payment_by_id(*args, **kwargs) @@ -178,117 +178,117 @@ async def get_wata_payment_by_local_id(*args, **kwargs): async def get_wata_payment_by_order_id(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.get_wata_payment_by_order_id(*args, **kwargs) async def update_wata_payment_status(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.update_wata_payment_status(*args, **kwargs) async def link_wata_payment_to_transaction(*args, **kwargs): - wata_crud = import_module("app.database.crud.wata") + wata_crud = import_module('app.database.crud.wata') return await wata_crud.link_wata_payment_to_transaction(*args, **kwargs) async def create_platega_payment(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.create_platega_payment(*args, **kwargs) async def get_platega_payment_by_id(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.get_platega_payment_by_id(*args, **kwargs) async def get_platega_payment_by_id_for_update(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.get_platega_payment_by_id_for_update(*args, **kwargs) async def get_platega_payment_by_transaction_id(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.get_platega_payment_by_transaction_id(*args, **kwargs) async def get_platega_payment_by_correlation_id(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.get_platega_payment_by_correlation_id(*args, **kwargs) async def update_platega_payment(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.update_platega_payment(*args, **kwargs) async def link_platega_payment_to_transaction(*args, **kwargs): - platega_crud = import_module("app.database.crud.platega") + platega_crud = import_module('app.database.crud.platega') return await platega_crud.link_platega_payment_to_transaction(*args, **kwargs) async def create_cryptobot_payment(*args, **kwargs): - crypto_crud = import_module("app.database.crud.cryptobot") + crypto_crud = import_module('app.database.crud.cryptobot') return await crypto_crud.create_cryptobot_payment(*args, **kwargs) async def get_cryptobot_payment_by_invoice_id(*args, **kwargs): - crypto_crud = import_module("app.database.crud.cryptobot") + crypto_crud = import_module('app.database.crud.cryptobot') return await crypto_crud.get_cryptobot_payment_by_invoice_id(*args, **kwargs) async def update_cryptobot_payment_status(*args, **kwargs): - crypto_crud = import_module("app.database.crud.cryptobot") + crypto_crud = import_module('app.database.crud.cryptobot') return await crypto_crud.update_cryptobot_payment_status(*args, **kwargs) async def link_cryptobot_payment_to_transaction(*args, **kwargs): - crypto_crud = import_module("app.database.crud.cryptobot") + crypto_crud = import_module('app.database.crud.cryptobot') return await crypto_crud.link_cryptobot_payment_to_transaction(*args, **kwargs) async def create_heleket_payment(*args, **kwargs): - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') return await heleket_crud.create_heleket_payment(*args, **kwargs) async def get_heleket_payment_by_uuid(*args, **kwargs): - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') return await heleket_crud.get_heleket_payment_by_uuid(*args, **kwargs) async def get_heleket_payment_by_id(*args, **kwargs): - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') return await heleket_crud.get_heleket_payment_by_id(*args, **kwargs) async def update_heleket_payment(*args, **kwargs): - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') return await heleket_crud.update_heleket_payment(*args, **kwargs) async def link_heleket_payment_to_transaction(*args, **kwargs): - heleket_crud = import_module("app.database.crud.heleket") + heleket_crud = import_module('app.database.crud.heleket') return await heleket_crud.link_heleket_payment_to_transaction(*args, **kwargs) async def create_cloudpayments_payment(*args, **kwargs): - cloudpayments_crud = import_module("app.database.crud.cloudpayments") + cloudpayments_crud = import_module('app.database.crud.cloudpayments') return await cloudpayments_crud.create_cloudpayments_payment(*args, **kwargs) async def get_cloudpayments_payment_by_invoice_id(*args, **kwargs): - cloudpayments_crud = import_module("app.database.crud.cloudpayments") + cloudpayments_crud = import_module('app.database.crud.cloudpayments') return await cloudpayments_crud.get_cloudpayments_payment_by_invoice_id(*args, **kwargs) async def get_cloudpayments_payment_by_id(*args, **kwargs): - cloudpayments_crud = import_module("app.database.crud.cloudpayments") + cloudpayments_crud = import_module('app.database.crud.cloudpayments') return await cloudpayments_crud.get_cloudpayments_payment_by_id(*args, **kwargs) async def update_cloudpayments_payment(*args, **kwargs): - cloudpayments_crud = import_module("app.database.crud.cloudpayments") + cloudpayments_crud = import_module('app.database.crud.cloudpayments') return await cloudpayments_crud.update_cloudpayments_payment(*args, **kwargs) @@ -309,38 +309,24 @@ class PaymentService( ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" - def __init__(self, bot: Optional[Bot] = None) -> None: + def __init__(self, bot: Bot | None = None) -> None: # Бот нужен для отправки уведомлений и создания звёздных инвойсов. self.bot = bot # Ниже инициализируем службы-обёртки только если соответствующий провайдер включён. - self.yookassa_service = ( - YooKassaService() if settings.is_yookassa_enabled() else None - ) + self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None self.stars_service = TelegramStarsService(bot) if bot else None - self.cryptobot_service = ( - CryptoBotService() if settings.is_cryptobot_enabled() else None - ) - self.heleket_service = ( - HeleketService() if settings.is_heleket_enabled() else None - ) - self.mulenpay_service = ( - MulenPayService() if settings.is_mulenpay_enabled() else None - ) - self.pal24_service = ( - Pal24Service() if settings.is_pal24_enabled() else None - ) - self.platega_service = ( - PlategaService() if settings.is_platega_enabled() else None - ) + self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None + self.heleket_service = HeleketService() if settings.is_heleket_enabled() else None + self.mulenpay_service = MulenPayService() if settings.is_mulenpay_enabled() else None + self.pal24_service = Pal24Service() if settings.is_pal24_enabled() else None + self.platega_service = PlategaService() if settings.is_platega_enabled() else None self.wata_service = WataService() if settings.is_wata_enabled() else None - self.cloudpayments_service = ( - CloudPaymentsService() if settings.is_cloudpayments_enabled() else None - ) + self.cloudpayments_service = CloudPaymentsService() if settings.is_cloudpayments_enabled() else None self.nalogo_service = NaloGoService() if settings.is_nalogo_enabled() else None mulenpay_name = settings.get_mulenpay_display_name() logger.debug( - "PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s, CloudPayments=%s)", + 'PaymentService инициализирован (YooKassa=%s, Stars=%s, CryptoBot=%s, Heleket=%s, %s=%s, Pal24=%s, Platega=%s, Wata=%s, CloudPayments=%s)', bool(self.yookassa_service), bool(self.stars_service), bool(self.cryptobot_service), diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index af36028e..8fcf0fc4 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -6,9 +6,10 @@ import asyncio import logging import re from collections import Counter +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional +from typing import TYPE_CHECKING, Any from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession @@ -23,8 +24,8 @@ from app.database.models import ( HeleketPayment, MulenPayPayment, Pal24Payment, - PlategaPayment, PaymentMethod, + PlategaPayment, Transaction, TransactionType, User, @@ -32,6 +33,7 @@ from app.database.models import ( YooKassaPayment, ) + logger = logging.getLogger(__name__) @@ -51,7 +53,7 @@ class PendingPayment: created_at: datetime user: User payment: Any - expires_at: Optional[datetime] = None + expires_at: datetime | None = None def is_recent(self, max_age: timedelta = PENDING_MAX_AGE) -> bool: return (datetime.utcnow() - self.created_at) <= max_age @@ -90,23 +92,23 @@ def method_display_name(method: PaymentMethod) -> str: if method == PaymentMethod.MULENPAY: return settings.get_mulenpay_display_name() if method == PaymentMethod.PAL24: - return "PayPalych" + return 'PayPalych' if method == PaymentMethod.YOOKASSA: - return "YooKassa" + return 'YooKassa' if method == PaymentMethod.WATA: - return "WATA" + return 'WATA' if method == PaymentMethod.PLATEGA: return settings.get_platega_display_name() if method == PaymentMethod.CRYPTOBOT: - return "CryptoBot" + return 'CryptoBot' if method == PaymentMethod.HELEKET: - return "Heleket" + return 'Heleket' if method == PaymentMethod.CLOUDPAYMENTS: - return "CloudPayments" + return 'CloudPayments' if method == PaymentMethod.FREEKASSA: - return "Freekassa" + return 'Freekassa' if method == PaymentMethod.TELEGRAM_STARS: - return "Telegram Stars" + return 'Telegram Stars' return method.value @@ -132,22 +134,18 @@ def _method_is_enabled(method: PaymentMethod) -> bool: return False -def get_enabled_auto_methods() -> List[PaymentMethod]: - return [ - method - for method in SUPPORTED_AUTO_CHECK_METHODS - if _method_is_enabled(method) - ] +def get_enabled_auto_methods() -> list[PaymentMethod]: + return [method for method in SUPPORTED_AUTO_CHECK_METHODS if _method_is_enabled(method)] class AutoPaymentVerificationService: """Background checker that periodically refreshes pending payments.""" def __init__(self) -> None: - self._task: Optional[asyncio.Task[None]] = None - self._payment_service: Optional["PaymentService"] = None + self._task: asyncio.Task[None] | None = None + self._payment_service: PaymentService | None = None - def set_payment_service(self, payment_service: "PaymentService") -> None: + def set_payment_service(self, payment_service: PaymentService) -> None: self._payment_service = payment_service def is_running(self) -> bool: @@ -157,30 +155,24 @@ class AutoPaymentVerificationService: await self.stop() if not settings.is_payment_verification_auto_check_enabled(): - logger.info("Автопроверка пополнений отключена настройками") + logger.info('Автопроверка пополнений отключена настройками') return if not self._payment_service: - logger.warning( - "Автопроверка пополнений не запущена: PaymentService не инициализирован" - ) + logger.warning('Автопроверка пополнений не запущена: PaymentService не инициализирован') return methods = get_enabled_auto_methods() if not methods: - logger.info( - "Автопроверка пополнений не запущена: нет активных провайдеров" - ) + logger.info('Автопроверка пополнений не запущена: нет активных провайдеров') return - display_names = ", ".join( - sorted(method_display_name(method) for method in methods) - ) + display_names = ', '.join(sorted(method_display_name(method) for method in methods)) interval_minutes = settings.get_payment_verification_auto_check_interval() self._task = asyncio.create_task(self._auto_check_loop()) logger.info( - "🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s", + '🔄 Автопроверка пополнений запущена (каждые %s мин) для: %s', interval_minutes, display_names, ) @@ -199,63 +191,48 @@ class AutoPaymentVerificationService: while True: interval_minutes = settings.get_payment_verification_auto_check_interval() try: - if ( - settings.is_payment_verification_auto_check_enabled() - and self._payment_service - ): + if settings.is_payment_verification_auto_check_enabled() and self._payment_service: methods = get_enabled_auto_methods() if methods: await self._run_checks(methods) else: - logger.debug( - "Автопроверка пополнений: активных провайдеров нет" - ) + logger.debug('Автопроверка пополнений: активных провайдеров нет') else: - logger.debug( - "Автопроверка пополнений: отключена настройками или сервис не готов" - ) + logger.debug('Автопроверка пополнений: отключена настройками или сервис не готов') except asyncio.CancelledError: raise - except Exception as error: # noqa: BLE001 - логируем непредвиденные ошибки + except Exception as error: logger.error( - "Ошибка автопроверки пополнений: %s", + 'Ошибка автопроверки пополнений: %s', error, exc_info=True, ) await asyncio.sleep(max(1, interval_minutes) * 60) except asyncio.CancelledError: - logger.info("Автопроверка пополнений остановлена") + logger.info('Автопроверка пополнений остановлена') raise - async def _run_checks(self, methods: List[PaymentMethod]) -> None: + async def _run_checks(self, methods: list[PaymentMethod]) -> None: if not self._payment_service: return async with AsyncSessionLocal() as session: try: pending = await list_recent_pending_payments(session) - candidates = [ - record - for record in pending - if record.method in methods and not record.is_paid - ] + candidates = [record for record in pending if record.method in methods and not record.is_paid] if not candidates: - logger.debug( - "Автопроверка пополнений: подходящих ожидающих платежей нет" - ) + logger.debug('Автопроверка пополнений: подходящих ожидающих платежей нет') return counts = Counter(record.method for record in candidates) - summary = ", ".join( - f"{method_display_name(method)}: {count}" - for method, count in sorted( - counts.items(), key=lambda item: method_display_name(item[0]) - ) + summary = ', '.join( + f'{method_display_name(method)}: {count}' + for method, count in sorted(counts.items(), key=lambda item: method_display_name(item[0])) ) logger.info( - "🔄 Автопроверка пополнений: найдено %s инвойсов (%s)", + '🔄 Автопроверка пополнений: найдено %s инвойсов (%s)', len(candidates), summary, ) @@ -270,7 +247,7 @@ class AutoPaymentVerificationService: if not refreshed: logger.debug( - "Автопроверка пополнений: не удалось обновить %s %s", + 'Автопроверка пополнений: не удалось обновить %s %s', method_display_name(record.method), record.identifier, ) @@ -278,24 +255,24 @@ class AutoPaymentVerificationService: if refreshed.is_paid and not record.is_paid: logger.info( - "✅ %s %s отмечен как оплаченный после автопроверки", + '✅ %s %s отмечен как оплаченный после автопроверки', method_display_name(refreshed.method), refreshed.identifier, ) elif refreshed.status != record.status: logger.info( - "ℹ️ %s %s обновлён: %s → %s", + 'ℹ️ %s %s обновлён: %s → %s', method_display_name(refreshed.method), refreshed.identifier, - record.status or "—", - refreshed.status or "—", + record.status or '—', + refreshed.status or '—', ) else: logger.debug( - "Автопроверка пополнений: %s %s без изменений (%s)", + 'Автопроверка пополнений: %s %s без изменений (%s)', method_display_name(refreshed.method), refreshed.identifier, - refreshed.status or "—", + refreshed.status or '—', ) if session.in_transaction(): @@ -308,77 +285,78 @@ class AutoPaymentVerificationService: auto_payment_verification_service = AutoPaymentVerificationService() + def _is_pal24_pending(payment: Pal24Payment) -> bool: if payment.is_paid: return False - status = (payment.status or "").upper() - return status in {"NEW", "PROCESS"} + status = (payment.status or '').upper() + return status in {'NEW', 'PROCESS'} def _is_mulenpay_pending(payment: MulenPayPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() - return status in {"created", "processing", "hold"} + status = (payment.status or '').lower() + return status in {'created', 'processing', 'hold'} def _is_wata_pending(payment: WataPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() + status = (payment.status or '').lower() return status not in { - "paid", - "closed", - "declined", - "canceled", - "cancelled", - "expired", + 'paid', + 'closed', + 'declined', + 'canceled', + 'cancelled', + 'expired', } def _is_platega_pending(payment: PlategaPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() - return status in {"pending", "inprogress", "in_progress"} + status = (payment.status or '').lower() + return status in {'pending', 'inprogress', 'in_progress'} def _is_heleket_pending(payment: HeleketPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() - return status not in {"paid", "paid_over", "cancel", "canceled", "failed", "fail", "expired"} + status = (payment.status or '').lower() + return status not in {'paid', 'paid_over', 'cancel', 'canceled', 'failed', 'fail', 'expired'} def _is_yookassa_pending(payment: YooKassaPayment) -> bool: - if getattr(payment, "is_paid", False) and payment.status == "succeeded": + if getattr(payment, 'is_paid', False) and payment.status == 'succeeded': return False - status = (payment.status or "").lower() - return status in {"pending", "waiting_for_capture"} + status = (payment.status or '').lower() + return status in {'pending', 'waiting_for_capture'} def _is_cryptobot_pending(payment: CryptoBotPayment) -> bool: - status = (payment.status or "").lower() - return status == "active" + status = (payment.status or '').lower() + return status == 'active' def _is_cloudpayments_pending(payment: CloudPaymentsPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() - return status in {"pending", "authorized"} + status = (payment.status or '').lower() + return status in {'pending', 'authorized'} def _is_freekassa_pending(payment: FreekassaPayment) -> bool: if payment.is_paid: return False - status = (payment.status or "").lower() - return status in {"pending", "created", "processing"} + status = (payment.status or '').lower() + return status in {'pending', 'created', 'processing'} def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: - payload = payment.payload or "" - match = re.search(r"_(\d+)$", payload) + payload = payment.payload or '' + match = re.search(r'_(\d+)$', payload) if match: try: return int(match.group(1)) @@ -388,26 +366,34 @@ def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: def _metadata_is_balance(payment: YooKassaPayment) -> bool: - metadata = getattr(payment, "metadata_json", {}) or {} - payment_type = str(metadata.get("type") or metadata.get("payment_type") or "").lower() - return payment_type.startswith("balance_topup") + metadata = getattr(payment, 'metadata_json', {}) or {} + payment_type = str(metadata.get('type') or metadata.get('payment_type') or '').lower() + return payment_type.startswith('balance_topup') -def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amount_kopeks: int, - status: str, is_paid: bool, expires_at: Optional[datetime] = None) -> Optional[PendingPayment]: - user = getattr(payment, "user", None) +def _build_record( + method: PaymentMethod, + payment: Any, + *, + identifier: str, + amount_kopeks: int, + status: str, + is_paid: bool, + expires_at: datetime | None = None, +) -> PendingPayment | None: + user = getattr(payment, 'user', None) if user is None: - logger.debug("Skipping %s payment %s without linked user", method.value, identifier) + logger.debug('Skipping %s payment %s without linked user', method.value, identifier) return None - created_at = getattr(payment, "created_at", None) + created_at = getattr(payment, 'created_at', None) if not isinstance(created_at, datetime): - logger.debug("Skipping %s payment %s without valid created_at", method.value, identifier) + logger.debug('Skipping %s payment %s without valid created_at', method.value, identifier) return None - local_id = getattr(payment, "id", None) + local_id = getattr(payment, 'id', None) if local_id is None: - logger.debug("Skipping %s payment without local id", method.value) + logger.debug('Skipping %s payment without local id', method.value) return None return PendingPayment( @@ -424,7 +410,7 @@ def _build_record(method: PaymentMethod, payment: Any, *, identifier: str, amoun ) -async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(Pal24Payment) .options(selectinload(Pal24Payment.user)) @@ -432,7 +418,7 @@ async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[Pend .order_by(desc(Pal24Payment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_pal24_pending(payment): continue @@ -441,16 +427,16 @@ async def _fetch_pal24_payments(db: AsyncSession, cutoff: datetime) -> List[Pend payment, identifier=payment.bill_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if record: records.append(record) return records -async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(MulenPayPayment) .options(selectinload(MulenPayPayment.user)) @@ -458,7 +444,7 @@ async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[P .order_by(desc(MulenPayPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_mulenpay_pending(payment): continue @@ -467,7 +453,7 @@ async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[P payment, identifier=payment.uuid, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) if record: @@ -475,7 +461,7 @@ async def _fetch_mulenpay_payments(db: AsyncSession, cutoff: datetime) -> List[P return records -async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(WataPayment) .options(selectinload(WataPayment.user)) @@ -483,7 +469,7 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi .order_by(desc(WataPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_wata_pending(payment): continue @@ -492,16 +478,16 @@ async def _fetch_wata_payments(db: AsyncSession, cutoff: datetime) -> List[Pendi payment, identifier=payment.payment_link_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if record: records.append(record) return records -async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(PlategaPayment) .options(selectinload(PlategaPayment.user)) @@ -509,7 +495,7 @@ async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[Pe .order_by(desc(PlategaPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_platega_pending(payment): continue @@ -519,16 +505,16 @@ async def _fetch_platega_payments(db: AsyncSession, cutoff: datetime) -> List[Pe payment, identifier=identifier, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if record: records.append(record) return records -async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(HeleketPayment) .options(selectinload(HeleketPayment.user)) @@ -536,7 +522,7 @@ async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[Pe .order_by(desc(HeleketPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_heleket_pending(payment): continue @@ -545,16 +531,16 @@ async def _fetch_heleket_payments(db: AsyncSession, cutoff: datetime) -> List[Pe payment, identifier=payment.uuid, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if record: records.append(record) return records -async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(YooKassaPayment) .options(selectinload(YooKassaPayment.user)) @@ -562,7 +548,7 @@ async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[P .order_by(desc(YooKassaPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if payment.transaction_id: continue @@ -575,15 +561,15 @@ async def _fetch_yookassa_payments(db: AsyncSession, cutoff: datetime) -> List[P payment, identifier=payment.yookassa_payment_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", - is_paid=bool(getattr(payment, "is_paid", False)), + status=payment.status or '', + is_paid=bool(getattr(payment, 'is_paid', False)), ) if record: records.append(record) return records -async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) @@ -591,10 +577,10 @@ async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[ .order_by(desc(CryptoBotPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): - status = (payment.status or "").lower() - if not _is_cryptobot_pending(payment) and status != "paid": + status = (payment.status or '').lower() + if not _is_cryptobot_pending(payment) and status != 'paid': continue amount_kopeks = _parse_cryptobot_amount_kopeks(payment) record = _build_record( @@ -602,7 +588,7 @@ async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[ payment, identifier=payment.invoice_id, amount_kopeks=amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) if record: @@ -610,7 +596,7 @@ async def _fetch_cryptobot_payments(db: AsyncSession, cutoff: datetime) -> List[ return records -async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(CloudPaymentsPayment) .options(selectinload(CloudPaymentsPayment.user)) @@ -618,7 +604,7 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L .order_by(desc(CloudPaymentsPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_cloudpayments_pending(payment): continue @@ -627,7 +613,7 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L payment, identifier=payment.invoice_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) if record: @@ -635,7 +621,7 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L return records -async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(FreekassaPayment) .options(selectinload(FreekassaPayment.user)) @@ -643,7 +629,7 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[ .order_by(desc(FreekassaPayment.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for payment in result.scalars().all(): if not _is_freekassa_pending(payment): continue @@ -652,7 +638,7 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[ payment, identifier=payment.order_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) if record: @@ -660,7 +646,7 @@ async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[ return records -async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: +async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> list[PendingPayment]: stmt = ( select(Transaction) .options(selectinload(Transaction.user)) @@ -672,14 +658,14 @@ async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[ .order_by(desc(Transaction.created_at)) ) result = await db.execute(stmt) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for transaction in result.scalars().all(): record = _build_record( PaymentMethod.TELEGRAM_STARS, transaction, identifier=transaction.external_id or str(transaction.id), amount_kopeks=transaction.amount_kopeks, - status="paid" if transaction.is_completed else "pending", + status='paid' if transaction.is_completed else 'pending', is_paid=bool(transaction.is_completed), ) if record: @@ -691,12 +677,12 @@ async def list_recent_pending_payments( db: AsyncSession, *, max_age: timedelta = PENDING_MAX_AGE, -) -> List[PendingPayment]: +) -> list[PendingPayment]: """Return pending payments (top-ups) from supported providers within the age window.""" cutoff = datetime.utcnow() - max_age - tasks: Iterable[List[PendingPayment]] = ( + tasks: Iterable[list[PendingPayment]] = ( await _fetch_yookassa_payments(db, cutoff), await _fetch_pal24_payments(db, cutoff), await _fetch_mulenpay_payments(db, cutoff), @@ -709,7 +695,7 @@ async def list_recent_pending_payments( await _fetch_stars_transactions(db, cutoff), ) - records: List[PendingPayment] = [] + records: list[PendingPayment] = [] for batch in tasks: records.extend(batch) @@ -721,7 +707,7 @@ async def get_payment_record( db: AsyncSession, method: PaymentMethod, local_payment_id: int, -) -> Optional[PendingPayment]: +) -> PendingPayment | None: """Load single payment record and normalize it to :class:`PendingPayment`.""" cutoff = datetime.utcnow() - PENDING_MAX_AGE @@ -730,28 +716,28 @@ async def get_payment_record( payment = await db.get(Pal24Payment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.bill_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if method == PaymentMethod.MULENPAY: payment = await db.get(MulenPayPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.uuid, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) @@ -759,76 +745,76 @@ async def get_payment_record( payment = await db.get(WataPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.payment_link_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if method == PaymentMethod.PLATEGA: payment = await db.get(PlategaPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) identifier = payment.platega_transaction_id or payment.correlation_id or str(payment.id) return _build_record( method, payment, identifier=identifier, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if method == PaymentMethod.HELEKET: payment = await db.get(HeleketPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.uuid, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), - expires_at=getattr(payment, "expires_at", None), + expires_at=getattr(payment, 'expires_at', None), ) if method == PaymentMethod.YOOKASSA: payment = await db.get(YooKassaPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) if payment.created_at < cutoff: - logger.debug("YooKassa payment %s is older than cutoff", payment.id) + logger.debug('YooKassa payment %s is older than cutoff', payment.id) return _build_record( method, payment, identifier=payment.yookassa_payment_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", - is_paid=bool(getattr(payment, "is_paid", False)), + status=payment.status or '', + is_paid=bool(getattr(payment, 'is_paid', False)), ) if method == PaymentMethod.CRYPTOBOT: payment = await db.get(CryptoBotPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) amount_kopeks = _parse_cryptobot_amount_kopeks(payment) return _build_record( method, payment, identifier=payment.invoice_id, amount_kopeks=amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) @@ -836,13 +822,13 @@ async def get_payment_record( payment = await db.get(CloudPaymentsPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.invoice_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) @@ -850,13 +836,13 @@ async def get_payment_record( payment = await db.get(FreekassaPayment, local_payment_id) if not payment: return None - await db.refresh(payment, attribute_names=["user"]) + await db.refresh(payment, attribute_names=['user']) return _build_record( method, payment, identifier=payment.order_id, amount_kopeks=payment.amount_kopeks, - status=payment.status or "", + status=payment.status or '', is_paid=bool(payment.is_paid), ) @@ -864,7 +850,7 @@ async def get_payment_record( transaction = await db.get(Transaction, local_payment_id) if not transaction: return None - await db.refresh(transaction, attribute_names=["user"]) + await db.refresh(transaction, attribute_names=['user']) if transaction.payment_method != PaymentMethod.TELEGRAM_STARS.value: return None return _build_record( @@ -872,11 +858,11 @@ async def get_payment_record( transaction, identifier=transaction.external_id or str(transaction.id), amount_kopeks=transaction.amount_kopeks, - status="paid" if transaction.is_completed else "pending", + status='paid' if transaction.is_completed else 'pending', is_paid=bool(transaction.is_completed), ) - logger.debug("Unsupported payment method requested: %s", method) + logger.debug('Unsupported payment method requested: %s', method) return None @@ -884,41 +870,39 @@ async def run_manual_check( db: AsyncSession, method: PaymentMethod, local_payment_id: int, - payment_service: "PaymentService", -) -> Optional[PendingPayment]: + payment_service: PaymentService, +) -> PendingPayment | None: """Trigger provider specific status refresh and return the updated record.""" try: if method == PaymentMethod.PAL24: result = await payment_service.get_pal24_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.MULENPAY: result = await payment_service.get_mulenpay_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.WATA: result = await payment_service.get_wata_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.PLATEGA: result = await payment_service.get_platega_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.HELEKET: - payment = await payment_service.sync_heleket_payment_status( - db, local_payment_id=local_payment_id - ) + payment = await payment_service.sync_heleket_payment_status(db, local_payment_id=local_payment_id) elif method == PaymentMethod.YOOKASSA: result = await payment_service.get_yookassa_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.CRYPTOBOT: result = await payment_service.get_cryptobot_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.CLOUDPAYMENTS: result = await payment_service.get_cloudpayments_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None elif method == PaymentMethod.FREEKASSA: result = await payment_service.get_freekassa_payment_status(db, local_payment_id) - payment = result.get("payment") if result else None + payment = result.get('payment') if result else None else: - logger.warning("Manual check requested for unsupported method %s", method) + logger.warning('Manual check requested for unsupported method %s', method) return None if not payment: @@ -928,7 +912,7 @@ async def run_manual_check( except Exception as error: # pragma: no cover - defensive logging logger.error( - "Manual status check failed for %s payment %s: %s", + 'Manual status check failed for %s payment %s: %s', method.value, local_payment_id, error, @@ -939,4 +923,3 @@ async def run_manual_check( if TYPE_CHECKING: # pragma: no cover from app.services.payment_service import PaymentService - diff --git a/app/services/pinned_message_service.py b/app/services/pinned_message_service.py index b73eff69..0acd93c0 100644 --- a/app/services/pinned_message_service.py +++ b/app/services/pinned_message_service.py @@ -1,7 +1,6 @@ import asyncio import logging from datetime import datetime -from typing import Optional from aiogram import Bot from aiogram.exceptions import ( @@ -17,10 +16,11 @@ from app.database.database import AsyncSessionLocal from app.database.models import PinnedMessage, User, UserStatus from app.utils.validators import sanitize_html, validate_html_tags + logger = logging.getLogger(__name__) -async def get_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage]: +async def get_active_pinned_message(db: AsyncSession) -> PinnedMessage | None: result = await db.execute( select(PinnedMessage) .where(PinnedMessage.is_active.is_(True)) @@ -33,19 +33,19 @@ async def get_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage] async def set_active_pinned_message( db: AsyncSession, content: str, - created_by: Optional[int] = None, - media_type: Optional[str] = None, - media_file_id: Optional[str] = None, - send_before_menu: Optional[bool] = None, - send_on_every_start: Optional[bool] = None, + created_by: int | None = None, + media_type: str | None = None, + media_file_id: str | None = None, + send_before_menu: bool | None = None, + send_on_every_start: bool | None = None, ) -> PinnedMessage: - sanitized_content = sanitize_html(content or "") + sanitized_content = sanitize_html(content or '') is_valid, error_message = validate_html_tags(sanitized_content) if not is_valid: raise ValueError(error_message) - if media_type not in {None, "photo", "video"}: - raise ValueError("Поддерживаются только фото или видео в закрепленном сообщении") + if media_type not in {None, 'photo', 'video'}: + raise ValueError('Поддерживаются только фото или видео в закрепленном сообщении') if created_by is not None: creator_id = await db.scalar(select(User.id).where(User.id == created_by)) @@ -54,11 +54,7 @@ async def set_active_pinned_message( previous_active = await get_active_pinned_message(db) - await db.execute( - update(PinnedMessage) - .where(PinnedMessage.is_active.is_(True)) - .values(is_active=False) - ) + await db.execute(update(PinnedMessage).where(PinnedMessage.is_active.is_(True)).values(is_active=False)) pinned_message = PinnedMessage( content=sanitized_content, @@ -67,14 +63,12 @@ async def set_active_pinned_message( is_active=True, created_by=creator_id, send_before_menu=( - send_before_menu - if send_before_menu is not None - else getattr(previous_active, "send_before_menu", True) + send_before_menu if send_before_menu is not None else getattr(previous_active, 'send_before_menu', True) ), send_on_every_start=( send_on_every_start if send_on_every_start is not None - else getattr(previous_active, "send_on_every_start", True) + else getattr(previous_active, 'send_on_every_start', True) ), ) @@ -82,11 +76,11 @@ async def set_active_pinned_message( await db.commit() await db.refresh(pinned_message) - logger.info("Создано новое закрепленное сообщение #%s", pinned_message.id) + logger.info('Создано новое закрепленное сообщение #%s', pinned_message.id) return pinned_message -async def deactivate_active_pinned_message(db: AsyncSession) -> Optional[PinnedMessage]: +async def deactivate_active_pinned_message(db: AsyncSession) -> PinnedMessage | None: pinned_message = await get_active_pinned_message(db) if not pinned_message: return None @@ -95,7 +89,7 @@ async def deactivate_active_pinned_message(db: AsyncSession) -> Optional[PinnedM pinned_message.updated_at = datetime.utcnow() await db.commit() await db.refresh(pinned_message) - logger.info("Деактивировано закрепленное сообщение #%s", pinned_message.id) + logger.info('Деактивировано закрепленное сообщение #%s', pinned_message.id) return pinned_message @@ -103,14 +97,14 @@ async def deliver_pinned_message_to_user( bot: Bot, db: AsyncSession, user: User, - pinned_message: Optional[PinnedMessage] = None, + pinned_message: PinnedMessage | None = None, ) -> bool: pinned_message = pinned_message or await get_active_pinned_message(db) if not pinned_message: return False if not pinned_message.send_on_every_start: - last_pinned_id = getattr(user, "last_pinned_message_id", None) + last_pinned_id = getattr(user, 'last_pinned_message_id', None) if last_pinned_id == pinned_message.id: return False @@ -120,7 +114,7 @@ async def deliver_pinned_message_to_user( success = await _send_and_pin_message(bot, user.telegram_id, pinned_message) if success: - await _mark_pinned_delivery(user_id=getattr(user, "id", None), pinned_message_id=pinned_message.id) + await _mark_pinned_delivery(user_id=getattr(user, 'id', None), pinned_message_id=pinned_message.id) return success @@ -175,14 +169,14 @@ async def broadcast_pinned_message( except TelegramRetryAfter as retry_error: delay = min(retry_error.retry_after + 1, 30) logger.warning( - "RetryAfter for user %s, waiting %s seconds", + 'RetryAfter for user %s, waiting %s seconds', user.telegram_id, delay, ) await asyncio.sleep(delay) - except Exception as send_error: # noqa: BLE001 + except Exception as send_error: logger.error( - "Ошибка отправки закрепленного сообщения пользователю %s: %s", + 'Ошибка отправки закрепленного сообщения пользователю %s: %s', user.telegram_id, send_error, ) @@ -246,15 +240,15 @@ async def unpin_active_pinned_message( except TelegramRetryAfter as retry_error: delay = min(retry_error.retry_after + 1, 30) logger.warning( - "RetryAfter while unpinning for user %s, waiting %s seconds", + 'RetryAfter while unpinning for user %s, waiting %s seconds', user.telegram_id, delay, ) await asyncio.sleep(delay) await unpin_for_user(user) - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error( - "Ошибка открепления сообщения у пользователя %s: %s", + 'Ошибка открепления сообщения у пользователя %s: %s', user.telegram_id, error, ) @@ -270,7 +264,7 @@ async def unpin_active_pinned_message( async def _mark_pinned_delivery( - user_id: Optional[int], + user_id: int | None, pinned_message_id: int, ) -> None: if not user_id: @@ -297,27 +291,27 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe return False try: - if pinned_message.media_type == "photo" and pinned_message.media_file_id: + if pinned_message.media_type == 'photo' and pinned_message.media_file_id: sent_message = await bot.send_photo( chat_id=chat_id, photo=pinned_message.media_file_id, caption=pinned_message.content or None, - parse_mode="HTML" if pinned_message.content else None, + parse_mode='HTML' if pinned_message.content else None, disable_notification=True, ) - elif pinned_message.media_type == "video" and pinned_message.media_file_id: + elif pinned_message.media_type == 'video' and pinned_message.media_file_id: sent_message = await bot.send_video( chat_id=chat_id, video=pinned_message.media_file_id, caption=pinned_message.content or None, - parse_mode="HTML" if pinned_message.content else None, + parse_mode='HTML' if pinned_message.content else None, disable_notification=True, ) else: sent_message = await bot.send_message( chat_id=chat_id, text=pinned_message.content, - parse_mode="HTML", + parse_mode='HTML', disable_web_page_preview=True, disable_notification=True, ) @@ -331,13 +325,13 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe return False except TelegramBadRequest as error: logger.warning( - "Некорректный запрос при отправке закрепленного сообщения в чат %s: %s", + 'Некорректный запрос при отправке закрепленного сообщения в чат %s: %s', chat_id, error, ) - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error( - "Не удалось отправить закрепленное сообщение пользователю %s: %s", + 'Не удалось отправить закрепленное сообщение пользователю %s: %s', chat_id, error, ) @@ -353,9 +347,9 @@ async def _unpin_message_for_user(bot: Bot, chat_id: int) -> bool: return False except TelegramBadRequest: return False - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error( - "Не удалось открепить сообщение у пользователя %s: %s", + 'Не удалось открепить сообщение у пользователя %s: %s', chat_id, error, ) diff --git a/app/services/platega_service.py b/app/services/platega_service.py index 484ced42..ac90ea5c 100644 --- a/app/services/platega_service.py +++ b/app/services/platega_service.py @@ -6,12 +6,13 @@ import asyncio import json import logging from datetime import datetime, timedelta -from typing import Any, Dict, Optional +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ class PlategaService: """Обертка над Platega API с базовой повторной отправкой запросов.""" def __init__(self) -> None: - self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/") + self.base_url = (settings.PLATEGA_BASE_URL or 'https://app.platega.io').rstrip('/') self.merchant_id = settings.PLATEGA_MERCHANT_ID self.secret = settings.PLATEGA_SECRET self._timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=25) @@ -38,93 +39,90 @@ class PlategaService: payment_method: int, amount: float, currency: str, - description: Optional[str] = None, - return_url: Optional[str] = None, - failed_url: Optional[str] = None, - payload: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - body: Dict[str, Any] = { - "paymentMethod": payment_method, - "paymentDetails": { - "amount": round(amount, 2), - "currency": currency, + description: str | None = None, + return_url: str | None = None, + failed_url: str | None = None, + payload: str | None = None, + ) -> dict[str, Any] | None: + body: dict[str, Any] = { + 'paymentMethod': payment_method, + 'paymentDetails': { + 'amount': round(amount, 2), + 'currency': currency, }, } if description: - sanitized_description = self._sanitize_description( - description, self._description_max_length - ) - body["description"] = sanitized_description + sanitized_description = self._sanitize_description(description, self._description_max_length) + body['description'] = sanitized_description if return_url: - body["return"] = return_url + body['return'] = return_url if failed_url: - body["failedUrl"] = failed_url + body['failedUrl'] = failed_url if payload: - body["payload"] = payload + body['payload'] = payload - return await self._request("POST", "/transaction/process", json_data=body) + return await self._request('POST', '/transaction/process', json_data=body) - async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]: - endpoint = f"/transaction/{transaction_id}" - return await self._request("GET", endpoint) + async def get_transaction(self, transaction_id: str) -> dict[str, Any] | None: + endpoint = f'/transaction/{transaction_id}' + return await self._request('GET', endpoint) async def _request( self, method: str, endpoint: str, *, - json_data: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: + json_data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: if not self.is_configured: - logger.error("Platega service is not configured") + logger.error('Platega service is not configured') return None - url = f"{self.base_url}{endpoint}" + url = f'{self.base_url}{endpoint}' headers = { - "X-MerchantId": self.merchant_id or "", - "X-Secret": self.secret or "", - "Content-Type": "application/json", + 'X-MerchantId': self.merchant_id or '', + 'X-Secret': self.secret or '', + 'Content-Type': 'application/json', } - last_error: Optional[BaseException] = None + last_error: BaseException | None = None for attempt in range(1, self._max_retries + 1): try: - async with aiohttp.ClientSession(timeout=self._timeout) as session: - async with session.request( + async with ( + aiohttp.ClientSession(timeout=self._timeout) as session, + session.request( method, url, json=json_data, params=params, headers=headers, - ) as response: - data, raw_text = await self._deserialize_response(response) + ) as response, + ): + data, raw_text = await self._deserialize_response(response) - if response.status >= 400: - logger.error( - "Platega API error %s %s: %s", - response.status, - endpoint, - raw_text, - ) - if ( - response.status in self._retryable_statuses - and attempt < self._max_retries - ): - await asyncio.sleep(self._retry_delay * attempt) - continue - return None + if response.status >= 400: + logger.error( + 'Platega API error %s %s: %s', + response.status, + endpoint, + raw_text, + ) + if response.status in self._retryable_statuses and attempt < self._max_retries: + await asyncio.sleep(self._retry_delay * attempt) + continue + return None - return data + return data except asyncio.CancelledError: - logger.debug("Platega request cancelled: %s %s", method, endpoint) + logger.debug('Platega request cancelled: %s %s', method, endpoint) raise - except asyncio.TimeoutError as error: + except TimeoutError as error: last_error = error logger.warning( - "Platega request timeout (%s %s) attempt %s/%s", + 'Platega request timeout (%s %s) attempt %s/%s', method, endpoint, attempt, @@ -133,7 +131,7 @@ class PlategaService: except aiohttp.ClientError as error: last_error = error logger.warning( - "Platega client error (%s %s) attempt %s/%s: %s", + 'Platega client error (%s %s) attempt %s/%s: %s', method, endpoint, attempt, @@ -141,7 +139,7 @@ class PlategaService: error, ) except Exception as error: # pragma: no cover - safety - logger.exception("Unexpected Platega error: %s", error) + logger.exception('Unexpected Platega error: %s', error) return None if attempt < self._max_retries: @@ -149,7 +147,7 @@ class PlategaService: if last_error is not None: logger.error( - "Platega request failed after %s attempts (%s %s): %s", + 'Platega request failed after %s attempts (%s %s): %s', self._max_retries, method, endpoint, @@ -161,18 +159,18 @@ class PlategaService: @staticmethod async def _deserialize_response( response: aiohttp.ClientResponse, - ) -> tuple[Optional[Dict[str, Any]], str]: + ) -> tuple[dict[str, Any] | None, str]: raw_text = await response.text() if not raw_text: - return None, "" + return None, '' - content_type = response.headers.get("Content-Type", "") - if "json" in content_type.lower() or not content_type: + content_type = response.headers.get('Content-Type', '') + if 'json' in content_type.lower() or not content_type: try: return json.loads(raw_text), raw_text except json.JSONDecodeError as error: logger.error( - "Failed to decode Platega JSON response %s: %s", + 'Failed to decode Platega JSON response %s: %s', response.url, error, ) @@ -184,16 +182,16 @@ class PlategaService: def _sanitize_description(description: str, max_bytes: int) -> str: """Обрезает описание с учётом байтового лимита Platega.""" - cleaned = (description or "").strip() + cleaned = (description or '').strip() if not max_bytes: return cleaned - encoded = cleaned.encode("utf-8") + encoded = cleaned.encode('utf-8') if len(encoded) <= max_bytes: return cleaned logger.debug( - "Platega description trimmed from %s to %s bytes", + 'Platega description trimmed from %s to %s bytes', len(encoded), max_bytes, ) @@ -201,20 +199,19 @@ class PlategaService: trimmed_bytes = encoded[:max_bytes] while True: try: - return trimmed_bytes.decode("utf-8") + return trimmed_bytes.decode('utf-8') except UnicodeDecodeError: trimmed_bytes = trimmed_bytes[:-1] - @staticmethod - def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]: + def parse_expires_at(expires_in: str | None) -> datetime | None: if not expires_in: return None try: - hours, minutes, seconds = [int(part) for part in expires_in.split(":", 2)] + hours, minutes, seconds = [int(part) for part in expires_in.split(':', 2)] delta = timedelta(hours=hours, minutes=minutes, seconds=seconds) return datetime.utcnow() + delta except Exception: - logger.warning("Failed to parse Platega expiresIn value: %s", expires_in) + logger.warning('Failed to parse Platega expiresIn value: %s', expires_in) return None diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 3f7f3c2a..c57893d5 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,7 +1,7 @@ import asyncio import logging +from collections.abc import Iterable from types import SimpleNamespace -from typing import Iterable from aiogram import Bot from aiogram.exceptions import TelegramBadRequest @@ -21,31 +21,32 @@ from app.database.models import ( ) from app.localization.texts import get_texts + logger = logging.getLogger(__name__) def _build_poll_invitation_text(poll: Poll, language: str) -> str: texts = get_texts(language) - lines: list[str] = [f"🗳️ {poll.title}"] + lines: list[str] = [f'🗳️ {poll.title}'] if poll.description: lines.append(poll.description) if poll.reward_enabled and poll.reward_amount_kopeks > 0: reward_line = texts.t( - "POLL_INVITATION_REWARD", - "🎁 За участие вы получите {amount}.", + 'POLL_INVITATION_REWARD', + '🎁 За участие вы получите {amount}.', ).format(amount=settings.format_price(poll.reward_amount_kopeks)) lines.append(reward_line) lines.append( texts.t( - "POLL_INVITATION_START", - "Нажмите кнопку ниже, чтобы пройти опрос.", + 'POLL_INVITATION_START', + 'Нажмите кнопку ниже, чтобы пройти опрос.', ) ) - return "\n\n".join(lines) + return '\n\n'.join(lines) def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarkup: @@ -54,8 +55,8 @@ def build_start_keyboard(response_id: int, language: str) -> InlineKeyboardMarku inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("POLL_START_BUTTON", "📝 Пройти опрос"), - callback_data=f"poll_start:{response_id}", + text=texts.t('POLL_START_BUTTON', '📝 Пройти опрос'), + callback_data=f'poll_start:{response_id}', ) ] ] @@ -69,7 +70,7 @@ async def send_poll_to_users( users: Iterable[User], ) -> dict: from app.database.database import AsyncSessionLocal - + sent = 0 failed = 0 skipped = 0 @@ -88,12 +89,7 @@ async def send_poll_to_users( # Получаем список пользователей, которые уже прошли опрос, за один запрос user_ids = [user_snapshot.id for user_snapshot in user_snapshots] existing_responses_result = await db.execute( - select(PollResponse.user_id).where( - and_( - PollResponse.poll_id == poll_id, - PollResponse.user_id.in_(user_ids) - ) - ) + select(PollResponse.user_id).where(and_(PollResponse.poll_id == poll_id, PollResponse.user_id.in_(user_ids))) ) existing_user_ids = set(existing_responses_result.scalars().all()) @@ -106,12 +102,12 @@ async def send_poll_to_users( async with semaphore: # Skip email-only users (no telegram_id) if not user_snapshot.telegram_id: - return "skipped" + return 'skipped' # Пропускаем пользователей, которые уже прошли опрос if user_snapshot.id in existing_user_ids: - return "skipped" - + return 'skipped' + # Создаем новую сессию для изоляции транзакции async with AsyncSessionLocal() as new_db: try: @@ -126,7 +122,7 @@ async def send_poll_to_users( ) existing_id = existing_response.scalar_one_or_none() if existing_id: - return "skipped" + return 'skipped' response = PollResponse( poll_id=poll_id, @@ -143,26 +139,26 @@ async def send_poll_to_users( chat_id=user_snapshot.telegram_id, text=text, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', disable_web_page_preview=True, ) await new_db.commit() - return "sent" + return 'sent' except TelegramBadRequest as error: error_text = str(error).lower() - if "chat not found" in error_text or "bot was blocked by the user" in error_text: + if 'chat not found' in error_text or 'bot was blocked by the user' in error_text: await new_db.rollback() - return "skipped" - else: # pragma: no cover - unexpected telegram error - await new_db.rollback() - return "failed" + return 'skipped' + # pragma: no cover - unexpected telegram error + await new_db.rollback() + return 'failed' except Exception as error: # pragma: no cover - defensive logging await new_db.rollback() # Проверяем, является ли ошибка связанной с лимитом подключений - if "too many clients" in str(error).lower(): + if 'too many clients' in str(error).lower(): logger.warning( - "⚠️ Ограничение на количество подключений к БД: %s пользователю %s", + '⚠️ Ограничение на количество подключений к БД: %s пользователю %s', poll_id, user_snapshot.telegram_id, ) @@ -170,12 +166,12 @@ async def send_poll_to_users( await asyncio.sleep(0.1) else: logger.error( - "❌ Ошибка отправки опроса %s пользователю %s: %s", + '❌ Ошибка отправки опроса %s пользователю %s: %s', poll_id, user_snapshot.telegram_id, error, ) - return "failed" + return 'failed' # Отправляем все приглашения одновременно без задержек для максимальной скорости tasks = [send_poll_invitation(user_snapshot) for user_snapshot in user_snapshots] @@ -183,20 +179,20 @@ async def send_poll_to_users( for result in results: if isinstance(result, str): # Успешно выполненная задача - if result == "sent": + if result == 'sent': sent += 1 - elif result == "failed": + elif result == 'failed': failed += 1 - elif result == "skipped": + elif result == 'skipped': skipped += 1 elif isinstance(result, Exception): # Ошибка выполнения задачи failed += 1 return { - "sent": sent, - "failed": failed, - "skipped": skipped, - "total": sent + failed + skipped, + 'sent': sent, + 'failed': failed, + 'skipped': skipped, + 'total': sent + failed + skipped, } @@ -214,7 +210,7 @@ async def reward_user_for_poll( return response.reward_amount_kopeks user = response.user - description = f"Награда за участие в опросе \"{poll.title}\"" + description = f'Награда за участие в опросе "{poll.title}"' response.reward_given = True response.reward_amount_kopeks = poll.reward_amount_kopeks @@ -232,7 +228,7 @@ async def reward_user_for_poll( await db.refresh( response, - attribute_names=["reward_given", "reward_amount_kopeks"], + attribute_names=['reward_given', 'reward_amount_kopeks'], ) return poll.reward_amount_kopeks diff --git a/app/services/privacy_policy_service.py b/app/services/privacy_policy_service.py index bba935fb..8b99171e 100644 --- a/app/services/privacy_policy_service.py +++ b/app/services/privacy_policy_service.py @@ -1,5 +1,4 @@ import logging -from typing import Optional, List from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +10,7 @@ from app.database.crud.privacy_policy import ( ) from app.database.models import PrivacyPolicy + logger = logging.getLogger(__name__) @@ -21,8 +21,8 @@ class PrivacyPolicyService: @staticmethod def _normalize_language(language: str) -> str: - base_language = language or settings.DEFAULT_LANGUAGE or "ru" - return base_language.split("-")[0].lower() + base_language = language or settings.DEFAULT_LANGUAGE or 'ru' + return base_language.split('-')[0].lower() @staticmethod def normalize_language(language: str) -> str: @@ -35,7 +35,7 @@ class PrivacyPolicyService: language: str, *, fallback: bool = False, - ) -> Optional[PrivacyPolicy]: + ) -> PrivacyPolicy | None: lang = cls._normalize_language(language) policy = await get_privacy_policy(db, lang) @@ -53,7 +53,7 @@ class PrivacyPolicyService: cls, db: AsyncSession, language: str, - ) -> Optional[PrivacyPolicy]: + ) -> PrivacyPolicy | None: lang = cls._normalize_language(language) policy = await get_privacy_policy(db, lang) @@ -88,7 +88,7 @@ class PrivacyPolicyService: content, enable_if_new=enable_if_new, ) - logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang) + logger.info('✅ Политика конфиденциальности обновлена для языка %s', lang) return policy @classmethod @@ -122,11 +122,11 @@ class PrivacyPolicyService: content: str, *, max_length: int = None, - ) -> List[str]: + ) -> list[str]: if not content: return [] - normalized = content.replace("\r\n", "\n").strip() + normalized = content.replace('\r\n', '\n').strip() if not normalized: return [] @@ -135,23 +135,19 @@ class PrivacyPolicyService: if len(normalized) <= max_len: return [normalized] - paragraphs = [ - paragraph.strip() - for paragraph in normalized.split("\n\n") - if paragraph.strip() - ] + paragraphs = [paragraph.strip() for paragraph in normalized.split('\n\n') if paragraph.strip()] - pages: List[str] = [] - current = "" + pages: list[str] = [] + current = '' def flush_current() -> None: nonlocal current if current: pages.append(current.strip()) - current = "" + current = '' for paragraph in paragraphs: - candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph + candidate = f'{current}\n\n{paragraph}'.strip() if current else paragraph if len(candidate) <= max_len: current = candidate continue @@ -164,11 +160,11 @@ class PrivacyPolicyService: start_index = 0 while start_index < len(paragraph): - chunk = paragraph[start_index:start_index + max_len] + chunk = paragraph[start_index : start_index + max_len] pages.append(chunk.strip()) start_index += max_len - current = "" + current = '' flush_current() diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 03f3ac82..43631d9e 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -1,6 +1,5 @@ import logging from datetime import datetime -from typing import Optional from aiogram import Bot from aiogram.client.default import DefaultBotProperties @@ -12,31 +11,32 @@ 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], + old_group: PromoGroup | None, new_group: PromoGroup, total_spent_kopeks: int, ): - if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False): + if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False): return - bot_token = getattr(settings, "BOT_TOKEN", None) + bot_token = getattr(settings, 'BOT_TOKEN', None) if not bot_token: - logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе") + logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе') return - bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML")) + 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}₽" + 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, @@ -49,7 +49,7 @@ async def _notify_admins_about_auto_assignment( ) except Exception as exc: logger.error( - "Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s", + 'Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s', user.telegram_id, exc, ) @@ -64,7 +64,7 @@ async def _get_best_group_for_spending( db: AsyncSession, total_spent_kopeks: int, min_threshold_kopeks: int = 0, -) -> Optional[PromoGroup]: +) -> PromoGroup | None: if total_spent_kopeks <= 0: return None @@ -78,11 +78,7 @@ 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 - and threshold > min_threshold_kopeks - ): + if threshold and total_spent_kopeks >= threshold and threshold > min_threshold_kopeks: return group return None @@ -91,7 +87,7 @@ async def _get_best_group_for_spending( async def maybe_assign_promo_group_by_total_spent( db: AsyncSession, user_id: int, -) -> Optional[PromoGroup]: +) -> PromoGroup | None: from app.database.crud.user_promo_group import ( add_user_to_promo_group, has_user_promo_group, @@ -100,7 +96,7 @@ async def maybe_assign_promo_group_by_total_spent( user = await db.get(User, user_id) if not user: - logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id) + logger.debug('Не удалось найти пользователя %s для автовыдачи промогруппы', user_id) return None # Получаем текущую primary промогруппу @@ -156,7 +152,7 @@ async def maybe_assign_promo_group_by_total_spent( if not already_has_group: # Добавляем новую промогруппу к существующим - await add_user_to_promo_group(db, user_id, target_group.id, assigned_by="auto") + await add_user_to_promo_group(db, user_id, target_group.id, assigned_by='auto') logger.info( "🤖 Пользователю %s добавлена промогруппа '%s' за траты %s ₽", user.telegram_id, @@ -185,7 +181,7 @@ async def maybe_assign_promo_group_by_total_spent( return target_group except Exception as exc: logger.error( - "Ошибка при автоматическом назначении промогруппы пользователю %s: %s", + 'Ошибка при автоматическом назначении промогруппы пользователю %s: %s', user_id, exc, ) diff --git a/app/services/promo_offer_service.py b/app/services/promo_offer_service.py index 11de8c5e..0e81ec7f 100644 --- a/app/services/promo_offer_service.py +++ b/app/services/promo_offer_service.py @@ -1,13 +1,14 @@ from __future__ import annotations import logging +from collections.abc import Sequence from datetime import datetime, timedelta -from typing import Dict, List, Optional, Sequence, Tuple from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.database.crud.promo_offer_log import log_promo_offer_action from app.database.models import ( DiscountOffer, Subscription, @@ -15,7 +16,7 @@ from app.database.models import ( User, ) from app.services.subscription_service import SubscriptionService -from app.database.crud.promo_offer_log import log_promo_offer_action + logger = logging.getLogger(__name__) @@ -29,13 +30,13 @@ class PromoOfferService: db: AsyncSession, user: User, offer: DiscountOffer, - ) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]: - subscription = getattr(user, "subscription", None) + ) -> tuple[bool, list[str] | None, datetime | None, str]: + subscription = getattr(user, 'subscription', None) if not subscription: - return False, None, None, "subscription_missing" + return False, None, None, 'subscription_missing' payload = offer.extra_data or {} - raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or [] + raw_squads = payload.get('test_squad_uuids') or payload.get('squads') or [] if isinstance(raw_squads, str): candidates = [raw_squads] else: @@ -46,16 +47,16 @@ class PromoOfferService: squad_uuids: Sequence[str] = [str(item) for item in candidates if item] if not squad_uuids: - return False, None, None, "squads_missing" + return False, None, None, 'squads_missing' squad_uuids = list(dict.fromkeys(squad_uuids)) connected = {str(item) for item in subscription.connected_squads or []} if squad_uuids and set(squad_uuids).issubset(connected): - return False, None, None, "already_connected" + return False, None, None, 'already_connected' try: - duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24) + duration_hours = int(payload.get('test_duration_hours') or payload.get('duration_hours') or 24) except (TypeError, ValueError): duration_hours = 24 @@ -66,7 +67,7 @@ class PromoOfferService: expires_at = now + timedelta(hours=duration_hours) original_connected = set(connected) - newly_added: List[str] = [] + newly_added: list[str] = [] changes_made = False for squad_uuid in squad_uuids: @@ -119,17 +120,17 @@ class PromoOfferService: await db.rollback() await db.refresh(subscription) logger.error( - "Не удалось синхронизировать временный доступ подписки %s с RemnaWave", + 'Не удалось синхронизировать временный доступ подписки %s с RemnaWave', subscription.id, ) - return False, None, None, "remnawave_sync_failed" + return False, None, None, 'remnawave_sync_failed' await db.refresh(subscription) elif changes_made: await db.commit() await db.refresh(subscription) - return True, newly_added, expires_at, "ok" + return True, newly_added, expires_at, 'ok' async def cleanup_expired_test_access(self, db: AsyncSession) -> int: now = datetime.utcnow() @@ -140,7 +141,7 @@ class PromoOfferService: selectinload(SubscriptionTemporaryAccess.offer), ) .where( - SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.is_active == True, SubscriptionTemporaryAccess.expires_at <= now, ) ) @@ -148,8 +149,8 @@ class PromoOfferService: if not entries: return 0 - subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {} - log_payloads: List[Dict[str, object]] = [] + subscriptions_updates: dict[int, tuple[Subscription, set[str]]] = {} + log_payloads: list[dict[str, object]] = [] for entry in entries: entry.is_active = False @@ -167,14 +168,14 @@ class PromoOfferService: offer = entry.offer log_payloads.append( { - "user_id": user_id, - "offer_id": entry.offer_id, - "source": getattr(offer, "notification_type", None), - "percent": getattr(offer, "discount_percent", None), - "effect_type": getattr(offer, "effect_type", "test_access"), - "details": { - "reason": "test_access_expired", - "squad_uuid": entry.squad_uuid, + 'user_id': user_id, + 'offer_id': entry.offer_id, + 'source': getattr(offer, 'notification_type', None), + 'percent': getattr(offer, 'discount_percent', None), + 'effect_type': getattr(offer, 'effect_type', 'test_access'), + 'details': { + 'reason': 'test_access_expired', + 'squad_uuid': entry.squad_uuid, }, } ) @@ -191,7 +192,7 @@ class PromoOfferService: await self.subscription_service.update_remnawave_user(db, subscription) except Exception as exc: # pragma: no cover - defensive logging logger.error( - "Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s", + 'Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s', subscription.id, exc, ) @@ -201,25 +202,25 @@ class PromoOfferService: try: await log_promo_offer_action( db, - user_id=payload["user_id"], - offer_id=payload.get("offer_id"), - action="disabled", - source=payload.get("source"), - percent=payload.get("percent"), - effect_type=payload.get("effect_type"), - details=payload.get("details"), + user_id=payload['user_id'], + offer_id=payload.get('offer_id'), + action='disabled', + source=payload.get('source'), + percent=payload.get('percent'), + effect_type=payload.get('effect_type'), + details=payload.get('details'), ) except Exception as exc: # pragma: no cover - defensive logging logger.warning( - "Failed to record promo offer test access disable log for user %s: %s", - payload.get("user_id"), + 'Failed to record promo offer test access disable log for user %s: %s', + payload.get('user_id'), exc, ) try: await db.rollback() except Exception as rollback_error: # pragma: no cover - defensive logging logger.warning( - "Failed to rollback session after promo offer test access log failure: %s", + 'Failed to rollback session after promo offer test access log failure: %s', rollback_error, ) return len(entries) diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py index 93141c53..1224e227 100644 --- a/app/services/promocode_service.py +++ b/app/services/promocode_service.py @@ -1,27 +1,26 @@ import logging -from datetime import datetime -from typing import Dict, Any +from typing import Any + from sqlalchemy.ext.asyncio import AsyncSession -from app.database.crud.promocode import ( - get_promocode_by_code, use_promocode, check_user_promocode_usage, - create_promocode_use, get_promocode_use_by_user_and_code -) -from app.database.crud.user import add_user_balance, get_user_by_id -from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id -from app.database.crud.user_promo_group import ( - has_user_promo_group, add_user_to_promo_group -) from app.database.crud.promo_group import get_promo_group_by_id -from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode +from app.database.crud.promocode import ( + check_user_promocode_usage, + create_promocode_use, + get_promocode_by_code, +) +from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id +from app.database.crud.user import add_user_balance, get_user_by_id +from app.database.crud.user_promo_group import add_user_to_promo_group, has_user_promo_group +from app.database.models import PromoCode, PromoCodeType, User from app.services.remnawave_service import RemnaWaveService from app.services.subscription_service import SubscriptionService + logger = logging.getLogger(__name__) class PromoCodeService: - def __init__(self): self.remnawave_service = RemnaWaveService() self.subscription_service = SubscriptionService() @@ -32,55 +31,51 @@ class PromoCodeService: if user.telegram_id: return str(user.telegram_id) if user.email: - return f"{user.id} ({user.email})" - return f"#{user.id}" - - async def activate_promocode( - self, - db: AsyncSession, - user_id: int, - code: str - ) -> Dict[str, Any]: - + return f'{user.id} ({user.email})' + return f'#{user.id}' + + async def activate_promocode(self, db: AsyncSession, user_id: int, code: str) -> dict[str, Any]: try: user = await get_user_by_id(db, user_id) if not user: - return {"success": False, "error": "user_not_found"} - + return {'success': False, 'error': 'user_not_found'} + promocode = await get_promocode_by_code(db, code) if not promocode: - return {"success": False, "error": "not_found"} - + return {'success': False, 'error': 'not_found'} + if not promocode.is_valid: if promocode.current_uses >= promocode.max_uses: - return {"success": False, "error": "used"} - else: - return {"success": False, "error": "expired"} - + return {'success': False, 'error': 'used'} + return {'success': False, 'error': 'expired'} + existing_use = await check_user_promocode_usage(db, user_id, promocode.id) if existing_use: - return {"success": False, "error": "already_used_by_user"} + return {'success': False, 'error': 'already_used_by_user'} # Проверка "только для первой покупки" if getattr(promocode, 'first_purchase_only', False): if getattr(user, 'has_had_paid_subscription', False): - return {"success": False, "error": "not_first_purchase"} + return {'success': False, 'error': 'not_first_purchase'} balance_before_kopeks = user.balance_kopeks try: result_description = await self._apply_promocode_effects(db, user, promocode) except ValueError as e: - if str(e) == "active_discount_exists": - return {"success": False, "error": "active_discount_exists"} + if str(e) == 'active_discount_exists': + return {'success': False, 'error': 'active_discount_exists'} raise balance_after_kopeks = user.balance_kopeks 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"🎯 Пользователь {self._format_user_log(user)} получил платную подписку через промокод {code}") + logger.info( + f'🎯 Пользователь {self._format_user_log(user)} получил платную подписку через промокод {code}' + ) # Assign promo group if promocode has one if promocode.promo_group_id: @@ -95,31 +90,28 @@ class PromoCodeService: if promo_group: # Add promo group to user await add_user_to_promo_group( - db, - user_id, - promocode.promo_group_id, - assigned_by="promocode" + db, user_id, promocode.promo_group_id, assigned_by='promocode' ) logger.info( f"🎯 Пользователю {self._format_user_log(user)} назначена промогруппа '{promo_group.name}' " - f"(приоритет: {promo_group.priority}) через промокод {code}" + f'(приоритет: {promo_group.priority}) через промокод {code}' ) # Add to result description - result_description += f"\n🎁 Назначена промогруппа: {promo_group.name}" + result_description += f'\n🎁 Назначена промогруппа: {promo_group.name}' else: logger.warning( - f"⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}" + f'⚠️ Промогруппа ID {promocode.promo_group_id} не найдена для промокода {code}' ) else: logger.info( - f"ℹ️ Пользователь {self._format_user_log(user)} уже имеет промогруппу ID {promocode.promo_group_id}" + f'ℹ️ Пользователь {self._format_user_log(user)} уже имеет промогруппу ID {promocode.promo_group_id}' ) except Exception as pg_error: logger.error( - f"❌ Ошибка назначения промогруппы для пользователя {self._format_user_log(user)} " - f"при активации промокода {code}: {pg_error}" + f'❌ Ошибка назначения промогруппы для пользователя {self._format_user_log(user)} ' + f'при активации промокода {code}: {pg_error}' ) # Don't fail the whole promocode activation if promo group assignment fails @@ -128,31 +120,31 @@ class PromoCodeService: promocode.current_uses += 1 await db.commit() - logger.info(f"✅ Пользователь {self._format_user_log(user)} активировал промокод {code}") + logger.info(f'✅ Пользователь {self._format_user_log(user)} активировал промокод {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, - "promo_group_id": promocode.promo_group_id, + '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, + 'promo_group_id': promocode.promo_group_id, } return { - "success": True, - "description": result_description, - "promocode": promocode_data, - "balance_before_kopeks": balance_before_kopeks, - "balance_after_kopeks": balance_after_kopeks, + 'success': True, + 'description': result_description, + 'promocode': promocode_data, + 'balance_before_kopeks': balance_before_kopeks, + 'balance_after_kopeks': balance_after_kopeks, } - + except Exception as e: - logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}") + logger.error(f'Ошибка активации промокода {code} для пользователя {user_id}: {e}') await db.rollback() - return {"success": False, "error": "server_error"} + return {'success': False, 'error': 'server_error'} async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str: """ @@ -183,10 +175,10 @@ class PromoCodeService: if current_discount > 0: if expires_at is None or expires_at > datetime.utcnow(): logger.warning( - f"⚠️ Пользователь {self._format_user_log(user)} попытался активировать промокод {promocode.code}, " - f"но у него уже есть активная скидка {current_discount}% до {expires_at}" + f'⚠️ Пользователь {self._format_user_log(user)} попытался активировать промокод {promocode.code}, ' + f'но у него уже есть активная скидка {current_discount}% до {expires_at}' ) - raise ValueError("active_discount_exists") + raise ValueError('active_discount_exists') # balance_bonus_kopeks хранит процент скидки (1-100) discount_percent = promocode.balance_bonus_kopeks @@ -195,49 +187,48 @@ class PromoCodeService: # Устанавливаем процент скидки user.promo_offer_discount_percent = discount_percent - user.promo_offer_discount_source = f"promocode:{promocode.code}" + user.promo_offer_discount_source = f'promocode:{promocode.code}' # Устанавливаем срок действия скидки if discount_hours > 0: user.promo_offer_discount_expires_at = datetime.utcnow() + timedelta(hours=discount_hours) - effects.append(f"💸 Получена скидка {discount_percent}% (действует {discount_hours} ч.)") + effects.append(f'💸 Получена скидка {discount_percent}% (действует {discount_hours} ч.)') else: # 0 часов = бессрочно до первой покупки user.promo_offer_discount_expires_at = None - effects.append(f"💸 Получена скидка {discount_percent}% до первой покупки") + effects.append(f'💸 Получена скидка {discount_percent}% до первой покупки') await db.flush() logger.info( - f"✅ Пользователю {self._format_user_log(user)} назначена скидка {discount_percent}% " - f"(срок: {discount_hours} ч.) по промокоду {promocode.code}" + f'✅ Пользователю {self._format_user_log(user)} назначена скидка {discount_percent}% ' + f'(срок: {discount_hours} ч.) по промокоду {promocode.code}' ) if promocode.type == PromoCodeType.BALANCE.value and promocode.balance_bonus_kopeks > 0: - await add_user_balance( - db, user, promocode.balance_bonus_kopeks, - f"Бонус по промокоду {promocode.code}" - ) - + await add_user_balance(db, user, promocode.balance_bonus_kopeks, f'Бонус по промокоду {promocode.code}') + balance_bonus_rubles = promocode.balance_bonus_kopeks / 100 - effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽") + effects.append(f'💰 Баланс пополнен на {balance_bonus_rubles}₽') if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0: from app.config import settings - + subscription = await get_subscription_by_user_id(db, user.id) - + if subscription: await extend_subscription(db, subscription, promocode.subscription_days) - + await self.subscription_service.update_remnawave_user(db, subscription) - - effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней") - logger.info(f"✅ Подписка пользователя {self._format_user_log(user)} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами") - + + effects.append(f'⏰ Подписка продлена на {promocode.subscription_days} дней') + logger.info( + f'✅ Подписка пользователя {self._format_user_log(user)} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами' + ) + else: from app.database.crud.subscription import create_paid_subscription - + trial_squads = [] try: from app.database.crud.server_squad import get_random_trial_squad_uuid @@ -247,11 +238,11 @@ class PromoCodeService: trial_squads = [trial_uuid] except Exception as error: logger.error( - "Не удалось подобрать сквад для подписки по промокоду %s: %s", + 'Не удалось подобрать сквад для подписки по промокоду %s: %s', promocode.code, error, ) - + forced_devices = None if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() @@ -269,21 +260,25 @@ class PromoCodeService: connected_squads=trial_squads, update_server_counters=True, ) - + await self.subscription_service.create_remnawave_user(db, new_subscription) - - effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней") - logger.info(f"✅ Создана новая подписка для пользователя {self._format_user_log(user)} на {promocode.subscription_days} дней с триал сквадом {trial_squads}") - + + effects.append(f'🎉 Получена подписка на {promocode.subscription_days} дней') + logger.info( + f'✅ Создана новая подписка для пользователя {self._format_user_log(user)} на {promocode.subscription_days} дней с триал сквадом {trial_squads}' + ) + if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value: - from app.database.crud.subscription import create_trial_subscription from app.config import settings - + from app.database.crud.subscription import create_trial_subscription + subscription = await get_subscription_by_user_id(db, user.id) - + if not subscription: - trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS - + trial_days = ( + promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS + ) + forced_devices = None if not settings.is_devices_selection_enabled(): forced_devices = settings.get_disabled_mode_device_limit() @@ -294,12 +289,14 @@ class PromoCodeService: duration_days=trial_days, device_limit=forced_devices, ) - + await self.subscription_service.create_remnawave_user(db, trial_subscription) - - effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней") - logger.info(f"✅ Создана триал подписка для пользователя {self._format_user_log(user)} на {trial_days} дней") + + effects.append(f'🎁 Активирована тестовая подписка на {trial_days} дней') + logger.info( + f'✅ Создана триал подписка для пользователя {self._format_user_log(user)} на {trial_days} дней' + ) else: - effects.append("ℹ️ У вас уже есть активная подписка") - - return "\n".join(effects) if effects else "✅ Промокод активирован" + effects.append('ℹ️ У вас уже есть активная подписка') + + return '\n'.join(effects) if effects else '✅ Промокод активирован' diff --git a/app/services/public_offer_service.py b/app/services/public_offer_service.py index 053d48af..94ee9f19 100644 --- a/app/services/public_offer_service.py +++ b/app/services/public_offer_service.py @@ -1,6 +1,5 @@ import logging from html.parser import HTMLParser -from typing import List, Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -12,6 +11,7 @@ from app.database.crud.public_offer import ( ) from app.database.models import PublicOffer + logger = logging.getLogger(__name__) @@ -22,8 +22,8 @@ class PublicOfferService: @staticmethod def _normalize_language(language: str) -> str: - base_language = language or settings.DEFAULT_LANGUAGE or "ru" - return base_language.split("-")[0].lower() + base_language = language or settings.DEFAULT_LANGUAGE or 'ru' + return base_language.split('-')[0].lower() @staticmethod def normalize_language(language: str) -> str: @@ -36,7 +36,7 @@ class PublicOfferService: language: str, *, fallback: bool = False, - ) -> Optional[PublicOffer]: + ) -> PublicOffer | None: lang = cls._normalize_language(language) offer = await get_public_offer(db, lang) @@ -54,7 +54,7 @@ class PublicOfferService: cls, db: AsyncSession, language: str, - ) -> Optional[PublicOffer]: + ) -> PublicOffer | None: lang = cls._normalize_language(language) offer = await get_public_offer(db, lang) @@ -93,7 +93,7 @@ class PublicOfferService: content, enable_if_new=enable_if_new, ) - logger.info("✅ Публичная оферта обновлена для языка %s", lang) + logger.info('✅ Публичная оферта обновлена для языка %s', lang) return offer @classmethod @@ -125,27 +125,27 @@ class PublicOfferService: class _RichTextPaginator(HTMLParser): """Split HTML-like text into Telegram-safe chunks.""" - SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"} + SELF_CLOSING_TAGS = {'br', 'hr', 'img', 'input', 'meta', 'link'} def __init__(self, max_len: int) -> None: super().__init__(convert_charrefs=False) self.max_len = max_len - self.pages: List[str] = [] - self.current_parts: List[str] = [] + self.pages: list[str] = [] + self.current_parts: list[str] = [] self.current_length = 0 - self.open_stack: List[Tuple[str, str, int]] = [] + self.open_stack: list[tuple[str, str, int]] = [] self.closing_length = 0 self.needs_prefix = False self.prefix_length = 0 def _closing_sequence(self) -> str: - return "".join(f"" for name, _, _ in reversed(self.open_stack)) + return ''.join(f'' for name, _, _ in reversed(self.open_stack)) def _ensure_prefix(self) -> None: if not self.needs_prefix: return - prefix = "".join(token for _, token, _ in self.open_stack) + prefix = ''.join(token for _, token, _ in self.open_stack) if prefix: self.current_parts.append(prefix) self.current_length += len(prefix) @@ -155,7 +155,7 @@ class PublicOfferService: if not self.current_parts and not self.closing_length: return - content = "".join(self.current_parts) + content = ''.join(self.current_parts) closing_tags = self._closing_sequence() page = (content + closing_tags).strip() if page: @@ -172,14 +172,14 @@ class PublicOfferService: self.needs_prefix = bool(self.open_stack) @staticmethod - def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str: + def _format_attrs(attrs: list[tuple[str, str | None]]) -> str: parts = [] for name, value in attrs: if value is None: - parts.append(f" {name}") + parts.append(f' {name}') else: - parts.append(f" {name}=\"{value}\"") - return "".join(parts) + parts.append(f' {name}="{value}"') + return ''.join(parts) def _append_token(self, token: str) -> None: while True: @@ -217,22 +217,22 @@ class PublicOfferService: self._flush() def handle_entityref(self, name: str) -> None: - self.handle_data(f"&{name};") + self.handle_data(f'&{name};') def handle_charref(self, name: str) -> None: - self.handle_data(f"&#{name};") + self.handle_data(f'&#{name};') - def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: - token = f"<{tag}{self._format_attrs(attrs)}/>" + def _handle_self_closing(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + token = f'<{tag}{self._format_attrs(attrs)}/>' self._append_token(token) - def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag in self.SELF_CLOSING_TAGS: self._handle_self_closing(tag, attrs) return - token = f"<{tag}{self._format_attrs(attrs)}>" - closing_token = f"" + token = f'<{tag}{self._format_attrs(attrs)}>' + closing_token = f'' closing_len = len(closing_token) while True: @@ -257,7 +257,7 @@ class PublicOfferService: break def handle_endtag(self, tag: str) -> None: - token = f"" + token = f'' closing_len_reduction = 0 index_to_remove = None for index in range(len(self.open_stack) - 1, -1, -1): @@ -269,8 +269,7 @@ class PublicOfferService: while True: self._ensure_prefix() projected_closing_length = self.closing_length - closing_len_reduction - if projected_closing_length < 0: - projected_closing_length = 0 + projected_closing_length = max(projected_closing_length, 0) projected_total = self.current_length + len(token) + projected_closing_length if projected_total <= self.max_len or not self.current_parts: @@ -284,15 +283,15 @@ class PublicOfferService: self._flush() - def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: self._handle_self_closing(tag, attrs) - def finalize(self) -> List[str]: + def finalize(self) -> list[str]: self._flush() return self.pages @classmethod - def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]: + def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> list[str]: if len(paragraph) <= max_len: return [paragraph] @@ -308,11 +307,11 @@ class PublicOfferService: content: str, *, max_length: int = None, - ) -> List[str]: + ) -> list[str]: if not content: return [] - normalized = content.replace("\r\n", "\n").strip() + normalized = content.replace('\r\n', '\n').strip() if not normalized: return [] @@ -321,20 +320,16 @@ class PublicOfferService: if len(normalized) <= max_len: return [normalized] - paragraphs = [ - paragraph.strip() - for paragraph in normalized.split("\n\n") - if paragraph.strip() - ] + paragraphs = [paragraph.strip() for paragraph in normalized.split('\n\n') if paragraph.strip()] - pages: List[str] = [] - current = "" + pages: list[str] = [] + current = '' def flush_current() -> None: nonlocal current if current: pages.append(current.strip()) - current = "" + current = '' for paragraph in paragraphs: segments = cls._split_rich_paragraph(paragraph, max_len) @@ -343,7 +338,7 @@ class PublicOfferService: if not segment: continue - candidate = f"{current}\n\n{segment}".strip() if current else segment + candidate = f'{current}\n\n{segment}'.strip() if current else segment if len(candidate) <= max_len: current = candidate continue diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index a8eca5e9..e3cb76da 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -1,7 +1,7 @@ import asyncio import logging -from datetime import datetime, date, time, timedelta, timezone -from typing import Optional, Sequence, Tuple +from collections.abc import Sequence +from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo from aiogram import Bot @@ -23,13 +23,14 @@ from app.database.crud.user import get_user_by_id from app.database.database import AsyncSessionLocal from app.database.models import ReferralContest, User + logger = logging.getLogger(__name__) class ReferralContestService: def __init__(self) -> None: - self.bot: Optional[Bot] = None - self._task: Optional[asyncio.Task] = None + self.bot: Bot | None = None + self._task: asyncio.Task | None = None self._poll_interval_seconds = 60 def set_bot(self, bot: Bot) -> None: @@ -42,15 +43,15 @@ class ReferralContestService: await self.stop() if not settings.is_contests_enabled(): - logger.info("Сервис конкурсов отключен настройками") + logger.info('Сервис конкурсов отключен настройками') return if not self.bot: - logger.warning("Невозможно запустить сервис конкурсов без экземпляра бота") + logger.warning('Невозможно запустить сервис конкурсов без экземпляра бота') return self._task = asyncio.create_task(self._run_loop()) - logger.info("🏆 Сервис конкурсов запущен") + logger.info('🏆 Сервис конкурсов запущен') async def stop(self) -> None: if self._task and not self._task.done(): @@ -68,12 +69,12 @@ class ReferralContestService: await self._process_summaries() except asyncio.CancelledError: raise - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка сервиса конкурсов: %s", exc) + except Exception as exc: + logger.error('Ошибка сервиса конкурсов: %s', exc) await asyncio.sleep(self._poll_interval_seconds) except asyncio.CancelledError: - logger.info("Сервис конкурсов остановлен") + logger.info('Сервис конкурсов остановлен') raise async def _process_summaries(self) -> None: @@ -90,9 +91,9 @@ class ReferralContestService: await self._maybe_send_final_summary(db, contest, now_utc) except asyncio.CancelledError: raise - except Exception as exc: # noqa: BLE001 + except Exception as exc: logger.error( - "Ошибка обработки конкурса %s (%s): %s", + 'Ошибка обработки конкурса %s (%s): %s', contest.id, contest.title, exc, @@ -105,7 +106,7 @@ class ReferralContestService: now_utc: datetime, ) -> None: tz = self._get_timezone(contest) - now_local = now_utc.replace(tzinfo=timezone.utc).astimezone(tz) + now_local = now_utc.replace(tzinfo=UTC).astimezone(tz) start_local = self._to_local(contest.start_at, tz) end_local = self._to_local(contest.end_at, tz) @@ -115,7 +116,7 @@ class ReferralContestService: summary_times = self._get_summary_times(contest) for summary_time in summary_times: summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz) - summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None) + summary_dt_utc = summary_dt.astimezone(UTC).replace(tzinfo=None) if now_utc < summary_dt_utc: continue @@ -146,7 +147,7 @@ class ReferralContestService: summary_times = self._get_summary_times(contest) summary_time = summary_times[-1] if summary_times else time(hour=12, minute=0) summary_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz) - summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None) + summary_dt_utc = summary_dt.astimezone(UTC).replace(tzinfo=None) if now_utc < contest.end_at: return @@ -164,13 +165,13 @@ class ReferralContestService: target_date: date, *, is_final: bool, - summary_dt_utc: Optional[datetime] = None, + summary_dt_utc: datetime | None = None, ) -> None: tz = self._get_timezone(contest) day_start_local = datetime.combine(target_date, time.min, tzinfo=tz) day_end_local = day_start_local + timedelta(days=1) - day_start_utc = day_start_local.astimezone(timezone.utc).replace(tzinfo=None) - day_end_utc = day_end_local.astimezone(timezone.utc).replace(tzinfo=None) + day_start_utc = day_start_local.astimezone(UTC).replace(tzinfo=None) + day_end_utc = day_end_local.astimezone(UTC).replace(tzinfo=None) leaderboard = list(await get_contest_leaderboard(db, contest.id)) total_events = await get_contest_events_count(db, contest.id) @@ -200,7 +201,7 @@ class ReferralContestService: ) if not leaderboard: - logger.info("Конкурс %s: пока нет участников", contest.id) + logger.info('Конкурс %s: пока нет участников', contest.id) if is_final: await mark_final_summary_sent(db, contest) @@ -212,7 +213,7 @@ class ReferralContestService: db: AsyncSession, *, contest: ReferralContest, - leaderboard: Sequence[Tuple[User, int, int]], + leaderboard: Sequence[tuple[User, int, int]], total_events: int, today_events: int, day_start_utc: datetime, @@ -227,13 +228,17 @@ class ReferralContestService: for user, score, _ in leaderboard: rank = score_map.get(user.id, (None, score))[0] - today_score = await get_referrer_score( - db=db, - contest_id=contest.id, - referrer_id=user.id, - start=day_start_utc, - end=day_end_utc, - ) if score else 0 + today_score = ( + await get_referrer_score( + db=db, + contest_id=contest.id, + referrer_id=user.id, + start=day_start_utc, + end=day_end_utc, + ) + if score + else 0 + ) text = self._build_participant_message( contest=contest, @@ -247,24 +252,24 @@ class ReferralContestService: # Skip email-only users (no telegram_id) if not user.telegram_id: - logger.debug(f"Skipping contest notification for email-only user {user.id}") + logger.debug(f'Skipping contest notification for email-only user {user.id}') continue try: await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True) except (TelegramForbiddenError, TelegramNotFound): logger.info( - "Не удалось отправить сообщение участнику %s (вероятно, блокировка)", + 'Не удалось отправить сообщение участнику %s (вероятно, блокировка)', user.telegram_id, ) - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка отправки участнику конкурса %s: %s", user.telegram_id, exc) + except Exception as exc: + logger.error('Ошибка отправки участнику конкурса %s: %s', user.telegram_id, exc) async def _notify_admins( self, *, contest: ReferralContest, - leaderboard: Sequence[Tuple[User, int, int]], + leaderboard: Sequence[tuple[User, int, int]], total_events: int, today_events: int, is_final: bool, @@ -278,42 +283,42 @@ class ReferralContestService: return lines = [ - "🏆 Конкурс рефералов", - f"Название: {contest.title}", - f"Статус: {'финал' if is_final else 'дневная сводка'}", - f"Временная зона: {tz.key}", - f"Всего рефералов: {total_events}", - "", - "Топ участников:", + '🏆 Конкурс рефералов', + f'Название: {contest.title}', + f'Статус: {"финал" if is_final else "дневная сводка"}', + f'Временная зона: {tz.key}', + f'Всего рефералов: {total_events}', + '', + 'Топ участников:', ] if leaderboard: for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): name = user.full_name - user_id_display = user.telegram_id or user.email or f"#{user.id}" - lines.append(f"{idx}. {name} ({user_id_display}) — {score}") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + lines.append(f'{idx}. {name} ({user_id_display}) — {score}') else: - lines.append("Пока нет участников.") + lines.append('Пока нет участников.') if contest.prize_text: - lines.append("") - lines.append(f"Приз: {contest.prize_text}") + lines.append('') + lines.append(f'Приз: {contest.prize_text}') try: await self.bot.send_message( chat_id=chat_id, - text="\n".join(lines), + text='\n'.join(lines), disable_web_page_preview=True, message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID, ) - except Exception as exc: # noqa: BLE001 - logger.error("Не удалось отправить админскую сводку конкурса: %s", exc) + except Exception as exc: + logger.error('Не удалось отправить админскую сводку конкурса: %s', exc) async def _notify_public_channel( self, *, contest: ReferralContest, - leaderboard: Sequence[Tuple[User, int, int]], + leaderboard: Sequence[tuple[User, int, int]], total_events: int, today_events: int, is_final: bool, @@ -332,34 +337,34 @@ class ReferralContestService: channel_id = channel_id_raw lines = [ - f"🏆 {contest.title}", - "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги", - f"Время зоны: {tz.key}", - f"Всего участников: {len(leaderboard)}", - "", - "Топ участников:", + f'🏆 {contest.title}', + '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги', + f'Время зоны: {tz.key}', + f'Всего участников: {len(leaderboard)}', + '', + 'Топ участников:', ] if leaderboard: for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): - lines.append(f"{idx}. {user.full_name} — {score}") + lines.append(f'{idx}. {user.full_name} — {score}') else: - lines.append("Пока нет участников.") + lines.append('Пока нет участников.') if contest.prize_text: - lines.append("") - lines.append(f"Приз: {contest.prize_text}") + lines.append('') + lines.append(f'Приз: {contest.prize_text}') try: await self.bot.send_message( chat_id=channel_id, - text="\n".join(lines), + text='\n'.join(lines), disable_web_page_preview=True, ) except (TelegramForbiddenError, TelegramNotFound): - logger.info("Не удалось отправить сводку конкурса в канал %s", channel_id_raw) - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка отправки сводки конкурса в канал %s: %s", channel_id_raw, exc) + logger.info('Не удалось отправить сводку конкурса в канал %s', channel_id_raw) + except Exception as exc: + logger.error('Ошибка отправки сводки конкурса в канал %s: %s', channel_id_raw, exc) def _build_participant_message( self, @@ -372,36 +377,36 @@ class ReferralContestService: today_events: int, is_final: bool, ) -> str: - status_line = "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги" + status_line = '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги' lines = [ - f"🏆 {contest.title}", + f'🏆 {contest.title}', status_line, - "", - f"Ваше место: {rank}", - f"Зачётов за всё время: {score}", - f"За сегодня: {today_score}", - f"Общий пул зачётов: {total_events} (сегодня {today_events})", + '', + f'Ваше место: {rank}', + f'Зачётов за всё время: {score}', + f'За сегодня: {today_score}', + f'Общий пул зачётов: {total_events} (сегодня {today_events})', ] if contest.prize_text: - lines.append("") - lines.append(f"Призовой фонд: {contest.prize_text}") + lines.append('') + lines.append(f'Призовой фонд: {contest.prize_text}') if not is_final: - remaining = contest.end_at - datetime.now(timezone.utc) + remaining = contest.end_at - datetime.now(UTC) if remaining.total_seconds() > 0: hours_left = int(remaining.total_seconds() // 3600) - lines.append("") - lines.append(f"До окончания: ~{hours_left} ч.") + lines.append('') + lines.append(f'До окончания: ~{hours_left} ч.') - return "\n".join(lines) + return '\n'.join(lines) async def get_detailed_contest_stats(self, db: AsyncSession, contest_id: int) -> dict: from app.database.crud.referral_contest import ( get_contest_leaderboard, - get_referral_contest, get_contest_payment_stats, get_contest_transaction_breakdown, + get_referral_contest, ) contest = await get_referral_contest(db, contest_id) @@ -448,14 +453,16 @@ class ReferralContestService: # Build participants stats directly from leaderboard (already has User objects) participants_stats = [] for user, score, amount in leaderboard: - participants_stats.append({ - 'referrer_id': user.id, - 'full_name': user.full_name, - 'total_referrals': score, - 'paid_referrals': score if amount > 0 else 0, - 'unpaid_referrals': 0 if amount > 0 else score, - 'total_paid_amount': amount, - }) + participants_stats.append( + { + 'referrer_id': user.id, + 'full_name': user.full_name, + 'total_referrals': score, + 'paid_referrals': score if amount > 0 else 0, + 'unpaid_referrals': 0 if amount > 0 else score, + 'total_paid_amount': amount, + } + ) return { 'total_participants': total_participants, @@ -473,20 +480,20 @@ class ReferralContestService: tz_name = contest.timezone or settings.TIMEZONE try: return ZoneInfo(tz_name) - except Exception: # noqa: BLE001 - logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) - return ZoneInfo("UTC") + except Exception: + logger.warning('Не удалось загрузить TZ %s, используем UTC', tz_name) + return ZoneInfo('UTC') - def _parse_times(self, times_str: Optional[str]) -> list[time]: + def _parse_times(self, times_str: str | None) -> list[time]: if not times_str: return [] parsed: list[time] = [] - for part in times_str.split(","): + for part in times_str.split(','): part = part.strip() if not part: continue try: - parsed.append(datetime.strptime(part, "%H:%M").time()) + parsed.append(datetime.strptime(part, '%H:%M').time()) except Exception: continue return parsed @@ -502,7 +509,7 @@ class ReferralContestService: def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime: base = dt_value if dt_value.tzinfo is None: - base = dt_value.replace(tzinfo=timezone.utc) + base = dt_value.replace(tzinfo=UTC) return base.astimezone(tz) async def on_subscription_payment( @@ -522,7 +529,7 @@ class ReferralContestService: contests = await get_contests_for_events( db, now_utc, - contest_types=["referral_paid"], + contest_types=['referral_paid'], ) if not contests: return @@ -535,17 +542,17 @@ class ReferralContestService: referrer_id=user.referred_by_id, referral_id=user.id, amount_kopeks=amount_kopeks, - event_type="subscription_purchase", + event_type='subscription_purchase', ) if event: logger.info( - "Записан зачёт конкурса %s: реферер %s, реферал %s", + 'Записан зачёт конкурса %s: реферер %s, реферал %s', contest.id, user.referred_by_id, user.id, ) - except Exception as exc: # noqa: BLE001 - logger.error("Не удалось записать зачёт конкурса %s: %s", contest.id, exc) + except Exception as exc: + logger.error('Не удалось записать зачёт конкурса %s: %s', contest.id, exc) async def on_referral_registration( self, @@ -563,7 +570,7 @@ class ReferralContestService: contests = await get_contests_for_events( db, now_utc, - contest_types=["referral_registered"], + contest_types=['referral_registered'], ) if not contests: return @@ -576,17 +583,17 @@ class ReferralContestService: referrer_id=user.referred_by_id, referral_id=user.id, amount_kopeks=0, - event_type="referral_registration", + event_type='referral_registration', ) if event: logger.info( - "Записан зачёт конкурса регистрации %s: реферер %s, реферал %s", + 'Записан зачёт конкурса регистрации %s: реферер %s, реферал %s', contest.id, user.referred_by_id, user.id, ) - except Exception as exc: # noqa: BLE001 - logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc) + except Exception as exc: + logger.error('Не удалось записать зачёт регистрации для конкурса %s: %s', contest.id, exc) async def sync_contest( self, @@ -602,18 +609,18 @@ class ReferralContestService: try: stats = await sync_contest_events(db, contest_id) - if "error" not in stats: + if 'error' not in stats: logger.info( - "Синхронизация конкурса %s: создано %s, обновлено %s, пропущено %s", + 'Синхронизация конкурса %s: создано %s, обновлено %s, пропущено %s', contest_id, - stats.get("created", 0), - stats.get("updated", 0), - stats.get("skipped", 0), + stats.get('created', 0), + stats.get('updated', 0), + stats.get('skipped', 0), ) return stats except Exception as exc: - logger.error("Ошибка синхронизации конкурса %s: %s", contest_id, exc) - return {"error": str(exc)} + logger.error('Ошибка синхронизации конкурса %s: %s', contest_id, exc) + return {'error': str(exc)} referral_contest_service = ReferralContestService() diff --git a/app/services/referral_service.py b/app/services/referral_service.py index f6b6f6ab..8fbcaf81 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -1,29 +1,29 @@ import logging -from typing import Optional -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, delete + from aiogram import Bot +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.crud.user import add_user_balance, get_user_by_id from app.database.crud.referral import create_referral_earning -from app.database.models import TransactionType, ReferralEarning, User -from app.utils.user_utils import get_effective_referral_commission_percent +from app.database.crud.user import add_user_balance, get_user_by_id +from app.database.models import ReferralEarning, User from app.services.notification_delivery_service import ( notification_delivery_service, - NotificationType, ) +from app.utils.user_utils import get_effective_referral_commission_percent + logger = logging.getLogger(__name__) async def send_referral_notification( bot: Bot, - telegram_id: Optional[int], + telegram_id: int | None, message: str, - user: Optional[User] = None, + user: User | None = None, bonus_kopeks: int = 0, - referral_name: str = "", + referral_name: str = '', ): """ Отправляет реферальное уведомление в Telegram или по email. @@ -46,44 +46,35 @@ async def send_referral_notification( telegram_message=message, ) if success: - logger.info(f"✅ Email уведомление о реферале отправлено пользователю {user.id}") + logger.info(f'✅ Email уведомление о реферале отправлено пользователю {user.id}') else: - logger.warning(f"⚠️ Не удалось отправить email уведомление пользователю {user.id}") + logger.warning(f'⚠️ Не удалось отправить email уведомление пользователю {user.id}') else: - logger.debug("Пропуск уведомления: пользователь без telegram_id и без User object") + logger.debug('Пропуск уведомления: пользователь без telegram_id и без User object') return try: - await bot.send_message(telegram_id, message, parse_mode="HTML") - logger.info(f"✅ Уведомление отправлено пользователю {telegram_id}") + await bot.send_message(telegram_id, message, parse_mode='HTML') + logger.info(f'✅ Уведомление отправлено пользователю {telegram_id}') except Exception as e: - logger.error(f"❌ Ошибка отправки уведомления пользователю {telegram_id}: {e}") + logger.error(f'❌ Ошибка отправки уведомления пользователю {telegram_id}: {e}') -async def process_referral_registration( - db: AsyncSession, - new_user_id: int, - referrer_id: int, - bot: Bot = None -): +async def process_referral_registration(db: AsyncSession, new_user_id: int, referrer_id: int, bot: Bot = None): try: new_user = await get_user_by_id(db, new_user_id) referrer = await get_user_by_id(db, referrer_id) - + if not new_user or not referrer: - logger.error(f"Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}") + logger.error(f'Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}') return False - + if new_user.referred_by_id != referrer_id: - logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}") + logger.error(f'Пользователь {new_user_id} не привязан к рефереру {referrer_id}') return False - + await create_referral_earning( - db=db, - user_id=referrer_id, - referral_id=new_user_id, - amount_kopeks=0, - reason="referral_registration_pending" + db=db, user_id=referrer_id, referral_id=new_user_id, amount_kopeks=0, reason='referral_registration_pending' ) try: @@ -91,73 +82,63 @@ async def process_referral_registration( await referral_contest_service.on_referral_registration(db, new_user_id) except Exception as exc: - logger.debug("Не удалось записать конкурсную регистрацию: %s", exc) + logger.debug('Не удалось записать конкурсную регистрацию: %s', exc) if bot: commission_percent = get_effective_referral_commission_percent(referrer) referral_notification = ( - f"🎉 Добро пожаловать!\n\n" - f"Вы перешли по реферальной ссылке пользователя {referrer.full_name}!\n\n" - f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} " - f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n" + f'🎉 Добро пожаловать!\n\n' + f'Вы перешли по реферальной ссылке пользователя {referrer.full_name}!\n\n' + f'💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ' + f'вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n' # f"🎁 Ваш реферер также получит награду за ваше первое пополнение." ) - await send_referral_notification( - bot, new_user.telegram_id, referral_notification, user=new_user - ) + await send_referral_notification(bot, new_user.telegram_id, referral_notification, user=new_user) inviter_notification = ( - f"👥 Новый реферал!\n\n" - f"По вашей ссылке зарегистрировался пользователь {new_user.full_name}!\n\n" - f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, " - f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или " - f"{commission_percent}% от суммы (что больше).\n\n" - f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии." + f'👥 Новый реферал!\n\n' + f'По вашей ссылке зарегистрировался пользователь {new_user.full_name}!\n\n' + f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, ' + f'вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или ' + f'{commission_percent}% от суммы (что больше).\n\n' + f'📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии.' ) await send_referral_notification( - bot, referrer.telegram_id, inviter_notification, - user=referrer, referral_name=new_user.full_name + bot, referrer.telegram_id, inviter_notification, user=referrer, referral_name=new_user.full_name ) - - logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.") + + logger.info( + f'✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.' + ) return True - + except Exception as e: - logger.error(f"Ошибка обработки реферальной регистрации: {e}") + logger.error(f'Ошибка обработки реферальной регистрации: {e}') return False -async def process_referral_topup( - db: AsyncSession, - user_id: int, - topup_amount_kopeks: int, - bot: Bot = None -): +async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_kopeks: int, bot: Bot = None): try: user = await get_user_by_id(db, user_id) if not user or not user.referred_by_id: - logger.info(f"Пользователь {user_id} не является рефералом") + logger.info(f'Пользователь {user_id} не является рефералом') return True - + referrer = await get_user_by_id(db, user.referred_by_id) if not referrer: - logger.error(f"Реферер {user.referred_by_id} не найден") + logger.error(f'Реферер {user.referred_by_id} не найден') return False commission_percent = get_effective_referral_commission_percent(referrer) - qualifies_for_first_bonus = ( - topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS - ) + qualifies_for_first_bonus = topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS commission_amount = 0 if commission_percent > 0: - commission_amount = int( - topup_amount_kopeks * commission_percent / 100 - ) + commission_amount = int(topup_amount_kopeks * commission_percent / 100) if not user.has_made_first_topup: if not qualifies_for_first_bonus: logger.info( - "Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена", + 'Пополнение %s на %s₽ меньше минимума для первого бонуса, но комиссия будет начислена', user_id, topup_amount_kopeks / 100, ) @@ -167,7 +148,7 @@ async def process_referral_topup( db, referrer, commission_amount, - f"Комиссия {commission_percent}% с пополнения {user.full_name}", + f'Комиссия {commission_percent}% с пополнения {user.full_name}', bot=bot, ) @@ -176,75 +157,82 @@ async def process_referral_topup( user_id=referrer.id, referral_id=user.id, amount_kopeks=commission_amount, - reason="referral_commission_topup", + reason='referral_commission_topup', ) logger.info( - "💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)", + '💰 Комиссия с пополнения: %s получил %s₽ (до первого бонуса)', referrer.telegram_id, commission_amount / 100, ) if bot: commission_notification = ( - f"💰 Реферальная комиссия!\n\n" - f"Ваш реферал {user.full_name} пополнил баланс на " - f"{settings.format_price(topup_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({commission_percent}%): " - f"{settings.format_price(commission_amount)}\n\n" - f"💎 Средства зачислены на ваш баланс." + f'💰 Реферальная комиссия!\n\n' + f'Ваш реферал {user.full_name} пополнил баланс на ' + f'{settings.format_price(topup_amount_kopeks)}\n\n' + f'🎁 Ваша комиссия ({commission_percent}%): ' + f'{settings.format_price(commission_amount)}\n\n' + f'💎 Средства зачислены на ваш баланс.' ) await send_referral_notification( - bot, referrer.telegram_id, commission_notification, - user=referrer, bonus_kopeks=commission_amount, referral_name=user.full_name + bot, + referrer.telegram_id, + commission_notification, + user=referrer, + bonus_kopeks=commission_amount, + referral_name=user.full_name, ) return True user.has_made_first_topup = True await db.commit() - + try: await db.execute( delete(ReferralEarning).where( ReferralEarning.user_id == referrer.id, - ReferralEarning.referral_id == user.id, - ReferralEarning.reason == "referral_registration_pending" + ReferralEarning.referral_id == user.id, + ReferralEarning.reason == 'referral_registration_pending', ) ) await db.commit() logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user.id}") except Exception as e: - logger.error(f"Ошибка удаления записи ожидания: {e}") - + logger.error(f'Ошибка удаления записи ожидания: {e}') + if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0: await add_user_balance( - db, user, settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, - f"Бонус за первое пополнение по реферальной программе", - bot=bot + db, + user, + settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, + 'Бонус за первое пополнение по реферальной программе', + bot=bot, ) - logger.info(f"💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}₽") - + logger.info(f'💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100}₽') + if bot: bonus_notification = ( - f"🎉 Бонус получен!\n\n" - f"За первое пополнение вы получили бонус " - f"{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n" - f"💎 Средства зачислены на ваш баланс." + f'🎉 Бонус получен!\n\n' + f'За первое пополнение вы получили бонус ' + f'{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n' + f'💎 Средства зачислены на ваш баланс.' ) await send_referral_notification( - bot, user.telegram_id, bonus_notification, - user=user, bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS + bot, + user.telegram_id, + bonus_notification, + user=user, + bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS, ) - + commission_amount = int(topup_amount_kopeks * commission_percent / 100) inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount) if inviter_bonus > 0: await add_user_balance( - db, referrer, inviter_bonus, - f"Бонус за первое пополнение реферала {user.full_name}", - bot=bot + db, referrer, inviter_bonus, f'Бонус за первое пополнение реферала {user.full_name}', bot=bot ) await create_referral_earning( @@ -252,126 +240,134 @@ async def process_referral_topup( user_id=referrer.id, referral_id=user.id, amount_kopeks=inviter_bonus, - reason="referral_first_topup" + reason='referral_first_topup', ) - referrer_id = referrer.telegram_id or referrer.email or f"user#{referrer.id}" - logger.info(f"💰 Реферер {referrer_id} получил бонус {inviter_bonus/100}₽") + referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' + logger.info(f'💰 Реферер {referrer_id} получил бонус {inviter_bonus / 100}₽') if bot: inviter_bonus_notification = ( - f"💰 Реферальная награда!\n\n" - f"Ваш реферал {user.full_name} сделал первое пополнение!\n\n" - f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n" - f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии." + f'💰 Реферальная награда!\n\n' + f'Ваш реферал {user.full_name} сделал первое пополнение!\n\n' + f'🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n' + f'📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии.' ) await send_referral_notification( - bot, referrer.telegram_id, inviter_bonus_notification, - user=referrer, bonus_kopeks=inviter_bonus, referral_name=user.full_name + bot, + referrer.telegram_id, + inviter_bonus_notification, + user=referrer, + bonus_kopeks=inviter_bonus, + referral_name=user.full_name, ) - - else: - if commission_amount > 0: - await add_user_balance( - db, referrer, commission_amount, - f"Комиссия {commission_percent}% с пополнения {user.full_name}", - bot=bot + + elif commission_amount > 0: + await add_user_balance( + db, + referrer, + commission_amount, + f'Комиссия {commission_percent}% с пополнения {user.full_name}', + bot=bot, + ) + + await create_referral_earning( + db=db, + user_id=referrer.id, + referral_id=user.id, + amount_kopeks=commission_amount, + reason='referral_commission_topup', + ) + + referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' + logger.info(f'💰 Комиссия с пополнения: {referrer_id} получил {commission_amount / 100}₽') + + if bot: + commission_notification = ( + f'💰 Реферальная комиссия!\n\n' + f'Ваш реферал {user.full_name} пополнил баланс на ' + f'{settings.format_price(topup_amount_kopeks)}\n\n' + f'🎁 Ваша комиссия ({commission_percent}%): ' + f'{settings.format_price(commission_amount)}\n\n' + f'💎 Средства зачислены на ваш баланс.' ) - - await create_referral_earning( - db=db, - user_id=referrer.id, - referral_id=user.id, - amount_kopeks=commission_amount, - reason="referral_commission_topup" + await send_referral_notification( + bot, + referrer.telegram_id, + commission_notification, + user=referrer, + bonus_kopeks=commission_amount, + referral_name=user.full_name, ) - referrer_id = referrer.telegram_id or referrer.email or f"user#{referrer.id}" - logger.info(f"💰 Комиссия с пополнения: {referrer_id} получил {commission_amount/100}₽") - - if bot: - commission_notification = ( - f"💰 Реферальная комиссия!\n\n" - f"Ваш реферал {user.full_name} пополнил баланс на " - f"{settings.format_price(topup_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({commission_percent}%): " - f"{settings.format_price(commission_amount)}\n\n" - f"💎 Средства зачислены на ваш баланс." - ) - await send_referral_notification( - bot, referrer.telegram_id, commission_notification, - user=referrer, bonus_kopeks=commission_amount, referral_name=user.full_name - ) - return True - + except Exception as e: - logger.error(f"Ошибка обработки пополнения реферала: {e}") + logger.error(f'Ошибка обработки пополнения реферала: {e}') return False async def process_referral_purchase( - db: AsyncSession, - user_id: int, - purchase_amount_kopeks: int, - transaction_id: int = None, - bot: Bot = None + db: AsyncSession, user_id: int, purchase_amount_kopeks: int, transaction_id: int = None, bot: Bot = None ): try: user = await get_user_by_id(db, user_id) if not user or not user.referred_by_id: return True - + referrer = await get_user_by_id(db, user.referred_by_id) if not referrer: - logger.error(f"Реферер {user.referred_by_id} не найден") + logger.error(f'Реферер {user.referred_by_id} не найден') return False - + commission_percent = get_effective_referral_commission_percent(referrer) - + commission_amount = int(purchase_amount_kopeks * commission_percent / 100) - + if commission_amount > 0: await add_user_balance( - db, referrer, commission_amount, - f"Комиссия {commission_percent}% с покупки {user.full_name}", - bot=bot + db, referrer, commission_amount, f'Комиссия {commission_percent}% с покупки {user.full_name}', bot=bot ) - + await create_referral_earning( db=db, user_id=referrer.id, - referral_id=user.id, + referral_id=user.id, amount_kopeks=commission_amount, - reason="referral_commission", - referral_transaction_id=transaction_id + reason='referral_commission', + referral_transaction_id=transaction_id, ) - - referrer_id = referrer.telegram_id or referrer.email or f"user#{referrer.id}" - logger.info(f"💰 Комиссия с покупки: {referrer_id} получил {commission_amount/100}₽") - + + referrer_id = referrer.telegram_id or referrer.email or f'user#{referrer.id}' + logger.info(f'💰 Комиссия с покупки: {referrer_id} получил {commission_amount / 100}₽') + if bot: purchase_commission_notification = ( - f"💰 Комиссия с покупки!\n\n" - f"Ваш реферал {user.full_name} совершил покупку на " - f"{settings.format_price(purchase_amount_kopeks)}\n\n" - f"🎁 Ваша комиссия ({commission_percent}%): " - f"{settings.format_price(commission_amount)}\n\n" - f"💎 Средства зачислены на ваш баланс." + f'💰 Комиссия с покупки!\n\n' + f'Ваш реферал {user.full_name} совершил покупку на ' + f'{settings.format_price(purchase_amount_kopeks)}\n\n' + f'🎁 Ваша комиссия ({commission_percent}%): ' + f'{settings.format_price(commission_amount)}\n\n' + f'💎 Средства зачислены на ваш баланс.' ) await send_referral_notification( - bot, referrer.telegram_id, purchase_commission_notification, - user=referrer, bonus_kopeks=commission_amount, referral_name=user.full_name + bot, + referrer.telegram_id, + purchase_commission_notification, + user=referrer, + bonus_kopeks=commission_amount, + referral_name=user.full_name, ) - + if not user.has_had_paid_subscription: user.has_had_paid_subscription = True await db.commit() - logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку") - + logger.info(f'✅ Пользователь {user_id} отмечен как имевший платную подписку') + return True - + except Exception as e: - logger.error(f"Ошибка обработки покупки реферала: {e}") + logger.error(f'Ошибка обработки покупки реферала: {e}') import traceback - logger.error(f"Полный traceback: {traceback.format_exc()}") + + logger.error(f'Полный traceback: {traceback.format_exc()}') return False diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py index 3d242220..eb7aebc8 100644 --- a/app/services/referral_withdrawal_service.py +++ b/app/services/referral_withdrawal_service.py @@ -2,23 +2,24 @@ Сервис для обработки запросов на вывод реферального баланса с анализом на подозрительную активность (отмывание денег). """ + import json import logging from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple -from sqlalchemy import select, func, and_ +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import ( - User, - Transaction, ReferralEarning, + Transaction, + User, WithdrawalRequest, WithdrawalRequestStatus, ) + logger = logging.getLogger(__name__) @@ -33,8 +34,7 @@ class ReferralWithdrawalService: Возвращает сумму в копейках. """ result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.user_id == user_id) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.user_id == user_id) ) return result.scalar() or 0 @@ -43,22 +43,18 @@ class ReferralWithdrawalService: Получает сумму собственных пополнений пользователя (НЕ реферальные). """ result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( - Transaction.user_id == user_id, - Transaction.type == "deposit", - Transaction.is_completed == True + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( + Transaction.user_id == user_id, Transaction.type == 'deposit', Transaction.is_completed == True ) ) return result.scalar() or 0 - async def get_first_referral_earning_date(self, db: AsyncSession, user_id: int) -> Optional[datetime]: + async def get_first_referral_earning_date(self, db: AsyncSession, user_id: int) -> datetime | None: """ Получает дату первого реферального начисления. """ result = await db.execute( - select(func.min(ReferralEarning.created_at)) - .where(ReferralEarning.user_id == user_id) + select(func.min(ReferralEarning.created_at)).where(ReferralEarning.user_id == user_id) ) return result.scalar() @@ -67,11 +63,10 @@ class ReferralWithdrawalService: Получает сумму трат пользователя (покупки подписок, сброс трафика и т.д.). """ result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( Transaction.user_id == user_id, - Transaction.type.in_(["subscription_payment", "withdrawal"]), - Transaction.is_completed == True + Transaction.type.in_(['subscription_payment', 'withdrawal']), + Transaction.is_completed == True, ) ) return abs(result.scalar() or 0) @@ -86,12 +81,11 @@ class ReferralWithdrawalService: return 0 result = await db.execute( - select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where( Transaction.user_id == user_id, - Transaction.type.in_(["subscription_payment", "withdrawal"]), + Transaction.type.in_(['subscription_payment', 'withdrawal']), Transaction.is_completed == True, - Transaction.created_at >= first_earning_date + Transaction.created_at >= first_earning_date, ) ) return abs(result.scalar() or 0) @@ -101,13 +95,11 @@ class ReferralWithdrawalService: Получает сумму уже выведенных средств (одобренные/выполненные заявки). """ result = await db.execute( - select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)) - .where( + select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where( WithdrawalRequest.user_id == user_id, - WithdrawalRequest.status.in_([ - WithdrawalRequestStatus.APPROVED.value, - WithdrawalRequestStatus.COMPLETED.value - ]) + WithdrawalRequest.status.in_( + [WithdrawalRequestStatus.APPROVED.value, WithdrawalRequestStatus.COMPLETED.value] + ), ) ) return result.scalar() or 0 @@ -117,15 +109,13 @@ class ReferralWithdrawalService: Получает сумму заявок в ожидании (заморожено). """ result = await db.execute( - select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)) - .where( - WithdrawalRequest.user_id == user_id, - WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value + select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where( + WithdrawalRequest.user_id == user_id, WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value ) ) return result.scalar() or 0 - async def get_referral_balance_stats(self, db: AsyncSession, user_id: int) -> Dict: + async def get_referral_balance_stats(self, db: AsyncSession, user_id: int) -> dict: """ Получает полную статистику реферального баланса. """ @@ -153,27 +143,25 @@ class ReferralWithdrawalService: available_total = available_referral return { - "total_earned": total_earned, # Всего заработано с рефералов - "own_deposits": own_deposits, # Собственные пополнения - "spending": spending, # Потрачено на подписки и пр. - "referral_spent": referral_spent, # Сколько реф. баланса потрачено - "withdrawn": withdrawn, # Уже выведено - "pending": pending, # На рассмотрении - "available_referral": available_referral, # Доступно реф. баланса - "available_total": available_total, # Всего доступно к выводу - "only_referral_mode": settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE, + 'total_earned': total_earned, # Всего заработано с рефералов + 'own_deposits': own_deposits, # Собственные пополнения + 'spending': spending, # Потрачено на подписки и пр. + 'referral_spent': referral_spent, # Сколько реф. баланса потрачено + 'withdrawn': withdrawn, # Уже выведено + 'pending': pending, # На рассмотрении + 'available_referral': available_referral, # Доступно реф. баланса + 'available_total': available_total, # Всего доступно к выводу + 'only_referral_mode': settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE, } async def get_available_for_withdrawal(self, db: AsyncSession, user_id: int) -> int: """Получает сумму, доступную для вывода.""" stats = await self.get_referral_balance_stats(db, user_id) - return stats["available_total"] + return stats['available_total'] # ==================== ПРОВЕРКИ ==================== - async def get_last_withdrawal_request( - self, db: AsyncSession, user_id: int - ) -> Optional[WithdrawalRequest]: + async def get_last_withdrawal_request(self, db: AsyncSession, user_id: int) -> WithdrawalRequest | None: """Получает последнюю заявку на вывод пользователя.""" result = await db.execute( select(WithdrawalRequest) @@ -183,23 +171,21 @@ class ReferralWithdrawalService: ) return result.scalar_one_or_none() - async def can_request_withdrawal( - self, db: AsyncSession, user_id: int - ) -> Tuple[bool, str]: + async def can_request_withdrawal(self, db: AsyncSession, user_id: int) -> tuple[bool, str]: """ Проверяет, может ли пользователь запросить вывод. Возвращает (can_request, reason). """ if not settings.is_referral_withdrawal_enabled(): - return False, "Функция вывода реферального баланса отключена" + return False, 'Функция вывода реферального баланса отключена' # Проверяем доступный баланс stats = await self.get_referral_balance_stats(db, user_id) - available = stats["available_total"] + available = stats['available_total'] min_amount = settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS if available < min_amount: - return False, f"Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}₽" + return False, f'Минимальная сумма вывода: {min_amount / 100:.0f}₽. Доступно: {available / 100:.0f}₽' # Проверяем cooldown (пропускаем в тестовом режиме) last_request = await self.get_last_withdrawal_request(db, user_id) @@ -211,61 +197,47 @@ class ReferralWithdrawalService: if datetime.utcnow() < cooldown_end: days_left = (cooldown_end - datetime.utcnow()).days + 1 - return False, f"Следующий запрос на вывод будет доступен через {days_left} дн." + return False, f'Следующий запрос на вывод будет доступен через {days_left} дн.' # Проверяем, нет ли активной заявки if last_request.status == WithdrawalRequestStatus.PENDING.value: - return False, "У вас уже есть активная заявка на рассмотрении" + return False, 'У вас уже есть активная заявка на рассмотрении' - return True, "OK" + return True, 'OK' # ==================== АНАЛИЗ НА ОТМЫВАНИЕ ==================== - async def analyze_for_money_laundering( - self, db: AsyncSession, user_id: int - ) -> Dict: + async def analyze_for_money_laundering(self, db: AsyncSession, user_id: int) -> dict: """ Детальный анализ активности пользователя на предмет отмывания денег. """ - analysis = { - "risk_score": 0, - "risk_level": "low", - "recommendation": "approve", - "flags": [], - "details": {} - } + analysis = {'risk_score': 0, 'risk_level': 'low', 'recommendation': 'approve', 'flags': [], 'details': {}} # Получаем статистику баланса balance_stats = await self.get_referral_balance_stats(db, user_id) - analysis["details"]["balance_stats"] = balance_stats + analysis['details']['balance_stats'] = balance_stats # 1. ПРОВЕРКА: Пользователь пополнил но не покупал подписки - own_deposits = balance_stats["own_deposits"] - spending = balance_stats["spending"] + own_deposits = balance_stats['own_deposits'] + spending = balance_stats['spending'] ratio_threshold = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO if own_deposits > 0 and spending == 0: - analysis["risk_score"] += 40 - analysis["flags"].append( - f"🔴 Пополнил {own_deposits / 100:.0f}₽, но ничего не покупал!" - ) + analysis['risk_score'] += 40 + analysis['flags'].append(f'🔴 Пополнил {own_deposits / 100:.0f}₽, но ничего не покупал!') elif own_deposits > spending * ratio_threshold and spending > 0: - analysis["risk_score"] += 25 - analysis["flags"].append( - f"🟠 Пополнил {own_deposits / 100:.0f}₽, потратил только {spending / 100:.0f}₽" - ) + analysis['risk_score'] += 25 + analysis['flags'].append(f'🟠 Пополнил {own_deposits / 100:.0f}₽, потратил только {spending / 100:.0f}₽') # 2. Получаем информацию о рефералах - referrals = await db.execute( - select(User).where(User.referred_by_id == user_id) - ) + referrals = await db.execute(select(User).where(User.referred_by_id == user_id)) referrals_list = referrals.scalars().all() referral_count = len(referrals_list) - analysis["details"]["referral_count"] = referral_count + analysis['details']['referral_count'] = referral_count - if referral_count == 0 and balance_stats["total_earned"] > 0: - analysis["risk_score"] += 50 - analysis["flags"].append("🔴 Нет рефералов, но есть реферальный доход!") + if referral_count == 0 and balance_stats['total_earned'] > 0: + analysis['risk_score'] += 50 + analysis['flags'].append('🔴 Нет рефералов, но есть реферальный доход!') # 3. Анализ пополнений каждого реферала referral_ids = [r.id for r in referrals_list] @@ -277,19 +249,18 @@ class ReferralWithdrawalService: for ref_id in referral_ids: ref_user = next((r for r in referrals_list if r.id == ref_id), None) - ref_name = ref_user.full_name if ref_user else f"ID{ref_id}" + ref_name = ref_user.full_name if ref_user else f'ID{ref_id}' # Пополнения этого реферала за месяц ref_deposits = await db.execute( select( - func.count().label("count"), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total") - ) - .where( + func.count().label('count'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total'), + ).where( Transaction.user_id == ref_id, - Transaction.type == "deposit", + Transaction.type == 'deposit', Transaction.is_completed == True, - Transaction.created_at >= month_ago + Transaction.created_at >= month_ago, ) ) deposit_data = ref_deposits.fetchone() @@ -301,125 +272,118 @@ class ReferralWithdrawalService: # Проверка: слишком много пополнений от одного реферала max_deposits = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH if deposit_count > max_deposits: - analysis["risk_score"] += 15 - suspicious_flags.append(f"{deposit_count} пополнений/мес") + analysis['risk_score'] += 15 + suspicious_flags.append(f'{deposit_count} пополнений/мес') # Проверка: большие суммы от одного реферала min_suspicious = settings.REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS if deposit_total > min_suspicious: - analysis["risk_score"] += 10 - suspicious_flags.append(f"сумма {deposit_total / 100:.0f}₽") + analysis['risk_score'] += 10 + suspicious_flags.append(f'сумма {deposit_total / 100:.0f}₽') if suspicious_flags: - suspicious_referrals.append({ - "name": ref_name, - "deposits_count": deposit_count, - "deposits_total": deposit_total, - "flags": suspicious_flags - }) + suspicious_referrals.append( + { + 'name': ref_name, + 'deposits_count': deposit_count, + 'deposits_total': deposit_total, + 'flags': suspicious_flags, + } + ) - analysis["details"]["suspicious_referrals"] = suspicious_referrals + analysis['details']['suspicious_referrals'] = suspicious_referrals if suspicious_referrals: - analysis["flags"].append( - f"⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)" - ) + analysis['flags'].append(f'⚠️ Подозрительная активность у {len(suspicious_referrals)} реферала(ов)') # Общая статистика по рефералам all_ref_deposits = await db.execute( select( - func.count(func.distinct(Transaction.user_id)).label("paying_count"), - func.count().label("total_deposits"), - func.coalesce(func.sum(Transaction.amount_kopeks), 0).label("total_amount") - ) - .where( + func.count(func.distinct(Transaction.user_id)).label('paying_count'), + func.count().label('total_deposits'), + func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'), + ).where( Transaction.user_id.in_(referral_ids), - Transaction.type == "deposit", - Transaction.is_completed == True + Transaction.type == 'deposit', + Transaction.is_completed == True, ) ) ref_stats = all_ref_deposits.fetchone() - analysis["details"]["referral_deposits"] = { - "paying_referrals": ref_stats.paying_count, - "total_deposits": ref_stats.total_deposits, - "total_amount": ref_stats.total_amount + analysis['details']['referral_deposits'] = { + 'paying_referrals': ref_stats.paying_count, + 'total_deposits': ref_stats.total_deposits, + 'total_amount': ref_stats.total_amount, } # Проверка: только 1 платящий реферал - if ref_stats.paying_count == 1 and balance_stats["total_earned"] > 50000: - analysis["risk_score"] += 20 - analysis["flags"].append("⚠️ Весь доход от одного реферала") + if ref_stats.paying_count == 1 and balance_stats['total_earned'] > 50000: + analysis['risk_score'] += 20 + analysis['flags'].append('⚠️ Весь доход от одного реферала') # 4. Анализ реферальных начислений по типам earnings = await db.execute( select( ReferralEarning.reason, - func.count().label("count"), - func.sum(ReferralEarning.amount_kopeks).label("total") + func.count().label('count'), + func.sum(ReferralEarning.amount_kopeks).label('total'), ) .where(ReferralEarning.user_id == user_id) .group_by(ReferralEarning.reason) ) - earnings_by_reason = {r.reason: {"count": r.count, "total": r.total} for r in earnings.fetchall()} - analysis["details"]["earnings_by_reason"] = earnings_by_reason + earnings_by_reason = {r.reason: {'count': r.count, 'total': r.total} for r in earnings.fetchall()} + analysis['details']['earnings_by_reason'] = earnings_by_reason # 5. Проверка: много начислений за последнюю неделю week_ago = datetime.utcnow() - timedelta(days=7) recent_earnings = await db.execute( - select(func.count(), func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where( - ReferralEarning.user_id == user_id, - ReferralEarning.created_at >= week_ago + select(func.count(), func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + ReferralEarning.user_id == user_id, ReferralEarning.created_at >= week_ago ) ) recent_data = recent_earnings.fetchone() recent_count, recent_amount = recent_data if recent_count > 20: - analysis["risk_score"] += 15 - analysis["flags"].append(f"⚠️ {recent_count} начислений за неделю ({recent_amount / 100:.0f}₽)") + analysis['risk_score'] += 15 + analysis['flags'].append(f'⚠️ {recent_count} начислений за неделю ({recent_amount / 100:.0f}₽)') - analysis["details"]["recent_activity"] = { - "week_earnings_count": recent_count, - "week_earnings_amount": recent_amount + analysis['details']['recent_activity'] = { + 'week_earnings_count': recent_count, + 'week_earnings_amount': recent_amount, } # ==================== ИТОГОВАЯ ОЦЕНКА ==================== - score = analysis["risk_score"] + score = analysis['risk_score'] # Ограничиваем максимум score = min(score, 100) - analysis["risk_score"] = score + analysis['risk_score'] = score if score >= 70: - analysis["risk_level"] = "critical" - analysis["recommendation"] = "reject" - analysis["recommendation_text"] = "🔴 РЕКОМЕНДУЕТСЯ ОТКЛОНИТЬ" + analysis['risk_level'] = 'critical' + analysis['recommendation'] = 'reject' + analysis['recommendation_text'] = '🔴 РЕКОМЕНДУЕТСЯ ОТКЛОНИТЬ' elif score >= 50: - analysis["risk_level"] = "high" - analysis["recommendation"] = "review" - analysis["recommendation_text"] = "🟠 ТРЕБУЕТ ПРОВЕРКИ" + analysis['risk_level'] = 'high' + analysis['recommendation'] = 'review' + analysis['recommendation_text'] = '🟠 ТРЕБУЕТ ПРОВЕРКИ' elif score >= 30: - analysis["risk_level"] = "medium" - analysis["recommendation"] = "review" - analysis["recommendation_text"] = "🟡 Рекомендуется проверить" + analysis['risk_level'] = 'medium' + analysis['recommendation'] = 'review' + analysis['recommendation_text'] = '🟡 Рекомендуется проверить' else: - analysis["risk_level"] = "low" - analysis["recommendation"] = "approve" - analysis["recommendation_text"] = "🟢 Можно одобрить" + analysis['risk_level'] = 'low' + analysis['recommendation'] = 'approve' + analysis['recommendation_text'] = '🟢 Можно одобрить' return analysis # ==================== СОЗДАНИЕ И УПРАВЛЕНИЕ ЗАЯВКАМИ ==================== async def create_withdrawal_request( - self, - db: AsyncSession, - user_id: int, - amount_kopeks: int, - payment_details: str - ) -> Tuple[Optional[WithdrawalRequest], str]: + self, db: AsyncSession, user_id: int, amount_kopeks: int, payment_details: str + ) -> tuple[WithdrawalRequest | None, str]: """ Создаёт заявку на вывод с анализом на отмывание. Возвращает (request, error_message). @@ -431,15 +395,15 @@ class ReferralWithdrawalService: # Проверяем сумму stats = await self.get_referral_balance_stats(db, user_id) - available = stats["available_total"] + available = stats['available_total'] if amount_kopeks > available: - return None, f"Недостаточно средств. Доступно: {available / 100:.0f}₽" + return None, f'Недостаточно средств. Доступно: {available / 100:.0f}₽' # В режиме "только реф. баланс" проверяем реф. баланс if settings.REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: - if amount_kopeks > stats["available_referral"]: - return None, f"Недостаточно реферального баланса. Доступно: {stats['available_referral'] / 100:.0f}₽" + if amount_kopeks > stats['available_referral']: + return None, f'Недостаточно реферального баланса. Доступно: {stats["available_referral"] / 100:.0f}₽' # Анализируем на отмывание analysis = await self.analyze_for_money_laundering(db, user_id) @@ -449,17 +413,17 @@ class ReferralWithdrawalService: user_id=user_id, amount_kopeks=amount_kopeks, payment_details=payment_details, - risk_score=analysis["risk_score"], - risk_analysis=json.dumps(analysis, ensure_ascii=False, default=str) + risk_score=analysis['risk_score'], + risk_analysis=json.dumps(analysis, ensure_ascii=False, default=str), ) db.add(request) await db.commit() await db.refresh(request) - return request, "" + return request, '' - async def get_pending_requests(self, db: AsyncSession) -> List[WithdrawalRequest]: + async def get_pending_requests(self, db: AsyncSession) -> list[WithdrawalRequest]: """Получает все ожидающие заявки на вывод.""" result = await db.execute( select(WithdrawalRequest) @@ -468,68 +432,55 @@ class ReferralWithdrawalService: ) return result.scalars().all() - async def get_all_requests( - self, db: AsyncSession, limit: int = 50, offset: int = 0 - ) -> List[WithdrawalRequest]: + async def get_all_requests(self, db: AsyncSession, limit: int = 50, offset: int = 0) -> list[WithdrawalRequest]: """Получает все заявки на вывод (журнал).""" result = await db.execute( - select(WithdrawalRequest) - .order_by(WithdrawalRequest.created_at.desc()) - .limit(limit) - .offset(offset) + select(WithdrawalRequest).order_by(WithdrawalRequest.created_at.desc()).limit(limit).offset(offset) ) return result.scalars().all() async def approve_request( - self, - db: AsyncSession, - request_id: int, - admin_id: int, - comment: Optional[str] = None - ) -> Tuple[bool, str]: + self, db: AsyncSession, request_id: int, admin_id: int, comment: str | None = None + ) -> tuple[bool, str]: """ Одобряет заявку на вывод и списывает средства с баланса. Возвращает (success, error_message). """ - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request: - return False, "Заявка не найдена" + return False, 'Заявка не найдена' if request.status != WithdrawalRequestStatus.PENDING.value: - return False, "Заявка уже обработана" + return False, 'Заявка уже обработана' # Проверяем, что баланс всё ещё достаточен stats = await self.get_referral_balance_stats(db, request.user_id) - if request.amount_kopeks > stats["available_total"]: - return False, f"Недостаточно средств у пользователя. Доступно: {stats['available_total'] / 100:.0f}₽" + if request.amount_kopeks > stats['available_total']: + return False, f'Недостаточно средств у пользователя. Доступно: {stats["available_total"] / 100:.0f}₽' # Получаем пользователя для списания с баланса - user_result = await db.execute( - select(User).where(User.id == request.user_id) - ) + user_result = await db.execute(select(User).where(User.id == request.user_id)) user = user_result.scalar_one_or_none() if not user: - return False, "Пользователь не найден" + return False, 'Пользователь не найден' # Списываем с баланса if user.balance_kopeks < request.amount_kopeks: - return False, f"Недостаточно средств на балансе. Баланс: {user.balance_kopeks / 100:.0f}₽" + return False, f'Недостаточно средств на балансе. Баланс: {user.balance_kopeks / 100:.0f}₽' user.balance_kopeks -= request.amount_kopeks # Создаём транзакцию списания withdrawal_tx = Transaction( user_id=request.user_id, - type="withdrawal", + type='withdrawal', amount_kopeks=-request.amount_kopeks, - description=f"Вывод реферального баланса (заявка #{request.id})", + description=f'Вывод реферального баланса (заявка #{request.id})', is_completed=True, - completed_at=datetime.utcnow() + completed_at=datetime.utcnow(), ) db.add(withdrawal_tx) @@ -540,19 +491,13 @@ class ReferralWithdrawalService: request.admin_comment = comment await db.commit() - return True, "" + return True, '' async def reject_request( - self, - db: AsyncSession, - request_id: int, - admin_id: int, - comment: Optional[str] = None + self, db: AsyncSession, request_id: int, admin_id: int, comment: str | None = None ) -> bool: """Отклоняет заявку на вывод.""" - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request or request.status != WithdrawalRequestStatus.PENDING.value: @@ -567,16 +512,10 @@ class ReferralWithdrawalService: return True async def complete_request( - self, - db: AsyncSession, - request_id: int, - admin_id: int, - comment: Optional[str] = None + self, db: AsyncSession, request_id: int, admin_id: int, comment: str | None = None ) -> bool: """Отмечает заявку как выполненную (деньги переведены).""" - result = await db.execute( - select(WithdrawalRequest).where(WithdrawalRequest.id == request_id) - ) + result = await db.execute(select(WithdrawalRequest).where(WithdrawalRequest.id == request_id)) request = result.scalar_one_or_none() if not request or request.status != WithdrawalRequestStatus.APPROVED.value: @@ -586,59 +525,63 @@ class ReferralWithdrawalService: request.processed_by = admin_id request.processed_at = datetime.utcnow() if comment: - request.admin_comment = (request.admin_comment or "") + f"\n{comment}" + request.admin_comment = (request.admin_comment or '') + f'\n{comment}' await db.commit() return True # ==================== ФОРМАТИРОВАНИЕ ==================== - def format_balance_stats_for_user(self, stats: Dict, texts) -> str: + def format_balance_stats_for_user(self, stats: dict, texts) -> str: """Форматирует статистику баланса для пользователя.""" - text = "" - text += texts.t( - "REFERRAL_WITHDRAWAL_STATS_EARNED", - "📈 Всего заработано с рефералов: {amount}" - ).format(amount=texts.format_price(stats["total_earned"])) + "\n" + text = '' + text += ( + texts.t('REFERRAL_WITHDRAWAL_STATS_EARNED', '📈 Всего заработано с рефералов: {amount}').format( + amount=texts.format_price(stats['total_earned']) + ) + + '\n' + ) - text += texts.t( - "REFERRAL_WITHDRAWAL_STATS_SPENT", - "💳 Потрачено на подписки: {amount}" - ).format(amount=texts.format_price(stats["referral_spent"])) + "\n" + text += ( + texts.t('REFERRAL_WITHDRAWAL_STATS_SPENT', '💳 Потрачено на подписки: {amount}').format( + amount=texts.format_price(stats['referral_spent']) + ) + + '\n' + ) - text += texts.t( - "REFERRAL_WITHDRAWAL_STATS_WITHDRAWN", - "💸 Выведено: {amount}" - ).format(amount=texts.format_price(stats["withdrawn"])) + "\n" + text += ( + texts.t('REFERRAL_WITHDRAWAL_STATS_WITHDRAWN', '💸 Выведено: {amount}').format( + amount=texts.format_price(stats['withdrawn']) + ) + + '\n' + ) - if stats["pending"] > 0: - text += texts.t( - "REFERRAL_WITHDRAWAL_STATS_PENDING", - "⏳ На рассмотрении: {amount}" - ).format(amount=texts.format_price(stats["pending"])) + "\n" + if stats['pending'] > 0: + text += ( + texts.t('REFERRAL_WITHDRAWAL_STATS_PENDING', '⏳ На рассмотрении: {amount}').format( + amount=texts.format_price(stats['pending']) + ) + + '\n' + ) - text += "\n" - text += texts.t( - "REFERRAL_WITHDRAWAL_STATS_AVAILABLE", - "✅ Доступно к выводу: {amount}" - ).format(amount=texts.format_price(stats["available_total"])) + "\n" + text += '\n' + text += ( + texts.t('REFERRAL_WITHDRAWAL_STATS_AVAILABLE', '✅ Доступно к выводу: {amount}').format( + amount=texts.format_price(stats['available_total']) + ) + + '\n' + ) - if stats["only_referral_mode"]: - text += texts.t( - "REFERRAL_WITHDRAWAL_ONLY_REF_MODE", - "ℹ️ Выводить можно только реферальный баланс" - ) + "\n" + if stats['only_referral_mode']: + text += ( + texts.t('REFERRAL_WITHDRAWAL_ONLY_REF_MODE', 'ℹ️ Выводить можно только реферальный баланс') + '\n' + ) return text - def format_analysis_for_admin(self, analysis: Dict) -> str: + def format_analysis_for_admin(self, analysis: dict) -> str: """Форматирует анализ для отображения админу.""" - risk_emoji = { - "low": "🟢", - "medium": "🟡", - "high": "🟠", - "critical": "🔴" - } + risk_emoji = {'low': '🟢', 'medium': '🟡', 'high': '🟠', 'critical': '🔴'} text = f""" 🔍 Анализ на подозрительную активность @@ -648,48 +591,48 @@ class ReferralWithdrawalService: {analysis.get('recommendation_text', '')} """ - if analysis.get("flags"): - text += "\n⚠️ Предупреждения:\n" - for flag in analysis["flags"]: - text += f" {flag}\n" + if analysis.get('flags'): + text += '\n⚠️ Предупреждения:\n' + for flag in analysis['flags']: + text += f' {flag}\n' - details = analysis.get("details", {}) + details = analysis.get('details', {}) # Статистика баланса - if "balance_stats" in details: - bs = details["balance_stats"] - text += "\n💰 Баланс:\n" - text += f"• Заработано с рефералов: {bs['total_earned'] / 100:.0f}₽\n" - text += f"• Собственные пополнения: {bs['own_deposits'] / 100:.0f}₽\n" - text += f"• Потрачено: {bs['spending'] / 100:.0f}₽\n" - text += f"• Уже выведено: {bs['withdrawn'] / 100:.0f}₽\n" + if 'balance_stats' in details: + bs = details['balance_stats'] + text += '\n💰 Баланс:\n' + text += f'• Заработано с рефералов: {bs["total_earned"] / 100:.0f}₽\n' + text += f'• Собственные пополнения: {bs["own_deposits"] / 100:.0f}₽\n' + text += f'• Потрачено: {bs["spending"] / 100:.0f}₽\n' + text += f'• Уже выведено: {bs["withdrawn"] / 100:.0f}₽\n' # Статистика по рефералам - if "referral_deposits" in details: - rd = details["referral_deposits"] - text += f"\n👥 Рефералы:\n" - text += f"• Всего: {details.get('referral_count', 0)}\n" - text += f"• Платящих: {rd['paying_referrals']}\n" - text += f"• Всего пополнений: {rd['total_deposits']} ({rd['total_amount'] / 100:.0f}₽)\n" + if 'referral_deposits' in details: + rd = details['referral_deposits'] + text += '\n👥 Рефералы:\n' + text += f'• Всего: {details.get("referral_count", 0)}\n' + text += f'• Платящих: {rd["paying_referrals"]}\n' + text += f'• Всего пополнений: {rd["total_deposits"]} ({rd["total_amount"] / 100:.0f}₽)\n' # Подозрительные рефералы - if details.get("suspicious_referrals"): - text += "\n🚨 Подозрительные рефералы:\n" - for sr in details["suspicious_referrals"][:5]: - text += f"• {sr['name']}: {sr['deposits_count']} поп., {sr['deposits_total'] / 100:.0f}₽\n" - text += f" Флаги: {', '.join(sr['flags'])}\n" + if details.get('suspicious_referrals'): + text += '\n🚨 Подозрительные рефералы:\n' + for sr in details['suspicious_referrals'][:5]: + text += f'• {sr["name"]}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}₽\n' + text += f' Флаги: {", ".join(sr["flags"])}\n' # Источники дохода - if "earnings_by_reason" in details: - text += "\n📊 Источники дохода:\n" + if 'earnings_by_reason' in details: + text += '\n📊 Источники дохода:\n' reason_names = { - "referral_first_topup": "Бонус за 1-е пополнение", - "referral_commission_topup": "Комиссия с пополнений", - "referral_commission": "Комиссия с покупок" + 'referral_first_topup': 'Бонус за 1-е пополнение', + 'referral_commission_topup': 'Комиссия с пополнений', + 'referral_commission': 'Комиссия с покупок', } - for reason, data in details["earnings_by_reason"].items(): + for reason, data in details['earnings_by_reason'].items(): name = reason_names.get(reason, reason) - text += f"• {name}: {data['count']} шт. ({data['total'] / 100:.0f}₽)\n" + text += f'• {name}: {data["count"]} шт. ({data["total"] / 100:.0f}₽)\n' return text diff --git a/app/services/remnawave_service.py b/app/services/remnawave_service.py index 9db14b41..25e094bb 100644 --- a/app/services/remnawave_service.py +++ b/app/services/remnawave_service.py @@ -2,52 +2,48 @@ import asyncio import logging import re from contextlib import AsyncExitStack, asynccontextmanager -from datetime import datetime, timedelta from dataclasses import asdict, is_dataclass -from typing import Any, Dict, List, Optional, Tuple - +from datetime import datetime, timedelta +from typing import Any, Optional from zoneinfo import ZoneInfo -from app.config import settings -from app.external.remnawave_api import ( - RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad, - RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError -) -from sqlalchemy import and_, cast, delete, func, select, update, String +from sqlalchemy import String, and_, cast, delete, func, select, update from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from app.database.crud.user import ( - create_user_no_commit, - get_users_list, - get_user_by_telegram_id, - update_user, -) +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.subscription import ( - get_subscription_by_user_id, - update_subscription_usage, decrement_subscription_server_counts, ) -from app.database.crud.server_squad import get_server_squad_by_uuid +from app.database.crud.user import ( + create_user_no_commit, + get_user_by_telegram_id, +) from app.database.models import ( - User, + ServerSquad, Subscription, SubscriptionServer, - Transaction, - ReferralEarning, - PromoCodeUse, SubscriptionStatus, - ServerSquad, + User, +) +from app.external.remnawave_api import ( + RemnaWaveAPI, + RemnaWaveAPIError, + TrafficLimitStrategy, + UserStatus, ) from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) from app.utils.timezone import get_local_timezone + logger = logging.getLogger(__name__) -def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int: +def _get_user_traffic_bytes(panel_user: dict[str, Any]) -> int: """Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" # Новый формат: userTraffic.usedTrafficBytes user_traffic = panel_user.get('userTraffic') @@ -57,7 +53,7 @@ def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int: return panel_user.get('usedTrafficBytes', 0) -def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int: +def _get_lifetime_traffic_bytes(panel_user: dict[str, Any]) -> int: """Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)""" # Новый формат: userTraffic.lifetimeUsedTrafficBytes user_traffic = panel_user.get('userTraffic') @@ -73,45 +69,45 @@ _UUID_MAP_MISSING = object() class _UUIDMapMutation: """Tracks in-memory UUID map/user changes so they can be rolled back.""" - __slots__ = ("uuid_map", "_map_original", "_user_original") + __slots__ = ('_map_original', '_user_original', 'uuid_map') - def __init__(self, uuid_map: Dict[str, "User"]): + def __init__(self, uuid_map: dict[str, 'User']): self.uuid_map = uuid_map - self._map_original: Dict[str, Any] = {} - self._user_original: Dict["User", Tuple[Optional[str], Optional[datetime]]] = {} + self._map_original: dict[str, Any] = {} + self._user_original: dict[User, tuple[str | None, datetime | None]] = {} - def _capture_user_state(self, user: Optional["User"]) -> None: + def _capture_user_state(self, user: Optional['User']) -> None: if not user or user in self._user_original: return self._user_original[user] = ( - getattr(user, "remnawave_uuid", None), - getattr(user, "updated_at", None), + getattr(user, 'remnawave_uuid', None), + getattr(user, 'updated_at', None), ) - def _capture_map_entry(self, key: Optional[str]) -> None: + def _capture_map_entry(self, key: str | None) -> None: if key is None or key in self._map_original: return self._map_original[key] = self.uuid_map.get(key, _UUID_MAP_MISSING) - def set_user_uuid(self, user: Optional["User"], value: Optional[str]) -> None: + def set_user_uuid(self, user: Optional['User'], value: str | None) -> None: if not user: return self._capture_user_state(user) user.remnawave_uuid = value - def set_user_updated_at(self, user: Optional["User"], value: datetime) -> None: + def set_user_updated_at(self, user: Optional['User'], value: datetime) -> None: if not user: return self._capture_user_state(user) user.updated_at = value - def remove_map_entry(self, key: Optional[str]) -> None: + def remove_map_entry(self, key: str | None) -> None: if key is None: return self._capture_map_entry(key) self.uuid_map.pop(key, None) - def set_map_entry(self, key: Optional[str], value: Optional["User"]) -> None: + def set_map_entry(self, key: str | None, value: Optional['User']) -> None: if key is None: return self._capture_map_entry(key) @@ -140,34 +136,33 @@ class RemnaWaveConfigurationError(Exception): class RemnaWaveService: - def __init__(self): auth_params = settings.get_remnawave_auth_params() - base_url = (auth_params.get("base_url") or "").strip() - api_key = (auth_params.get("api_key") or "").strip() + base_url = (auth_params.get('base_url') or '').strip() + api_key = (auth_params.get('api_key') or '').strip() - self._config_error: Optional[str] = None + self._config_error: str | None = None self._panel_timezone = get_local_timezone() - self._utc_timezone = ZoneInfo("UTC") + self._utc_timezone = ZoneInfo('UTC') if not base_url: - self._config_error = "REMNAWAVE_API_URL не настроен" + self._config_error = 'REMNAWAVE_API_URL не настроен' elif not api_key: - self._config_error = "REMNAWAVE_API_KEY не настроен" + self._config_error = 'REMNAWAVE_API_KEY не настроен' - self.api: Optional[RemnaWaveAPI] + self.api: RemnaWaveAPI | None 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"), - caddy_token=auth_params.get("caddy_token"), - auth_type=auth_params.get("auth_type") or "api_key", + secret_key=auth_params.get('secret_key'), + username=auth_params.get('username'), + password=auth_params.get('password'), + caddy_token=auth_params.get('caddy_token'), + auth_type=auth_params.get('auth_type') or 'api_key', ) @property @@ -175,27 +170,25 @@ class RemnaWaveService: return self._config_error is None @property - def configuration_error(self) -> Optional[str]: + def configuration_error(self) -> str | None: 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 не настроен" - ) + raise RemnaWaveConfigurationError(self._config_error or 'RemnaWave API не настроен') def _ensure_user_remnawave_uuid( self, - user: "User", - panel_uuid: Optional[str], - uuid_map: Dict[str, "User"], - ) -> Tuple[bool, Optional[_UUIDMapMutation]]: + user: 'User', + panel_uuid: str | None, + uuid_map: dict[str, 'User'], + ) -> tuple[bool, _UUIDMapMutation | None]: """Обновляет UUID пользователя, если он изменился в панели.""" if not panel_uuid: return False, None - current_uuid = getattr(user, "remnawave_uuid", None) + current_uuid = getattr(user, 'remnawave_uuid', None) if current_uuid == panel_uuid: return False, None @@ -204,10 +197,10 @@ class RemnaWaveService: conflicting_user = uuid_map.get(panel_uuid) if conflicting_user and conflicting_user is not user: logger.warning( - "♻️ Обнаружен конфликт UUID %s между пользователями %s и %s. Сбрасываем у старой записи.", + '♻️ Обнаружен конфликт UUID %s между пользователями %s и %s. Сбрасываем у старой записи.', panel_uuid, - getattr(conflicting_user, "telegram_id", "?"), - getattr(user, "telegram_id", "?"), + getattr(conflicting_user, 'telegram_id', '?'), + getattr(user, 'telegram_id', '?'), ) mutation.set_user_uuid(conflicting_user, None) mutation.set_user_updated_at(conflicting_user, datetime.utcnow()) @@ -221,8 +214,8 @@ class RemnaWaveService: mutation.set_map_entry(panel_uuid, user) logger.info( - "🔁 Обновлен RemnaWave UUID пользователя %s: %s → %s", - getattr(user, "telegram_id", "?"), + '🔁 Обновлен RemnaWave UUID пользователя %s: %s → %s', + getattr(user, 'telegram_id', '?'), current_uuid, panel_uuid, ) @@ -248,7 +241,6 @@ class RemnaWaveService: return self._now_utc() + timedelta(days=30) try: - cleaned_date = date_str.strip() if cleaned_date.endswith('Z'): @@ -268,18 +260,14 @@ class RemnaWaveService: utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None) - logger.debug( - f"Успешно распарсена дата: {date_str} -> {utc_normalized} (нормализовано в UTC)" - ) + logger.debug(f'Успешно распарсена дата: {date_str} -> {utc_normalized} (нормализовано в UTC)') return utc_normalized except Exception as e: - logger.warning( - f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату." - ) + logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.") return self._now_utc() + timedelta(days=30) - def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime: + def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime: """Гарантирует, что дата окончания не в прошлом для панели.""" now = self._now_utc() @@ -294,7 +282,7 @@ class RemnaWaveService: if normalized_expire < minimum_expire: logger.debug( - "⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели", + '⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели', normalized_expire, minimum_expire, ) @@ -302,7 +290,7 @@ class RemnaWaveService: return normalized_expire - def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime: + def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime: """Парсит дату окончания подписки пользователя панели для сравнения.""" expire_at_value = panel_user.get('expireAt') @@ -319,8 +307,8 @@ class RemnaWaveService: def _is_preferred_panel_user( self, *, - candidate: Dict[str, Any], - current: Dict[str, Any], + candidate: dict[str, Any], + current: dict[str, Any], ) -> bool: """Определяет, является ли новая запись предпочтительной для Telegram ID.""" @@ -343,11 +331,11 @@ class RemnaWaveService: def _deduplicate_panel_users_by_telegram_id( self, - panel_users: List[Dict[str, Any]], - ) -> Dict[Any, Dict[str, Any]]: + panel_users: list[dict[str, Any]], + ) -> dict[Any, dict[str, Any]]: """Возвращает уникальных пользователей панели по Telegram ID.""" - unique_users: Dict[Any, Dict[str, Any]] = {} + unique_users: dict[Any, dict[str, Any]] = {} for panel_user in panel_users: telegram_id = panel_user.get('telegramId') @@ -363,32 +351,32 @@ class RemnaWaveService: return unique_users - def _extract_user_data_from_description(self, description: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: + def _extract_user_data_from_description(self, description: str) -> tuple[str | None, str | None, str | None]: """ Извлекает имя, фамилию и username из описания пользователя в панели Remnawave. - + Args: description: Описание пользователя из панели - + Returns: Tuple[first_name, last_name, username] - извлеченные данные """ logger.debug(f"📥 Парсинг описания пользователя: '{description}'") - + if not description: - logger.debug("❌ Пустое описание пользователя") + logger.debug('❌ Пустое описание пользователя') return None, None, None - + # Ищем строки в формате "Bot user: ..." import re - + # Паттерн для извлечения данных из "Bot user: Name @username" или "Bot user: Name" # Также поддерживаем просто "Name @username" без префикса bot_user_patterns = [ - r"Bot user:\s*(.+)", # С префиксом - r"^([\w\s]+(?:@[\w_]+)?)$", # Без префикса + r'Bot user:\s*(.+)', # С префиксом + r'^([\w\s]+(?:@[\w_]+)?)$', # Без префикса ] - + user_info = None for pattern in bot_user_patterns: match = re.search(pattern, description) @@ -396,38 +384,40 @@ class RemnaWaveService: user_info = match.group(1).strip() logger.debug(f"🔍 Найдена информация о пользователе: '{user_info}'") break - + if not user_info: - logger.debug("❌ Не удалось найти информацию о пользователе в описании") + logger.debug('❌ Не удалось найти информацию о пользователе в описании') return None, None, None - + # Паттерн для извлечения username (@username в конце) - username_pattern = r"\s+(@[\w_]+)$" + username_pattern = r'\s+(@[\w_]+)$' username_match = re.search(username_pattern, user_info) - + if username_match: username_with_at = username_match.group(1) - username = username_with_at[1:] if username_with_at.startswith('@') else username_with_at # Убираем символ @ + username = username_with_at.removeprefix('@') # Убираем символ @ # Убираем username из основной информации - name_part = user_info[:username_match.start()].strip() - logger.debug(f"📱 Найден username: '{username_with_at}' (обработанный: '{username}'), остаток: '{name_part}'") + name_part = user_info[: username_match.start()].strip() + logger.debug( + f"📱 Найден username: '{username_with_at}' (обработанный: '{username}'), остаток: '{name_part}'" + ) else: username = None name_part = user_info logger.debug(f"📱 Username не найден, имя: '{name_part}'") - + # Разделяем имя и фамилию - if name_part and not name_part.startswith("@"): + if name_part and not name_part.startswith('@'): # Если есть имя (не начинается с @), используем его name_parts = name_part.split() - logger.debug(f"🔤 Части имени: {name_parts}") - + logger.debug(f'🔤 Части имени: {name_parts}') + if len(name_parts) >= 2: # Первое слово - имя, остальные - фамилия first_name = name_parts[0] - last_name = " ".join(name_parts[1:]) if len(name_parts) > 1 else None + last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else None logger.debug(f"👤 Имя: '{first_name}', Фамилия: '{last_name}'") - elif len(name_parts) == 1 and not name_parts[0].startswith("@"): + elif len(name_parts) == 1 and not name_parts[0].startswith('@'): # Только имя first_name = name_parts[0] last_name = None @@ -435,47 +425,48 @@ class RemnaWaveService: else: first_name = None last_name = None - logger.debug("👤 Имя не определено") + logger.debug('👤 Имя не определено') else: first_name = None last_name = None - logger.debug("👤 Имя не определено (начинается с @)") - - logger.debug(f"✅ Результат парсинга: first_name='{first_name}', last_name='{last_name}', username='{username}'") + logger.debug('👤 Имя не определено (начинается с @)') + + logger.debug( + f"✅ Результат парсинга: first_name='{first_name}', last_name='{last_name}', username='{username}'" + ) return first_name, last_name, username async def _get_or_create_bot_user_from_panel( self, db: AsyncSession, - panel_user: Dict[str, Any], - ) -> Tuple[Optional[User], bool]: + panel_user: dict[str, Any], + ) -> tuple[User | None, bool]: """Возвращает пользователя бота, создавая его при необходимости. При конфликте уникальности telegram_id повторно загружает пользователя из базы данных и сообщает, что запись не была создана заново. """ - telegram_id = panel_user.get("telegramId") + telegram_id = panel_user.get('telegramId') if telegram_id is None: return None, False # Извлекаем настоящее имя пользователя из описания - description = panel_user.get("description") or "" - first_name_from_desc, last_name_from_desc, username_from_desc = self._extract_user_data_from_description(description) - + description = panel_user.get('description') or '' + first_name_from_desc, last_name_from_desc, username_from_desc = self._extract_user_data_from_description( + description + ) + # Используем извлеченное имя или дефолтное значение - fallback_first_name = f"User {telegram_id}" + fallback_first_name = f'User {telegram_id}' full_first_name = fallback_first_name full_last_name = None - if first_name_from_desc and last_name_from_desc: - full_first_name = first_name_from_desc - full_last_name = last_name_from_desc - elif first_name_from_desc: + if (first_name_from_desc and last_name_from_desc) or first_name_from_desc: full_first_name = first_name_from_desc full_last_name = last_name_from_desc - username = username_from_desc or panel_user.get("username") + username = username_from_desc or panel_user.get('username') try: create_kwargs = dict( @@ -484,14 +475,14 @@ class RemnaWaveService: username=username, first_name=full_first_name, last_name=full_last_name, - language="ru", + language='ru', ) db_user = await create_user_no_commit(**create_kwargs) return db_user, True except IntegrityError as create_error: logger.info( - "♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.", + '♻️ Пользователь с telegram_id %s уже существует. Используем существующую запись.', telegram_id, ) @@ -504,356 +495,344 @@ class RemnaWaveService: try: existing_user = await get_user_by_telegram_id(db, telegram_id) if existing_user is None: - logger.error("❌ Не удалось найти существующего пользователя с telegram_id %s", telegram_id) + logger.error('❌ Не удалось найти существующего пользователя с telegram_id %s', telegram_id) return None, False logger.debug( - "Используется существующий пользователь %s после конфликта уникальности: %s", + 'Используется существующий пользователь %s после конфликта уникальности: %s', telegram_id, create_error, ) return existing_user, False except Exception as load_error: - logger.error("❌ Ошибка загрузки существующего пользователя %s: %s", telegram_id, load_error) + logger.error('❌ Ошибка загрузки существующего пользователя %s: %s', telegram_id, load_error) return None, False except Exception as general_error: - logger.error("❌ Общая ошибка создания/загрузки пользователя %s: %s", telegram_id, general_error) + logger.error('❌ Общая ошибка создания/загрузки пользователя %s: %s', telegram_id, general_error) try: await db.rollback() except: pass return None, False - - async def get_system_statistics(self) -> Dict[str, Any]: - try: - async with self.get_api_client() as api: - logger.info("Получение системной статистики RemnaWave...") - - try: - system_stats = await api.get_system_stats() - logger.info(f"Системная статистика получена") - except Exception as e: - logger.error(f"Ошибка получения системной статистики: {e}") - system_stats = {} - - try: - bandwidth_stats = await api.get_bandwidth_stats() - logger.info(f"Статистика трафика получена") - except Exception as e: - logger.error(f"Ошибка получения статистики трафика: {e}") - bandwidth_stats = {} - - try: - realtime_usage = await api.get_nodes_realtime_usage() - logger.info(f"Реалтайм статистика получена") - except Exception as e: - logger.error(f"Ошибка получения реалтайм статистики: {e}") - realtime_usage = [] - - try: - nodes_stats = await api.get_nodes_statistics() - except Exception as e: - logger.error(f"Ошибка получения статистики нод: {e}") - nodes_stats = {} - - - total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) - total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) - total_realtime_traffic = total_download + total_upload - - total_user_traffic = int(system_stats.get('users', {}).get('totalTrafficBytes', '0')) - - nodes_weekly_data = [] - if nodes_stats.get('lastSevenDays'): - nodes_by_name = {} - for day_data in nodes_stats['lastSevenDays']: - node_name = day_data['nodeName'] - if node_name not in nodes_by_name: - nodes_by_name[node_name] = { - 'name': node_name, - 'total_bytes': 0, - 'days_data': [] - } - - daily_bytes = int(day_data['totalBytes']) - nodes_by_name[node_name]['total_bytes'] += daily_bytes - nodes_by_name[node_name]['days_data'].append({ - 'date': day_data['date'], - 'bytes': daily_bytes - }) - - nodes_weekly_data = list(nodes_by_name.values()) - nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True) - - uptime_seconds = 0 - uptime_value = system_stats.get('uptime') - try: - uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0 - except (TypeError, ValueError): - logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0") - result = { - "system": { - "users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0), - "total_users": system_stats.get('users', {}).get('totalUsers', 0), - "active_connections": system_stats.get('onlineStats', {}).get('onlineNow', 0), - "nodes_online": system_stats.get('nodes', {}).get('totalOnline', 0), - "users_last_day": system_stats.get('onlineStats', {}).get('lastDay', 0), - "users_last_week": system_stats.get('onlineStats', {}).get('lastWeek', 0), - "users_never_online": system_stats.get('onlineStats', {}).get('neverOnline', 0), - "total_user_traffic": total_user_traffic - }, - "users_by_status": system_stats.get('users', {}).get('statusCounts', {}), - "server_info": { - "cpu_cores": system_stats.get('cpu', {}).get('cores', 0), - "cpu_physical_cores": system_stats.get('cpu', {}).get('physicalCores', 0), - "memory_total": system_stats.get('memory', {}).get('total', 0), - "memory_used": system_stats.get('memory', {}).get('used', 0), - "memory_free": system_stats.get('memory', {}).get('free', 0), - "memory_available": system_stats.get('memory', {}).get('available', 0), - "uptime_seconds": uptime_seconds - }, - "bandwidth": { - "realtime_download": total_download, - "realtime_upload": total_upload, - "realtime_total": total_realtime_traffic - }, - "traffic_periods": { - "last_2_days": { - "current": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLastTwoDays', {}).get('current', '0 B') - ), - "previous": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLastTwoDays', {}).get('previous', '0 B') - ), - "difference": bandwidth_stats.get('bandwidthLastTwoDays', {}).get('difference', '0 B') - }, - "last_7_days": { - "current": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLastSevenDays', {}).get('current', '0 B') - ), - "previous": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLastSevenDays', {}).get('previous', '0 B') - ), - "difference": bandwidth_stats.get('bandwidthLastSevenDays', {}).get('difference', '0 B') - }, - "last_30_days": { - "current": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLast30Days', {}).get('current', '0 B') - ), - "previous": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthLast30Days', {}).get('previous', '0 B') - ), - "difference": bandwidth_stats.get('bandwidthLast30Days', {}).get('difference', '0 B') - }, - "current_month": { - "current": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthCalendarMonth', {}).get('current', '0 B') - ), - "previous": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthCalendarMonth', {}).get('previous', '0 B') - ), - "difference": bandwidth_stats.get('bandwidthCalendarMonth', {}).get('difference', '0 B') - }, - "current_year": { - "current": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthCurrentYear', {}).get('current', '0 B') - ), - "previous": self._parse_bandwidth_string( - bandwidth_stats.get('bandwidthCurrentYear', {}).get('previous', '0 B') - ), - "difference": bandwidth_stats.get('bandwidthCurrentYear', {}).get('difference', '0 B') - } - }, - "nodes_realtime": realtime_usage, - "nodes_weekly": nodes_weekly_data, - "last_updated": datetime.now() - } - - logger.info(f"Статистика сформирована: пользователи={result['system']['total_users']}, общий трафик={total_user_traffic}") - return result - - except RemnaWaveAPIError as e: - logger.error(f"Ошибка Remnawave API при получении статистики: {e}") - return {"error": str(e)} - except Exception as e: - logger.error(f"Общая ошибка получения системной статистики: {e}") - return {"error": f"Внутренняя ошибка сервера: {str(e)}"} + async def get_system_statistics(self) -> dict[str, Any]: + try: + async with self.get_api_client() as api: + logger.info('Получение системной статистики RemnaWave...') - - def _parse_bandwidth_string(self, bandwidth_str: str) -> int: - try: - if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0': - return 0 - - bandwidth_str = bandwidth_str.replace(' ', '').upper() - - units = { - 'B': 1, - 'KB': 1024, - 'MB': 1024 ** 2, - 'GB': 1024 ** 3, - 'TB': 1024 ** 4, - 'PB': 1024 ** 5, - 'KIB': 1024, - 'MIB': 1024 ** 2, - 'GIB': 1024 ** 3, - 'TIB': 1024 ** 4, - 'PIB': 1024 ** 5, - 'KBPS': 1024, - 'MBPS': 1024 ** 2, - 'GBPS': 1024 ** 3, - 'TBPS': 1024 ** 4, + try: + system_stats = await api.get_system_stats() + logger.info('Системная статистика получена') + except Exception as e: + logger.error(f'Ошибка получения системной статистики: {e}') + system_stats = {} + + try: + bandwidth_stats = await api.get_bandwidth_stats() + logger.info('Статистика трафика получена') + except Exception as e: + logger.error(f'Ошибка получения статистики трафика: {e}') + bandwidth_stats = {} + + try: + realtime_usage = await api.get_nodes_realtime_usage() + logger.info('Реалтайм статистика получена') + except Exception as e: + logger.error(f'Ошибка получения реалтайм статистики: {e}') + realtime_usage = [] + + try: + nodes_stats = await api.get_nodes_statistics() + except Exception as e: + logger.error(f'Ошибка получения статистики нод: {e}') + nodes_stats = {} + + total_download = sum(node.get('downloadBytes', 0) for node in realtime_usage) + total_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage) + total_realtime_traffic = total_download + total_upload + + total_user_traffic = int(system_stats.get('users', {}).get('totalTrafficBytes', '0')) + + nodes_weekly_data = [] + if nodes_stats.get('lastSevenDays'): + nodes_by_name = {} + for day_data in nodes_stats['lastSevenDays']: + node_name = day_data['nodeName'] + if node_name not in nodes_by_name: + nodes_by_name[node_name] = {'name': node_name, 'total_bytes': 0, 'days_data': []} + + daily_bytes = int(day_data['totalBytes']) + nodes_by_name[node_name]['total_bytes'] += daily_bytes + nodes_by_name[node_name]['days_data'].append({'date': day_data['date'], 'bytes': daily_bytes}) + + nodes_weekly_data = list(nodes_by_name.values()) + nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True) + + uptime_seconds = 0 + uptime_value = system_stats.get('uptime') + try: + uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0 + except (TypeError, ValueError): + logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0") + + result = { + 'system': { + 'users_online': system_stats.get('onlineStats', {}).get('onlineNow', 0), + 'total_users': system_stats.get('users', {}).get('totalUsers', 0), + 'active_connections': system_stats.get('onlineStats', {}).get('onlineNow', 0), + 'nodes_online': system_stats.get('nodes', {}).get('totalOnline', 0), + 'users_last_day': system_stats.get('onlineStats', {}).get('lastDay', 0), + 'users_last_week': system_stats.get('onlineStats', {}).get('lastWeek', 0), + 'users_never_online': system_stats.get('onlineStats', {}).get('neverOnline', 0), + 'total_user_traffic': total_user_traffic, + }, + 'users_by_status': system_stats.get('users', {}).get('statusCounts', {}), + 'server_info': { + 'cpu_cores': system_stats.get('cpu', {}).get('cores', 0), + 'cpu_physical_cores': system_stats.get('cpu', {}).get('physicalCores', 0), + 'memory_total': system_stats.get('memory', {}).get('total', 0), + 'memory_used': system_stats.get('memory', {}).get('used', 0), + 'memory_free': system_stats.get('memory', {}).get('free', 0), + 'memory_available': system_stats.get('memory', {}).get('available', 0), + 'uptime_seconds': uptime_seconds, + }, + 'bandwidth': { + 'realtime_download': total_download, + 'realtime_upload': total_upload, + 'realtime_total': total_realtime_traffic, + }, + 'traffic_periods': { + 'last_2_days': { + 'current': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastTwoDays', {}).get('current', '0 B') + ), + 'previous': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastTwoDays', {}).get('previous', '0 B') + ), + 'difference': bandwidth_stats.get('bandwidthLastTwoDays', {}).get('difference', '0 B'), + }, + 'last_7_days': { + 'current': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastSevenDays', {}).get('current', '0 B') + ), + 'previous': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLastSevenDays', {}).get('previous', '0 B') + ), + 'difference': bandwidth_stats.get('bandwidthLastSevenDays', {}).get('difference', '0 B'), + }, + 'last_30_days': { + 'current': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLast30Days', {}).get('current', '0 B') + ), + 'previous': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthLast30Days', {}).get('previous', '0 B') + ), + 'difference': bandwidth_stats.get('bandwidthLast30Days', {}).get('difference', '0 B'), + }, + 'current_month': { + 'current': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCalendarMonth', {}).get('current', '0 B') + ), + 'previous': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCalendarMonth', {}).get('previous', '0 B') + ), + 'difference': bandwidth_stats.get('bandwidthCalendarMonth', {}).get('difference', '0 B'), + }, + 'current_year': { + 'current': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCurrentYear', {}).get('current', '0 B') + ), + 'previous': self._parse_bandwidth_string( + bandwidth_stats.get('bandwidthCurrentYear', {}).get('previous', '0 B') + ), + 'difference': bandwidth_stats.get('bandwidthCurrentYear', {}).get('difference', '0 B'), + }, + }, + 'nodes_realtime': realtime_usage, + 'nodes_weekly': nodes_weekly_data, + 'last_updated': datetime.now(), } - - match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str) - if match: - value_str = match.group(1).replace(',', '.') - value = float(value_str) - unit = match.group(2) - - if unit in units: - result = int(value * units[unit]) - logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт") - return result - else: - logger.warning(f"Неизвестная единица измерения: {unit}") - - logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'") + + logger.info( + f'Статистика сформирована: пользователи={result["system"]["total_users"]}, общий трафик={total_user_traffic}' + ) + return result + + except RemnaWaveAPIError as e: + logger.error(f'Ошибка Remnawave API при получении статистики: {e}') + return {'error': str(e)} + except Exception as e: + logger.error(f'Общая ошибка получения системной статистики: {e}') + return {'error': f'Внутренняя ошибка сервера: {e!s}'} + + def _parse_bandwidth_string(self, bandwidth_str: str) -> int: + try: + if not bandwidth_str or bandwidth_str == '0 B' or bandwidth_str == '0': return 0 - - except Exception as e: - logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") - return 0 - - async def get_all_nodes(self) -> List[Dict[str, Any]]: - + + bandwidth_str = bandwidth_str.replace(' ', '').upper() + + units = { + 'B': 1, + 'KB': 1024, + 'MB': 1024**2, + 'GB': 1024**3, + 'TB': 1024**4, + 'PB': 1024**5, + 'KIB': 1024, + 'MIB': 1024**2, + 'GIB': 1024**3, + 'TIB': 1024**4, + 'PIB': 1024**5, + 'KBPS': 1024, + 'MBPS': 1024**2, + 'GBPS': 1024**3, + 'TBPS': 1024**4, + } + + match = re.match(r'([0-9.,]+)([A-Z]+)', bandwidth_str) + if match: + value_str = match.group(1).replace(',', '.') + value = float(value_str) + unit = match.group(2) + + if unit in units: + result = int(value * units[unit]) + logger.debug(f"Парсинг '{bandwidth_str}': {value} {unit} = {result} байт") + return result + logger.warning(f'Неизвестная единица измерения: {unit}') + + logger.warning(f"Не удалось распарсить строку трафика: '{bandwidth_str}'") + return 0 + + except Exception as e: + logger.error(f"Ошибка парсинга строки трафика '{bandwidth_str}': {e}") + return 0 + + async def get_all_nodes(self) -> list[dict[str, Any]]: try: async with self.get_api_client() as api: nodes = await api.get_all_nodes() - + result = [] for node in nodes: - result.append({ - 'uuid': node.uuid, - 'name': node.name, - 'address': node.address, - 'country_code': node.country_code, - 'is_connected': node.is_connected, - 'is_disabled': node.is_disabled, - 'is_node_online': node.is_node_online, - 'is_xray_running': node.is_xray_running, - 'users_online': node.users_online, - 'traffic_used_bytes': node.traffic_used_bytes, - 'traffic_limit_bytes': node.traffic_limit_bytes - }) - - logger.info(f"✅ Получено {len(result)} нод из Remnawave") + result.append( + { + 'uuid': node.uuid, + 'name': node.name, + 'address': node.address, + 'country_code': node.country_code, + 'is_connected': node.is_connected, + 'is_disabled': node.is_disabled, + 'is_node_online': node.is_node_online, + 'is_xray_running': node.is_xray_running, + 'users_online': node.users_online, + 'traffic_used_bytes': node.traffic_used_bytes, + 'traffic_limit_bytes': node.traffic_limit_bytes, + } + ) + + logger.info(f'✅ Получено {len(result)} нод из Remnawave') return result - + except Exception as e: - logger.error(f"Ошибка получения нод из Remnawave: {e}") + logger.error(f'Ошибка получения нод из Remnawave: {e}') return [] async def test_connection(self) -> bool: - try: async with self.get_api_client() as api: - stats = await api.get_system_stats() - logger.info("✅ Соединение с Remnawave API работает") + await api.get_system_stats() + logger.info('✅ Соединение с Remnawave API работает') return True - + except Exception as e: - logger.error(f"❌ Ошибка соединения с Remnawave API: {e}") + logger.error(f'❌ Ошибка соединения с Remnawave API: {e}') return False - - async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]: + + async def get_node_details(self, node_uuid: str) -> dict[str, Any] | None: try: async with self.get_api_client() as api: node = await api.get_node_by_uuid(node_uuid) - + if not node: return None - + return { - "uuid": node.uuid, - "name": node.name, - "address": node.address, - "country_code": node.country_code, - "is_connected": node.is_connected, - "is_disabled": node.is_disabled, - "is_node_online": node.is_node_online, - "is_xray_running": node.is_xray_running, - "users_online": node.users_online or 0, - "traffic_used_bytes": node.traffic_used_bytes or 0, - "traffic_limit_bytes": node.traffic_limit_bytes or 0, - "last_status_change": node.last_status_change, - "last_status_message": node.last_status_message, - "xray_uptime": node.xray_uptime, - "is_traffic_tracking_active": node.is_traffic_tracking_active, - "traffic_reset_day": node.traffic_reset_day, - "notify_percent": node.notify_percent, - "consumption_multiplier": node.consumption_multiplier, - "cpu_count": node.cpu_count, - "cpu_model": node.cpu_model, - "total_ram": node.total_ram, - "created_at": node.created_at, - "updated_at": node.updated_at, - "provider_uuid": node.provider_uuid, + 'uuid': node.uuid, + 'name': node.name, + 'address': node.address, + 'country_code': node.country_code, + 'is_connected': node.is_connected, + 'is_disabled': node.is_disabled, + 'is_node_online': node.is_node_online, + 'is_xray_running': node.is_xray_running, + 'users_online': node.users_online or 0, + 'traffic_used_bytes': node.traffic_used_bytes or 0, + 'traffic_limit_bytes': node.traffic_limit_bytes or 0, + 'last_status_change': node.last_status_change, + 'last_status_message': node.last_status_message, + 'xray_uptime': node.xray_uptime, + 'is_traffic_tracking_active': node.is_traffic_tracking_active, + 'traffic_reset_day': node.traffic_reset_day, + 'notify_percent': node.notify_percent, + 'consumption_multiplier': node.consumption_multiplier, + 'cpu_count': node.cpu_count, + 'cpu_model': node.cpu_model, + 'total_ram': node.total_ram, + 'created_at': node.created_at, + 'updated_at': node.updated_at, + 'provider_uuid': node.provider_uuid, } - + except Exception as e: - logger.error(f"Ошибка получения информации о ноде {node_uuid}: {e}") + logger.error(f'Ошибка получения информации о ноде {node_uuid}: {e}') return None - + async def manage_node(self, node_uuid: str, action: str) -> bool: try: async with self.get_api_client() as api: - if action == "enable": + if action == 'enable': await api.enable_node(node_uuid) - elif action == "disable": + elif action == 'disable': await api.disable_node(node_uuid) - elif action == "restart": + elif action == 'restart': await api.restart_node(node_uuid) else: return False - - logger.info(f"✅ Действие {action} выполнено для ноды {node_uuid}") + + logger.info(f'✅ Действие {action} выполнено для ноды {node_uuid}') return True - + except Exception as e: - logger.error(f"Ошибка управления нодой {node_uuid}: {e}") + logger.error(f'Ошибка управления нодой {node_uuid}: {e}') return False - + async def restart_all_nodes(self) -> bool: try: async with self.get_api_client() as api: result = await api.restart_all_nodes() - + if result: - logger.info("✅ Команда перезагрузки всех нод отправлена") - + logger.info('✅ Команда перезагрузки всех нод отправлена') + return result - + except Exception as e: - logger.error(f"Ошибка перезагрузки всех нод: {e}") + logger.error(f'Ошибка перезагрузки всех нод: {e}') return False - async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool: + async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: list[str]) -> bool: try: async with self.get_api_client() as api: - data = { - 'uuid': squad_uuid, - 'inbounds': inbound_uuids - } - response = await api._make_request('PATCH', '/api/internal-squads', data) + data = {'uuid': squad_uuid, 'inbounds': inbound_uuids} + await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: - logger.error(f"Error updating squad inbounds: {e}") + logger.error(f'Error updating squad inbounds: {e}') return False - - async def get_all_squads(self) -> List[Dict[str, Any]]: - + + async def get_all_squads(self) -> list[dict[str, Any]]: try: async with self.get_api_client() as api: squads = await api.get_internal_squads() @@ -861,60 +840,61 @@ class RemnaWaveService: result = [] for squad in squads: inbounds = [ - asdict(inbound) if is_dataclass(inbound) else inbound - for inbound in squad.inbounds or [] + asdict(inbound) if is_dataclass(inbound) else inbound for inbound in squad.inbounds or [] ] - result.append({ - 'uuid': squad.uuid, - 'name': squad.name, - 'members_count': squad.members_count, - 'inbounds_count': squad.inbounds_count, - 'inbounds': inbounds, - }) - - logger.info(f"✅ Получено {len(result)} сквадов из Remnawave") + result.append( + { + 'uuid': squad.uuid, + 'name': squad.name, + 'members_count': squad.members_count, + 'inbounds_count': squad.inbounds_count, + 'inbounds': inbounds, + } + ) + + logger.info(f'✅ Получено {len(result)} сквадов из Remnawave') return result - + except Exception as e: - logger.error(f"Ошибка получения сквадов из Remnawave: {e}") + logger.error(f'Ошибка получения сквадов из Remnawave: {e}') return [] - - async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]: + + async def create_squad(self, name: str, inbounds: list[str]) -> str | None: try: async with self.get_api_client() as api: squad = await api.create_internal_squad(name, inbounds) - - logger.info(f"✅ Создан новый сквад: {name}") + + logger.info(f'✅ Создан новый сквад: {name}') return squad.uuid - + except Exception as e: - logger.error(f"Ошибка создания сквада {name}: {e}") + logger.error(f'Ошибка создания сквада {name}: {e}') return None - - async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool: + + async def update_squad(self, uuid: str, name: str = None, inbounds: list[str] = None) -> bool: try: async with self.get_api_client() as api: await api.update_internal_squad(uuid, name, inbounds) - - logger.info(f"✅ Обновлен сквад {uuid}") + + logger.info(f'✅ Обновлен сквад {uuid}') return True - + except Exception as e: - logger.error(f"Ошибка обновления сквада {uuid}: {e}") + logger.error(f'Ошибка обновления сквада {uuid}: {e}') return False - + async def delete_squad(self, uuid: str) -> bool: try: async with self.get_api_client() as api: result = await api.delete_internal_squad(uuid) if result: - logger.info(f"✅ Удален сквад {uuid}") + logger.info(f'✅ Удален сквад {uuid}') return result except Exception as e: - logger.error(f"Ошибка удаления сквада {uuid}: {e}") + logger.error(f'Ошибка удаления сквада {uuid}: {e}') return False async def migrate_squad_users( @@ -922,14 +902,14 @@ class RemnaWaveService: db: AsyncSession, source_uuid: str, target_uuid: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Переносит активных подписок с одного сквада на другой.""" if source_uuid == target_uuid: return { - "success": False, - "error": "same_squad", - "message": "Источник и назначение совпадают", + 'success': False, + 'error': 'same_squad', + 'message': 'Источник и назначение совпадают', } source_uuid = source_uuid.strip() @@ -940,9 +920,9 @@ class RemnaWaveService: if not source_server or not target_server: return { - "success": False, - "error": "not_found", - "message": "Сквады не найдены", + 'success': False, + 'error': 'not_found', + 'message': 'Сквады не найдены', } subscription_query = ( @@ -955,9 +935,7 @@ class RemnaWaveService: SubscriptionStatus.TRIAL.value, ] ), - cast(Subscription.connected_squads, String).like( - f'%"{source_uuid}"%' - ), + cast(Subscription.connected_squads, String).like(f'%"{source_uuid}"%'), ) ) @@ -967,16 +945,16 @@ class RemnaWaveService: total_candidates = len(subscriptions) if not subscriptions: logger.info( - "🚚 Переезд сквада %s → %s: подходящих подписок не найдено", + '🚚 Переезд сквада %s → %s: подходящих подписок не найдено', source_uuid, target_uuid, ) return { - "success": True, - "total": 0, - "updated": 0, - "panel_updated": 0, - "panel_failed": 0, + 'success': True, + 'total': 0, + 'updated': 0, + 'panel_updated': 0, + 'panel_failed': 0, } exit_stack = AsyncExitStack() @@ -988,8 +966,7 @@ class RemnaWaveService: try: needs_panel_update = any( - subscription.user and subscription.user.remnawave_uuid - for subscription in subscriptions + subscription.user and subscription.user.remnawave_uuid for subscription in subscriptions ) api = None @@ -1002,9 +979,7 @@ class RemnaWaveService: continue had_target_before = target_uuid in current_squads - new_squads = [ - squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid - ] + new_squads = [squad_uuid for squad_uuid in current_squads if squad_uuid != source_uuid] if not had_target_before: new_squads.append(target_uuid) @@ -1012,7 +987,7 @@ class RemnaWaveService: if api is None: panel_failed += 1 logger.error( - "❌ RemnaWave API недоступен для обновления пользователя %s", + '❌ RemnaWave API недоступен для обновления пользователя %s', subscription.user.telegram_id, ) continue @@ -1026,7 +1001,7 @@ class RemnaWaveService: except Exception as error: panel_failed += 1 logger.error( - "❌ Ошибка обновления сквадов пользователя %s: %s", + '❌ Ошибка обновления сквадов пользователя %s: %s', subscription.user.telegram_id, error, ) @@ -1058,10 +1033,8 @@ class RemnaWaveService: await db.execute( delete(SubscriptionServer).where( and_( - SubscriptionServer.subscription_id - == subscription.id, - SubscriptionServer.server_squad_id - == source_server.id, + SubscriptionServer.subscription_id == subscription.id, + SubscriptionServer.server_squad_id == source_server.id, ) ) ) @@ -1092,9 +1065,7 @@ class RemnaWaveService: await db.execute( update(ServerSquad) .where(ServerSquad.id == target_server.id) - .values( - current_users=ServerSquad.current_users + target_increment - ) + .values(current_users=ServerSquad.current_users + target_increment) ) await db.commit() @@ -1102,7 +1073,7 @@ class RemnaWaveService: await db.rollback() logger.info( - "🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)", + '🚚 Завершен переезд сквада %s → %s: обновлено %s подписок (%s не обновлены в панели)', source_uuid, target_uuid, updated_subscriptions, @@ -1110,13 +1081,13 @@ class RemnaWaveService: ) return { - "success": True, - "total": total_candidates, - "updated": updated_subscriptions, - "panel_updated": panel_updated, - "panel_failed": panel_failed, - "source_removed": source_decrement, - "target_added": target_increment, + 'success': True, + 'total': total_candidates, + 'updated': updated_subscriptions, + 'panel_updated': panel_updated, + 'panel_failed': panel_failed, + 'source_removed': source_decrement, + 'target_added': target_increment, } except RemnaWaveConfigurationError: @@ -1125,41 +1096,41 @@ class RemnaWaveService: except Exception as error: await db.rollback() logger.error( - "❌ Ошибка переезда сквада %s → %s: %s", + '❌ Ошибка переезда сквада %s → %s: %s', source_uuid, target_uuid, error, ) return { - "success": False, - "error": "unexpected", - "message": str(error), + 'success': False, + 'error': 'unexpected', + 'message': str(error), } finally: await exit_stack.aclose() - async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = "all") -> Dict[str, int]: + async def sync_users_from_panel(self, db: AsyncSession, sync_type: str = 'all') -> dict[str, int]: try: - stats = {"created": 0, "updated": 0, "errors": 0, "deleted": 0} - - logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}") - + stats = {'created': 0, 'updated': 0, 'errors': 0, 'deleted': 0} + + logger.info(f'🔄 Начинаем синхронизацию типа: {sync_type}') + async with self.get_api_client() as api: panel_users = [] start = 0 - size = 500 # Увеличен размер батча для ускорения загрузки - + size = 500 # Увеличен размер батча для ускорения загрузки + while True: - logger.info(f"📥 Загружаем пользователей: start={start}, size={size}") + logger.info(f'📥 Загружаем пользователей: start={start}, size={size}') # enrich_happ_links=False - happ_crypto_link уже возвращается API в поле happ.cryptoLink # Не делаем дополнительные HTTP-запросы для каждого пользователя response = await api.get_all_users(start=start, size=size, enrich_happ_links=False) users_batch = response['users'] total_users = response['total'] - - logger.info(f"📊 Получено {len(users_batch)} пользователей из {total_users}") - + + logger.info(f'📊 Получено {len(users_batch)} пользователей из {total_users}') + for user_obj in users_batch: user_dict = { 'uuid': user_obj.uuid, @@ -1173,55 +1144,44 @@ class RemnaWaveService: 'hwidDeviceLimit': user_obj.hwid_device_limit, 'subscriptionUrl': user_obj.subscription_url, 'subscriptionCryptoLink': user_obj.happ_crypto_link, - 'activeInternalSquads': user_obj.active_internal_squads + 'activeInternalSquads': user_obj.active_internal_squads, } panel_users.append(user_dict) - + if len(users_batch) < size: break - + start += size - + if start > total_users: break - - logger.info(f"✅ Всего загружено пользователей из панели: {len(panel_users)}") - + + logger.info(f'✅ Всего загружено пользователей из панели: {len(panel_users)}') + # Загрузка пользователей с их подписками за один запрос (bulk loading) - from sqlalchemy.orm import selectinload - from app.database.models import User, Subscription from sqlalchemy import select - + from sqlalchemy.orm import selectinload + + from app.database.models import Subscription, User + # Получаем всех пользователей с их подписками за один запрос - bot_users_result = await db.execute( - select(User) - .options(selectinload(User.subscription)) - ) + bot_users_result = await db.execute(select(User).options(selectinload(User.subscription))) bot_users = bot_users_result.scalars().all() # Filter out email-only users (telegram_id=None) to avoid None key issues - bot_users_by_telegram_id = { - user.telegram_id: user - for user in bot_users - if user.telegram_id is not None - } + bot_users_by_telegram_id = {user.telegram_id: user for user in bot_users if user.telegram_id is not None} bot_users_by_uuid = { - user.remnawave_uuid: user - for user in bot_users - if getattr(user, "remnawave_uuid", None) + user.remnawave_uuid: user for user in bot_users if getattr(user, 'remnawave_uuid', None) } # Also index email-only users by their remnawave_uuid for sync email_users_count = sum(1 for u in bot_users if u.telegram_id is None) if email_users_count > 0: - logger.info(f"📧 Email-only пользователей (без telegram_id): {email_users_count}") + logger.info(f'📧 Email-only пользователей (без telegram_id): {email_users_count}') - logger.info(f"📊 Пользователей в боте: {len(bot_users)}") - - panel_users_with_tg = [ - user for user in panel_users - if user.get('telegramId') is not None - ] + logger.info(f'📊 Пользователей в боте: {len(bot_users)}') - logger.info(f"📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}") + panel_users_with_tg = [user for user in panel_users if user.get('telegramId') is not None] + + logger.info(f'📊 Пользователей в панели с Telegram ID: {len(panel_users_with_tg)}') unique_panel_users_map = self._deduplicate_panel_users_by_telegram_id(panel_users_with_tg) unique_panel_users = list(unique_panel_users_map.values()) @@ -1229,7 +1189,7 @@ class RemnaWaveService: if duplicates_count: logger.info( - "♻️ Обнаружено %s дубликатов пользователей по Telegram ID. Используем самые свежие записи.", + '♻️ Обнаружено %s дубликатов пользователей по Telegram ID. Используем самые свежие записи.', duplicates_count, ) @@ -1238,43 +1198,41 @@ class RemnaWaveService: # Для ускорения - подготовим данные о подписках # Соберем все существующие подписки за один запрос existing_subscriptions_result = await db.execute( - select(Subscription) - .join(User) - .options(selectinload(Subscription.user)) + select(Subscription).join(User).options(selectinload(Subscription.user)) ) existing_subscriptions = existing_subscriptions_result.scalars().all() - + # Создадим словарь для быстрого доступа к подпискам - subscriptions_by_user_id = {sub.user_id: sub for sub in existing_subscriptions} + {sub.user_id: sub for sub in existing_subscriptions} # Для оптимизации коммитим изменения каждые N пользователей batch_size = 50 - pending_uuid_mutations: List[_UUIDMapMutation] = [] + pending_uuid_mutations: list[_UUIDMapMutation] = [] for i, panel_user in enumerate(unique_panel_users): - uuid_mutation: Optional[_UUIDMapMutation] = None + uuid_mutation: _UUIDMapMutation | None = None try: telegram_id = panel_user.get('telegramId') if not telegram_id: continue if (i + 1) % 10 == 0: - logger.info(f"🔄 Обрабатываем пользователя {i+1}/{len(unique_panel_users)}: {telegram_id}") - + logger.info(f'🔄 Обрабатываем пользователя {i + 1}/{len(unique_panel_users)}: {telegram_id}') + db_user = bot_users_by_telegram_id.get(telegram_id) - + if not db_user: - if sync_type in ["new_only", "all"]: - logger.info(f"🆕 Создание пользователя для telegram_id {telegram_id}") + if sync_type in ['new_only', 'all']: + logger.info(f'🆕 Создание пользователя для telegram_id {telegram_id}') db_user, is_created = await self._get_or_create_bot_user_from_panel(db, panel_user) if not db_user: logger.error( - "❌ Не удалось создать или получить пользователя для telegram_id %s", + '❌ Не удалось создать или получить пользователя для telegram_id %s', telegram_id, ) - stats["errors"] += 1 + stats['errors'] += 1 continue bot_users_by_telegram_id[telegram_id] = db_user @@ -1284,7 +1242,7 @@ class RemnaWaveService: updated_fields = [] # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения if updated_fields: - logger.info(f"🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}") + logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}') await db.flush() # Сохраняем изменения без коммита _, uuid_mutation = self._ensure_user_remnawave_uuid( @@ -1295,49 +1253,46 @@ class RemnaWaveService: if is_created: await self._create_subscription_from_panel_data(db, db_user, panel_user) - stats["created"] += 1 - logger.info(f"✅ Создан пользователь {telegram_id} с подпиской") + stats['created'] += 1 + logger.info(f'✅ Создан пользователь {telegram_id} с подпиской') else: # Обновляем данные существующего пользователя # Но теперь мы уже загрузили подписку с пользователем, нет необходимости перезагружать await self._update_subscription_from_panel_data(db, db_user, panel_user) - stats["updated"] += 1 - logger.info( - f"♻️ Обновлена подписка существующего пользователя {telegram_id}" - ) - - else: - if sync_type in ["update_only", "all"]: - logger.debug(f"🔄 Обновление пользователя {telegram_id}") - - # При синхронизации не обновляем имя и username пользователя - # только сохраняем изменения, если были обновлены другие поля (подписка и т.д.) - updated_fields = [] - # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения - if updated_fields: - logger.info(f"🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}") - await db.flush() # Сохраняем изменения без коммита - - # Проверяем, есть ли у пользователя подписка, загруженная с пользователем - if hasattr(db_user, 'subscription') and db_user.subscription: - # Используем уже загруженную подписку - await self._update_subscription_from_panel_data(db, db_user, panel_user) - else: - # Если подписки нет, создаем новую - await self._create_subscription_from_panel_data(db, db_user, panel_user) + stats['updated'] += 1 + logger.info(f'♻️ Обновлена подписка существующего пользователя {telegram_id}') - _, uuid_mutation = self._ensure_user_remnawave_uuid( - db_user, - panel_user.get('uuid'), - bot_users_by_uuid, - ) + elif sync_type in ['update_only', 'all']: + logger.debug(f'🔄 Обновление пользователя {telegram_id}') - stats["updated"] += 1 - logger.debug(f"✅ Обновлён пользователь {telegram_id}") + # При синхронизации не обновляем имя и username пользователя + # только сохраняем изменения, если были обновлены другие поля (подписка и т.д.) + updated_fields = [] + # Если были обновлены другие поля (подписка, статус и т.д.), сохраняем изменения + if updated_fields: + logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}') + await db.flush() # Сохраняем изменения без коммита + + # Проверяем, есть ли у пользователя подписка, загруженная с пользователем + if hasattr(db_user, 'subscription') and db_user.subscription: + # Используем уже загруженную подписку + await self._update_subscription_from_panel_data(db, db_user, panel_user) + else: + # Если подписки нет, создаем новую + await self._create_subscription_from_panel_data(db, db_user, panel_user) + + _, uuid_mutation = self._ensure_user_remnawave_uuid( + db_user, + panel_user.get('uuid'), + bot_users_by_uuid, + ) + + stats['updated'] += 1 + logger.debug(f'✅ Обновлён пользователь {telegram_id}') except Exception as user_error: - logger.error(f"❌ Ошибка обработки пользователя {telegram_id}: {user_error}") - stats["errors"] += 1 + logger.error(f'❌ Ошибка обработки пользователя {telegram_id}: {user_error}') + stats['errors'] += 1 if uuid_mutation: uuid_mutation.rollback() if pending_uuid_mutations: @@ -1358,33 +1313,33 @@ class RemnaWaveService: if (i + 1) % batch_size == 0: try: await db.commit() - logger.debug(f"📦 Коммит изменений после обработки {i+1} пользователей") + logger.debug(f'📦 Коммит изменений после обработки {i + 1} пользователей') pending_uuid_mutations.clear() except Exception as commit_error: - logger.error(f"❌ Ошибка коммита после обработки {i+1} пользователей: {commit_error}") + logger.error(f'❌ Ошибка коммита после обработки {i + 1} пользователей: {commit_error}') await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() pending_uuid_mutations.clear() - stats["errors"] += batch_size # Учитываем ошибки за всю группу + stats['errors'] += batch_size # Учитываем ошибки за всю группу # Коммитим оставшиеся изменения try: await db.commit() pending_uuid_mutations.clear() except Exception as final_commit_error: - logger.error(f"❌ Ошибка финального коммита: {final_commit_error}") + logger.error(f'❌ Ошибка финального коммита: {final_commit_error}') await db.rollback() for mutation in reversed(pending_uuid_mutations): mutation.rollback() pending_uuid_mutations.clear() - if sync_type == "all": - logger.info("🗑️ Деактивация подписок пользователей, отсутствующих в панели...") + if sync_type == 'all': + logger.info('🗑️ Деактивация подписок пользователей, отсутствующих в панели...') batch_size = 50 processed_count = 0 - cleanup_uuid_mutations: List[_UUIDMapMutation] = [] + cleanup_uuid_mutations: list[_UUIDMapMutation] = [] # Собираем список пользователей для деактивации users_to_deactivate = [ @@ -1396,7 +1351,7 @@ class RemnaWaveService: ] if users_to_deactivate: - logger.info(f"📊 Найдено {len(users_to_deactivate)} пользователей для деактивации") + logger.info(f'📊 Найдено {len(users_to_deactivate)} пользователей для деактивации') # Используем один API клиент для всех операций сброса HWID hwid_api_client = None @@ -1404,14 +1359,14 @@ class RemnaWaveService: hwid_api_client = self.get_api_client() await hwid_api_client.__aenter__() except Exception as api_init_error: - logger.warning(f"⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}") + logger.warning(f'⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}') hwid_api_client = None try: for telegram_id, db_user in users_to_deactivate: - cleanup_mutation: Optional[_UUIDMapMutation] = None + cleanup_mutation: _UUIDMapMutation | None = None try: - logger.info(f"🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)") + logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)') subscription = db_user.subscription @@ -1419,12 +1374,13 @@ class RemnaWaveService: try: devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid) if devices_reset: - logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}") + logger.info(f'🔧 Сброшены HWID устройства для пользователя {telegram_id}') except Exception as hwid_error: - logger.error(f"❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}") - + logger.error(f'❌ Ошибка сброса HWID устройств для {telegram_id}: {hwid_error}') + try: from sqlalchemy import delete + from app.database.models import SubscriptionServer await decrement_subscription_server_counts(db, subscription) @@ -1434,17 +1390,14 @@ class RemnaWaveService: SubscriptionServer.subscription_id == subscription.id ) ) - logger.info(f"🗑️ Удалены серверы подписки для {telegram_id}") + logger.info(f'🗑️ Удалены серверы подписки для {telegram_id}') except Exception as servers_error: - logger.warning(f"⚠️ Не удалось удалить серверы подписки: {servers_error}") - + logger.warning(f'⚠️ Не удалось удалить серверы подписки: {servers_error}') + from app.database.models import SubscriptionStatus # Проверяем, была ли это платная подписка - was_paid = ( - not subscription.is_trial - or getattr(db_user, 'has_had_paid_subscription', False) - ) + was_paid = not subscription.is_trial or getattr(db_user, 'has_had_paid_subscription', False) subscription.status = SubscriptionStatus.DISABLED.value @@ -1452,8 +1405,8 @@ class RemnaWaveService: # Для платных подписок - НЕ сбрасываем is_trial и end_date! # Сохраняем оригинальные значения чтобы можно было восстановить logger.warning( - f"⚠️ ПЛАТНАЯ подписка пользователя {telegram_id} отключена (нет в панели), " - f"но is_trial={subscription.is_trial} и end_date={subscription.end_date} СОХРАНЕНЫ" + f'⚠️ ПЛАТНАЯ подписка пользователя {telegram_id} отключена (нет в панели), ' + f'но is_trial={subscription.is_trial} и end_date={subscription.end_date} СОХРАНЕНЫ' ) else: # Для триальных подписок - сбрасываем как раньше @@ -1466,24 +1419,24 @@ class RemnaWaveService: subscription.connected_squads = [] subscription.autopay_enabled = False subscription.remnawave_short_uuid = None - subscription.subscription_url = "" - subscription.subscription_crypto_link = "" + subscription.subscription_url = '' + subscription.subscription_crypto_link = '' - old_uuid = getattr(db_user, "remnawave_uuid", None) + old_uuid = getattr(db_user, 'remnawave_uuid', None) cleanup_mutation = _UUIDMapMutation(bot_users_by_uuid) if old_uuid: cleanup_mutation.remove_map_entry(old_uuid) cleanup_mutation.set_user_uuid(db_user, None) cleanup_mutation.set_user_updated_at(db_user, datetime.utcnow()) - stats["deleted"] += 1 - logger.info(f"✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)") + stats['deleted'] += 1 + logger.info(f'✅ Деактивирована подписка пользователя {telegram_id} (сохранен баланс)') processed_count += 1 except Exception as delete_error: - logger.error(f"❌ Ошибка деактивации подписки {telegram_id}: {delete_error}") - stats["errors"] += 1 + logger.error(f'❌ Ошибка деактивации подписки {telegram_id}: {delete_error}') + stats['errors'] += 1 if cleanup_mutation: cleanup_mutation.rollback() if cleanup_uuid_mutations: @@ -1502,15 +1455,17 @@ class RemnaWaveService: if processed_count % batch_size == 0: try: await db.commit() - logger.debug(f"📦 Коммит изменений после деактивации {processed_count} подписок") + logger.debug(f'📦 Коммит изменений после деактивации {processed_count} подписок') cleanup_uuid_mutations.clear() except Exception as commit_error: - logger.error(f"❌ Ошибка коммита после деактивации {processed_count} подписок: {commit_error}") + logger.error( + f'❌ Ошибка коммита после деактивации {processed_count} подписок: {commit_error}' + ) await db.rollback() for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() cleanup_uuid_mutations.clear() - stats["errors"] += batch_size + stats['errors'] += batch_size break # Прерываем цикл при ошибке коммита # Коммитим оставшиеся изменения @@ -1518,7 +1473,7 @@ class RemnaWaveService: await db.commit() cleanup_uuid_mutations.clear() except Exception as final_commit_error: - logger.error(f"❌ Ошибка финального коммита при деактивации: {final_commit_error}") + logger.error(f'❌ Ошибка финального коммита при деактивации: {final_commit_error}') await db.rollback() for mutation in reversed(cleanup_uuid_mutations): mutation.rollback() @@ -1532,31 +1487,33 @@ class RemnaWaveService: except Exception: pass - logger.info(f"🎯 Синхронизация завершена: создано {stats['created']}, обновлено {stats['updated']}, деактивировано {stats['deleted']}, ошибок {stats['errors']}") + logger.info( + f'🎯 Синхронизация завершена: создано {stats["created"]}, обновлено {stats["updated"]}, деактивировано {stats["deleted"]}, ошибок {stats["errors"]}' + ) return stats - + except Exception as e: - logger.error(f"❌ Критическая ошибка синхронизации пользователей: {e}") - return {"created": 0, "updated": 0, "errors": 1, "deleted": 0} + logger.error(f'❌ Критическая ошибка синхронизации пользователей: {e}') + return {'created': 0, 'updated': 0, 'errors': 1, 'deleted': 0} async def _create_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): try: from app.database.crud.subscription import create_subscription_no_commit from app.database.models import SubscriptionStatus - + expire_at_str = panel_user.get('expireAt', '') expire_at = self._parse_remnawave_date(expire_at_str) - + panel_status = panel_user.get('status', 'ACTIVE') current_time = self._now_utc() - + if panel_status == 'ACTIVE' and expire_at > current_time: status = SubscriptionStatus.ACTIVE elif expire_at <= current_time: status = SubscriptionStatus.EXPIRED else: status = SubscriptionStatus.DISABLED - + traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 @@ -1571,7 +1528,7 @@ class RemnaWaveService: squad_uuids.append(squad['uuid']) elif isinstance(squad, str): squad_uuids.append(squad) - + subscription_data = { 'user_id': user.id, 'status': status.value, @@ -1584,21 +1541,20 @@ class RemnaWaveService: 'remnawave_short_uuid': panel_user.get('shortUuid'), 'subscription_url': panel_user.get('subscriptionUrl', ''), 'subscription_crypto_link': ( - panel_user.get('subscriptionCryptoLink') - or (panel_user.get('happ') or {}).get('cryptoLink', '') - ) + panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') + ), } - - subscription = await create_subscription_no_commit(db, **subscription_data) - logger.info(f"✅ Подготовлена подписка для пользователя {user.telegram_id} до {expire_at}") - + + await create_subscription_no_commit(db, **subscription_data) + logger.info(f'✅ Подготовлена подписка для пользователя {user.telegram_id} до {expire_at}') + except Exception as e: - logger.error(f"❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}") + logger.error(f'❌ Ошибка создания подписки для пользователя {user.telegram_id}: {e}') try: from app.database.crud.subscription import create_subscription_no_commit from app.database.models import SubscriptionStatus - - basic_subscription = await create_subscription_no_commit( + + await create_subscription_no_commit( db=db, user_id=user.id, status=SubscriptionStatus.ACTIVE.value, @@ -1611,19 +1567,18 @@ class RemnaWaveService: remnawave_short_uuid=panel_user.get('shortUuid'), subscription_url=panel_user.get('subscriptionUrl', ''), subscription_crypto_link=( - panel_user.get('subscriptionCryptoLink') - or (panel_user.get('happ') or {}).get('cryptoLink', '') - ) + panel_user.get('subscriptionCryptoLink') or (panel_user.get('happ') or {}).get('cryptoLink', '') + ), ) - logger.info(f"✅ Подготовлена базовая подписка для пользователя {user.telegram_id}") + logger.info(f'✅ Подготовлена базовая подписка для пользователя {user.telegram_id}') except Exception as basic_error: - logger.error(f"❌ Ошибка создания базовой подписки: {basic_error}") + logger.error(f'❌ Ошибка создания базовой подписки: {basic_error}') async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user): try: from app.database.crud.subscription import get_subscription_by_user_id from app.database.models import SubscriptionStatus - + # Сначала пытаемся использовать уже загруженную подписку, если она есть subscription = None try: @@ -1636,14 +1591,14 @@ class RemnaWaveService: except: # Если не удалось получить подписку через ленивую загрузку subscription = await get_subscription_by_user_id(db, user.id) - + if not subscription: await self._create_subscription_from_panel_data(db, user, panel_user) return - + panel_status = panel_user.get('status', 'ACTIVE') expire_at_str = panel_user.get('expireAt', '') - + if expire_at_str: expire_at = self._parse_remnawave_date(expire_at_str) @@ -1654,14 +1609,14 @@ class RemnaWaveService: if expire_at > subscription.end_date: # RemnaWave имеет более позднюю дату - обновляем subscription.end_date = expire_at - logger.debug(f"Обновлена дата окончания подписки до {expire_at}") + logger.debug(f'Обновлена дата окончания подписки до {expire_at}') else: # Локальная дата позже - НЕ перезаписываем, логируем предупреждение logger.warning( - f"⚠️ Sync: пропускаем обновление end_date для user {getattr(user, 'telegram_id', '?')}: " - f"локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})" + f'⚠️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: ' + f'локальная дата ({subscription.end_date}) позже чем в RemnaWave ({expire_at})' ) - + current_time = self._now_utc() if panel_status == 'ACTIVE' and subscription.end_date > current_time: new_status = SubscriptionStatus.ACTIVE.value @@ -1673,9 +1628,9 @@ class RemnaWaveService: # а реальная end_date уже обновлена продлением if subscription.status == SubscriptionStatus.ACTIVE.value: logger.warning( - f"⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, 'telegram_id', '?')}: " - f"статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. " - f"Деактивация будет выполнена через middleware с буфером." + f'⚠️ Sync: пропускаем деактивацию подписки user {getattr(user, "telegram_id", "?")}: ' + f'статус ACTIVE, end_date в памяти ({subscription.end_date}) <= now. ' + f'Деактивация будет выполнена через middleware с буфером.' ) new_status = subscription.status # Сохраняем текущий статус else: @@ -1685,49 +1640,48 @@ class RemnaWaveService: if subscription.status != new_status: subscription.status = new_status - logger.debug(f"Обновлен статус подписки: {new_status}") + logger.debug(f'Обновлен статус подписки: {new_status}') used_traffic_bytes = _get_user_traffic_bytes(panel_user) traffic_used_gb = used_traffic_bytes / (1024**3) if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01: subscription.traffic_used_gb = traffic_used_gb - logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB") - + logger.debug(f'Обновлен использованный трафик: {traffic_used_gb} GB') + traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0) traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0 - + if subscription.traffic_limit_gb != traffic_limit_gb: subscription.traffic_limit_gb = traffic_limit_gb - logger.debug(f"Обновлен лимит трафика: {traffic_limit_gb} GB") - + logger.debug(f'Обновлен лимит трафика: {traffic_limit_gb} GB') + device_limit = panel_user.get('hwidDeviceLimit', 1) or 1 if subscription.device_limit != device_limit: subscription.device_limit = device_limit - logger.debug(f"Обновлен лимит устройств: {device_limit}") - + logger.debug(f'Обновлен лимит устройств: {device_limit}') + new_short_uuid = panel_user.get('shortUuid') if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid: old_short_uuid = subscription.remnawave_short_uuid subscription.remnawave_short_uuid = new_short_uuid logger.debug( - "Обновлен short UUID подписки пользователя %s: %s → %s", - getattr(user, "telegram_id", "?"), + 'Обновлен short UUID подписки пользователя %s: %s → %s', + getattr(user, 'telegram_id', '?'), old_short_uuid, new_short_uuid, ) - + 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', '') + 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 = [] if isinstance(active_squads, list): @@ -1736,28 +1690,28 @@ class RemnaWaveService: squad_uuids.append(squad['uuid']) elif isinstance(squad, str): squad_uuids.append(squad) - + current_squads = set(subscription.connected_squads or []) new_squads = set(squad_uuids) - + if current_squads != new_squads: subscription.connected_squads = squad_uuids - logger.debug(f"Обновлены подключенные сквады: {squad_uuids}") - + logger.debug(f'Обновлены подключенные сквады: {squad_uuids}') + # Коммитим изменения позже, в основном цикле, чтобы уменьшить количество транзакций - logger.debug(f"✅ Обновлена подписка для пользователя {user.telegram_id}") - + logger.debug(f'✅ Обновлена подписка для пользователя {user.telegram_id}') + except Exception as e: - logger.error(f"❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}") + logger.error(f'❌ Ошибка обновления подписки для пользователя {user.telegram_id}: {e}') # Не делаем rollback, так как это может повлиять на другие операции # Ошибку прокидываем выше для корректной обработки в основном цикле raise - - async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]: + + async def sync_users_to_panel(self, db: AsyncSession) -> dict[str, int]: from app.database.crud.subscription import get_subscriptions_batch try: - stats = {"created": 0, "updated": 0, "errors": 0} + stats = {'created': 0, 'updated': 0, 'errors': 0} batch_size = 500 offset = 0 @@ -1792,7 +1746,8 @@ class RemnaWaveService: # Определяем статус для панели is_subscription_active = ( - sub.status in ( + sub.status + in ( SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value, ) @@ -1810,13 +1765,13 @@ class RemnaWaveService: username=username, expire_at=expire_at, status=status, - traffic_limit_bytes=sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0, + traffic_limit_bytes=sub.traffic_limit_gb * (1024**3) + if sub.traffic_limit_gb > 0 + else 0, traffic_limit_strategy=TrafficLimitStrategy.MONTH, telegram_id=user.telegram_id, description=settings.format_remnawave_user_description( - full_name=user.full_name, - username=user.username, - telegram_id=user.telegram_id + full_name=user.full_name, username=user.username, telegram_id=user.telegram_id ), active_internal_squads=sub.connected_squads, ) @@ -1832,7 +1787,7 @@ class RemnaWaveService: existing_users = await api.get_user_by_telegram_id(user.telegram_id) if existing_users: panel_uuid = existing_users[0].uuid - logger.debug(f"Найден пользователь {user.telegram_id} в панели: {panel_uuid}") + logger.debug(f'Найден пользователь {user.telegram_id} в панели: {panel_uuid}') if panel_uuid: update_kwargs = dict( @@ -1853,20 +1808,21 @@ class RemnaWaveService: # Сохраняем UUID если его не было if not user.remnawave_uuid: user.remnawave_uuid = panel_uuid - return ("updated", sub, None) + return ('updated', sub, None) except RemnaWaveAPIError as api_error: if api_error.status_code == 404: new_user = await api.create_user(**create_kwargs) - return ("created", sub, new_user) - else: - raise + return ('created', sub, new_user) + raise else: new_user = await api.create_user(**create_kwargs) - return ("created", sub, new_user) + return ('created', sub, new_user) except Exception as e: - logger.error(f"Ошибка синхронизации пользователя {sub.user.telegram_id if sub.user else 'N/A'} в панель: {e}") - return ("error", sub, None) + logger.error( + f'Ошибка синхронизации пользователя {sub.user.telegram_id if sub.user else "N/A"} в панель: {e}' + ) + return ('error', sub, None) # Выполняем параллельно tasks = [process_subscription(s) for s in valid_subscriptions] @@ -1875,33 +1831,33 @@ class RemnaWaveService: # Обрабатываем результаты for result in results: if isinstance(result, Exception): - stats["errors"] += 1 + stats['errors'] += 1 continue action, sub, new_user = result - if action == "created": + if action == 'created': if new_user and sub.user: sub.user.remnawave_uuid = new_user.uuid sub.remnawave_short_uuid = new_user.short_uuid - stats["created"] += 1 - elif action == "updated": - stats["updated"] += 1 + stats['created'] += 1 + elif action == 'updated': + stats['updated'] += 1 else: - stats["errors"] += 1 + stats['errors'] += 1 try: await db.commit() except Exception as commit_error: logger.error( - "Ошибка фиксации транзакции при синхронизации в панель: %s", + 'Ошибка фиксации транзакции при синхронизации в панель: %s', commit_error, ) await db.rollback() - stats["errors"] += len(valid_subscriptions) + stats['errors'] += len(valid_subscriptions) logger.info( - f"📦 Обработано {offset + len(subscriptions)} подписок: " - f"создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}" + f'📦 Обработано {offset + len(subscriptions)} подписок: ' + f'создано {stats["created"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' ) if len(subscriptions) < batch_size: @@ -1910,15 +1866,15 @@ class RemnaWaveService: offset += batch_size logger.info( - f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}" + f'✅ Синхронизация в панель завершена: создано {stats["created"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' ) return stats except Exception as e: - logger.error(f"Ошибка синхронизации пользователей в панель: {e}") - return {"created": 0, "updated": 0, "errors": 1} - - async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]: + logger.error(f'Ошибка синхронизации пользователей в панель: {e}') + return {'created': 0, 'updated': 0, 'errors': 1} + + async def get_user_traffic_stats(self, telegram_id: int) -> dict[str, Any] | None: try: async with self.get_api_client() as api: users = await api.get_user_by_telegram_id(telegram_id) @@ -1929,20 +1885,20 @@ class RemnaWaveService: user = users[0] return { - "used_traffic_bytes": user.used_traffic_bytes, - "used_traffic_gb": user.used_traffic_bytes / (1024**3), - "lifetime_used_traffic_bytes": user.lifetime_used_traffic_bytes, - "lifetime_used_traffic_gb": user.lifetime_used_traffic_bytes / (1024**3), - "traffic_limit_bytes": user.traffic_limit_bytes, - "traffic_limit_gb": user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, - "subscription_url": user.subscription_url + 'used_traffic_bytes': user.used_traffic_bytes, + 'used_traffic_gb': user.used_traffic_bytes / (1024**3), + 'lifetime_used_traffic_bytes': user.lifetime_used_traffic_bytes, + 'lifetime_used_traffic_gb': user.lifetime_used_traffic_bytes / (1024**3), + 'traffic_limit_bytes': user.traffic_limit_bytes, + 'traffic_limit_gb': user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, + 'subscription_url': user.subscription_url, } except Exception as e: - logger.error(f"Ошибка получения статистики трафика для пользователя {telegram_id}: {e}") + logger.error(f'Ошибка получения статистики трафика для пользователя {telegram_id}: {e}') return None - async def get_user_traffic_stats_by_uuid(self, remnawave_uuid: str) -> Optional[Dict[str, Any]]: + async def get_user_traffic_stats_by_uuid(self, remnawave_uuid: str) -> dict[str, Any] | None: """ Получить статистику трафика по RemnaWave UUID. @@ -1956,20 +1912,20 @@ class RemnaWaveService: return None return { - "used_traffic_bytes": user.used_traffic_bytes, - "used_traffic_gb": user.used_traffic_bytes / (1024**3), - "lifetime_used_traffic_bytes": user.lifetime_used_traffic_bytes, - "lifetime_used_traffic_gb": user.lifetime_used_traffic_bytes / (1024**3), - "traffic_limit_bytes": user.traffic_limit_bytes, - "traffic_limit_gb": user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, - "subscription_url": user.subscription_url + 'used_traffic_bytes': user.used_traffic_bytes, + 'used_traffic_gb': user.used_traffic_bytes / (1024**3), + 'lifetime_used_traffic_bytes': user.lifetime_used_traffic_bytes, + 'lifetime_used_traffic_gb': user.lifetime_used_traffic_bytes / (1024**3), + 'traffic_limit_bytes': user.traffic_limit_bytes, + 'traffic_limit_gb': user.traffic_limit_bytes / (1024**3) if user.traffic_limit_bytes > 0 else 0, + 'subscription_url': user.subscription_url, } except Exception as e: - logger.error(f"Ошибка получения статистики трафика по UUID {remnawave_uuid}: {e}") + logger.error(f'Ошибка получения статистики трафика по UUID {remnawave_uuid}: {e}') return None - async def get_telegram_id_by_email(self, user_identifier: str) -> Optional[int]: + async def get_telegram_id_by_email(self, user_identifier: str) -> int | None: """ Получить telegram_id пользователя по email или username из панели RemnaWave. @@ -1980,7 +1936,7 @@ class RemnaWaveService: telegram_id если найден, иначе None """ if not self.is_configured: - logger.warning("RemnaWave API не настроен для поиска пользователя") + logger.warning('RemnaWave API не настроен для поиска пользователя') return None try: @@ -1990,8 +1946,7 @@ class RemnaWaveService: user = await api.get_user_by_username(user_identifier) if user and user.telegram_id: logger.info( - f"Найден пользователь по username '{user_identifier}': " - f"telegram_id={user.telegram_id}" + f"Найден пользователь по username '{user_identifier}': telegram_id={user.telegram_id}" ) return user.telegram_id except Exception as e: @@ -2008,8 +1963,7 @@ class RemnaWaveService: panel_telegram_id = panel_user.telegram_id if hasattr(panel_user, 'telegram_id') else None if panel_telegram_id: logger.info( - f"Найден пользователь по email '{user_identifier}': " - f"telegram_id={panel_telegram_id}" + f"Найден пользователь по email '{user_identifier}': telegram_id={panel_telegram_id}" ) return panel_telegram_id except Exception as e: @@ -2022,73 +1976,68 @@ class RemnaWaveService: logger.error(f"Ошибка получения telegram_id для '{user_identifier}': {e}") return None - async def test_api_connection(self) -> Dict[str, Any]: + 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, + 'status': 'not_configured', + 'message': self.configuration_error or 'RemnaWave API не настроен', + 'api_url': settings.REMNAWAVE_API_URL, } try: 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 + 'status': 'connected', + 'message': 'Подключение успешно', + 'api_url': settings.REMNAWAVE_API_URL, + 'system_info': system_stats, } except RemnaWaveAPIError as e: return { - "status": "error", - "message": f"Ошибка API: {e.message}", - "status_code": e.status_code, - "api_url": settings.REMNAWAVE_API_URL + 'status': 'error', + 'message': f'Ошибка API: {e.message}', + '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, + 'status': 'not_configured', + 'message': str(e), + 'api_url': settings.REMNAWAVE_API_URL, } except Exception as e: - return { - "status": "error", - "message": f"Ошибка подключения: {str(e)}", - "api_url": settings.REMNAWAVE_API_URL - } - - async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: + return {'status': 'error', 'message': f'Ошибка подключения: {e!s}', 'api_url': settings.REMNAWAVE_API_URL} + + async def get_nodes_realtime_usage(self) -> list[dict[str, Any]]: try: async with self.get_api_client() as api: usage_data = await api.get_nodes_realtime_usage() return usage_data - + except Exception as e: - logger.error(f"Ошибка получения актуального использования нод: {e}") + logger.error(f'Ошибка получения актуального использования нод: {e}') return [] - async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]: + async def get_squad_details(self, squad_uuid: str) -> dict | None: try: async with self.get_api_client() as api: squad = await api.get_internal_squad_by_uuid(squad_uuid) if squad: inbounds = [ - asdict(inbound) if is_dataclass(inbound) else inbound - for inbound in squad.inbounds or [] + asdict(inbound) if is_dataclass(inbound) else inbound for inbound in squad.inbounds or [] ] return { 'uuid': squad.uuid, 'name': squad.name, 'members_count': squad.members_count, 'inbounds_count': squad.inbounds_count, - 'inbounds': inbounds + 'inbounds': inbounds, } return None except Exception as e: - logger.error(f"Error getting squad details: {e}") + logger.error(f'Error getting squad details: {e}') return None async def add_all_users_to_squad(self, squad_uuid: str) -> bool: @@ -2097,24 +2046,26 @@ class RemnaWaveService: 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: - logger.error(f"Error adding users to squad: {e}") + logger.error(f'Error adding users to squad: {e}') return False async def remove_all_users_from_squad(self, squad_uuid: str) -> bool: try: async with self.get_api_client() as api: - response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users') + 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: - logger.error(f"Error removing users from squad: {e}") + logger.error(f'Error removing users from squad: {e}') return False - async def get_all_inbounds(self) -> List[Dict]: + async def get_all_inbounds(self) -> list[dict]: try: 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', []) - + return [ { 'uuid': inbound['uuid'], @@ -2122,99 +2073,89 @@ class RemnaWaveService: 'type': inbound['type'], 'network': inbound.get('network'), 'security': inbound.get('security'), - 'port': inbound.get('port') + 'port': inbound.get('port'), } for inbound in inbounds_data ] except Exception as e: - logger.error(f"Error getting all inbounds: {e}") + logger.error(f'Error getting all inbounds: {e}') return [] async def rename_squad(self, squad_uuid: str, new_name: str) -> bool: try: async with self.get_api_client() as api: - data = { - 'uuid': squad_uuid, - 'name': new_name - } - response = await api._make_request('PATCH', '/api/internal-squads', data) + data = {'uuid': squad_uuid, 'name': new_name} + await api._make_request('PATCH', '/api/internal-squads', data) return True except Exception as e: - logger.error(f"Error renaming squad: {e}") + logger.error(f'Error renaming squad: {e}') return False - async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]: + async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> list[dict[str, Any]]: try: async with self.get_api_client() as api: - start_str = start_date.isoformat() + "Z" - end_str = end_date.isoformat() + "Z" - - params = { - 'start': start_str, - 'end': end_str - } - + start_str = start_date.isoformat() + 'Z' + end_str = end_date.isoformat() + 'Z' + + params = {'start': start_str, 'end': end_str} + usage_data = await api._make_request( - 'GET', - f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', - params=params + 'GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params ) - + return usage_data.get('response', []) - + except Exception as e: - logger.error(f"Ошибка получения статистики использования ноды {node_uuid}: {e}") + logger.error(f'Ошибка получения статистики использования ноды {node_uuid}: {e}') return [] - async def get_node_statistics(self, node_uuid: str) -> Optional[Dict[str, Any]]: + async def get_node_statistics(self, node_uuid: str) -> dict[str, Any] | None: try: node = await self.get_node_details(node_uuid) if not node: return None - + realtime_stats = await self.get_nodes_realtime_usage() - + node_realtime = None for stats in realtime_stats: if stats.get('nodeUuid') == node_uuid: node_realtime = stats break - + end_date = datetime.now() start_date = end_date - timedelta(days=7) - - usage_history = await self.get_node_user_usage_by_range( - node_uuid, start_date, end_date - ) - + + usage_history = await self.get_node_user_usage_by_range(node_uuid, start_date, end_date) + return { 'node': node, 'realtime': node_realtime, 'usage_history': usage_history, - 'last_updated': datetime.now().isoformat() + 'last_updated': datetime.now().isoformat(), } - + except Exception as e: - logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}") + logger.error(f'Ошибка получения статистики ноды {node_uuid}: {e}') async def validate_user_data_before_sync(self, panel_user) -> bool: try: if not panel_user.telegram_id: - logger.debug(f"Нет telegram_id для пользователя {panel_user.uuid}") + logger.debug(f'Нет telegram_id для пользователя {panel_user.uuid}') return False - + if not panel_user.uuid: - logger.debug(f"Нет UUID для пользователя {panel_user.telegram_id}") + logger.debug(f'Нет UUID для пользователя {panel_user.telegram_id}') return False - + if panel_user.telegram_id <= 0: - logger.debug(f"Некорректный telegram_id: {panel_user.telegram_id}") + logger.debug(f'Некорректный telegram_id: {panel_user.telegram_id}') return False - + return True - + except Exception as e: - logger.error(f"Ошибка валидации данных пользователя: {e}") + logger.error(f'Ошибка валидации данных пользователя: {e}') return False async def force_cleanup_user_data(self, db: AsyncSession, user: User) -> bool: @@ -2229,72 +2170,65 @@ class RemnaWaveService: or (user.subscription and not user.subscription.is_trial) or user.balance_kopeks > 0 ) - user_id_display = user.telegram_id or user.email or f"#{user.id}" + user_id_display = user.telegram_id or user.email or f'#{user.id}' if was_paid: logger.warning( - f"⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя {user_id_display}! " - f"has_had_paid_subscription={user.has_had_paid_subscription}, " - f"balance={user.balance_kopeks}, " - f"is_trial={user.subscription.is_trial if user.subscription else 'N/A'}" + f'⚠️ ВНИМАНИЕ: force_cleanup_user_data вызвана для ПЛАТНОГО пользователя {user_id_display}! ' + f'has_had_paid_subscription={user.has_had_paid_subscription}, ' + f'balance={user.balance_kopeks}, ' + f'is_trial={user.subscription.is_trial if user.subscription else "N/A"}' ) - logger.info(f"🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user_id_display}") - + logger.info(f'🗑️ ПРИНУДИТЕЛЬНАЯ полная очистка данных пользователя {user_id_display}') + if user.remnawave_uuid: try: 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_id_display}") + logger.info(f'🔧 Сброшены HWID устройства для {user_id_display}') except Exception as hwid_error: - logger.warning(f"⚠️ Ошибка сброса HWID устройств: {hwid_error}") - + logger.warning(f'⚠️ Ошибка сброса HWID устройств: {hwid_error}') + try: from sqlalchemy import delete + from app.database.models import ( - SubscriptionServer, Transaction, ReferralEarning, - PromoCodeUse, SubscriptionStatus + PromoCodeUse, + ReferralEarning, + SubscriptionServer, + SubscriptionStatus, + Transaction, ) - + if user.subscription: await decrement_subscription_server_counts(db, user.subscription) await db.execute( - delete(SubscriptionServer).where( - SubscriptionServer.subscription_id == user.subscription.id - ) + delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) - logger.info(f"🗑️ Удалены серверы подписки для {user_id_display}") + logger.info(f'🗑️ Удалены серверы подписки для {user_id_display}') - await db.execute( - delete(Transaction).where(Transaction.user_id == user.id) - ) - logger.info(f"🗑️ Удалены транзакции для {user_id_display}") + await db.execute(delete(Transaction).where(Transaction.user_id == user.id)) + logger.info(f'🗑️ Удалены транзакции для {user_id_display}') - await db.execute( - delete(ReferralEarning).where(ReferralEarning.user_id == user.id) - ) - await db.execute( - delete(ReferralEarning).where(ReferralEarning.referral_id == user.id) - ) - logger.info(f"🗑️ Удалены реферальные доходы для {user_id_display}") + await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id)) + await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)) + logger.info(f'🗑️ Удалены реферальные доходы для {user_id_display}') + + await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)) + logger.info(f'🗑️ Удалены использования промокодов для {user_id_display}') - await db.execute( - delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id) - ) - logger.info(f"🗑️ Удалены использования промокодов для {user_id_display}") - except Exception as records_error: - logger.error(f"❌ Ошибка удаления связанных записей: {records_error}") - + logger.error(f'❌ Ошибка удаления связанных записей: {records_error}') + try: - user.balance_kopeks = 0 user.remnawave_uuid = None user.has_had_paid_subscription = False user.used_promocodes = 0 user.updated_at = self._now_utc() - + if user.subscription: user.subscription.status = SubscriptionStatus.DISABLED.value user.subscription.is_trial = True @@ -2306,195 +2240,201 @@ class RemnaWaveService: user.subscription.autopay_enabled = False user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE user.subscription.remnawave_short_uuid = None - user.subscription.subscription_url = "" - user.subscription.subscription_crypto_link = "" + user.subscription.subscription_url = '' + user.subscription.subscription_crypto_link = '' user.subscription.updated_at = self._now_utc() - + await db.commit() - - logger.info(f"✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user_id_display}") + + logger.info(f'✅ ПРИНУДИТЕЛЬНО очищены ВСЕ данные пользователя {user_id_display}') return True - + except Exception as cleanup_error: - logger.error(f"❌ Ошибка финальной очистки пользователя: {cleanup_error}") + logger.error(f'❌ Ошибка финальной очистки пользователя: {cleanup_error}') await db.rollback() return False - + except Exception as e: - logger.error(f"❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}") + logger.error(f'❌ Критическая ошибка принудительной очистки пользователя {user.telegram_id}: {e}') await db.rollback() return False - async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]: + async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> dict[str, int]: try: - stats = {"deactivated": 0, "errors": 0, "checked": 0} - - logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...") - + stats = {'deactivated': 0, 'errors': 0, 'checked': 0} + + logger.info('🧹 Начинаем усиленную очистку неактуальных подписок...') + 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'] - + panel_telegram_ids = set() for panel_user in panel_users: telegram_id = panel_user.get('telegramId') if telegram_id: panel_telegram_ids.add(telegram_id) - - logger.info(f"📊 Найдено {len(panel_telegram_ids)} пользователей в панели") - + + logger.info(f'📊 Найдено {len(panel_telegram_ids)} пользователей в панели') + from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus - + page = 1 limit = 100 - + while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) - + if not subscriptions: break - + for subscription in subscriptions: try: - stats["checked"] += 1 + stats['checked'] += 1 user = subscription.user - + if subscription.status == SubscriptionStatus.DISABLED.value: continue - + if user.telegram_id not in panel_telegram_ids: - logger.info(f"🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)") - + logger.info( + f'🗑️ ПОЛНАЯ деактивация подписки пользователя {user.telegram_id} (отсутствует в панели)' + ) + cleanup_success = await self.force_cleanup_user_data(db, user) - + if cleanup_success: - stats["deactivated"] += 1 + stats['deactivated'] += 1 else: - stats["errors"] += 1 - + stats['errors'] += 1 + except Exception as sub_error: - logger.error(f"❌ Ошибка обработки подписки {subscription.id}: {sub_error}") - stats["errors"] += 1 - + logger.error(f'❌ Ошибка обработки подписки {subscription.id}: {sub_error}') + stats['errors'] += 1 + page += 1 if len(subscriptions) < limit: break - - logger.info(f"🧹 Усиленная очистка завершена: проверено {stats['checked']}, деактивировано {stats['deactivated']}, ошибок {stats['errors']}") + + logger.info( + f'🧹 Усиленная очистка завершена: проверено {stats["checked"]}, деактивировано {stats["deactivated"]}, ошибок {stats["errors"]}' + ) return stats - + except Exception as e: - logger.error(f"❌ Критическая ошибка усиленной очистки подписок: {e}") - return {"deactivated": 0, "errors": 1, "checked": 0} + logger.error(f'❌ Критическая ошибка усиленной очистки подписок: {e}') + return {'deactivated': 0, 'errors': 1, 'checked': 0} - - async def sync_subscription_statuses(self, db: AsyncSession) -> Dict[str, int]: + async def sync_subscription_statuses(self, db: AsyncSession) -> dict[str, int]: try: - stats = {"updated": 0, "errors": 0, "checked": 0} - - logger.info("🔄 Начинаем синхронизацию статусов подписок...") - + stats = {'updated': 0, 'errors': 0, 'checked': 0} + + logger.info('🔄 Начинаем синхронизацию статусов подписок...') + 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'] - + panel_users_dict = {} for panel_user in panel_users: telegram_id = panel_user.get('telegramId') if telegram_id: panel_users_dict[telegram_id] = panel_user - - logger.info(f"📊 Найдено {len(panel_users_dict)} пользователей в панели для синхронизации") - + + logger.info(f'📊 Найдено {len(panel_users_dict)} пользователей в панели для синхронизации') + from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus - + page = 1 limit = 100 - + while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) - + if not subscriptions: break - + for subscription in subscriptions: try: - stats["checked"] += 1 + stats['checked'] += 1 user = subscription.user # Skip email-only users (no telegram_id for panel lookup) if not user.telegram_id: - logger.debug(f"Пропускаем email-пользователя {user.id} при синхронизации с панелью") + logger.debug(f'Пропускаем email-пользователя {user.id} при синхронизации с панелью') continue panel_user = panel_users_dict.get(user.telegram_id) if panel_user: await self._update_subscription_from_panel_data(db, user, panel_user) - stats["updated"] += 1 - else: - if subscription.status != SubscriptionStatus.DISABLED.value: - logger.info(f"🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)") - - from app.database.crud.subscription import deactivate_subscription - await deactivate_subscription(db, subscription) - stats["updated"] += 1 - + stats['updated'] += 1 + elif subscription.status != SubscriptionStatus.DISABLED.value: + logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)') + + from app.database.crud.subscription import deactivate_subscription + + await deactivate_subscription(db, subscription) + stats['updated'] += 1 + except Exception as sub_error: - logger.error(f"❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}") - stats["errors"] += 1 - + logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}') + stats['errors'] += 1 + page += 1 if len(subscriptions) < limit: break - - logger.info(f"🔄 Синхронизация статусов завершена: проверено {stats['checked']}, обновлено {stats['updated']}, ошибок {stats['errors']}") + + logger.info( + f'🔄 Синхронизация статусов завершена: проверено {stats["checked"]}, обновлено {stats["updated"]}, ошибок {stats["errors"]}' + ) return stats - + except Exception as e: - logger.error(f"❌ Критическая ошибка синхронизации статусов: {e}") - return {"updated": 0, "errors": 1, "checked": 0} + logger.error(f'❌ Критическая ошибка синхронизации статусов: {e}') + return {'updated': 0, 'errors': 1, 'checked': 0} - - async def validate_and_fix_subscriptions(self, db: AsyncSession) -> Dict[str, int]: + async def validate_and_fix_subscriptions(self, db: AsyncSession) -> dict[str, int]: try: - stats = {"fixed": 0, "errors": 0, "checked": 0, "issues_found": 0} - - logger.info("🔍 Начинаем валидацию подписок...") - + stats = {'fixed': 0, 'errors': 0, 'checked': 0, 'issues_found': 0} + + logger.info('🔍 Начинаем валидацию подписок...') + from app.database.crud.subscription import get_all_subscriptions from app.database.models import SubscriptionStatus - + page = 1 limit = 100 - + while True: subscriptions, total_count = await get_all_subscriptions(db, page, limit) - + if not subscriptions: break - + for subscription in subscriptions: try: - stats["checked"] += 1 + stats['checked'] += 1 user = subscription.user issues_fixed = 0 - + current_time = self._now_utc() # Добавляем буфер 5 минут для защиты от race condition при продлении expiry_buffer = timedelta(minutes=5) - if (subscription.end_date + expiry_buffer <= current_time and - subscription.status == SubscriptionStatus.ACTIVE.value): + if ( + subscription.end_date + expiry_buffer <= current_time + and subscription.status == SubscriptionStatus.ACTIVE.value + ): time_since_expiry = current_time - subscription.end_date logger.warning( - f"🔧 fix_data_issues: деактивируем подписку {subscription.id} " - f"(user={user.telegram_id}), просрочена на {time_since_expiry}" + f'🔧 fix_data_issues: деактивируем подписку {subscription.id} ' + f'(user={user.telegram_id}), просрочена на {time_since_expiry}' ) subscription.status = SubscriptionStatus.EXPIRED.value issues_fixed += 1 - + if not subscription.remnawave_short_uuid and user.remnawave_uuid: try: async with self.get_api_client() as api: @@ -2503,251 +2443,245 @@ class RemnaWaveService: 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}") + logger.info(f'🔧 Восстановлены данные Remnawave для {user.telegram_id}') issues_fixed += 1 except Exception as rw_error: - logger.warning(f"⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}") - + logger.warning( + f'⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}' + ) + if subscription.traffic_limit_gb < 0: subscription.traffic_limit_gb = 0 - logger.info(f"🔧 Исправлен некорректный лимит трафика для {user.telegram_id}") + logger.info(f'🔧 Исправлен некорректный лимит трафика для {user.telegram_id}') issues_fixed += 1 - + if subscription.traffic_used_gb < 0: subscription.traffic_used_gb = 0.0 - logger.info(f"🔧 Исправлено некорректное использование трафика для {user.telegram_id}") + logger.info(f'🔧 Исправлено некорректное использование трафика для {user.telegram_id}') issues_fixed += 1 - + if subscription.device_limit <= 0: subscription.device_limit = 1 - logger.info(f"🔧 Исправлен лимит устройств для {user.telegram_id}") + logger.info(f'🔧 Исправлен лимит устройств для {user.telegram_id}') issues_fixed += 1 - + if subscription.connected_squads is None: subscription.connected_squads = [] - logger.info(f"🔧 Инициализирован список сквадов для {user.telegram_id}") + logger.info(f'🔧 Инициализирован список сквадов для {user.telegram_id}') issues_fixed += 1 - + if issues_fixed > 0: - stats["issues_found"] += issues_fixed - stats["fixed"] += 1 + stats['issues_found'] += issues_fixed + stats['fixed'] += 1 await db.commit() - + except Exception as sub_error: - logger.error(f"❌ Ошибка валидации подписки {subscription.id}: {sub_error}") - stats["errors"] += 1 + logger.error(f'❌ Ошибка валидации подписки {subscription.id}: {sub_error}') + stats['errors'] += 1 await db.rollback() - + page += 1 if len(subscriptions) < limit: break - - logger.info(f"🔍 Валидация завершена: проверено {stats['checked']}, исправлено подписок {stats['fixed']}, найдено проблем {stats['issues_found']}, ошибок {stats['errors']}") + + logger.info( + f'🔍 Валидация завершена: проверено {stats["checked"]}, исправлено подписок {stats["fixed"]}, найдено проблем {stats["issues_found"]}, ошибок {stats["errors"]}' + ) return stats - + except Exception as e: - logger.error(f"❌ Критическая ошибка валидации: {e}") - return {"fixed": 0, "errors": 1, "checked": 0, "issues_found": 0} + logger.error(f'❌ Критическая ошибка валидации: {e}') + return {'fixed': 0, 'errors': 1, 'checked': 0, 'issues_found': 0} - - async def get_sync_recommendations(self, db: AsyncSession) -> Dict[str, Any]: + async def get_sync_recommendations(self, db: AsyncSession) -> dict[str, Any]: try: recommendations = { - "should_sync": False, - "sync_type": "none", - "reasons": [], - "priority": "low", - "estimated_time": "1-2 минуты" - } - - from app.database.crud.user import get_users_list - bot_users = await get_users_list(db, offset=0, limit=10000) - - users_without_uuid = sum(1 for user in bot_users if not user.remnawave_uuid and user.subscription) - - from app.database.crud.subscription import get_expired_subscriptions - expired_subscriptions = await get_expired_subscriptions(db) - active_expired = sum(1 for sub in expired_subscriptions if sub.status == "active") - - if users_without_uuid > 10: - recommendations["should_sync"] = True - recommendations["sync_type"] = "all" - recommendations["priority"] = "high" - recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с Remnawave") - recommendations["estimated_time"] = "3-5 минут" - - if active_expired > 5: - recommendations["should_sync"] = True - if recommendations["sync_type"] == "none": - recommendations["sync_type"] = "update_only" - recommendations["priority"] = "medium" if recommendations["priority"] == "low" else recommendations["priority"] - recommendations["reasons"].append(f"Найдено {active_expired} активных подписок с истекшим сроком") - - if not recommendations["should_sync"]: - recommendations["sync_type"] = "update_only" - recommendations["reasons"].append("Рекомендуется регулярная синхронизация данных") - recommendations["estimated_time"] = "1-2 минуты" - - return recommendations - - except Exception as e: - logger.error(f"❌ Ошибка получения рекомендаций: {e}") - return { - "should_sync": True, - "sync_type": "all", - "reasons": ["Ошибка анализа - рекомендуется полная синхронизация"], - "priority": "medium", - "estimated_time": "3-5 минут" + 'should_sync': False, + 'sync_type': 'none', + 'reasons': [], + 'priority': 'low', + 'estimated_time': '1-2 минуты', } - async def monitor_panel_status(self, bot) -> Dict[str, Any]: + from app.database.crud.user import get_users_list + + bot_users = await get_users_list(db, offset=0, limit=10000) + + users_without_uuid = sum(1 for user in bot_users if not user.remnawave_uuid and user.subscription) + + from app.database.crud.subscription import get_expired_subscriptions + + expired_subscriptions = await get_expired_subscriptions(db) + active_expired = sum(1 for sub in expired_subscriptions if sub.status == 'active') + + if users_without_uuid > 10: + recommendations['should_sync'] = True + recommendations['sync_type'] = 'all' + recommendations['priority'] = 'high' + recommendations['reasons'].append(f'Найдено {users_without_uuid} пользователей без связи с Remnawave') + recommendations['estimated_time'] = '3-5 минут' + + if active_expired > 5: + recommendations['should_sync'] = True + if recommendations['sync_type'] == 'none': + recommendations['sync_type'] = 'update_only' + recommendations['priority'] = ( + 'medium' if recommendations['priority'] == 'low' else recommendations['priority'] + ) + recommendations['reasons'].append(f'Найдено {active_expired} активных подписок с истекшим сроком') + + if not recommendations['should_sync']: + recommendations['sync_type'] = 'update_only' + recommendations['reasons'].append('Рекомендуется регулярная синхронизация данных') + recommendations['estimated_time'] = '1-2 минуты' + + return recommendations + + except Exception as e: + logger.error(f'❌ Ошибка получения рекомендаций: {e}') + return { + 'should_sync': True, + 'sync_type': 'all', + 'reasons': ['Ошибка анализа - рекомендуется полная синхронизация'], + 'priority': 'medium', + 'estimated_time': '3-5 минут', + } + + async def monitor_panel_status(self, bot) -> dict[str, Any]: try: from app.utils.cache import cache - previous_status = await cache.get("remnawave_panel_status") or "unknown" - - status_result = await self.check_panel_health() - current_status = status_result.get("status", "offline") - - if current_status != previous_status and previous_status != "unknown": - await self._send_status_change_notification( - bot, - previous_status, - current_status, - status_result - ) - - await cache.set("remnawave_panel_status", current_status, expire=300) - - return status_result - - except Exception as e: - logger.error(f"Ошибка мониторинга статуса панели Remnawave: {e}") - return {"status": "error", "error": str(e)} - - + previous_status = await cache.get('remnawave_panel_status') or 'unknown' + + status_result = await self.check_panel_health() + current_status = status_result.get('status', 'offline') + + if current_status != previous_status and previous_status != 'unknown': + await self._send_status_change_notification(bot, previous_status, current_status, status_result) + + await cache.set('remnawave_panel_status', current_status, expire=300) + + return status_result + + except Exception as e: + logger.error(f'Ошибка мониторинга статуса панели Remnawave: {e}') + return {'status': 'error', 'error': str(e)} + async def _send_status_change_notification( - self, - bot, - old_status: str, - new_status: str, - status_data: Dict[str, Any] + self, bot, old_status: str, new_status: str, status_data: dict[str, Any] ): try: from app.services.admin_notification_service import AdminNotificationService - - notification_service = AdminNotificationService(bot) - - details = { - "api_url": status_data.get("api_url"), - "response_time": status_data.get("response_time"), - "last_check": status_data.get("last_check"), - "users_online": status_data.get("users_online"), - "nodes_online": status_data.get("nodes_online"), - "total_nodes": status_data.get("total_nodes"), - "old_status": old_status - } - - if new_status == "offline": - details["error"] = status_data.get("api_error") - elif new_status == "degraded": - issues = [] - if status_data.get("response_time", 0) > 10: - issues.append(f"Медленный отклик API ({status_data.get('response_time')}с)") - if status_data.get("nodes_health") == "unhealthy": - issues.append(f"Проблемы с нодами ({status_data.get('nodes_online')}/{status_data.get('total_nodes')} онлайн)") - details["issues"] = issues - - await notification_service.send_remnawave_panel_status_notification( - new_status, - details - ) - - logger.info(f"Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}") - - except Exception as e: - logger.error(f"Ошибка отправки уведомления об изменении статуса: {e}") - - - async def send_manual_status_notification(self, bot, status: str, message: str = ""): + notification_service = AdminNotificationService(bot) + + details = { + 'api_url': status_data.get('api_url'), + 'response_time': status_data.get('response_time'), + 'last_check': status_data.get('last_check'), + 'users_online': status_data.get('users_online'), + 'nodes_online': status_data.get('nodes_online'), + 'total_nodes': status_data.get('total_nodes'), + 'old_status': old_status, + } + + if new_status == 'offline': + details['error'] = status_data.get('api_error') + elif new_status == 'degraded': + issues = [] + if status_data.get('response_time', 0) > 10: + issues.append(f'Медленный отклик API ({status_data.get("response_time")}с)') + if status_data.get('nodes_health') == 'unhealthy': + issues.append( + f'Проблемы с нодами ({status_data.get("nodes_online")}/{status_data.get("total_nodes")} онлайн)' + ) + details['issues'] = issues + + await notification_service.send_remnawave_panel_status_notification(new_status, details) + + logger.info(f'Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}') + + except Exception as e: + logger.error(f'Ошибка отправки уведомления об изменении статуса: {e}') + + async def send_manual_status_notification(self, bot, status: str, message: str = ''): try: from app.services.admin_notification_service import AdminNotificationService - + notification_service = AdminNotificationService(bot) - + details = { - "api_url": settings.REMNAWAVE_API_URL, - "last_check": datetime.utcnow(), - "manual_message": message + 'api_url': settings.REMNAWAVE_API_URL, + 'last_check': datetime.utcnow(), + 'manual_message': message, } - - if status == "maintenance": - details["maintenance_reason"] = message or "Плановое обслуживание" - + + if status == 'maintenance': + details['maintenance_reason'] = message or 'Плановое обслуживание' + await notification_service.send_remnawave_panel_status_notification(status, details) - - logger.info(f"Отправлено ручное уведомление о статусе панели: {status}") + + logger.info(f'Отправлено ручное уведомление о статусе панели: {status}') return True - + except Exception as e: - logger.error(f"Ошибка отправки ручного уведомления: {e}") + logger.error(f'Ошибка отправки ручного уведомления: {e}') return False - async def get_panel_status_summary(self) -> Dict[str, Any]: + async def get_panel_status_summary(self) -> dict[str, Any]: try: status_data = await self.check_panel_health() - + status_descriptions = { - "online": "🟢 Панель работает нормально", - "offline": "🔴 Панель недоступна", - "degraded": "🟡 Панель работает со сбоями", - "maintenance": "🔧 Панель на обслуживании" + 'online': '🟢 Панель работает нормально', + 'offline': '🔴 Панель недоступна', + 'degraded': '🟡 Панель работает со сбоями', + 'maintenance': '🔧 Панель на обслуживании', } - - status = status_data.get("status", "offline") - + + status = status_data.get('status', 'offline') + summary = { - "status": status, - "description": status_descriptions.get(status, "❓ Статус неизвестен"), - "response_time": status_data.get("response_time", 0), - "api_available": status_data.get("api_available", False), - "nodes_status": f"{status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)} нод онлайн", - "users_online": status_data.get("users_online", 0), - "last_check": status_data.get("last_check"), - "has_issues": status in ["offline", "degraded"] + 'status': status, + 'description': status_descriptions.get(status, '❓ Статус неизвестен'), + 'response_time': status_data.get('response_time', 0), + 'api_available': status_data.get('api_available', False), + 'nodes_status': f'{status_data.get("nodes_online", 0)}/{status_data.get("total_nodes", 0)} нод онлайн', + 'users_online': status_data.get('users_online', 0), + 'last_check': status_data.get('last_check'), + 'has_issues': status in ['offline', 'degraded'], } - - if status == "offline": - summary["recommendation"] = "Проверьте подключение к серверу и работоспособность панели" - elif status == "degraded": - summary["recommendation"] = "Рекомендуется проверить состояние нод и производительность сервера" + + if status == 'offline': + summary['recommendation'] = 'Проверьте подключение к серверу и работоспособность панели' + elif status == 'degraded': + summary['recommendation'] = 'Рекомендуется проверить состояние нод и производительность сервера' else: - summary["recommendation"] = "Все системы работают нормально" - + summary['recommendation'] = 'Все системы работают нормально' + return summary - + except Exception as e: - logger.error(f"Ошибка получения сводки статуса панели: {e}") + logger.error(f'Ошибка получения сводки статуса панели: {e}') return { - "status": "error", - "description": "❌ Ошибка проверки статуса", - "response_time": 0, - "api_available": False, - "nodes_status": "неизвестно", - "users_online": 0, - "last_check": datetime.utcnow(), - "has_issues": True, - "recommendation": "Обратитесь к системному администратору", - "error": str(e) + 'status': 'error', + 'description': '❌ Ошибка проверки статуса', + 'response_time': 0, + 'api_available': False, + 'nodes_status': 'неизвестно', + 'users_online': 0, + 'last_check': datetime.utcnow(), + 'has_issues': True, + 'recommendation': 'Обратитесь к системному администратору', + 'error': str(e), } - - async def check_panel_health(self) -> Dict[str, Any]: + + async def check_panel_health(self) -> dict[str, Any]: attempts = settings.get_maintenance_retry_attempts() attempts = max(1, attempts) - last_result: Optional[Dict[str, Any]] = None - last_error: Optional[Exception] = None + last_result: dict[str, Any] | None = None + last_error: Exception | None = None for attempt in range(1, attempts + 1): try: @@ -2765,56 +2699,52 @@ class RemnaWaveService: try: nodes = await api.get_all_nodes() - nodes_online = sum( - 1 for node in nodes if node.is_connected and node.is_node_online - ) + nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online) total_nodes = len(nodes) - nodes_health = "healthy" if nodes_online > 0 else "unhealthy" + nodes_health = 'healthy' if nodes_online > 0 else 'unhealthy' except Exception: nodes_online = 0 total_nodes = 0 - nodes_health = "unknown" + nodes_health = 'unknown' end_time = datetime.utcnow() response_time = (end_time - start_time).total_seconds() if not api_available: - status = "offline" - elif response_time > 10: - status = "degraded" - elif nodes_health == "unhealthy": - status = "degraded" + status = 'offline' + elif response_time > 10 or nodes_health == 'unhealthy': + status = 'degraded' else: - status = "online" + status = 'online' result = { - "status": status, - "api_available": api_available, - "api_error": api_error, - "response_time": round(response_time, 2), - "nodes_online": nodes_online, - "total_nodes": total_nodes, - "nodes_health": nodes_health, - "users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0), - "total_users": system_stats.get('users', {}).get('totalUsers', 0), - "last_check": end_time, - "api_url": settings.REMNAWAVE_API_URL, - "attempts_used": attempt, + 'status': status, + 'api_available': api_available, + 'api_error': api_error, + 'response_time': round(response_time, 2), + 'nodes_online': nodes_online, + 'total_nodes': total_nodes, + 'nodes_health': nodes_health, + 'users_online': system_stats.get('onlineStats', {}).get('onlineNow', 0), + 'total_users': system_stats.get('users', {}).get('totalUsers', 0), + 'last_check': end_time, + 'api_url': settings.REMNAWAVE_API_URL, + 'attempts_used': attempt, } - if result["api_available"]: + if result['api_available']: if attempt > 1: - logger.info("Панель Remnawave ответила с %s попытки", attempt) + logger.info('Панель Remnawave ответила с %s попытки', attempt) return result last_result = result if attempt < attempts: logger.warning( - "Панель Remnawave недоступна (попытка %s/%s): %s", + 'Панель Remnawave недоступна (попытка %s/%s): %s', attempt, attempts, - result.get("api_error") or "неизвестная ошибка", + result.get('api_error') or 'неизвестная ошибка', ) await asyncio.sleep(1) @@ -2822,7 +2752,7 @@ class RemnaWaveService: last_error = error if attempt < attempts: logger.warning( - "Ошибка проверки здоровья панели (попытка %s/%s): %s", + 'Ошибка проверки здоровья панели (попытка %s/%s): %s', attempt, attempts, error, @@ -2830,23 +2760,23 @@ class RemnaWaveService: await asyncio.sleep(1) continue - logger.error(f"Ошибка проверки здоровья панели: {error}") + logger.error(f'Ошибка проверки здоровья панели: {error}') if last_result is not None: return last_result - error_message = str(last_error) if last_error else "Неизвестная ошибка" + error_message = str(last_error) if last_error else 'Неизвестная ошибка' return { - "status": "offline", - "api_available": False, - "api_error": error_message, - "response_time": 0, - "nodes_online": 0, - "total_nodes": 0, - "nodes_health": "unknown", - "last_check": datetime.utcnow(), - "api_url": settings.REMNAWAVE_API_URL, - "attempts_used": attempts, + 'status': 'offline', + 'api_available': False, + 'api_error': error_message, + 'response_time': 0, + 'nodes_online': 0, + 'total_nodes': 0, + 'nodes_health': 'unknown', + 'last_check': datetime.utcnow(), + 'api_url': settings.REMNAWAVE_API_URL, + 'attempts_used': attempts, } diff --git a/app/services/remnawave_sync_service.py b/app/services/remnawave_sync_service.py index 775d63fb..f28f521d 100644 --- a/app/services/remnawave_sync_service.py +++ b/app/services/remnawave_sync_service.py @@ -1,14 +1,15 @@ import asyncio import logging +from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta, time -from typing import Any, Callable, Dict, List, Optional, Tuple +from datetime import datetime, time, timedelta +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.database import AsyncSessionLocal from app.database.crud.server_squad import sync_with_remnawave +from app.database.database import AsyncSessionLocal from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, @@ -22,15 +23,15 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class RemnaWaveAutoSyncStatus: enabled: bool - times: List[time] - next_run: Optional[datetime] - last_run_started_at: Optional[datetime] - last_run_finished_at: Optional[datetime] - last_run_success: Optional[bool] - last_run_reason: Optional[str] - last_run_error: Optional[str] - last_user_stats: Optional[Dict[str, Any]] - last_server_stats: Optional[Dict[str, Any]] + times: list[time] + next_run: datetime | None + last_run_started_at: datetime | None + last_run_finished_at: datetime | None + last_run_success: bool | None + last_run_reason: str | None + last_run_error: str | None + last_user_stats: dict[str, Any] | None + last_server_stats: dict[str, Any] | None is_running: bool @@ -39,25 +40,25 @@ class RemnaWaveAutoSyncService: self, service_factory: Callable[[], RemnaWaveService] = RemnaWaveService, ) -> None: - self._scheduler_task: Optional[asyncio.Task] = None + self._scheduler_task: asyncio.Task | None = None self._scheduler_lock = asyncio.Lock() self._sync_lock = asyncio.Lock() self._service_factory = service_factory self._service = self._service_factory() - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None self._initialized = False self._pending_refresh = False self._pending_run_immediately = False - self._next_run: Optional[datetime] = None - self._last_run_started_at: Optional[datetime] = None - self._last_run_finished_at: Optional[datetime] = None - self._last_run_success: Optional[bool] = None - self._last_run_reason: Optional[str] = None - self._last_run_error: Optional[str] = None - self._last_user_stats: Optional[Dict[str, Any]] = None - self._last_server_stats: Optional[Dict[str, Any]] = None + self._next_run: datetime | None = None + self._last_run_started_at: datetime | None = None + self._last_run_finished_at: datetime | None = None + self._last_run_success: bool | None = None + self._last_run_reason: str | None = None + self._last_run_error: str | None = None + self._last_user_stats: dict[str, Any] | None = None + self._last_server_stats: dict[str, Any] | None = None async def initialize(self) -> None: self._loop = asyncio.get_running_loop() @@ -92,16 +93,14 @@ class RemnaWaveAutoSyncService: times = settings.get_remnawave_auto_sync_times() if not times: - logger.warning( - "⚠️ Автосинхронизация включена, но расписание пустое. Укажите время запуска." - ) + logger.warning('⚠️ Автосинхронизация включена, но расписание пустое. Укажите время запуска.') self._next_run = None return self._scheduler_task = asyncio.create_task(self._run_scheduler(times)) if run_immediately: - asyncio.create_task(self.run_sync_now(reason="immediate")) + asyncio.create_task(self.run_sync_now(reason='immediate')) def schedule_refresh(self, *, run_immediately: bool = False) -> None: if not self._initialized: @@ -124,9 +123,9 @@ class RemnaWaveAutoSyncService: self._scheduler_task = None self._next_run = None - async def run_sync_now(self, *, reason: str = "manual") -> Dict[str, Any]: + async def run_sync_now(self, *, reason: str = 'manual') -> dict[str, Any]: if self._sync_lock.locked(): - return {"started": False, "reason": "already_running"} + return {'started': False, 'reason': 'already_running'} async with self._sync_lock: self._last_run_started_at = datetime.utcnow() @@ -144,13 +143,13 @@ class RemnaWaveAutoSyncService: self._last_user_stats = None self._last_server_stats = None self._last_run_finished_at = datetime.utcnow() - logger.error("❌ Автосинхронизация RemnaWave: %s", message) + logger.error('❌ Автосинхронизация RemnaWave: %s', message) return { - "started": True, - "success": False, - "error": message, - "user_stats": None, - "server_stats": None, + 'started': True, + 'success': False, + 'error': message, + 'user_stats': None, + 'server_stats': None, } except Exception as error: message = str(error) @@ -159,13 +158,13 @@ class RemnaWaveAutoSyncService: self._last_user_stats = None self._last_server_stats = None self._last_run_finished_at = datetime.utcnow() - logger.exception("❌ Ошибка автосинхронизации RemnaWave: %s", error) + logger.exception('❌ Ошибка автосинхронизации RemnaWave: %s', error) return { - "started": True, - "success": False, - "error": message, - "user_stats": None, - "server_stats": None, + 'started': True, + 'success': False, + 'error': message, + 'user_stats': None, + 'server_stats': None, } self._last_run_success = True @@ -175,11 +174,11 @@ class RemnaWaveAutoSyncService: self._last_run_finished_at = datetime.utcnow() return { - "started": True, - "success": True, - "error": None, - "user_stats": user_stats, - "server_stats": server_stats, + 'started': True, + 'success': True, + 'error': None, + 'user_stats': user_stats, + 'server_stats': server_stats, } def get_status(self) -> RemnaWaveAutoSyncStatus: @@ -200,7 +199,7 @@ class RemnaWaveAutoSyncService: is_running=self._sync_lock.locked(), ) - async def _run_scheduler(self, times: List[time]) -> None: + async def _run_scheduler(self, times: list[time]) -> None: try: while True: next_run = self._calculate_next_run(times) @@ -210,7 +209,7 @@ class RemnaWaveAutoSyncService: if delay > 0: await asyncio.sleep(delay) - await self.run_sync_now(reason="auto") + await self.run_sync_now(reason='auto') except asyncio.CancelledError: raise finally: @@ -220,16 +219,14 @@ class RemnaWaveAutoSyncService: self._service = self._service_factory() return self._service - async def _perform_sync(self) -> Tuple[Dict[str, Any], Dict[str, Any]]: + async def _perform_sync(self) -> tuple[dict[str, Any], dict[str, Any]]: service = self._refresh_service() if not service.is_configured: - raise RemnaWaveConfigurationError( - service.configuration_error or "RemnaWave API не настроен" - ) + raise RemnaWaveConfigurationError(service.configuration_error or 'RemnaWave API не настроен') async with AsyncSessionLocal() as session: - user_stats = await service.sync_users_from_panel(session, "all") + user_stats = await service.sync_users_from_panel(session, 'all') server_stats = await self._sync_servers(session, service) return user_stats, server_stats @@ -238,29 +235,29 @@ class RemnaWaveAutoSyncService: self, session: AsyncSession, service: RemnaWaveService, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: squads = await service.get_all_squads() if not squads: - logger.warning("⚠️ Не удалось получить сквады из RemnaWave для автосинхронизации") - return {"created": 0, "updated": 0, "removed": 0, "total": 0} + logger.warning('⚠️ Не удалось получить сквады из RemnaWave для автосинхронизации') + return {'created': 0, 'updated': 0, 'removed': 0, 'total': 0} created, updated, removed = await sync_with_remnawave(session, squads) try: - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') except Exception as error: - logger.warning("⚠️ Не удалось очистить кеш стран после автосинхронизации: %s", error) + logger.warning('⚠️ Не удалось очистить кеш стран после автосинхронизации: %s', error) return { - "created": created, - "updated": updated, - "removed": removed, - "total": len(squads), + 'created': created, + 'updated': updated, + 'removed': removed, + 'total': len(squads), } @staticmethod - def _calculate_next_run(times: List[time]) -> datetime: + def _calculate_next_run(times: list[time]) -> datetime: now = datetime.utcnow() today = now.date() diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index 14a12fb3..7e22adbc 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -1,11 +1,9 @@ import asyncio import logging from dataclasses import dataclass -from datetime import date, datetime, time as datetime_time, timedelta, timezone +from datetime import UTC, date, datetime, time as datetime_time, timedelta from enum import Enum from html import escape -from typing import Dict, List, Optional, Tuple - from zoneinfo import ZoneInfo from aiogram import Bot @@ -19,7 +17,6 @@ from app.database.crud.subscription import get_subscriptions_statistics from app.database.crud.transaction import REAL_PAYMENT_METHODS from app.database.database import AsyncSessionLocal from app.database.models import ( - PaymentMethod, Subscription, SubscriptionConversion, SubscriptionStatus, @@ -39,9 +36,9 @@ class ReportingServiceError(RuntimeError): class ReportPeriod(Enum): - DAILY = "daily" - WEEKLY = "weekly" - MONTHLY = "monthly" + DAILY = 'daily' + WEEKLY = 'weekly' + MONTHLY = 'monthly' @dataclass(slots=True) @@ -55,9 +52,9 @@ class ReportingService: """Generates admin summary reports (text only, no charts).""" def __init__(self) -> None: - self.bot: Optional[Bot] = None - self._task: Optional[asyncio.Task] = None - self._moscow_tz = ZoneInfo("Europe/Moscow") + self.bot: Bot | None = None + self._task: asyncio.Task | None = None + self._moscow_tz = ZoneInfo('Europe/Moscow') def set_bot(self, bot: Bot) -> None: self.bot = bot @@ -69,27 +66,27 @@ class ReportingService: await self.stop() if not settings.ADMIN_REPORTS_ENABLED: - logger.info("Сервис отчетов отключен настройками") + logger.info('Сервис отчетов отключен настройками') return if not self.bot: - logger.warning("Невозможно запустить сервис отчетов без экземпляра бота") + logger.warning('Невозможно запустить сервис отчетов без экземпляра бота') return chat_id = settings.get_reports_chat_id() if not chat_id: - logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов") + logger.warning('Сервис отчетов не запущен: не указан чат для отправки отчетов') return send_time = settings.get_reports_send_time() if not send_time: - logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки") + logger.warning('Сервис отчетов не запущен: не указано время ежедневной отправки') return self._task = asyncio.create_task(self._auto_daily_loop(send_time)) logger.info( - "📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК", - send_time.strftime("%H:%M"), + '📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК', + send_time.strftime('%H:%M'), ) async def stop(self) -> None: @@ -105,7 +102,7 @@ class ReportingService: self, period: ReportPeriod, *, - report_date: Optional[date] = None, + report_date: date | None = None, send_to_topic: bool = False, ) -> str: report_text = await self._build_report(period, report_date) @@ -120,7 +117,7 @@ class ReportingService: next_run_utc, report_date = self._calculate_next_run(send_time) while True: - now_utc = datetime.now(timezone.utc) + now_utc = datetime.now(UTC) delay = (next_run_utc - now_utc).total_seconds() if delay > 0: @@ -133,26 +130,26 @@ class ReportingService: send_to_topic=True, ) logger.info( - "📊 Автоматический отчет за %s отправлен", - report_date.strftime("%d.%m.%Y"), + '📊 Автоматический отчет за %s отправлен', + report_date.strftime('%d.%m.%Y'), ) except asyncio.CancelledError: raise - except Exception as exc: # noqa: BLE001 - logger.error("Ошибка автоматической отправки отчета: %s", exc) + except Exception as exc: + logger.error('Ошибка автоматической отправки отчета: %s', exc) next_run_utc, report_date = self._calculate_next_run(send_time) except asyncio.CancelledError: - logger.info("Сервис отчетов остановлен") + logger.info('Сервис отчетов остановлен') raise - except Exception as exc: # noqa: BLE001 - logger.error("Критическая ошибка в сервисе отчетов: %s", exc) + except Exception as exc: + logger.error('Критическая ошибка в сервисе отчетов: %s', exc) def _calculate_next_run( self, send_time: datetime_time, - ) -> Tuple[datetime, date]: + ) -> tuple[datetime, date]: now_msk = datetime.now(self._moscow_tz) candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz) @@ -160,15 +157,15 @@ class ReportingService: candidate += timedelta(days=1) report_date = (candidate - timedelta(days=1)).date() - return candidate.astimezone(timezone.utc), report_date + return candidate.astimezone(UTC), report_date async def _deliver_report(self, report_text: str) -> None: if not self.bot: - raise ReportingServiceError("Бот не инициализирован для отправки отчета") + raise ReportingServiceError('Бот не инициализирован для отправки отчета') chat_id = settings.get_reports_chat_id() if not chat_id: - raise ReportingServiceError("Не задан чат для отправки отчета") + raise ReportingServiceError('Не задан чат для отправки отчета') topic_id = settings.get_reports_topic_id() @@ -177,48 +174,48 @@ class ReportingService: chat_id=chat_id, text=report_text, message_thread_id=topic_id, - parse_mode="HTML", + parse_mode='HTML', ) except (TelegramBadRequest, TelegramForbiddenError) as exc: - logger.error("Не удалось отправить отчет: %s", exc) - raise ReportingServiceError("Не удалось отправить отчет в чат") from exc + logger.error('Не удалось отправить отчет: %s', exc) + raise ReportingServiceError('Не удалось отправить отчет в чат') from exc # ---------- referral helpers ---------- - def _referral_markers(self) -> List: + def _referral_markers(self) -> list: """ Набор условий, по которым операция помечается как реферальная (если вдруг записана типом DEPOSIT). """ clauses = [] # Явные флаги - if hasattr(Transaction, "is_referral_bonus"): + if hasattr(Transaction, 'is_referral_bonus'): clauses.append(Transaction.is_referral_bonus == true()) - if hasattr(Transaction, "is_bonus"): + if hasattr(Transaction, 'is_bonus'): clauses.append(Transaction.is_bonus == true()) # Источник/причина - if hasattr(Transaction, "source"): - clauses.append(Transaction.source == "referral") - clauses.append(Transaction.source == "referral_bonus") - if hasattr(Transaction, "reason"): - clauses.append(Transaction.reason == "referral") - clauses.append(Transaction.reason == "referral_bonus") - clauses.append(Transaction.reason == "referral_reward") + if hasattr(Transaction, 'source'): + clauses.append(Transaction.source == 'referral') + clauses.append(Transaction.source == 'referral_bonus') + if hasattr(Transaction, 'reason'): + clauses.append(Transaction.reason == 'referral') + clauses.append(Transaction.reason == 'referral_bonus') + clauses.append(Transaction.reason == 'referral_reward') # Текстовые поля - like_patterns = ["%реферал%", "%реферальн%", "%referral%"] - if hasattr(Transaction, "description"): + like_patterns = ['%реферал%', '%реферальн%', '%referral%'] + if hasattr(Transaction, 'description'): for pattern in like_patterns: try: clauses.append(Transaction.description.ilike(pattern)) - except Exception: # noqa: BLE001 - best effort + except Exception: pass - if hasattr(Transaction, "comment"): + if hasattr(Transaction, 'comment'): for pattern in like_patterns: try: clauses.append(Transaction.comment.ilike(pattern)) - except Exception: # noqa: BLE001 - best effort + except Exception: pass return [clause for clause in clauses if clause is not None] @@ -238,11 +235,11 @@ class ReportingService: async def _build_report( self, period: ReportPeriod, - report_date: Optional[date], + report_date: date | None, ) -> str: period_range = self._get_period_range(period, report_date) - start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None) - end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None) + start_utc = period_range.start_msk.astimezone(UTC).replace(tzinfo=None) + end_utc = period_range.end_msk.astimezone(UTC).replace(tzinfo=None) async with AsyncSessionLocal() as session: totals = await self._collect_current_totals(session) @@ -251,92 +248,85 @@ class ReportingService: usage = await self._get_user_usage_stats(session) conversion_rate = ( - (stats["trial_to_paid_conversions"] / stats["new_trials"] * 100) - if stats["new_trials"] > 0 - else 0.0 + (stats['trial_to_paid_conversions'] / stats['new_trials'] * 100) if stats['new_trials'] > 0 else 0.0 ) - lines: List[str] = [] + lines: list[str] = [] header = ( - f"📊 Отчет за {period_range.label}" + f'📊 Отчет за {period_range.label}' if period == ReportPeriod.DAILY - else f"📊 Отчет за период {period_range.label}" + else f'📊 Отчет за период {period_range.label}' ) - lines += [header, ""] + lines += [header, ''] # TL;DR lines += [ - "🧭 Итог по периоду", - f"• Новых пользователей: {stats['new_users']}", - f"• Новых триалов: {stats['new_trials']}", + '🧭 Итог по периоду', + f'• Новых пользователей: {stats["new_users"]}', + f'• Новых триалов: {stats["new_trials"]}', ( - f"• Конверсий триал → платная: {stats['trial_to_paid_conversions']} " - f"({conversion_rate:.1f}%)" + f'• Конверсий триал → платная: {stats["trial_to_paid_conversions"]} ' + f'({conversion_rate:.1f}%)' ), - f"• Новых платных (всего): {stats['new_paid_subscriptions']}", - f"• Поступления всего (только пополнения): {self._format_amount(stats['deposits_amount'])}", - "", + f'• Новых платных (всего): {stats["new_paid_subscriptions"]}', + f'• Поступления всего (только пополнения): {self._format_amount(stats["deposits_amount"])}', + '', ] # Подписки lines += [ - "💎 Подписки", - f"• Активные триалы сейчас: {totals['active_trials']}", - f"• Активные платные сейчас: {totals['active_paid']}", - "", + '💎 Подписки', + f'• Активные триалы сейчас: {totals["active_trials"]}', + f'• Активные платные сейчас: {totals["active_paid"]}', + '', ] # Финансы lines += [ - "💰 Финансы", + '💰 Финансы', ( - "• Оплаты подписок: " - f"{stats['subscription_payments_count']} на сумму {self._format_amount(stats['subscription_payments_amount'])}" + '• Оплаты подписок: ' + f'{stats["subscription_payments_count"]} на сумму {self._format_amount(stats["subscription_payments_amount"])}' ), + (f'• Пополнения: {stats["deposits_count"]} на сумму {self._format_amount(stats["deposits_amount"])}'), ( - "• Пополнения: " - f"{stats['deposits_count']} на сумму {self._format_amount(stats['deposits_amount'])}" + 'Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы ' + 'исключены.' ), - ( - "Примечание: «Поступления всего» учитывают только пополнения; покупки подписок и реферальные бонусы " - "исключены." - ), - "", + '', ] # Поддержка lines += [ - "🎟️ Поддержка", - f"• Новых тикетов: {stats['new_tickets']}", - f"• Активных тикетов сейчас: {totals['open_tickets']}", - "", + '🎟️ Поддержка', + f'• Новых тикетов: {stats["new_tickets"]}', + f'• Активных тикетов сейчас: {totals["open_tickets"]}', + '', ] # Активность пользователей lines += [ - "👤 Активность пользователей", - f"• Пользователей с активной платной подпиской: {usage['active_paid_users']}", - f"• Пользователей, ни разу не подключившихся: {usage['never_connected_users']}", - "", + '👤 Активность пользователей', + f'• Пользователей с активной платной подпиской: {usage["active_paid_users"]}', + f'• Пользователей, ни разу не подключившихся: {usage["never_connected_users"]}', + '', ] # Топ по рефералам - lines += ["🤝 Топ по рефералам (за период)"] + lines += ['🤝 Топ по рефералам (за период)'] if top_referrers: for index, row in enumerate(top_referrers, 1): - referrer_label = escape(row["referrer_label"], quote=False) - lines.append( - f"{index}. {referrer_label}: {row['count']} приглашений" - ) + referrer_label = escape(row['referrer_label'], quote=False) + lines.append(f'{index}. {referrer_label}: {row["count"]} приглашений') else: - lines.append("— данных нет") + lines.append('— данных нет') - return "\n".join(lines) + return '\n'.join(lines) def _get_period_range( self, period: ReportPeriod, - report_date: Optional[date], + report_date: date | None, ) -> ReportPeriodRange: now_msk = datetime.now(self._moscow_tz) @@ -355,7 +345,7 @@ class ReportingService: start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz) end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz) else: # pragma: no cover - defensive branch - raise ReportingServiceError(f"Неизвестный период отчета: {period}") + raise ReportingServiceError(f'Неизвестный период отчета: {period}') label = self._format_period_label(start, end) return ReportPeriodRange(start, end, label) @@ -375,9 +365,9 @@ class ReportingService: ) 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, + '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( @@ -437,24 +427,18 @@ class ReportingService: ) subscription_payments_count, subscription_payments_amount = ( - ( - await session.execute( - self._txn_query_base( - TransactionType.SUBSCRIPTION_PAYMENT.value, - start_utc, - end_utc, - ) + await session.execute( + self._txn_query_base( + TransactionType.SUBSCRIPTION_PAYMENT.value, + start_utc, + end_utc, ) - ).one() - ) + ) + ).one() deposits_count, deposits_amount = ( - ( - await session.execute( - self._deposit_query_excluding_referrals(start_utc, end_utc) - ) - ).one() - ) + await session.execute(self._deposit_query_excluding_referrals(start_utc, end_utc)) + ).one() new_tickets = int( ( @@ -469,15 +453,15 @@ class ReportingService: ) return { - "new_users": new_users, - "new_trials": new_trials, - "new_paid_subscriptions": direct_paid + trial_to_paid_conversions, - "trial_to_paid_conversions": trial_to_paid_conversions, - "subscription_payments_count": int(subscription_payments_count or 0), - "subscription_payments_amount": int(subscription_payments_amount or 0), - "deposits_count": int(deposits_count or 0), - "deposits_amount": int(deposits_amount or 0), - "new_tickets": new_tickets, + 'new_users': new_users, + 'new_trials': new_trials, + 'new_paid_subscriptions': direct_paid + trial_to_paid_conversions, + 'trial_to_paid_conversions': trial_to_paid_conversions, + 'subscription_payments_count': int(subscription_payments_count or 0), + 'subscription_payments_amount': int(subscription_payments_amount or 0), + 'deposits_count': int(deposits_count or 0), + 'deposits_amount': int(deposits_amount or 0), + 'new_tickets': new_tickets, } def _txn_query_base(self, txn_type: str, start_utc: datetime, end_utc: datetime): @@ -515,11 +499,11 @@ class ReportingService: start_utc: datetime, end_utc: datetime, limit: int = 5, - ) -> List[Dict]: + ) -> list[dict]: rows = await session.execute( select( User.referred_by_id, - func.count(User.id).label("cnt"), + func.count(User.id).label('cnt'), ) .where( User.created_at >= start_utc, @@ -534,18 +518,18 @@ class ReportingService: if not rows: return [] ref_ids = [row[0] for row in rows if row[0] is not None] - users_map: Dict[int, str] = {} + users_map: dict[int, str] = {} if ref_ids: urows = await session.execute(select(User).where(User.id.in_(ref_ids))) for user in urows.scalars().all(): users_map[user.id] = self._user_label(user) return [ - {"referrer_label": users_map.get(ref_id, f"User #{ref_id}"), "count": int(count or 0)} + {'referrer_label': users_map.get(ref_id, f'User #{ref_id}'), 'count': int(count or 0)} for ref_id, count in rows ] - async def _get_user_usage_stats(self, session) -> Dict[str, int]: - now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + async def _get_user_usage_stats(self, session) -> dict[str, int]: + now_utc = datetime.now(UTC).replace(tzinfo=None) active_paid_q = await session.execute( select(func.count(func.distinct(Subscription.user_id))).where( @@ -568,21 +552,21 @@ class ReportingService: never_connected_users = int(never_connected_q.scalar() or 0) return { - "active_paid_users": active_paid_users, - "never_connected_users": never_connected_users, + 'active_paid_users': active_paid_users, + 'never_connected_users': never_connected_users, } def _user_label(self, user: User) -> str: - if getattr(user, "username", None): - return f"@{user.username}" + if getattr(user, 'username', None): + return f'@{user.username}' parts = [] - if getattr(user, "first_name", None): + if getattr(user, 'first_name', None): parts.append(user.first_name) - if getattr(user, "last_name", None): + if getattr(user, 'last_name', None): parts.append(user.last_name) if parts: - return " ".join(parts) - return f"User #{getattr(user, 'id', '?')}" + return ' '.join(parts) + return f'User #{getattr(user, "id", "?")}' def _format_period_label(self, start: datetime, end: datetime) -> str: start_date = start.astimezone(self._moscow_tz).date() @@ -590,14 +574,13 @@ class ReportingService: end_date = end_boundary.date() if start_date == end_date: - return start_date.strftime("%d.%m.%Y") + return start_date.strftime('%d.%m.%Y') - return f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}" + return f'{start_date.strftime("%d.%m.%Y")} - {end_date.strftime("%d.%m.%Y")}' def _format_amount(self, amount_kopeks: int) -> str: rubles = (amount_kopeks or 0) / 100 - return f"{rubles:,.2f} ₽".replace(",", " ") + return f'{rubles:,.2f} ₽'.replace(',', ' ') reporting_service = ReportingService() - diff --git a/app/services/server_status_service.py b/app/services/server_status_service.py index ae034bf6..e88e57cd 100644 --- a/app/services/server_status_service.py +++ b/app/services/server_status_service.py @@ -1,27 +1,26 @@ from __future__ import annotations -import asyncio import logging import re from dataclasses import dataclass -from typing import Dict, List, Optional, Tuple import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @dataclass class ServerStatusEntry: address: str - instance: Optional[str] - protocol: Optional[str] + instance: str | None + protocol: str | None name: str flag: str display_name: str - latency_ms: Optional[int] + latency_ms: int | None is_online: bool @@ -30,26 +29,22 @@ class ServerStatusError(Exception): class ServerStatusService: - _LATENCY_PATTERN = re.compile( - r"xray_proxy_latency_ms\{(?P[^}]*)\}\s+(?P[-+]?\d+(?:\.\d+)?)" - ) - _STATUS_PATTERN = re.compile( - r"xray_proxy_status\{(?P[^}]*)\}\s+(?P[-+]?\d+(?:\.\d+)?)" - ) - _LABEL_PATTERN = re.compile(r"(?P[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P(?:\\.|[^\"])*)\"") - _FLAG_PATTERN = re.compile(r"^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$") + _LATENCY_PATTERN = re.compile(r'xray_proxy_latency_ms\{(?P[^}]*)\}\s+(?P[-+]?\d+(?:\.\d+)?)') + _STATUS_PATTERN = re.compile(r'xray_proxy_status\{(?P[^}]*)\}\s+(?P[-+]?\d+(?:\.\d+)?)') + _LABEL_PATTERN = re.compile(r'(?P[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P(?:\\.|[^\"])*)\"') + _FLAG_PATTERN = re.compile(r'^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$') def __init__(self) -> None: self._logger = logging.getLogger(self.__class__.__name__) - async def get_servers(self) -> List[ServerStatusEntry]: + async def get_servers(self) -> list[ServerStatusEntry]: mode = settings.get_server_status_mode() - if mode != "xray": - raise ServerStatusError("Server status integration is not enabled") + if mode != 'xray': + raise ServerStatusError('Server status integration is not enabled') url = settings.get_server_status_metrics_url() if not url: - raise ServerStatusError("Metrics URL is not configured") + raise ServerStatusError('Metrics URL is not configured') timeout = aiohttp.ClientTimeout(total=settings.get_server_status_request_timeout()) auth = None @@ -59,31 +54,30 @@ class ServerStatusService: auth = aiohttp.BasicAuth(username, password) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get( + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.get( url, auth=auth, ssl=settings.SERVER_STATUS_METRICS_VERIFY_SSL, - ) as response: - if response.status != 200: - text = await response.text() - raise ServerStatusError( - f"Unexpected response status: {response.status}" - f" - {text[:200]}" - ) - metrics_body = await response.text() - except asyncio.TimeoutError as error: - raise ServerStatusError("Request to metrics endpoint timed out") from error + ) as response, + ): + if response.status != 200: + text = await response.text() + raise ServerStatusError(f'Unexpected response status: {response.status} - {text[:200]}') + metrics_body = await response.text() + except TimeoutError as error: + raise ServerStatusError('Request to metrics endpoint timed out') from error except aiohttp.ClientError as error: - raise ServerStatusError("Failed to fetch metrics") from error + raise ServerStatusError('Failed to fetch metrics') from error return self._parse_metrics(metrics_body) - def _parse_metrics(self, body: str) -> List[ServerStatusEntry]: - servers: Dict[Tuple[str, str, str, str], ServerStatusEntry] = {} + def _parse_metrics(self, body: str) -> list[ServerStatusEntry]: + servers: dict[tuple[str, str, str, str], ServerStatusEntry] = {} for match in self._LATENCY_PATTERN.finditer(body): - labels = self._parse_labels(match.group("labels")) + labels = self._parse_labels(match.group('labels')) key = self._build_key(labels) entry = servers.get(key) if not entry: @@ -91,13 +85,13 @@ class ServerStatusService: servers[key] = entry try: - value = float(match.group("value")) + value = float(match.group('value')) entry.latency_ms = int(round(value)) except (TypeError, ValueError): entry.latency_ms = None for match in self._STATUS_PATTERN.finditer(body): - labels = self._parse_labels(match.group("labels")) + labels = self._parse_labels(match.group('labels')) key = self._build_key(labels) entry = servers.get(key) if not entry: @@ -105,7 +99,7 @@ class ServerStatusService: servers[key] = entry try: - value = float(match.group("value")) + value = float(match.group('value')) entry.is_online = value >= 1 except (TypeError, ValueError): entry.is_online = False @@ -118,21 +112,21 @@ class ServerStatusService: ), ) - def _build_key(self, labels: Dict[str, str]) -> Tuple[str, str, str, str]: + def _build_key(self, labels: dict[str, str]) -> tuple[str, str, str, str]: return ( - labels.get("address", ""), - labels.get("instance", ""), - labels.get("protocol", ""), - labels.get("name", labels.get("address", "")), + labels.get('address', ''), + labels.get('instance', ''), + labels.get('protocol', ''), + labels.get('name', labels.get('address', '')), ) - def _create_entry(self, labels: Dict[str, str]) -> ServerStatusEntry: - name = labels.get("name") or labels.get("address") or "Unknown" + def _create_entry(self, labels: dict[str, str]) -> ServerStatusEntry: + name = labels.get('name') or labels.get('address') or 'Unknown' flag, display_name = self._extract_flag(name) return ServerStatusEntry( - address=labels.get("address", ""), - instance=labels.get("instance"), - protocol=labels.get("protocol"), + address=labels.get('address', ''), + instance=labels.get('instance'), + protocol=labels.get('protocol'), name=name, flag=flag, display_name=display_name or name, @@ -140,18 +134,17 @@ class ServerStatusService: is_online=False, ) - def _extract_flag(self, name: str) -> Tuple[str, str]: + def _extract_flag(self, name: str) -> tuple[str, str]: match = self._FLAG_PATTERN.match(name) if not match: - return "", name + return '', name flag, remainder = match.groups() return flag, remainder.strip() - def _parse_labels(self, labels_str: str) -> Dict[str, str]: - labels: Dict[str, str] = {} + def _parse_labels(self, labels_str: str) -> dict[str, str]: + labels: dict[str, str] = {} for match in self._LABEL_PATTERN.finditer(labels_str): - key = match.group("key") - value = match.group("value").replace('\\"', '"') + key = match.group('key') + value = match.group('value').replace('\\"', '"') labels[key] = value return labels - diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index cf68d3ca..b57c77fe 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Optional from aiogram import Bot from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup @@ -19,24 +18,25 @@ from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService from app.services.subscription_checkout_service import clear_subscription_checkout_draft from app.services.subscription_purchase_service import ( + MiniAppSubscriptionPurchaseService, + PurchaseBalanceError, PurchaseOptionsContext, PurchasePricingResult, PurchaseSelection, PurchaseValidationError, - PurchaseBalanceError, - MiniAppSubscriptionPurchaseService, ) from app.services.subscription_service import SubscriptionService from app.services.user_cart_service import user_cart_service from app.utils.pricing_utils import format_period_description from app.utils.timezone import format_local_datetime + logger = logging.getLogger(__name__) def _format_user_id(user: User) -> str: """Format user identifier for logging (supports email-only users).""" - return str(user.telegram_id) if user.telegram_id else f"email:{user.id}" + return str(user.telegram_id) if user.telegram_id else f'email:{user.id}' @dataclass(slots=True) @@ -57,25 +57,25 @@ class AutoExtendContext: period_days: int price_kopeks: int description: str - device_limit: Optional[int] = None - traffic_limit_gb: Optional[int] = None - squad_uuid: Optional[str] = None + device_limit: int | None = None + traffic_limit_gb: int | None = None + squad_uuid: str | None = None consume_promo_offer: bool = False - tariff_id: Optional[int] = None - allowed_squads: Optional[list] = None + tariff_id: int | None = None + allowed_squads: list | None = None async def _prepare_auto_purchase( db: AsyncSession, user: User, cart_data: dict, -) -> Optional[AutoPurchaseContext]: +) -> AutoPurchaseContext | None: """Builds purchase context and pricing for a saved cart.""" - period_days = int(cart_data.get("period_days") or 0) + period_days = int(cart_data.get('period_days') or 0) if period_days <= 0: logger.info( - "🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине", + '🔁 Автопокупка: у пользователя %s нет корректного периода в сохранённой корзине', _format_user_id(user), ) return None @@ -85,7 +85,7 @@ async def _prepare_auto_purchase( fresh_user = await get_user_by_id(db, user.id) if not fresh_user: logger.warning( - "🔁 Автопокупка: не удалось перезагрузить пользователя %s", + '🔁 Автопокупка: не удалось перезагрузить пользователя %s', _format_user_id(user), ) return None @@ -94,16 +94,16 @@ async def _prepare_auto_purchase( miniapp_service = MiniAppSubscriptionPurchaseService() context = await miniapp_service.build_options(db, user) - period_config = context.period_map.get(f"days:{period_days}") + period_config = context.period_map.get(f'days:{period_days}') if not period_config: logger.warning( - "🔁 Автопокупка: период %s дней недоступен для пользователя %s", + '🔁 Автопокупка: период %s дней недоступен для пользователя %s', period_days, _format_user_id(user), ) return None - traffic_value = cart_data.get("traffic_gb") + traffic_value = cart_data.get('traffic_gb') if traffic_value is None: traffic_value = ( period_config.traffic.current_value @@ -113,8 +113,8 @@ async def _prepare_auto_purchase( else: traffic_value = int(traffic_value) - devices = int(cart_data.get("devices") or period_config.devices.current or 1) - servers = list(cart_data.get("countries") or []) + devices = int(cart_data.get('devices') or period_config.devices.current or 1) + servers = list(cart_data.get('countries') or []) if not servers: servers = list(period_config.servers.default_selection) @@ -134,7 +134,7 @@ async def _prepare_auto_purchase( ) -def _safe_int(value: Optional[object], default: int = 0) -> int: +def _safe_int(value: object | None, default: int = 0) -> int: try: return int(value) # type: ignore[arg-type] except (TypeError, ValueError): @@ -154,7 +154,7 @@ async def _get_tariff_price_for_period( user: User, tariff_id: int, period_days: int, -) -> Optional[int]: +) -> int | None: """Получает актуальную цену тарифа для заданного периода с учётом скидки пользователя.""" from app.database.crud.tariff import get_tariff_by_id from app.utils.promo_offer import get_user_active_promo_discount_percent @@ -162,7 +162,7 @@ async def _get_tariff_price_for_period( tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - "🔁 Автопокупка: тариф %s недоступен для пользователя %s", + '🔁 Автопокупка: тариф %s недоступен для пользователя %s', tariff_id, _format_user_id(user), ) @@ -172,7 +172,7 @@ async def _get_tariff_price_for_period( base_price = prices.get(str(period_days)) if base_price is None: logger.warning( - "🔁 Автопокупка: период %s дней недоступен для тарифа %s", + '🔁 Автопокупка: период %s дней недоступен для тарифа %s', period_days, tariff_id, ) @@ -185,8 +185,7 @@ async def _get_tariff_price_for_period( discount_percent = getattr(promo_group, 'server_discount_percent', 0) personal_discount = get_user_active_promo_discount_percent(user) - if personal_discount > discount_percent: - discount_percent = personal_discount + discount_percent = max(discount_percent, personal_discount) final_price = _apply_promo_discount_for_tariff(base_price, discount_percent) return final_price @@ -196,66 +195,62 @@ async def _prepare_auto_extend_context( db: AsyncSession, user: User, cart_data: dict, -) -> Optional[AutoExtendContext]: +) -> AutoExtendContext | None: from app.database.crud.subscription import get_subscription_by_user_id subscription = await get_subscription_by_user_id(db, user.id) if subscription is None: logger.info( - "🔁 Автопокупка: у пользователя %s нет активной подписки для продления", + '🔁 Автопокупка: у пользователя %s нет активной подписки для продления', _format_user_id(user), ) return None - saved_subscription_id = cart_data.get("subscription_id") + saved_subscription_id = cart_data.get('subscription_id') if saved_subscription_id is not None: saved_subscription_id = _safe_int(saved_subscription_id, subscription.id) if saved_subscription_id != subscription.id: logger.warning( - "🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s", + '🔁 Автопокупка: сохранённая подписка %s не совпадает с текущей %s у пользователя %s', saved_subscription_id, subscription.id, _format_user_id(user), ) return None - period_days = _safe_int(cart_data.get("period_days")) + period_days = _safe_int(cart_data.get('period_days')) if period_days <= 0: logger.warning( - "🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s", + '🔁 Автопокупка: некорректное количество дней продления (%s) у пользователя %s', period_days, _format_user_id(user), ) return None # Если в корзине есть tariff_id - пересчитываем цену по актуальному тарифу - tariff_id = cart_data.get("tariff_id") + tariff_id = cart_data.get('tariff_id') if tariff_id: tariff_id = _safe_int(tariff_id) price_kopeks = await _get_tariff_price_for_period(db, user, tariff_id, period_days) if price_kopeks is None: # Тариф недоступен или период отсутствует - используем сохранённую цену как fallback price_kopeks = _safe_int( - cart_data.get("total_price") - or cart_data.get("price") - or cart_data.get("final_price"), + cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'), ) logger.warning( - "🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s", + '🔁 Автопокупка: не удалось пересчитать цену тарифа %s, используем сохранённую: %s', tariff_id, price_kopeks, ) else: price_kopeks = _safe_int( - cart_data.get("total_price") - or cart_data.get("price") - or cart_data.get("final_price"), + cart_data.get('total_price') or cart_data.get('price') or cart_data.get('final_price'), ) if price_kopeks <= 0: logger.warning( - "🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s", + '🔁 Автопокупка: некорректная цена продления (%s) у пользователя %s', price_kopeks, _format_user_id(user), ) @@ -264,23 +259,24 @@ async def _prepare_auto_extend_context( # Формируем описание с учётом тарифа if tariff_id: from app.database.crud.tariff import get_tariff_by_id - tariff = await get_tariff_by_id(db, tariff_id) - tariff_name = tariff.name if tariff else "тариф" - description = cart_data.get("description") or f"Продление тарифа {tariff_name} на {period_days} дней" - else: - description = cart_data.get("description") or f"Продление подписки на {period_days} дней" - device_limit = cart_data.get("device_limit") + tariff = await get_tariff_by_id(db, tariff_id) + tariff_name = tariff.name if tariff else 'тариф' + description = cart_data.get('description') or f'Продление тарифа {tariff_name} на {period_days} дней' + else: + description = cart_data.get('description') or f'Продление подписки на {period_days} дней' + + device_limit = cart_data.get('device_limit') if device_limit is not None: device_limit = _safe_int(device_limit, subscription.device_limit) - traffic_limit_gb = cart_data.get("traffic_limit_gb") + traffic_limit_gb = cart_data.get('traffic_limit_gb') if traffic_limit_gb is not None: traffic_limit_gb = _safe_int(traffic_limit_gb, subscription.traffic_limit_gb or 0) - squad_uuid = cart_data.get("squad_uuid") - consume_promo_offer = bool(cart_data.get("consume_promo_offer")) - allowed_squads = cart_data.get("allowed_squads") + squad_uuid = cart_data.get('squad_uuid') + consume_promo_offer = bool(cart_data.get('consume_promo_offer')) + allowed_squads = cart_data.get('allowed_squads') return AutoExtendContext( subscription=subscription, @@ -325,10 +321,7 @@ def _apply_extension_updates(context: AutoExtendContext) -> None: # Обновляем лимиты для платной подписки if context.traffic_limit_gb not in (None, 0): subscription.traffic_limit_gb = context.traffic_limit_gb - if ( - context.device_limit is not None - and context.device_limit > subscription.device_limit - ): + if context.device_limit is not None and context.device_limit > subscription.device_limit: subscription.device_limit = context.device_limit if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []): subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid] @@ -339,13 +332,13 @@ async def _auto_extend_subscription( user: User, cart_data: dict, *, - bot: Optional[Bot] = None, + bot: Bot | None = None, ) -> bool: try: prepared = await _prepare_auto_extend_context(db, user, cart_data) except Exception as error: # pragma: no cover - defensive logging logger.error( - "❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s", + '❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -357,7 +350,7 @@ async def _auto_extend_subscription( if user.balance_kopeks < prepared.price_kopeks: logger.info( - "🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)", + '🔁 Автопокупка: у пользователя %s недостаточно средств для продления (%s < %s)', _format_user_id(user), user.balance_kopeks, prepared.price_kopeks, @@ -374,7 +367,7 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s", + '❌ Автопокупка: ошибка списания средств при продлении пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -383,7 +376,7 @@ async def _auto_extend_subscription( if not deducted: logger.warning( - "❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено", + '❌ Автопокупка: списание средств для продления подписки пользователя %s не выполнено', _format_user_id(user), ) return False @@ -396,10 +389,7 @@ async def _auto_extend_subscription( _apply_extension_updates(prepared) # Определяем, произошла ли смена тарифа - is_tariff_change = ( - prepared.tariff_id is not None - and old_tariff_id != prepared.tariff_id - ) + is_tariff_change = prepared.tariff_id is not None and old_tariff_id != prepared.tariff_id try: # При смене тарифа передаём traffic_limit_gb для сброса трафика в БД @@ -415,18 +405,18 @@ async def _auto_extend_subscription( # НОВОЕ: Конвертируем триал в платную подписку ТОЛЬКО после успешного продления if was_trial and subscription.is_trial: subscription.is_trial = False - subscription.status = "active" + subscription.status = 'active' user.has_had_paid_subscription = True await db.commit() logger.info( - "✅ Триал конвертирован в платную подписку %s для пользователя %s", + '✅ Триал конвертирован в платную подписку %s для пользователя %s', subscription.id, _format_user_id(user), ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "❌ Автопокупка: не удалось продлить подписку пользователя %s: %s", + '❌ Автопокупка: не удалось продлить подписку пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -446,7 +436,7 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s", + '⚠️ Автопокупка: не удалось зафиксировать транзакцию продления для пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -460,11 +450,11 @@ async def _auto_extend_subscription( db, updated_subscription, reset_traffic=should_reset_traffic, - reset_reason="смена тарифа" if is_tariff_change else "продление подписки", + reset_reason='смена тарифа' if is_tariff_change else 'продление подписки', ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s", + '⚠️ Автопокупка: не удалось обновить RemnaWave пользователя %s после продления: %s', _format_user_id(user), error, ) @@ -472,13 +462,13 @@ async def _auto_extend_subscription( await user_cart_service.delete_user_cart(user.id) await clear_subscription_checkout_draft(user.id) - texts = get_texts(getattr(user, "language", "ru")) + texts = get_texts(getattr(user, 'language', 'ru')) period_label = format_period_description( prepared.period_days, - getattr(user, "language", "ru"), + getattr(user, 'language', 'ru'), ) new_end_date = updated_subscription.end_date - end_date_label = format_local_datetime(new_end_date, "%d.%m.%Y %H:%M") + end_date_label = format_local_datetime(new_end_date, '%d.%m.%Y %H:%M') if bot: try: @@ -495,7 +485,7 @@ async def _auto_extend_subscription( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s", + '⚠️ Автопокупка: не удалось уведомить администраторов о продлении пользователя %s: %s', _format_user_id(user), error, ) @@ -504,36 +494,34 @@ async def _auto_extend_subscription( if user.telegram_id: try: auto_message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED", - "✅ Subscription automatically extended for {period}.", + 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED', + '✅ Subscription automatically extended for {period}.', ).format(period=period_label) details_message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS", - "New expiration date: {date}.", + 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS', + 'New expiration date: {date}.', ).format(date=end_date_label) hint_message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_HINT", + 'AUTO_PURCHASE_SUBSCRIPTION_HINT', "Open the 'My subscription' section to access your link.", ) - full_message = "\n\n".join( - part.strip() - for part in [auto_message, details_message, hint_message] - if part and part.strip() + full_message = '\n\n'.join( + part.strip() for part in [auto_message, details_message, hint_message] if part and part.strip() ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"), - callback_data="menu_subscription", + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'), + callback_data='menu_subscription', ) ], [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'), + callback_data='back_to_menu', ) ], ] @@ -543,17 +531,17 @@ async def _auto_extend_subscription( chat_id=user.telegram_id, text=full_message, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s", + '⚠️ Автопокупка: не удалось уведомить пользователя %s о продлении: %s', user.telegram_id or user.id, error, ) logger.info( - "✅ Автопокупка: подписка продлена на %s дней для пользователя %s", + '✅ Автопокупка: подписка продлена на %s дней для пользователя %s', prepared.period_days, _format_user_id(user), ) @@ -566,24 +554,27 @@ async def _auto_purchase_tariff( user: User, cart_data: dict, *, - bot: Optional[Bot] = None, + bot: Bot | None = None, ) -> bool: """Автоматическая покупка периодного тарифа из сохранённой корзины.""" - from datetime import datetime + from app.database.crud.server_squad import get_all_server_squads + from app.database.crud.subscription import ( + create_paid_subscription, + extend_subscription, + get_subscription_by_user_id, + ) from app.database.crud.tariff import get_tariff_by_id - from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, extend_subscription from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance - from app.database.crud.server_squad import get_all_server_squads from app.database.models import TransactionType - tariff_id = _safe_int(cart_data.get("tariff_id")) - period_days = _safe_int(cart_data.get("period_days")) - discount_percent = _safe_int(cart_data.get("discount_percent")) + tariff_id = _safe_int(cart_data.get('tariff_id')) + period_days = _safe_int(cart_data.get('period_days')) + discount_percent = _safe_int(cart_data.get('discount_percent')) if not tariff_id or period_days <= 0: logger.warning( - "🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)", + '🔁 Автопокупка тарифа: некорректные данные корзины для пользователя %s (tariff_id=%s, period=%s)', _format_user_id(user), tariff_id, period_days, @@ -593,7 +584,7 @@ async def _auto_purchase_tariff( tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - "🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s", + '🔁 Автопокупка тарифа: тариф %s недоступен для пользователя %s', tariff_id, _format_user_id(user), ) @@ -604,7 +595,7 @@ async def _auto_purchase_tariff( base_price = prices.get(str(period_days)) if base_price is None: logger.warning( - "🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s", + '🔁 Автопокупка тарифа: период %s дней недоступен для тарифа %s', period_days, tariff_id, ) @@ -614,7 +605,7 @@ async def _auto_purchase_tariff( if user.balance_kopeks < final_price: logger.info( - "🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)", + '🔁 Автопокупка тарифа: у пользователя %s недостаточно средств (%s < %s)', _format_user_id(user), user.balance_kopeks, final_price, @@ -623,17 +614,17 @@ async def _auto_purchase_tariff( # Списываем баланс try: - description = f"Покупка тарифа {tariff.name} на {period_days} дней" + description = f'Покупка тарифа {tariff.name} на {period_days} дней' success = await subtract_user_balance(db, user, final_price, description) if not success: logger.warning( - "❌ Автопокупка тарифа: не удалось списать баланс пользователя %s", + '❌ Автопокупка тарифа: не удалось списать баланс пользователя %s', _format_user_id(user), ) return False except Exception as error: logger.error( - "❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s", + '❌ Автопокупка тарифа: ошибка списания баланса пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -664,7 +655,7 @@ async def _auto_purchase_tariff( was_trial_conversion = existing_subscription.is_trial if was_trial_conversion: subscription.is_trial = False - subscription.status = "active" + subscription.status = 'active' user.has_had_paid_subscription = True await db.commit() else: @@ -681,7 +672,7 @@ async def _auto_purchase_tariff( was_trial_conversion = False except Exception as error: logger.error( - "❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s", + '❌ Автопокупка тарифа: ошибка создания подписки для пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -700,7 +691,7 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - "⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s", + '⚠️ Автопокупка тарифа: не удалось создать транзакцию для пользователя %s: %s', _format_user_id(user), error, ) @@ -714,11 +705,11 @@ async def _auto_purchase_tariff( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа", + reset_reason='покупка тарифа', ) except Exception as error: logger.warning( - "⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s", + '⚠️ Автопокупка тарифа: не удалось обновить Remnawave для пользователя %s: %s', _format_user_id(user), error, ) @@ -729,8 +720,8 @@ async def _auto_purchase_tariff( # Уведомления if bot: - texts = get_texts(getattr(user, "language", "ru")) - period_label = format_period_description(period_days, getattr(user, "language", "ru")) + texts = get_texts(getattr(user, 'language', 'ru')) + period_label = format_period_description(period_days, getattr(user, 'language', 'ru')) try: notification_service = AdminNotificationService(bot) @@ -739,7 +730,7 @@ async def _auto_purchase_tariff( ) except Exception as error: logger.warning( - "⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s", + '⚠️ Автопокупка тарифа: не удалось уведомить админов о покупке пользователя %s: %s', _format_user_id(user), error, ) @@ -748,43 +739,47 @@ async def _auto_purchase_tariff( if user.telegram_id: try: message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS", - "✅ Подписка на {period} автоматически оформлена после пополнения баланса.", + 'AUTO_PURCHASE_SUBSCRIPTION_SUCCESS', + '✅ Подписка на {period} автоматически оформлена после пополнения баланса.', ).format(period=period_label) hint = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_HINT", - "Перейдите в раздел «Моя подписка», чтобы получить ссылку.", + 'AUTO_PURCHASE_SUBSCRIPTION_HINT', + 'Перейдите в раздел «Моя подписка», чтобы получить ссылку.', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - )], - [InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"), - callback_data="back_to_menu", - )], + [ + InlineKeyboardButton( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'), + callback_data='back_to_menu', + ) + ], ] ) await bot.send_message( chat_id=user.telegram_id, - text=f"{message}\n\n{hint}", + text=f'{message}\n\n{hint}', reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: logger.warning( - "⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s", + '⚠️ Автопокупка тарифа: не удалось уведомить пользователя %s: %s', user.telegram_id or user.id, error, ) logger.info( - "✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s", + '✅ Автопокупка тарифа: подписка на тариф %s (%s дней) оформлена для пользователя %s', tariff.name, period_days, _format_user_id(user), @@ -798,21 +793,22 @@ async def _auto_purchase_daily_tariff( user: User, cart_data: dict, *, - bot: Optional[Bot] = None, + bot: Bot | None = None, ) -> bool: """Автоматическая покупка суточного тарифа из сохранённой корзины.""" from datetime import datetime, timedelta - from app.database.crud.tariff import get_tariff_by_id + + from app.database.crud.server_squad import get_all_server_squads from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id + from app.database.crud.tariff import get_tariff_by_id from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance - from app.database.crud.server_squad import get_all_server_squads from app.database.models import TransactionType - tariff_id = _safe_int(cart_data.get("tariff_id")) + tariff_id = _safe_int(cart_data.get('tariff_id')) if not tariff_id: logger.warning( - "🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s", + '🔁 Автопокупка суточного тарифа: нет tariff_id в корзине пользователя %s', _format_user_id(user), ) return False @@ -820,7 +816,7 @@ async def _auto_purchase_daily_tariff( tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not tariff.is_active: logger.warning( - "🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s", + '🔁 Автопокупка суточного тарифа: тариф %s недоступен для пользователя %s', tariff_id, _format_user_id(user), ) @@ -828,7 +824,7 @@ async def _auto_purchase_daily_tariff( if not getattr(tariff, 'is_daily', False): logger.warning( - "🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s", + '🔁 Автопокупка суточного тарифа: тариф %s не является суточным для пользователя %s', tariff_id, _format_user_id(user), ) @@ -837,7 +833,7 @@ async def _auto_purchase_daily_tariff( daily_price = getattr(tariff, 'daily_price_kopeks', 0) if daily_price <= 0: logger.warning( - "🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s", + '🔁 Автопокупка суточного тарифа: некорректная цена тарифа %s для пользователя %s', tariff_id, _format_user_id(user), ) @@ -845,7 +841,7 @@ async def _auto_purchase_daily_tariff( if user.balance_kopeks < daily_price: logger.info( - "🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)", + '🔁 Автопокупка суточного тарифа: у пользователя %s недостаточно средств (%s < %s)', _format_user_id(user), user.balance_kopeks, daily_price, @@ -854,17 +850,17 @@ async def _auto_purchase_daily_tariff( # Списываем баланс за первый день try: - description = f"Активация суточного тарифа {tariff.name}" + description = f'Активация суточного тарифа {tariff.name}' success = await subtract_user_balance(db, user, daily_price, description) if not success: logger.warning( - "❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s", + '❌ Автопокупка суточного тарифа: не удалось списать баланс пользователя %s', _format_user_id(user), ) return False except Exception as error: logger.error( - "❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s", + '❌ Автопокупка суточного тарифа: ошибка списания баланса пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -889,7 +885,7 @@ async def _auto_purchase_daily_tariff( existing_subscription.traffic_limit_gb = tariff.traffic_limit_gb existing_subscription.device_limit = tariff.device_limit existing_subscription.connected_squads = squads - existing_subscription.status = "active" + existing_subscription.status = 'active' existing_subscription.is_trial = False existing_subscription.last_daily_charge_at = datetime.utcnow() existing_subscription.is_daily_paused = False @@ -918,7 +914,7 @@ async def _auto_purchase_daily_tariff( was_trial_conversion = False except Exception as error: logger.error( - "❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s", + '❌ Автопокупка суточного тарифа: ошибка создания подписки для пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -937,7 +933,7 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - "⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s", + '⚠️ Автопокупка суточного тарифа: не удалось создать транзакцию для пользователя %s: %s', _format_user_id(user), error, ) @@ -951,11 +947,11 @@ async def _auto_purchase_daily_tariff( db, subscription, reset_traffic=True, - reset_reason="активация суточного тарифа", + reset_reason='активация суточного тарифа', ) except Exception as error: logger.warning( - "⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s", + '⚠️ Автопокупка суточного тарифа: не удалось обновить Remnawave для пользователя %s: %s', _format_user_id(user), error, ) @@ -966,7 +962,7 @@ async def _auto_purchase_daily_tariff( # Уведомления if bot: - texts = get_texts(getattr(user, "language", "ru")) + texts = get_texts(getattr(user, 'language', 'ru')) try: notification_service = AdminNotificationService(bot) @@ -975,7 +971,7 @@ async def _auto_purchase_daily_tariff( ) except Exception as error: logger.warning( - "⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s", + '⚠️ Автопокупка суточного тарифа: не удалось уведомить админов о покупке пользователя %s: %s', _format_user_id(user), error, ) @@ -984,22 +980,26 @@ async def _auto_purchase_daily_tariff( if user.telegram_id: try: message = ( - f"✅ Суточный тариф «{tariff.name}» активирован!\n\n" - f"💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n" - f"🔄 Средства будут списываться автоматически раз в сутки.\n\n" - f"ℹ️ Вы можете приостановить подписку в любой момент." + f'✅ Суточный тариф «{tariff.name}» активирован!\n\n' + f'💰 Списано: {daily_price / 100:.0f} ₽ за первый день\n' + f'🔄 Средства будут списываться автоматически раз в сутки.\n\n' + f'ℹ️ Вы можете приостановить подписку в любой момент.' ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - )], - [InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Главное меню"), - callback_data="back_to_menu", - )], + [ + InlineKeyboardButton( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], + [ + InlineKeyboardButton( + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Главное меню'), + callback_data='back_to_menu', + ) + ], ] ) @@ -1007,17 +1007,17 @@ async def _auto_purchase_daily_tariff( chat_id=user.telegram_id, text=message, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: logger.warning( - "⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя %s: %s", + '⚠️ Автопокупка суточного тарифа: не удалось уведомить пользователя %s: %s', user.telegram_id or user.id, error, ) logger.info( - "✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s", + '✅ Автопокупка суточного тарифа: тариф %s активирован для пользователя %s', tariff.name, _format_user_id(user), ) @@ -1029,50 +1029,48 @@ async def auto_purchase_saved_cart_after_topup( db: AsyncSession, user: User, *, - bot: Optional[Bot] = None, + bot: Bot | None = None, ) -> bool: """Attempts to automatically purchase a subscription from a saved cart.""" if not settings.is_auto_purchase_after_topup_enabled(): return False - if not user or not getattr(user, "id", None): + if not user or not getattr(user, 'id', None): return False cart_data = await user_cart_service.get_user_cart(user.id) if not cart_data: return False - logger.info( - "🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s", _format_user_id(user) - ) + logger.info('🔁 Автопокупка: обнаружена сохранённая корзина у пользователя %s', _format_user_id(user)) - cart_mode = cart_data.get("cart_mode") or cart_data.get("mode") + cart_mode = cart_data.get('cart_mode') or cart_data.get('mode') # Обработка продления подписки - if cart_mode == "extend": + if cart_mode == 'extend': return await _auto_extend_subscription(db, user, cart_data, bot=bot) # Обработка покупки периодного тарифа - if cart_mode == "tariff_purchase": + if cart_mode == 'tariff_purchase': return await _auto_purchase_tariff(db, user, cart_data, bot=bot) # Обработка покупки суточного тарифа - if cart_mode == "daily_tariff_purchase": + if cart_mode == 'daily_tariff_purchase': return await _auto_purchase_daily_tariff(db, user, cart_data, bot=bot) try: prepared = await _prepare_auto_purchase(db, user, cart_data) except PurchaseValidationError as error: logger.error( - "❌ Автопокупка: ошибка валидации корзины пользователя %s: %s", + '❌ Автопокупка: ошибка валидации корзины пользователя %s: %s', _format_user_id(user), error, ) return False except Exception as error: # pragma: no cover - defensive logging logger.error( - "❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s", + '❌ Автопокупка: непредвиденная ошибка при подготовке корзины %s: %s', _format_user_id(user), error, exc_info=True, @@ -1087,7 +1085,7 @@ async def auto_purchase_saved_cart_after_topup( if pricing.final_total <= 0: logger.warning( - "❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)", + '❌ Автопокупка: итоговая сумма для пользователя %s некорректна (%s)', _format_user_id(user), pricing.final_total, ) @@ -1095,7 +1093,7 @@ async def auto_purchase_saved_cart_after_topup( if user.balance_kopeks < pricing.final_total: logger.info( - "🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)", + '🔁 Автопокупка: у пользователя %s недостаточно средств (%s < %s)', _format_user_id(user), user.balance_kopeks, pricing.final_total, @@ -1112,20 +1110,20 @@ async def auto_purchase_saved_cart_after_topup( ) except PurchaseBalanceError: logger.info( - "🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным", + '🔁 Автопокупка: баланс пользователя %s изменился и стал недостаточным', _format_user_id(user), ) return False except PurchaseValidationError as error: logger.error( - "❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s", + '❌ Автопокупка: не удалось подтвердить корзину пользователя %s: %s', _format_user_id(user), error, ) return False except Exception as error: # pragma: no cover - defensive logging logger.error( - "❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s", + '❌ Автопокупка: ошибка оформления подписки для пользователя %s: %s', _format_user_id(user), error, exc_info=True, @@ -1135,10 +1133,10 @@ async def auto_purchase_saved_cart_after_topup( await user_cart_service.delete_user_cart(user.id) await clear_subscription_checkout_draft(user.id) - subscription = purchase_result.get("subscription") - transaction = purchase_result.get("transaction") - was_trial_conversion = purchase_result.get("was_trial_conversion", False) - texts = get_texts(getattr(user, "language", "ru")) + subscription = purchase_result.get('subscription') + transaction = purchase_result.get('transaction') + was_trial_conversion = purchase_result.get('was_trial_conversion', False) + texts = get_texts(getattr(user, 'language', 'ru')) if bot: try: @@ -1153,7 +1151,7 @@ async def auto_purchase_saved_cart_after_topup( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s", + '⚠️ Автопокупка: не удалось отправить уведомление админам (%s): %s', _format_user_id(user), error, ) @@ -1163,37 +1161,35 @@ async def auto_purchase_saved_cart_after_topup( try: period_label = format_period_description( selection.period.days, - getattr(user, "language", "ru"), + getattr(user, 'language', 'ru'), ) auto_message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS", - "✅ Subscription purchased automatically after balance top-up ({period}).", + 'AUTO_PURCHASE_SUBSCRIPTION_SUCCESS', + '✅ Subscription purchased automatically after balance top-up ({period}).', ).format(period=period_label) hint_message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_HINT", + 'AUTO_PURCHASE_SUBSCRIPTION_HINT', "Open the 'My subscription' section to access your link.", ) - purchase_message = purchase_result.get("message", "") - full_message = "\n\n".join( - part.strip() - for part in [auto_message, purchase_message, hint_message] - if part and part.strip() + purchase_message = purchase_result.get('message', '') + full_message = '\n\n'.join( + part.strip() for part in [auto_message, purchase_message, hint_message] if part and part.strip() ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 My subscription"), - callback_data="menu_subscription", + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'), + callback_data='menu_subscription', ) ], [ InlineKeyboardButton( - text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "🏠 Main menu"), - callback_data="back_to_menu", + text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'), + callback_data='back_to_menu', ) ], ] @@ -1203,17 +1199,17 @@ async def auto_purchase_saved_cart_after_topup( chat_id=user.telegram_id, text=full_message, reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "⚠️ Автопокупка: не удалось уведомить пользователя %s: %s", + '⚠️ Автопокупка: не удалось уведомить пользователя %s: %s', user.telegram_id or user.id, error, ) logger.info( - "✅ Автопокупка: подписка на %s дней оформлена для пользователя %s", + '✅ Автопокупка: подписка на %s дней оформлена для пользователя %s', selection.period.days, _format_user_id(user), ) @@ -1225,8 +1221,8 @@ async def auto_activate_subscription_after_topup( db: AsyncSession, user: User, *, - bot: Optional[Bot] = None, - topup_amount: Optional[int] = None, + bot: Bot | None = None, + topup_amount: int | None = None, ) -> tuple[bool, bool]: """ Умная автоактивация после пополнения баланса. @@ -1247,16 +1243,17 @@ async def auto_activate_subscription_after_topup( - notification_sent: True если уведомление отправлено пользователю """ from datetime import datetime - from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription - from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads + + from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids + from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id from app.database.crud.transaction import create_transaction from app.database.crud.user import subtract_user_balance - from app.database.models import TransactionType, PaymentMethod - from app.services.subscription_service import SubscriptionService - from app.services.subscription_renewal_service import SubscriptionRenewalService + from app.database.models import PaymentMethod, TransactionType from app.services.admin_notification_service import AdminNotificationService + from app.services.subscription_renewal_service import SubscriptionRenewalService + from app.services.subscription_service import SubscriptionService - if not user or not getattr(user, "id", None): + if not user or not getattr(user, 'id', None): return (False, False) subscription = await get_subscription_by_user_id(db, user.id) @@ -1264,15 +1261,15 @@ async def auto_activate_subscription_after_topup( # Если автоактивация отключена - уведомление отправится из _send_payment_success_notification if not settings.is_auto_activate_after_topup_enabled(): logger.info( - "⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service", + '⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service', _format_user_id(user), ) return (False, False) # Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть) - if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow(): + if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow(): logger.info( - "🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем", + '🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем', _format_user_id(user), ) return (False, False) @@ -1298,10 +1295,7 @@ async def auto_activate_subscription_after_topup( # Если серверы не выбраны — берём бесплатные по умолчанию if not connected_squads: available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id) - connected_squads = [ - s.squad_uuid for s in available_servers - if s.is_available and s.price_kopeks == 0 - ] + connected_squads = [s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0] if not connected_squads and available_servers: connected_squads = [available_servers[0].squad_uuid] @@ -1311,7 +1305,7 @@ async def auto_activate_subscription_after_topup( available_periods = sorted(settings.get_available_subscription_periods(), reverse=True) if not available_periods: - logger.warning("🔁 Автоактивация: нет доступных периодов подписки") + logger.warning('🔁 Автоактивация: нет доступных периодов подписки') return (False, False) subscription_service = SubscriptionService() @@ -1323,12 +1317,7 @@ async def auto_activate_subscription_after_topup( for period in available_periods: try: price, _ = await subscription_service.calculate_subscription_price_with_months( - period, - traffic_limit_gb, - server_ids, - device_limit, - db, - user=user + period, traffic_limit_gb, server_ids, device_limit, db, user=user ) if price <= balance: best_period = period @@ -1336,7 +1325,7 @@ async def auto_activate_subscription_after_topup( break except Exception as calc_error: logger.warning( - "🔁 Автоактивация: ошибка расчёта цены для периода %s: %s", + '🔁 Автоактивация: ошибка расчёта цены для периода %s: %s', period, calc_error, ) @@ -1344,37 +1333,36 @@ async def auto_activate_subscription_after_topup( if not best_period: logger.info( - "🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода", + '🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода', _format_user_id(user), balance, ) # Уведомление отправится из _send_payment_success_notification logger.info( - "⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service", + '⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service', _format_user_id(user), ) return (False, False) - texts = get_texts(getattr(user, "language", "ru")) + texts = get_texts(getattr(user, 'language', 'ru')) try: if subscription: # Продление существующей подписки renewal_service = SubscriptionRenewalService() - pricing = await renewal_service.calculate_pricing( - db, user, subscription, best_period - ) + pricing = await renewal_service.calculate_pricing(db, user, subscription, best_period) - old_end_date = subscription.end_date result = await renewal_service.finalize( - db, user, subscription, + db, + user, + subscription, pricing, - description=f"Автоматическое продление на {best_period} дней", + description=f'Автоматическое продление на {best_period} дней', payment_method=PaymentMethod.BALANCE, ) logger.info( - "✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.", + '✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.', _format_user_id(user), best_period, best_price, @@ -1383,43 +1371,45 @@ async def auto_activate_subscription_after_topup( # Уведомление пользователю (только для Telegram-пользователей) if bot and user.telegram_id: try: - period_label = format_period_description(best_period, getattr(user, "language", "ru")) + period_label = format_period_description(best_period, getattr(user, 'language', 'ru')) new_end_date = result.subscription.end_date - end_date_str = new_end_date.strftime("%d.%m.%Y") if new_end_date else "—" + end_date_str = new_end_date.strftime('%d.%m.%Y') if new_end_date else '—' message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED", - "✅ Подписка автоматически продлена на {period}.", + 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED', + '✅ Подписка автоматически продлена на {period}.', ).format(period=period_label) details = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS", - "⏰ Новая дата окончания: {date}.", + 'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS', + '⏰ Новая дата окончания: {date}.', ).format(date=end_date_str) hint = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_HINT", - "Перейдите в раздел «Моя подписка», чтобы получить ссылку.", + 'AUTO_PURCHASE_SUBSCRIPTION_HINT', + 'Перейдите в раздел «Моя подписка», чтобы получить ссылку.', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - )], + [ + InlineKeyboardButton( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], ] ) await bot.send_message( chat_id=user.telegram_id, - text=f"{message}\n{details}\n\n{hint}", + text=f'{message}\n{details}\n\n{hint}', reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as notify_error: logger.warning( - "⚠️ Автоактивация: не удалось уведомить пользователя %s: %s", + '⚠️ Автоактивация: не удалось уведомить пользователя %s: %s', user.telegram_id or user.id, notify_error, ) @@ -1433,13 +1423,10 @@ async def auto_activate_subscription_after_topup( traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=connected_squads, - update_server_counters=True + update_server_counters=True, ) - await subtract_user_balance( - db, user, best_price, - f"Активация подписки на {best_period} дней" - ) + await subtract_user_balance(db, user, best_price, f'Активация подписки на {best_period} дней') await subscription_service.create_remnawave_user(db, new_subscription) @@ -1448,12 +1435,12 @@ async def auto_activate_subscription_after_topup( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=best_price, - description=f"Активация подписки на {best_period} дней", + description=f'Активация подписки на {best_period} дней', payment_method=PaymentMethod.BALANCE, ) logger.info( - "✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.", + '✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.', best_period, _format_user_id(user), best_price, @@ -1462,37 +1449,39 @@ async def auto_activate_subscription_after_topup( # Уведомление пользователю (только для Telegram-пользователей) if bot and user.telegram_id: try: - period_label = format_period_description(best_period, getattr(user, "language", "ru")) + period_label = format_period_description(best_period, getattr(user, 'language', 'ru')) message = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_SUCCESS", - "✅ Подписка на {period} автоматически оформлена после пополнения баланса.", + 'AUTO_PURCHASE_SUBSCRIPTION_SUCCESS', + '✅ Подписка на {period} автоматически оформлена после пополнения баланса.', ).format(period=period_label) hint = texts.t( - "AUTO_PURCHASE_SUBSCRIPTION_HINT", - "Перейдите в раздел «Моя подписка», чтобы получить ссылку.", + 'AUTO_PURCHASE_SUBSCRIPTION_HINT', + 'Перейдите в раздел «Моя подписка», чтобы получить ссылку.', ) keyboard = InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton( - text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), - callback_data="menu_subscription", - )], + [ + InlineKeyboardButton( + text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'), + callback_data='menu_subscription', + ) + ], ] ) await bot.send_message( chat_id=user.telegram_id, - text=f"{message}\n\n{hint}", + text=f'{message}\n\n{hint}', reply_markup=keyboard, - parse_mode="HTML", + parse_mode='HTML', ) except Exception as notify_error: logger.warning( - "⚠️ Автоактивация: не удалось уведомить пользователя %s: %s", + '⚠️ Автоактивация: не удалось уведомить пользователя %s: %s', user.telegram_id or user.id, notify_error, ) @@ -1511,7 +1500,7 @@ async def auto_activate_subscription_after_topup( ) except Exception as admin_error: logger.warning( - "⚠️ Автоактивация: не удалось уведомить админов: %s", + '⚠️ Автоактивация: не удалось уведомить админов: %s', admin_error, ) @@ -1519,7 +1508,7 @@ async def auto_activate_subscription_after_topup( except Exception as e: logger.error( - "❌ Автоактивация: ошибка для пользователя %s: %s", + '❌ Автоактивация: ошибка для пользователя %s: %s', _format_user_id(user), e, exc_info=True, @@ -1529,4 +1518,4 @@ async def auto_activate_subscription_after_topup( return False -__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"] +__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup'] diff --git a/app/services/subscription_checkout_service.py b/app/services/subscription_checkout_service.py index 8ceea4b3..6d2ad17d 100644 --- a/app/services/subscription_checkout_service.py +++ b/app/services/subscription_checkout_service.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from sqlalchemy.exc import MissingGreenlet @@ -10,19 +9,17 @@ from app.utils.cache import UserCache logger = logging.getLogger(__name__) -_CHECKOUT_SESSION_KEY = "subscription_checkout" +_CHECKOUT_SESSION_KEY = 'subscription_checkout' _CHECKOUT_TTL_SECONDS = 3600 -async def save_subscription_checkout_draft( - user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS -) -> bool: +async def save_subscription_checkout_draft(user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS) -> bool: """Persist subscription checkout draft data in cache.""" return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl) -async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]: +async def get_subscription_checkout_draft(user_id: int) -> dict | None: """Retrieve subscription checkout draft from cache.""" return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY) @@ -57,11 +54,11 @@ def should_offer_checkout_resume( if subscription is None: try: - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) except MissingGreenlet as error: logger.warning( - "Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s", - getattr(user, "id", None), + 'Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s', + getattr(user, 'id', None), error, ) subscription = None @@ -69,10 +66,10 @@ def should_offer_checkout_resume( if subscription is None: return True - if getattr(subscription, "is_trial", False): + if getattr(subscription, 'is_trial', False): return True - if getattr(subscription, "actual_status", None) == "expired": + if getattr(subscription, 'actual_status', None) == 'expired': return True return False diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index a0be72d2..20b389f1 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -1,7 +1,8 @@ import logging +from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +34,7 @@ from app.utils.pricing_utils import ( from app.utils.promo_offer import get_user_active_promo_discount_percent from app.utils.user_utils import mark_user_as_had_paid_subscription + logger = logging.getLogger(__name__) @@ -42,29 +44,29 @@ class PurchaseTrafficOption: label: str price_per_month: int price_label: str - original_price_per_month: Optional[int] = None - original_price_label: Optional[str] = None + original_price_per_month: int | None = None + original_price_label: str | None = None discount_percent: int = 0 is_available: bool = True is_default: bool = False - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "value": self.value, - "label": self.label, - "price_kopeks": self.price_per_month, - "price_label": self.price_label, - "is_available": self.is_available, + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'value': self.value, + 'label': self.label, + 'price_kopeks': self.price_per_month, + 'price_label': self.price_label, + 'is_available': self.is_available, } if self.original_price_per_month is not None and ( self.original_price_label and self.original_price_per_month != self.price_per_month ): - payload["original_price_kopeks"] = self.original_price_per_month - payload["original_price_label"] = self.original_price_label + payload['original_price_kopeks'] = self.original_price_per_month + payload['original_price_label'] = self.original_price_label if self.discount_percent: - payload["discount_percent"] = self.discount_percent + payload['discount_percent'] = self.discount_percent if self.is_default: - payload["is_default"] = True + payload['is_default'] = True return payload @@ -72,24 +74,24 @@ class PurchaseTrafficOption: class PurchaseTrafficConfig: selectable: bool mode: str - options: List[PurchaseTrafficOption] = field(default_factory=list) - default_value: Optional[int] = None - current_value: Optional[int] = None - hint: Optional[str] = None + options: list[PurchaseTrafficOption] = field(default_factory=list) + default_value: int | None = None + current_value: int | None = None + hint: str | None = None - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "selectable": self.selectable, - "mode": self.mode, + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'selectable': self.selectable, + 'mode': self.mode, } if self.options: - payload["options"] = [option.to_payload() for option in self.options] + payload['options'] = [option.to_payload() for option in self.options] if self.default_value is not None: - payload["default"] = self.default_value + payload['default'] = self.default_value if self.current_value is not None: - payload["current"] = self.current_value + payload['current'] = self.current_value if self.hint: - payload["hint"] = self.hint + payload['hint'] = self.hint return payload @@ -99,47 +101,47 @@ class PurchaseServerOption: name: str price_per_month: int price_label: str - original_price_per_month: Optional[int] = None - original_price_label: Optional[str] = None + original_price_per_month: int | None = None + original_price_label: str | None = None discount_percent: int = 0 is_available: bool = True - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "uuid": self.uuid, - "name": self.name, - "price_kopeks": self.price_per_month, - "price_label": self.price_label, - "is_available": self.is_available, + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'uuid': self.uuid, + 'name': self.name, + 'price_kopeks': self.price_per_month, + 'price_label': self.price_label, + 'is_available': self.is_available, } if self.original_price_per_month is not None and ( self.original_price_label and self.original_price_per_month != self.price_per_month ): - payload["original_price_kopeks"] = self.original_price_per_month - payload["original_price_label"] = self.original_price_label + payload['original_price_kopeks'] = self.original_price_per_month + payload['original_price_label'] = self.original_price_label if self.discount_percent: - payload["discount_percent"] = self.discount_percent + payload['discount_percent'] = self.discount_percent return payload @dataclass class PurchaseServersConfig: - options: List[PurchaseServerOption] + options: list[PurchaseServerOption] min_selectable: int max_selectable: int - default_selection: List[str] - hint: Optional[str] = None + default_selection: list[str] + hint: str | None = None - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "options": [option.to_payload() for option in self.options], - "min": self.min_selectable, - "max": self.max_selectable, - "default": list(self.default_selection), - "selected": list(self.default_selection), + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'options': [option.to_payload() for option in self.options], + 'min': self.min_selectable, + 'max': self.max_selectable, + 'default': list(self.default_selection), + 'selected': list(self.default_selection), } if self.hint: - payload["hint"] = self.hint + payload['hint'] = self.hint return payload @@ -152,27 +154,27 @@ class PurchaseDevicesConfig: price_per_device: int discounted_price_per_device: int price_label: str - original_price_label: Optional[str] = None + original_price_label: str | None = None discount_percent: int = 0 - hint: Optional[str] = None + hint: str | None = None - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "min": self.minimum, - "max": self.maximum, - "default": self.default, - "current": self.current, - "price_per_device_kopeks": self.discounted_price_per_device, - "price_per_device_label": self.price_label, + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'min': self.minimum, + 'max': self.maximum, + 'default': self.default, + 'current': self.current, + 'price_per_device_kopeks': self.discounted_price_per_device, + 'price_per_device_label': self.price_label, } if self.price_per_device and self.price_per_device != self.discounted_price_per_device: - payload["price_per_device_original_kopeks"] = self.price_per_device + payload['price_per_device_original_kopeks'] = self.price_per_device if self.original_price_label: - payload["price_per_device_original_label"] = self.original_price_label + payload['price_per_device_original_label'] = self.original_price_label if self.discount_percent: - payload["discount_percent"] = self.discount_percent + payload['discount_percent'] = self.discount_percent if self.hint: - payload["hint"] = self.hint + payload['hint'] = self.hint return payload @@ -185,7 +187,7 @@ class PurchasePeriodConfig: base_price: int base_price_label: str base_price_original: int - base_price_original_label: Optional[str] + base_price_original_label: str | None discount_percent: int per_month_price: int per_month_price_label: str @@ -193,33 +195,29 @@ class PurchasePeriodConfig: servers: PurchaseServersConfig devices: PurchaseDevicesConfig - def to_payload(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "id": self.id, - "code": self.id, - "period_id": self.id, - "period_days": self.days, - "period": self.days, - "months": self.months, - "label": self.label, - "price_kopeks": self.base_price, - "price_label": self.base_price_label, - "per_month_price_kopeks": self.per_month_price, - "per_month_price_label": self.per_month_price_label, - "is_available": True, - "traffic": self.traffic.to_payload(), - "servers": self.servers.to_payload(), - "devices": self.devices.to_payload(), + def to_payload(self) -> dict[str, Any]: + payload: dict[str, Any] = { + 'id': self.id, + 'code': self.id, + 'period_id': self.id, + 'period_days': self.days, + 'period': self.days, + 'months': self.months, + 'label': self.label, + 'price_kopeks': self.base_price, + 'price_label': self.base_price_label, + 'per_month_price_kopeks': self.per_month_price, + 'per_month_price_label': self.per_month_price_label, + 'is_available': True, + 'traffic': self.traffic.to_payload(), + 'servers': self.servers.to_payload(), + 'devices': self.devices.to_payload(), } if self.discount_percent: - payload["discount_percent"] = self.discount_percent - if ( - self.base_price_original - and self.base_price_original_label - and self.base_price_original != self.base_price - ): - payload["original_price_kopeks"] = self.base_price_original - payload["original_price_label"] = self.base_price_original_label + payload['discount_percent'] = self.discount_percent + if self.base_price_original and self.base_price_original_label and self.base_price_original != self.base_price: + payload['original_price_kopeks'] = self.base_price_original + payload['original_price_label'] = self.base_price_original_label return payload @@ -227,39 +225,39 @@ class PurchasePeriodConfig: class PurchaseSelection: period: PurchasePeriodConfig traffic_value: int - servers: List[str] + servers: list[str] devices: int @dataclass class PurchasePricingResult: selection: PurchaseSelection - server_ids: List[int] - server_prices_for_period: List[int] + server_ids: list[int] + server_prices_for_period: list[int] base_original_total: int discounted_total: int promo_discount_value: int promo_discount_percent: int final_total: int months: int - details: Dict[str, Any] + details: dict[str, Any] @dataclass class PurchaseOptionsContext: user: User - subscription: Optional[Subscription] + subscription: Subscription | None currency: str balance_kopeks: int - periods: List[PurchasePeriodConfig] + periods: list[PurchasePeriodConfig] default_period: PurchasePeriodConfig - period_map: Dict[str, PurchasePeriodConfig] - server_uuid_to_id: Dict[str, int] - payload: Dict[str, Any] + period_map: dict[str, PurchasePeriodConfig] + server_uuid_to_id: dict[str, int] + payload: dict[str, Any] class PurchaseValidationError(Exception): - def __init__(self, message: str, code: str = "invalid_selection") -> None: + def __init__(self, message: str, code: str = 'invalid_selection') -> None: super().__init__(message) self.code = code @@ -269,7 +267,7 @@ class PurchaseBalanceError(Exception): super().__init__(message) -def _apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: +def _apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]: if amount <= 0 or percent <= 0: return amount, 0 clamped = max(0, min(100, percent)) @@ -282,23 +280,23 @@ def _apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: return discounted, discount_value -def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, months: int) -> Dict[str, int]: +def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, months: int) -> dict[str, int]: discounted_per_month, discount_per_month = _apply_percentage_discount(amount_per_month, percent) return { - "original_per_month": amount_per_month, - "discounted_per_month": discounted_per_month, - "discount_percent": max(0, min(100, percent)), - "discount_per_month": discount_per_month, - "total": discounted_per_month * months, - "discount_total": discount_per_month * months, + 'original_per_month': amount_per_month, + 'discounted_per_month': discounted_per_month, + 'discount_percent': max(0, min(100, percent)), + 'discount_per_month': discount_per_month, + 'total': discounted_per_month * months, + 'discount_total': discount_per_month * months, } -def _get_promo_offer_discount_percent(user: Optional[User]) -> int: +def _get_promo_offer_discount_percent(user: User | None) -> int: return get_user_active_promo_discount_percent(user) -def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Tuple[int, int, int]: +def _apply_promo_offer_discount(user: User | None, amount: int) -> tuple[int, int, int]: percent = _get_promo_offer_discount_percent(user) if amount <= 0 or percent <= 0: return amount, 0, 0 @@ -311,17 +309,17 @@ def _build_server_option( discount_percent: int, texts, ) -> PurchaseServerOption: - base_per_month = int(getattr(server, "price_kopeks", 0) or 0) + base_per_month = int(getattr(server, 'price_kopeks', 0) or 0) discounted_per_month, _ = _apply_percentage_discount(base_per_month, discount_percent) return PurchaseServerOption( uuid=server.squad_uuid, - name=getattr(server, "display_name", server.squad_uuid) or server.squad_uuid, + name=getattr(server, 'display_name', server.squad_uuid) or server.squad_uuid, price_per_month=discounted_per_month, price_label=texts.format_price(discounted_per_month), original_price_per_month=base_per_month, original_price_label=texts.format_price(base_per_month) if base_per_month != discounted_per_month else None, discount_percent=max(0, discount_percent), - is_available=bool(getattr(server, "is_available", True) and not getattr(server, "is_full", False)), + is_available=bool(getattr(server, 'is_available', True) and not getattr(server, 'is_full', False)), ) @@ -332,17 +330,17 @@ class MiniAppSubscriptionPurchaseService: from app.database.crud.subscription import get_subscription_by_user_id subscription = await get_subscription_by_user_id(db, user.id) - balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0) - currency = (getattr(user, "balance_currency", None) or "RUB").upper() - texts = get_texts(getattr(user, "language", None)) + balance_kopeks = int(getattr(user, 'balance_kopeks', 0) or 0) + currency = (getattr(user, 'balance_currency', None) or 'RUB').upper() + texts = get_texts(getattr(user, 'language', None)) # Exclude trial-only servers from purchase options available_servers = await get_available_server_squads( db, - promo_group_id=getattr(user, "promo_group_id", None), + promo_group_id=getattr(user, 'promo_group_id', None), exclude_trial_only=True, ) - server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers} + server_catalog: dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers} if subscription and subscription.connected_squads: for uuid in subscription.connected_squads: @@ -351,34 +349,34 @@ class MiniAppSubscriptionPurchaseService: try: existing = await get_server_squad_by_uuid(db, uuid) except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load server squad %s: %s", uuid, error) + logger.warning('Failed to load server squad %s: %s', uuid, error) existing = None if existing: server_catalog[uuid] = existing - server_uuid_to_id: Dict[str, int] = {} + server_uuid_to_id: dict[str, int] = {} for server in server_catalog.values(): try: - server_uuid_to_id[server.squad_uuid] = int(getattr(server, "id", 0) or 0) + server_uuid_to_id[server.squad_uuid] = int(getattr(server, 'id', 0) or 0) except (TypeError, ValueError): continue - default_connected = list(getattr(subscription, "connected_squads", []) or []) + default_connected = list(getattr(subscription, 'connected_squads', []) or []) if not default_connected: for server in available_servers: - if getattr(server, "is_available", True) and not getattr(server, "is_full", False): + if getattr(server, 'is_available', True) and not getattr(server, 'is_full', False): default_connected = [server.squad_uuid] break available_periods: Sequence[int] = settings.get_available_subscription_periods() - periods: List[PurchasePeriodConfig] = [] - period_map: Dict[str, PurchasePeriodConfig] = {} + periods: list[PurchasePeriodConfig] = [] + period_map: dict[str, PurchasePeriodConfig] = {} default_devices = settings.DEFAULT_DEVICE_LIMIT # Для триала НЕ используем его ограничения как дефолтные, # чтобы при продлении клиент получил стандартные значения платной подписки - is_trial_subscription = subscription and getattr(subscription, "is_trial", False) - if subscription and getattr(subscription, "device_limit", None) and not is_trial_subscription: + is_trial_subscription = subscription and getattr(subscription, 'is_trial', False) + if subscription and getattr(subscription, 'device_limit', None) and not is_trial_subscription: default_devices = max(default_devices, int(subscription.device_limit)) fixed_traffic_value = None @@ -391,14 +389,12 @@ class MiniAppSubscriptionPurchaseService: for period_days in available_periods: months = calculate_months_from_days(period_days) - period_id = f"days:{period_days}" - label = format_period_description(period_days, getattr(user, "language", "ru")) + period_id = f'days:{period_days}' + label = format_period_description(period_days, getattr(user, 'language', 'ru')) base_price_original = PERIOD_PRICES.get(period_days, 0) - period_discount_percent = user.get_promo_discount("period", period_days) - base_price, base_discount_total = _apply_percentage_discount( - base_price_original, period_discount_percent - ) + period_discount_percent = user.get_promo_discount('period', period_days) + base_price, base_discount_total = _apply_percentage_discount(base_price_original, period_discount_percent) base_price_label = texts.format_price(base_price) base_price_original_label = ( texts.format_price(base_price_original) @@ -451,44 +447,44 @@ class MiniAppSubscriptionPurchaseService: period_map[period_id] = period_config if not periods: - raise PurchaseValidationError("No subscription periods configured", code="configuration") + raise PurchaseValidationError('No subscription periods configured', code='configuration') - default_period = period_map.get(f"days:{default_period_days}") or periods[0] + default_period = period_map.get(f'days:{default_period_days}') or periods[0] default_selection = { - "period_id": default_period.id, - "periodId": default_period.id, - "period_days": default_period.days, - "periodDays": default_period.days, - "traffic_value": default_period.traffic.current_value + 'period_id': default_period.id, + 'periodId': default_period.id, + 'period_days': default_period.days, + 'periodDays': default_period.days, + 'traffic_value': default_period.traffic.current_value if default_period.traffic.current_value is not None else default_period.traffic.default_value, - "trafficValue": default_period.traffic.current_value + 'trafficValue': default_period.traffic.current_value if default_period.traffic.current_value is not None else default_period.traffic.default_value, - "servers": list(default_period.servers.default_selection), - "countries": list(default_period.servers.default_selection), - "server_uuids": list(default_period.servers.default_selection), - "serverUuids": list(default_period.servers.default_selection), - "devices": default_period.devices.current, - "device_limit": default_period.devices.current, - "deviceLimit": default_period.devices.current, + 'servers': list(default_period.servers.default_selection), + 'countries': list(default_period.servers.default_selection), + 'server_uuids': list(default_period.servers.default_selection), + 'serverUuids': list(default_period.servers.default_selection), + 'devices': default_period.devices.current, + 'device_limit': default_period.devices.current, + 'deviceLimit': default_period.devices.current, } payload = { - "currency": currency, - "balance_kopeks": balance_kopeks, - "balanceKopeks": balance_kopeks, - "balance_label": texts.format_price(balance_kopeks), - "balanceLabel": texts.format_price(balance_kopeks), - "subscription_id": getattr(subscription, "id", None), - "subscriptionId": getattr(subscription, "id", None), - "periods": [period.to_payload() for period in periods], - "traffic": default_period.traffic.to_payload(), - "servers": default_period.servers.to_payload(), - "devices": default_period.devices.to_payload(), - "selection": default_selection, - "summary": None, + 'currency': currency, + 'balance_kopeks': balance_kopeks, + 'balanceKopeks': balance_kopeks, + 'balance_label': texts.format_price(balance_kopeks), + 'balanceLabel': texts.format_price(balance_kopeks), + 'subscription_id': getattr(subscription, 'id', None), + 'subscriptionId': getattr(subscription, 'id', None), + 'periods': [period.to_payload() for period in periods], + 'traffic': default_period.traffic.to_payload(), + 'servers': default_period.servers.to_payload(), + 'devices': default_period.devices.to_payload(), + 'selection': default_selection, + 'summary': None, } return PurchaseOptionsContext( @@ -509,7 +505,7 @@ class MiniAppSubscriptionPurchaseService: texts, period_days: int, months: int, - fixed_traffic_value: Optional[int], + fixed_traffic_value: int | None, ) -> PurchaseTrafficConfig: if settings.is_traffic_fixed(): value = fixed_traffic_value if fixed_traffic_value is not None else settings.get_fixed_traffic_limit() @@ -524,13 +520,13 @@ class MiniAppSubscriptionPurchaseService: hint=None, ) - packages = [package for package in settings.get_traffic_packages() if package.get("enabled", True)] - discount_percent = user.get_promo_discount("traffic", period_days) - options: List[PurchaseTrafficOption] = [] + packages = [package for package in settings.get_traffic_packages() if package.get('enabled', True)] + discount_percent = user.get_promo_discount('traffic', period_days) + options: list[PurchaseTrafficOption] = [] for package in packages: - value = int(package.get("gb") or 0) - price_per_month = int(package.get("price") or 0) + value = int(package.get('gb') or 0) + price_per_month = int(package.get('price') or 0) discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent) label = texts.format_traffic(value if value else 0) options.append( @@ -563,7 +559,7 @@ class MiniAppSubscriptionPurchaseService: return PurchaseTrafficConfig( selectable=True, - mode="selectable", + mode='selectable', options=options, default_value=default_value, current_value=default_value, @@ -575,13 +571,13 @@ class MiniAppSubscriptionPurchaseService: user: User, texts, period_days: int, - server_catalog: Dict[str, ServerSquad], - default_selection: List[str], + server_catalog: dict[str, ServerSquad], + default_selection: list[str], ) -> PurchaseServersConfig: - discount_percent = user.get_promo_discount("servers", period_days) - options: List[PurchaseServerOption] = [] + discount_percent = user.get_promo_discount('servers', period_days) + options: list[PurchaseServerOption] = [] - for uuid, server in server_catalog.items(): + for server in server_catalog.values(): option = _build_server_option(server, discount_percent, texts) options.append(option) @@ -603,14 +599,12 @@ class MiniAppSubscriptionPurchaseService: period_days: int, default_devices: int, ) -> PurchaseDevicesConfig: - discount_percent = user.get_promo_discount("devices", period_days) + discount_percent = user.get_promo_discount('devices', period_days) unit_price = settings.PRICE_PER_DEVICE discounted_unit_price, unit_discount_value = _apply_percentage_discount(unit_price, discount_percent) price_label = texts.format_price(discounted_unit_price) original_label = ( - texts.format_price(unit_price) - if unit_discount_value and unit_price != discounted_unit_price - else None + texts.format_price(unit_price) if unit_discount_value and unit_price != discounted_unit_price else None ) max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None @@ -635,27 +629,27 @@ class MiniAppSubscriptionPurchaseService: def parse_selection( self, context: PurchaseOptionsContext, - selection_payload: Dict[str, Any], + selection_payload: dict[str, Any], ) -> PurchaseSelection: period_id = ( - selection_payload.get("period_id") - or selection_payload.get("periodId") - or selection_payload.get("period") - or selection_payload.get("code") + selection_payload.get('period_id') + or selection_payload.get('periodId') + or selection_payload.get('period') + or selection_payload.get('code') ) if not period_id: - period_days = selection_payload.get("period_days") or selection_payload.get("periodDays") + period_days = selection_payload.get('period_days') or selection_payload.get('periodDays') if period_days is not None: - period_id = f"days:{int(period_days)}" + period_id = f'days:{int(period_days)}' if not period_id or period_id not in context.period_map: - raise PurchaseValidationError("Invalid or missing subscription period", code="invalid_period") + raise PurchaseValidationError('Invalid or missing subscription period', code='invalid_period') period = context.period_map[period_id] # Don't use `or` chaining - 0 is valid for unlimited traffic traffic_value = None - for key in ("traffic_value", "trafficValue", "traffic", "traffic_gb", "trafficGb"): + for key in ('traffic_value', 'trafficValue', 'traffic', 'traffic_gb', 'trafficGb'): value = selection_payload.get(key) if value is not None: traffic_value = value @@ -668,17 +662,17 @@ class MiniAppSubscriptionPurchaseService: else: traffic_value = int(traffic_value) if available_values and traffic_value not in available_values: - raise PurchaseValidationError("Selected traffic option is not available", code="invalid_traffic") + raise PurchaseValidationError('Selected traffic option is not available', code='invalid_traffic') else: traffic_value = period.traffic.current_value or period.traffic.default_value or 0 - raw_servers: List[str] = [] - for key in ("servers", "countries", "server_uuids", "serverUuids"): + raw_servers: list[str] = [] + for key in ('servers', 'countries', 'server_uuids', 'serverUuids'): value = selection_payload.get(key) if isinstance(value, list): raw_servers.extend(value) - servers: List[str] = [] + servers: list[str] = [] seen = set() for raw in raw_servers: if not raw: @@ -693,25 +687,24 @@ class MiniAppSubscriptionPurchaseService: servers = list(period.servers.default_selection) if period.servers.min_selectable and len(servers) < period.servers.min_selectable: - raise PurchaseValidationError("Select at least one server", code="invalid_servers") + raise PurchaseValidationError('Select at least one server', code='invalid_servers') if period.servers.max_selectable and len(servers) > period.servers.max_selectable: servers = servers[: period.servers.max_selectable] devices = ( - selection_payload.get("devices") - or selection_payload.get("device_limit") - or selection_payload.get("deviceLimit") + selection_payload.get('devices') + or selection_payload.get('device_limit') + or selection_payload.get('deviceLimit') or period.devices.current or period.devices.default ) try: devices = int(devices) except (TypeError, ValueError): - raise PurchaseValidationError("Invalid devices selection", code="invalid_devices") + raise PurchaseValidationError('Invalid devices selection', code='invalid_devices') - if devices < period.devices.minimum: - devices = period.devices.minimum + devices = max(devices, period.devices.minimum) if period.devices.maximum and devices > period.devices.maximum: devices = period.devices.maximum @@ -728,12 +721,12 @@ class MiniAppSubscriptionPurchaseService: context: PurchaseOptionsContext, selection: PurchaseSelection, ) -> PurchasePricingResult: - texts = get_texts(getattr(context.user, "language", None)) + get_texts(getattr(context.user, 'language', None)) months = selection.period.months server_ids = await get_server_ids_by_uuids(db, selection.servers) if len(server_ids) != len(selection.servers): - raise PurchaseValidationError("Some selected servers are not available", code="invalid_servers") + raise PurchaseValidationError('Some selected servers are not available', code='invalid_servers') total_without_promo, details = await self._calculate_base_total( db, @@ -743,10 +736,10 @@ class MiniAppSubscriptionPurchaseService: ) base_original_total = ( - details["base_price_original"] - + details["traffic_price_per_month"] * months - + details["servers_price_per_month"] * months - + details["devices_price_per_month"] * months + details['base_price_original'] + + details['traffic_price_per_month'] * months + + details['servers_price_per_month'] * months + + details['devices_price_per_month'] * months ) final_total, promo_discount_value, promo_percent = _apply_promo_offer_discount( @@ -756,30 +749,21 @@ class MiniAppSubscriptionPurchaseService: discounted_total = total_without_promo is_valid = validate_pricing_calculation( - details.get("base_price", 0), - ( - details.get("traffic_price_per_month", 0) - - details.get("traffic_discount_total", 0) // max(1, months) - ) - + ( - details.get("servers_price_per_month", 0) - - details.get("servers_discount_total", 0) // max(1, months) - ) - + ( - details.get("devices_price_per_month", 0) - - details.get("devices_discount_total", 0) // max(1, months) - ), + details.get('base_price', 0), + (details.get('traffic_price_per_month', 0) - details.get('traffic_discount_total', 0) // max(1, months)) + + (details.get('servers_price_per_month', 0) - details.get('servers_discount_total', 0) // max(1, months)) + + (details.get('devices_price_per_month', 0) - details.get('devices_discount_total', 0) // max(1, months)), months, discounted_total, ) if not is_valid: - raise PurchaseValidationError("Failed to validate pricing", code="calculation_error") + raise PurchaseValidationError('Failed to validate pricing', code='calculation_error') return PurchasePricingResult( selection=selection, server_ids=server_ids, - server_prices_for_period=list(details.get("servers_individual_prices", [])), + server_prices_for_period=list(details.get('servers_individual_prices', [])), base_original_total=base_original_total, discounted_total=discounted_total, promo_discount_value=promo_discount_value, @@ -794,8 +778,8 @@ class MiniAppSubscriptionPurchaseService: db: AsyncSession, user: User, selection: PurchaseSelection, - server_ids: List[int], - ) -> Tuple[int, Dict[str, Any]]: + server_ids: list[int], + ) -> tuple[int, dict[str, Any]]: from app.database.crud.subscription import calculate_subscription_total_cost total_cost, details = await calculate_subscription_total_cost( @@ -812,8 +796,8 @@ class MiniAppSubscriptionPurchaseService: self, context: PurchaseOptionsContext, pricing: PurchasePricingResult, - ) -> Dict[str, Any]: - texts = get_texts(getattr(context.user, "language", None)) + ) -> dict[str, Any]: + texts = get_texts(getattr(context.user, 'language', None)) details = pricing.details total_discount = pricing.base_original_total - pricing.final_total @@ -821,9 +805,9 @@ class MiniAppSubscriptionPurchaseService: if pricing.base_original_total > 0 and total_discount > 0: overall_discount_percent = int(round(total_discount * 100 / pricing.base_original_total)) - discount_lines: List[str] = [] + discount_lines: list[str] = [] - def build_discount_line(key: str, default: str, amount: int, percent: int) -> Optional[str]: + def build_discount_line(key: str, default: str, amount: int, percent: int) -> str | None: if not amount: return None return texts.t(key, default).format( @@ -831,49 +815,49 @@ class MiniAppSubscriptionPurchaseService: percent=percent, ) - def build_discount_note(amount: int, percent: int) -> Optional[str]: + def build_discount_note(amount: int, percent: int) -> str | None: if not amount: return None return texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE", - "Discount: -{amount} ({percent}%)", + 'MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE', + 'Discount: -{amount} ({percent}%)', ).format( amount=texts.format_price(amount), percent=percent, ) base_discount_line = build_discount_line( - "MINIAPP_PURCHASE_DISCOUNT_PERIOD", - "Period discount: -{amount} ({percent}%)", - details.get("base_discount_total", 0), - details.get("base_discount_percent", 0), + 'MINIAPP_PURCHASE_DISCOUNT_PERIOD', + 'Period discount: -{amount} ({percent}%)', + details.get('base_discount_total', 0), + details.get('base_discount_percent', 0), ) if base_discount_line: discount_lines.append(base_discount_line) traffic_discount_line = build_discount_line( - "MINIAPP_PURCHASE_DISCOUNT_TRAFFIC", - "Traffic discount: -{amount} ({percent}%)", - details.get("traffic_discount_total", 0), - details.get("traffic_discount_percent", 0), + 'MINIAPP_PURCHASE_DISCOUNT_TRAFFIC', + 'Traffic discount: -{amount} ({percent}%)', + details.get('traffic_discount_total', 0), + details.get('traffic_discount_percent', 0), ) if traffic_discount_line: discount_lines.append(traffic_discount_line) servers_discount_line = build_discount_line( - "MINIAPP_PURCHASE_DISCOUNT_SERVERS", - "Servers discount: -{amount} ({percent}%)", - details.get("servers_discount_total", 0), - details.get("servers_discount_percent", 0), + 'MINIAPP_PURCHASE_DISCOUNT_SERVERS', + 'Servers discount: -{amount} ({percent}%)', + details.get('servers_discount_total', 0), + details.get('servers_discount_percent', 0), ) if servers_discount_line: discount_lines.append(servers_discount_line) devices_discount_line = build_discount_line( - "MINIAPP_PURCHASE_DISCOUNT_DEVICES", - "Devices discount: -{amount} ({percent}%)", - details.get("devices_discount_total", 0), - details.get("devices_discount_percent", 0), + 'MINIAPP_PURCHASE_DISCOUNT_DEVICES', + 'Devices discount: -{amount} ({percent}%)', + details.get('devices_discount_total', 0), + details.get('devices_discount_percent', 0), ) if devices_discount_line: discount_lines.append(devices_discount_line) @@ -881,8 +865,8 @@ class MiniAppSubscriptionPurchaseService: promo_discount_line = None if pricing.promo_discount_value: promo_discount_line = texts.t( - "MINIAPP_PURCHASE_DISCOUNT_PROMO", - "Promo offer: -{amount} ({percent}%)", + 'MINIAPP_PURCHASE_DISCOUNT_PROMO', + 'Promo offer: -{amount} ({percent}%)', ).format( amount=texts.format_price(pricing.promo_discount_value), percent=pricing.promo_discount_percent, @@ -891,145 +875,138 @@ class MiniAppSubscriptionPurchaseService: breakdown = [ { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_BASE", - "Base plan", + 'label': texts.t( + 'MINIAPP_PURCHASE_BREAKDOWN_BASE', + 'Base plan', ), - "value": texts.format_price(details.get("base_price", 0)), + 'value': texts.format_price(details.get('base_price', 0)), } ] base_discount_note = build_discount_note( - details.get("base_discount_total", 0), - details.get("base_discount_percent", 0), + details.get('base_discount_total', 0), + details.get('base_discount_percent', 0), ) if base_discount_note: - breakdown[0]["discount_label"] = base_discount_note - breakdown[0]["discountLabel"] = base_discount_note + breakdown[0]['discount_label'] = base_discount_note + breakdown[0]['discountLabel'] = base_discount_note - if details.get("total_traffic_price"): + if details.get('total_traffic_price'): traffic_item = { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC", - "Traffic", + 'label': texts.t( + 'MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC', + 'Traffic', ), - "value": texts.format_price(details["total_traffic_price"]), + 'value': texts.format_price(details['total_traffic_price']), } traffic_discount_note = build_discount_note( - details.get("traffic_discount_total", 0), - details.get("traffic_discount_percent", 0), + details.get('traffic_discount_total', 0), + details.get('traffic_discount_percent', 0), ) if traffic_discount_note: - traffic_item["discount_label"] = traffic_discount_note - traffic_item["discountLabel"] = traffic_discount_note + traffic_item['discount_label'] = traffic_discount_note + traffic_item['discountLabel'] = traffic_discount_note breakdown.append(traffic_item) - if details.get("total_servers_price"): + if details.get('total_servers_price'): servers_item = { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_SERVERS", - "Servers", + 'label': texts.t( + 'MINIAPP_PURCHASE_BREAKDOWN_SERVERS', + 'Servers', ), - "value": texts.format_price(details["total_servers_price"]), + 'value': texts.format_price(details['total_servers_price']), } servers_discount_note = build_discount_note( - details.get("servers_discount_total", 0), - details.get("servers_discount_percent", 0), + details.get('servers_discount_total', 0), + details.get('servers_discount_percent', 0), ) if servers_discount_note: - servers_item["discount_label"] = servers_discount_note - servers_item["discountLabel"] = servers_discount_note + servers_item['discount_label'] = servers_discount_note + servers_item['discountLabel'] = servers_discount_note breakdown.append(servers_item) - if details.get("total_devices_price"): + if details.get('total_devices_price'): devices_item = { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_DEVICES", - "Devices", + 'label': texts.t( + 'MINIAPP_PURCHASE_BREAKDOWN_DEVICES', + 'Devices', ), - "value": texts.format_price(details["total_devices_price"]), + 'value': texts.format_price(details['total_devices_price']), } devices_discount_note = build_discount_note( - details.get("devices_discount_total", 0), - details.get("devices_discount_percent", 0), + details.get('devices_discount_total', 0), + details.get('devices_discount_percent', 0), ) if devices_discount_note: - devices_item["discount_label"] = devices_discount_note - devices_item["discountLabel"] = devices_discount_note + devices_item['discount_label'] = devices_discount_note + devices_item['discountLabel'] = devices_discount_note breakdown.append(devices_item) if pricing.promo_discount_value: promo_item = { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_PROMO", - "Promo discount", + 'label': texts.t( + 'MINIAPP_PURCHASE_BREAKDOWN_PROMO', + 'Promo discount', ), - "value": f"- {texts.format_price(pricing.promo_discount_value)}", + 'value': f'- {texts.format_price(pricing.promo_discount_value)}', } if promo_discount_line: - promo_item["discount_label"] = promo_discount_line - promo_item["discountLabel"] = promo_discount_line + promo_item['discount_label'] = promo_discount_line + promo_item['discountLabel'] = promo_discount_line breakdown.append(promo_item) missing = max(0, pricing.final_total - context.balance_kopeks) - status_message = "" + status_message = '' if missing > 0: status_message = texts.t( - "MINIAPP_PURCHASE_STATUS_INSUFFICIENT", - "Not enough funds on balance", + 'MINIAPP_PURCHASE_STATUS_INSUFFICIENT', + 'Not enough funds on balance', ) per_month_price = pricing.final_total // pricing.months if pricing.months else pricing.final_total return { - "total_price_kopeks": pricing.final_total, - "totalPriceKopeks": pricing.final_total, - "total_price_label": texts.format_price(pricing.final_total), - "totalPriceLabel": texts.format_price(pricing.final_total), - "original_price_kopeks": pricing.base_original_total if total_discount else None, - "originalPriceKopeks": pricing.base_original_total if total_discount else None, - "original_price_label": texts.format_price(pricing.base_original_total) - if total_discount - else None, - "originalPriceLabel": texts.format_price(pricing.base_original_total) - if total_discount - else None, - "discount_percent": overall_discount_percent, - "discountPercent": overall_discount_percent, - "discount_label": texts.t( - "MINIAPP_PURCHASE_SUMMARY_DISCOUNT", - "You save {amount}", + 'total_price_kopeks': pricing.final_total, + 'totalPriceKopeks': pricing.final_total, + 'total_price_label': texts.format_price(pricing.final_total), + 'totalPriceLabel': texts.format_price(pricing.final_total), + 'original_price_kopeks': pricing.base_original_total if total_discount else None, + 'originalPriceKopeks': pricing.base_original_total if total_discount else None, + 'original_price_label': texts.format_price(pricing.base_original_total) if total_discount else None, + 'originalPriceLabel': texts.format_price(pricing.base_original_total) if total_discount else None, + 'discount_percent': overall_discount_percent, + 'discountPercent': overall_discount_percent, + 'discount_label': texts.t( + 'MINIAPP_PURCHASE_SUMMARY_DISCOUNT', + 'You save {amount}', ).format(amount=texts.format_price(total_discount)) if total_discount else None, - "discountLabel": texts.t( - "MINIAPP_PURCHASE_SUMMARY_DISCOUNT", - "You save {amount}", + 'discountLabel': texts.t( + 'MINIAPP_PURCHASE_SUMMARY_DISCOUNT', + 'You save {amount}', ).format(amount=texts.format_price(total_discount)) if total_discount else None, - "discount_lines": discount_lines, - "discountLines": discount_lines, - "per_month_price_kopeks": per_month_price, - "perMonthPriceKopeks": per_month_price, - "per_month_price_label": texts.format_price(per_month_price), - "perMonthPriceLabel": texts.format_price(per_month_price), - "breakdown": [ - {"label": item["label"], "value": item["value"]} - for item in breakdown - ], - "balance_kopeks": context.balance_kopeks, - "balanceKopeks": context.balance_kopeks, - "balance_label": texts.format_price(context.balance_kopeks), - "balanceLabel": texts.format_price(context.balance_kopeks), - "missing_amount_kopeks": missing, - "missingAmountKopeks": missing, - "missing_amount_label": texts.format_price(missing) if missing else None, - "missingAmountLabel": texts.format_price(missing) if missing else None, - "can_purchase": missing == 0, - "canPurchase": missing == 0, - "status_message": status_message, - "statusMessage": status_message, + 'discount_lines': discount_lines, + 'discountLines': discount_lines, + 'per_month_price_kopeks': per_month_price, + 'perMonthPriceKopeks': per_month_price, + 'per_month_price_label': texts.format_price(per_month_price), + 'perMonthPriceLabel': texts.format_price(per_month_price), + 'breakdown': [{'label': item['label'], 'value': item['value']} for item in breakdown], + 'balance_kopeks': context.balance_kopeks, + 'balanceKopeks': context.balance_kopeks, + 'balance_label': texts.format_price(context.balance_kopeks), + 'balanceLabel': texts.format_price(context.balance_kopeks), + 'missing_amount_kopeks': missing, + 'missingAmountKopeks': missing, + 'missing_amount_label': texts.format_price(missing) if missing else None, + 'missingAmountLabel': texts.format_price(missing) if missing else None, + 'can_purchase': missing == 0, + 'canPurchase': missing == 0, + 'status_message': status_message, + 'statusMessage': status_message, } async def submit_purchase( @@ -1037,22 +1014,22 @@ class MiniAppSubscriptionPurchaseService: db: AsyncSession, context: PurchaseOptionsContext, pricing: PurchasePricingResult, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: user = context.user - texts = get_texts(getattr(user, "language", None)) + texts = get_texts(getattr(user, 'language', None)) if pricing.final_total <= 0: - raise PurchaseValidationError("Invalid total amount", code="calculation_error") + raise PurchaseValidationError('Invalid total amount', code='calculation_error') if user.balance_kopeks < pricing.final_total: raise PurchaseBalanceError( texts.t( - "MINIAPP_PURCHASE_STATUS_INSUFFICIENT", - "Not enough funds on balance", + 'MINIAPP_PURCHASE_STATUS_INSUFFICIENT', + 'Not enough funds on balance', ) ) - description = f"Покупка подписки на {pricing.selection.period.days} дней" + description = f'Покупка подписки на {pricing.selection.period.days} дней' success = await subtract_user_balance( db, user, @@ -1063,27 +1040,25 @@ class MiniAppSubscriptionPurchaseService: if not success: raise PurchaseBalanceError( texts.t( - "MINIAPP_PURCHASE_STATUS_INSUFFICIENT", - "Not enough funds on balance", + 'MINIAPP_PURCHASE_STATUS_INSUFFICIENT', + 'Not enough funds on balance', ) ) await db.refresh(user) subscription = context.subscription - if subscription is not None and getattr(subscription, "id", None): + if subscription is not None and getattr(subscription, 'id', None): try: await db.refresh(subscription) except Exception as refresh_error: # pragma: no cover - defensive logging logger.warning( - "Failed to refresh existing subscription %s: %s", - getattr(subscription, "id", None), + 'Failed to refresh existing subscription %s: %s', + getattr(subscription, 'id', None), refresh_error, ) else: - result = await db.execute( - select(Subscription).where(Subscription.user_id == user.id) - ) + result = await db.execute(select(Subscription).where(Subscription.user_id == user.id)) subscription = result.scalar_one_or_none() if subscription is not None: context.subscription = subscription @@ -1105,12 +1080,12 @@ class MiniAppSubscriptionPurchaseService: db=db, user_id=user.id, trial_duration_days=trial_duration, - payment_method="balance", + payment_method='balance', first_payment_amount_kopeks=pricing.final_total, first_paid_period_days=pricing.selection.period.days, ) except Exception as conversion_error: # pragma: no cover - defensive logging - logger.error("Failed to create subscription conversion record: %s", conversion_error) + logger.error('Failed to create subscription conversion record: %s', conversion_error) subscription.is_trial = False subscription.status = SubscriptionStatus.ACTIVE.value @@ -1153,65 +1128,65 @@ class MiniAppSubscriptionPurchaseService: ) await add_user_to_servers(db, pricing.server_ids) except Exception as error: # pragma: no cover - defensive logging - logger.error("Failed to register subscription servers: %s", error) + logger.error('Failed to register subscription servers: %s', error) subscription_service = SubscriptionService() # При покупке подписки ВСЕГДА сбрасываем трафик в панели try: - if getattr(user, "remnawave_uuid", None): + if getattr(user, 'remnawave_uuid', None): await subscription_service.update_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="miniapp purchase", + reset_reason='miniapp purchase', ) else: await subscription_service.create_remnawave_user( db, subscription, reset_traffic=True, - reset_reason="miniapp purchase", + reset_reason='miniapp purchase', ) except Exception as remnawave_error: # pragma: no cover - defensive logging - logger.error("Failed to sync subscription with RemnaWave: %s", remnawave_error) + logger.error('Failed to sync subscription with RemnaWave: %s', remnawave_error) transaction = await create_transaction( db=db, user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=pricing.final_total, - description=f"Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)", + description=f'Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)', ) await db.refresh(user) await db.refresh(subscription) message = texts.t( - "SUBSCRIPTION_PURCHASED", - "🎉 Subscription purchased successfully!", + 'SUBSCRIPTION_PURCHASED', + '🎉 Subscription purchased successfully!', ) if pricing.promo_discount_value: note = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", - "⚡ Extra discount {percent}%: -{amount}", + 'SUBSCRIPTION_PROMO_DISCOUNT_NOTE', + '⚡ Extra discount {percent}%: -{amount}', ).format( percent=pricing.promo_discount_percent, amount=texts.format_price(pricing.promo_discount_value), ) - message = f"{message}\n\n{note}" + message = f'{message}\n\n{note}' return { - "subscription": subscription, - "transaction": transaction, - "was_trial_conversion": was_trial_conversion, - "message": message, + 'subscription': subscription, + 'transaction': transaction, + 'was_trial_conversion': was_trial_conversion, + 'message': message, } class SubscriptionPurchaseService: """Service for handling simple subscription purchases with predefined parameters.""" - + async def create_subscription_order( self, db: AsyncSession, @@ -1221,12 +1196,11 @@ class SubscriptionPurchaseService: traffic_limit_gb: int, squad_uuid: str, payment_method: str, - total_price_kopeks: int + total_price_kopeks: int, ): """Creates a subscription order with predefined parameters.""" from app.database.crud.subscription import create_pending_subscription - from app.database.models import SubscriptionStatus - + # Create a pending subscription subscription = await create_pending_subscription( db=db, @@ -1236,9 +1210,9 @@ class SubscriptionPurchaseService: device_limit=device_limit, connected_squads=[squad_uuid] if squad_uuid else [], payment_method=payment_method, - total_price_kopeks=total_price_kopeks + total_price_kopeks=total_price_kopeks, ) - + return subscription diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index bea47cb5..7a79333b 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -3,9 +3,10 @@ from __future__ import annotations import base64 import json import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime -from typing import Any, Awaitable, Callable, Dict, List, Optional +from typing import Any from uuid import uuid4 from aiogram import Bot @@ -31,6 +32,7 @@ from app.utils.pricing_utils import ( validate_pricing_calculation, ) + logger = logging.getLogger(__name__) @@ -54,50 +56,50 @@ class SubscriptionRenewalPricing: promo_discount_percent: int overall_discount_percent: int per_month: int - server_ids: List[int] - details: Dict[str, Any] + server_ids: list[int] + details: dict[str, Any] - def to_payload(self) -> Dict[str, Any]: + def to_payload(self) -> dict[str, Any]: return { - "period_id": self.period_id, - "period_days": self.period_days, - "months": self.months, - "base_original_total": self.base_original_total, - "discounted_total": self.discounted_total, - "final_total": self.final_total, - "promo_discount_value": self.promo_discount_value, - "promo_discount_percent": self.promo_discount_percent, - "overall_discount_percent": self.overall_discount_percent, - "per_month": self.per_month, - "server_ids": list(self.server_ids), - "details": dict(self.details), + 'period_id': self.period_id, + 'period_days': self.period_days, + 'months': self.months, + 'base_original_total': self.base_original_total, + 'discounted_total': self.discounted_total, + 'final_total': self.final_total, + 'promo_discount_value': self.promo_discount_value, + 'promo_discount_percent': self.promo_discount_percent, + 'overall_discount_percent': self.overall_discount_percent, + 'per_month': self.per_month, + 'server_ids': list(self.server_ids), + 'details': dict(self.details), } @classmethod - def from_payload(cls, payload: Dict[str, Any]) -> "SubscriptionRenewalPricing": + def from_payload(cls, payload: dict[str, Any]) -> SubscriptionRenewalPricing: return cls( - period_days=int(payload.get("period_days", 0) or 0), - period_id=str(payload.get("period_id") or build_renewal_period_id(int(payload.get("period_days", 0) or 0))), - months=int(payload.get("months", 0) or 0), - base_original_total=int(payload.get("base_original_total", 0) or 0), - discounted_total=int(payload.get("discounted_total", 0) or 0), - final_total=int(payload.get("final_total", 0) or 0), - promo_discount_value=int(payload.get("promo_discount_value", 0) or 0), - promo_discount_percent=int(payload.get("promo_discount_percent", 0) or 0), - overall_discount_percent=int(payload.get("overall_discount_percent", 0) or 0), - per_month=int(payload.get("per_month", 0) or 0), - server_ids=list(payload.get("server_ids", []) or []), - details=dict(payload.get("details", {}) or {}), + period_days=int(payload.get('period_days', 0) or 0), + period_id=str(payload.get('period_id') or build_renewal_period_id(int(payload.get('period_days', 0) or 0))), + months=int(payload.get('months', 0) or 0), + base_original_total=int(payload.get('base_original_total', 0) or 0), + discounted_total=int(payload.get('discounted_total', 0) or 0), + final_total=int(payload.get('final_total', 0) or 0), + promo_discount_value=int(payload.get('promo_discount_value', 0) or 0), + promo_discount_percent=int(payload.get('promo_discount_percent', 0) or 0), + overall_discount_percent=int(payload.get('overall_discount_percent', 0) or 0), + per_month=int(payload.get('per_month', 0) or 0), + server_ids=list(payload.get('server_ids', []) or []), + details=dict(payload.get('details', {}) or {}), ) @dataclass(slots=True) class SubscriptionRenewalResult: subscription: Subscription - transaction: Optional[Transaction] + transaction: Transaction | None total_amount_kopeks: int charged_from_balance_kopeks: int - old_end_date: Optional[datetime] + old_end_date: datetime | None @dataclass(slots=True) @@ -108,7 +110,7 @@ class RenewalPaymentDescriptor: total_amount_kopeks: int missing_amount_kopeks: int payload_id: str - pricing_snapshot: Optional[Dict[str, Any]] = None + pricing_snapshot: dict[str, Any] | None = None @property def balance_component_kopeks(self) -> int: @@ -116,11 +118,11 @@ class RenewalPaymentDescriptor: return max(0, remaining) -_PAYLOAD_PREFIX = "subscription_renewal" +_PAYLOAD_PREFIX = 'subscription_renewal' def build_renewal_period_id(period_days: int) -> str: - return f"days:{period_days}" + return f'days:{period_days}' def build_payment_descriptor( @@ -130,7 +132,7 @@ def build_payment_descriptor( total_amount_kopeks: int, missing_amount_kopeks: int, *, - pricing_snapshot: Optional[Dict[str, Any]] = None, + pricing_snapshot: dict[str, Any] | None = None, ) -> RenewalPaymentDescriptor: return RenewalPaymentDescriptor( user_id=user_id, @@ -144,35 +146,35 @@ def build_payment_descriptor( def encode_payment_payload(descriptor: RenewalPaymentDescriptor) -> str: - snapshot_segment = "" + snapshot_segment = '' if descriptor.pricing_snapshot: try: raw_snapshot = json.dumps( descriptor.pricing_snapshot, - separators=(",", ":"), + separators=(',', ':'), ensure_ascii=False, - ).encode("utf-8") - snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode("ascii").rstrip("=") + ).encode('utf-8') + snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode('ascii').rstrip('=') except (TypeError, ValueError): - snapshot_segment = "" + snapshot_segment = '' payload = ( - f"{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|" - f"{descriptor.period_days}|{descriptor.total_amount_kopeks}|" - f"{descriptor.missing_amount_kopeks}|{descriptor.payload_id}" + f'{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|' + f'{descriptor.period_days}|{descriptor.total_amount_kopeks}|' + f'{descriptor.missing_amount_kopeks}|{descriptor.payload_id}' ) if snapshot_segment: - payload = f"{payload}|{snapshot_segment}" + payload = f'{payload}|{snapshot_segment}' return payload -def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) -> Optional[RenewalPaymentDescriptor]: - if not payload or not payload.startswith(f"{_PAYLOAD_PREFIX}|"): +def decode_payment_payload(payload: str, expected_user_id: int | None = None) -> RenewalPaymentDescriptor | None: + if not payload or not payload.startswith(f'{_PAYLOAD_PREFIX}|'): return None - parts = payload.split("|") + parts = payload.split('|') if len(parts) < 7: return None @@ -195,18 +197,18 @@ def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) except (TypeError, ValueError): return None - pricing_snapshot: Optional[Dict[str, Any]] = None + pricing_snapshot: dict[str, Any] | None = None if snapshot_parts: encoded_snapshot = snapshot_parts[0] if encoded_snapshot: - padding = "=" * (-len(encoded_snapshot) % 4) + padding = '=' * (-len(encoded_snapshot) % 4) try: - decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode("ascii")) - snapshot_data = json.loads(decoded.decode("utf-8")) + decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode('ascii')) + snapshot_data = json.loads(decoded.decode('utf-8')) if isinstance(snapshot_data, dict): pricing_snapshot = snapshot_data except (ValueError, json.JSONDecodeError, UnicodeDecodeError): - logger.warning("Failed to decode renewal pricing snapshot from payload") + logger.warning('Failed to decode renewal pricing snapshot from payload') if expected_user_id is not None and user_id != expected_user_id: return None @@ -222,39 +224,39 @@ def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) ) -def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> Dict[str, Any]: +def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> dict[str, Any]: return { - "payment_purpose": _PAYLOAD_PREFIX, - "subscription_id": str(descriptor.subscription_id), - "period_days": str(descriptor.period_days), - "total_amount_kopeks": str(descriptor.total_amount_kopeks), - "missing_amount_kopeks": str(descriptor.missing_amount_kopeks), - "payload_id": descriptor.payload_id, - "pricing_snapshot": descriptor.pricing_snapshot or {}, + 'payment_purpose': _PAYLOAD_PREFIX, + 'subscription_id': str(descriptor.subscription_id), + 'period_days': str(descriptor.period_days), + 'total_amount_kopeks': str(descriptor.total_amount_kopeks), + 'missing_amount_kopeks': str(descriptor.missing_amount_kopeks), + 'payload_id': descriptor.payload_id, + 'pricing_snapshot': descriptor.pricing_snapshot or {}, } def parse_payment_metadata( - metadata: Optional[Dict[str, Any]], + metadata: dict[str, Any] | None, *, - expected_user_id: Optional[int] = None, -) -> Optional[RenewalPaymentDescriptor]: + expected_user_id: int | None = None, +) -> RenewalPaymentDescriptor | None: if not metadata: return None - if metadata.get("payment_purpose") != _PAYLOAD_PREFIX: + if metadata.get('payment_purpose') != _PAYLOAD_PREFIX: return None try: - subscription_id = int(metadata.get("subscription_id")) - period_days = int(metadata.get("period_days")) - total_amount = int(metadata.get("total_amount_kopeks")) - missing_amount = int(metadata.get("missing_amount_kopeks")) + subscription_id = int(metadata.get('subscription_id')) + period_days = int(metadata.get('period_days')) + total_amount = int(metadata.get('total_amount_kopeks')) + missing_amount = int(metadata.get('missing_amount_kopeks')) except (TypeError, ValueError): return None - payload_id = str(metadata.get("payload_id") or "") - user_id = metadata.get("user_id") + payload_id = str(metadata.get('payload_id') or '') + user_id = metadata.get('user_id') if user_id is not None: try: user_id_int = int(user_id) @@ -266,7 +268,7 @@ def parse_payment_metadata( if expected_user_id is not None and user_id_int is not None and user_id_int != expected_user_id: return None - pricing_snapshot = metadata.get("pricing_snapshot") + pricing_snapshot = metadata.get('pricing_snapshot') if isinstance(pricing_snapshot, dict): snapshot_dict = pricing_snapshot else: @@ -286,10 +288,10 @@ def parse_payment_metadata( async def with_admin_notification_service( handler: Callable[[AdminNotificationService], Awaitable[Any]], ) -> None: - if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False): + if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False): return if not settings.BOT_TOKEN: - logger.debug("Skipping admin notification: bot token is not configured") + logger.debug('Skipping admin notification: bot token is not configured') return bot: Bot | None = None @@ -298,7 +300,7 @@ async def with_admin_notification_service( service = AdminNotificationService(bot) await handler(service) except Exception as error: # pragma: no cover - defensive logging - logger.error("Failed to send admin notification from renewal service: %s", error) + logger.error('Failed to send admin notification from renewal service: %s', error) finally: if bot is not None: await bot.session.close() @@ -315,7 +317,7 @@ class SubscriptionRenewalService: period_days: int, ) -> SubscriptionRenewalPricing: connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])] - server_ids: List[int] = [] + server_ids: list[int] = [] if connected_uuids: server_ids = await get_server_ids_by_uuids(db, connected_uuids) @@ -348,13 +350,13 @@ class SubscriptionRenewalService: user=user, ) - months = details.get("months_in_period") or calculate_months_from_days(period_days) + months = details.get('months_in_period') or calculate_months_from_days(period_days) base_original_total = ( - details.get("base_price_original", 0) - + details.get("traffic_price_per_month", 0) * months - + details.get("servers_price_per_month", 0) * months - + details.get("devices_price_per_month", 0) * months + details.get('base_price_original', 0) + + details.get('traffic_price_per_month', 0) * months + + details.get('servers_price_per_month', 0) * months + + details.get('devices_price_per_month', 0) * months ) discounted_total = total_cost @@ -362,19 +364,19 @@ class SubscriptionRenewalService: monthly_additions = 0 if months > 0: monthly_additions = ( - details.get("total_servers_price", 0) // months - + details.get("total_devices_price", 0) // months - + details.get("total_traffic_price", 0) // months + details.get('total_servers_price', 0) // months + + details.get('total_devices_price', 0) // months + + details.get('total_traffic_price', 0) // months ) if not validate_pricing_calculation( - details.get("base_price", 0), + details.get('base_price', 0), monthly_additions, months, discounted_total, ): logger.warning( - "Renewal pricing validation failed for subscription %s (period %s)", + 'Renewal pricing validation failed for subscription %s (period %s)', subscription.id, period_days, ) @@ -394,9 +396,7 @@ class SubscriptionRenewalService: overall_discount_value = max(0, base_original_total - final_total) overall_discount_percent = 0 if base_original_total > 0 and overall_discount_value > 0: - overall_discount_percent = int( - round(overall_discount_value * 100 / base_original_total) - ) + overall_discount_percent = int(round(overall_discount_value * 100 / base_original_total)) per_month = final_total // months if months else final_total @@ -422,13 +422,12 @@ class SubscriptionRenewalService: subscription: Subscription, pricing: SubscriptionRenewalPricing, *, - charge_balance_amount: Optional[int] = None, - description: Optional[str] = None, - payment_method: Optional[PaymentMethod] = None, + charge_balance_amount: int | None = None, + description: str | None = None, + payment_method: PaymentMethod | None = None, ) -> SubscriptionRenewalResult: final_total = int(pricing.final_total) - if final_total < 0: - final_total = 0 + final_total = max(final_total, 0) period_days = int(pricing.period_days) charge_from_balance = charge_balance_amount @@ -438,7 +437,7 @@ class SubscriptionRenewalService: consume_promo_offer = bool(pricing.promo_discount_value) - description_text = description or f"Продление подписки на {period_days} дней" + description_text = description or f'Продление подписки на {period_days} дней' if charge_from_balance > 0 or consume_promo_offer: success = await subtract_user_balance( @@ -449,7 +448,7 @@ class SubscriptionRenewalService: consume_promo_offer=consume_promo_offer, ) if not success: - raise SubscriptionRenewalChargeError("Failed to charge balance") + raise SubscriptionRenewalChargeError('Failed to charge balance') await db.refresh(user) subscription_before = subscription @@ -458,7 +457,7 @@ class SubscriptionRenewalService: subscription_after = await extend_subscription(db, subscription_before, period_days) server_ids = pricing.server_ids or [] - server_prices_for_period = pricing.details.get("servers_individual_prices", []) + server_prices_for_period = pricing.details.get('servers_individual_prices', []) if server_ids: try: await add_subscription_servers( @@ -469,7 +468,7 @@ class SubscriptionRenewalService: ) except Exception as error: # pragma: no cover - defensive logging logger.warning( - "Failed to record renewal server prices for subscription %s: %s", + 'Failed to record renewal server prices for subscription %s: %s', subscription_after.id, error, ) @@ -480,18 +479,18 @@ class SubscriptionRenewalService: db, subscription_after, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="subscription renewal", + reset_reason='subscription renewal', ) except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues - logger.warning("RemnaWave update skipped: %s", error) + logger.warning('RemnaWave update skipped: %s', error) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Failed to update RemnaWave user for subscription %s: %s", + 'Failed to update RemnaWave user for subscription %s: %s', subscription_after.id, error, ) - transaction: Optional[Transaction] = None + transaction: Transaction | None = None try: transaction = await create_transaction( db=db, @@ -503,7 +502,7 @@ class SubscriptionRenewalService: ) except Exception as error: # pragma: no cover - defensive logging logger.warning( - "Failed to create renewal transaction for subscription %s: %s", + 'Failed to create renewal transaction for subscription %s: %s', subscription_after.id, error, ) @@ -537,7 +536,7 @@ class SubscriptionRenewalService: self, db: AsyncSession, user: User, - server_uuids: List[str], + server_uuids: list[str], ) -> None: """ Проверяет, что все серверы подписки доступны для промогруппы пользователя. @@ -547,7 +546,7 @@ class SubscriptionRenewalService: return try: - await db.refresh(user, ["user_promo_groups", "promo_group"]) + await db.refresh(user, ['user_promo_groups', 'promo_group']) except Exception: pass @@ -565,12 +564,12 @@ class SubscriptionRenewalService: unavailable_servers.append(server.display_name or server.squad_uuid) if unavailable_servers: - user_identifier = user.telegram_id or user.email or f"user#{user.id}" + user_identifier = user.telegram_id or user.email or f'user#{user.id}' logger.warning( - f"⚠️ Пользователь {user_identifier} (promo_group={user_promo_group.name}) " - f"продлевает подписку с серверами, недоступными для его промогруппы: " - f"{', '.join(unavailable_servers)}. " - f"Это может привести к неправильному расчёту цены!" + f'⚠️ Пользователь {user_identifier} (promo_group={user_promo_group.name}) ' + f'продлевает подписку с серверами, недоступными для его промогруппы: ' + f'{", ".join(unavailable_servers)}. ' + f'Это может привести к неправильному расчёту цены!' ) def build_option_payload( @@ -578,30 +577,27 @@ class SubscriptionRenewalService: pricing: SubscriptionRenewalPricing, *, language: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: label = format_period_description(pricing.period_days, language) price_label = settings.format_price(pricing.final_total) original_label = None - if ( - pricing.base_original_total - and pricing.base_original_total != pricing.final_total - ): + if pricing.base_original_total and pricing.base_original_total != pricing.final_total: original_label = settings.format_price(pricing.base_original_total) per_month_label = settings.format_price(pricing.per_month) payload = { - "id": pricing.period_id, - "days": pricing.period_days, - "months": pricing.months, - "price_kopeks": pricing.final_total, - "price_label": price_label, - "original_price_kopeks": pricing.base_original_total, - "original_price_label": original_label, - "discount_percent": pricing.overall_discount_percent, - "price_per_month_kopeks": pricing.per_month, - "price_per_month_label": per_month_label, - "title": label, + 'id': pricing.period_id, + 'days': pricing.period_days, + 'months': pricing.months, + 'price_kopeks': pricing.final_total, + 'price_label': price_label, + 'original_price_kopeks': pricing.base_original_total, + 'original_price_label': original_label, + 'discount_percent': pricing.overall_discount_percent, + 'price_per_month_kopeks': pricing.per_month, + 'price_per_month_label': per_month_label, + 'title': label, } return payload @@ -613,4 +609,3 @@ def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int: if balance_kopeks <= 0: return total_kopeks return max(0, total_kopeks - min(balance_kopeks, total_kopeks)) - diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 13747ebc..a968cb6b 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -1,35 +1,31 @@ import logging from contextlib import asynccontextmanager -from datetime import datetime, timedelta -from typing import Optional, List, Tuple +from datetime import datetime + from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup -from app.external.remnawave_api import ( - RemnaWaveAPI, RemnaWaveUser, UserStatus, - TrafficLimitStrategy, RemnaWaveAPIError -) from app.database.crud.user import get_user_by_id +from app.database.models import PromoGroup, Subscription, SubscriptionStatus, User +from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus from app.utils.pricing_utils import ( calculate_months_from_days, get_remaining_months, - calculate_prorated_price, - validate_pricing_calculation ) from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) + logger = logging.getLogger(__name__) def _resolve_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], + user: User | None, + promo_group: PromoGroup | None, category: str, *, - period_days: Optional[int] = None, + period_days: int | None = None, ) -> int: if user is not None: try: @@ -44,15 +40,15 @@ def _resolve_discount_percent( def _resolve_addon_discount_percent( - user: Optional[User], - promo_group: Optional[PromoGroup], + user: User | None, + promo_group: PromoGroup | None, category: str, *, - period_days: Optional[int] = None, + period_days: int | None = None, ) -> int: group = promo_group or (user.get_primary_promo_group() if user else None) - if group is not None and not getattr(group, "apply_discounts_to_addons", True): + if group is not None and not getattr(group, 'apply_discounts_to_addons', True): return 0 return _resolve_discount_percent( @@ -62,6 +58,7 @@ def _resolve_addon_discount_percent( period_days=period_days, ) + def get_traffic_reset_strategy(tariff=None): """Получает стратегию сброса трафика. @@ -74,54 +71,50 @@ def get_traffic_reset_strategy(tariff=None): """ from app.config import settings - strategy_mapping = { - 'NO_RESET': 'NO_RESET', - 'DAY': 'DAY', - 'WEEK': 'WEEK', - 'MONTH': 'MONTH' - } + strategy_mapping = {'NO_RESET': 'NO_RESET', 'DAY': 'DAY', 'WEEK': 'WEEK', 'MONTH': 'MONTH'} # Проверяем настройку тарифа if tariff is not None: tariff_mode = getattr(tariff, 'traffic_reset_mode', None) if tariff_mode is not None: mapped_strategy = strategy_mapping.get(tariff_mode.upper(), 'NO_RESET') - logger.info(f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}") + logger.info( + f"🔄 Стратегия сброса трафика из тарифа '{getattr(tariff, 'name', 'N/A')}': {tariff_mode} -> {mapped_strategy}" + ) return getattr(TrafficLimitStrategy, mapped_strategy) # Используем глобальную настройку strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper() mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET') - logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}") + logger.info(f'🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}') return getattr(TrafficLimitStrategy, mapped_strategy) class SubscriptionService: - def __init__(self): - self._config_error: Optional[str] = None - self.api: Optional[RemnaWaveAPI] = None - self._last_config_signature: Optional[Tuple[str, ...]] = None + self._config_error: str | None = None + self.api: RemnaWaveAPI | None = None + self._last_config_signature: tuple[str, ...] | None = None self._refresh_configuration() def _refresh_configuration(self) -> None: auth_params = settings.get_remnawave_auth_params() - base_url = (auth_params.get("base_url") or "").strip() - api_key = (auth_params.get("api_key") or "").strip() - secret_key = (auth_params.get("secret_key") or "").strip() or None - username = (auth_params.get("username") or "").strip() or None - password = (auth_params.get("password") or "").strip() or None - caddy_token = (auth_params.get("caddy_token") or "").strip() or None - auth_type = (auth_params.get("auth_type") or "api_key").strip() + base_url = (auth_params.get('base_url') or '').strip() + api_key = (auth_params.get('api_key') or '').strip() + secret_key = (auth_params.get('secret_key') or '').strip() or None + username = (auth_params.get('username') or '').strip() or None + password = (auth_params.get('password') or '').strip() or None + caddy_token = (auth_params.get('caddy_token') or '').strip() or None + auth_type = (auth_params.get('auth_type') or 'api_key').strip() config_signature = ( base_url, api_key, - secret_key or "", - username or "", - password or "", - caddy_token or "", + secret_key or '', + username or '', + password or '', + caddy_token or '', auth_type, ) @@ -129,10 +122,10 @@ class SubscriptionService: return if not base_url: - self._config_error = "REMNAWAVE_API_URL не настроен" + self._config_error = 'REMNAWAVE_API_URL не настроен' self.api = None elif not api_key: - self._config_error = "REMNAWAVE_API_KEY не настроен" + self._config_error = 'REMNAWAVE_API_KEY не настроен' self.api = None else: self._config_error = None @@ -148,15 +141,14 @@ class SubscriptionService: if self._config_error: logger.warning( - "RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.", - self._config_error + 'RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.', self._config_error ) self._last_config_signature = config_signature @staticmethod - def _resolve_user_tag(subscription: Subscription) -> Optional[str]: - if getattr(subscription, "is_trial", False): + def _resolve_user_tag(subscription: Subscription) -> str | None: + if getattr(subscription, 'is_trial', False): return settings.get_trial_user_tag() return settings.get_paid_subscription_user_tag() @@ -166,15 +158,13 @@ class SubscriptionService: return self._config_error is None @property - def configuration_error(self) -> Optional[str]: + def configuration_error(self) -> str | None: return self._config_error def _ensure_configured(self) -> None: self._refresh_configuration() if not self.api or not self.is_configured: - raise RemnaWaveAPIError( - self._config_error or "RemnaWave API не настроен" - ) + raise RemnaWaveAPIError(self._config_error or 'RemnaWave API не настроен') @asynccontextmanager async def get_api_client(self): @@ -182,25 +172,24 @@ class SubscriptionService: assert self.api is not None async with self.api as api: yield api - + async def create_remnawave_user( self, db: AsyncSession, subscription: Subscription, *, reset_traffic: bool = False, - reset_reason: Optional[str] = None, - ) -> Optional[RemnaWaveUser]: - + reset_reason: str | None = None, + ) -> RemnaWaveUser | None: try: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.error(f"Пользователь {subscription.user_id} не найден") + logger.error(f'Пользователь {subscription.user_id} не найден') return None - + validation_success = await self.validate_and_clean_subscription(db, subscription, user) if not validation_success: - logger.error(f"Ошибка валидации подписки для пользователя {self._format_user_log(user)}") + logger.error(f'Ошибка валидации подписки для пользователя {self._format_user_log(user)}') return None user_tag = self._resolve_user_tag(subscription) @@ -222,14 +211,14 @@ class SubscriptionService: pass if existing_users: - logger.info(f"🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}") + logger.info(f'🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}') remnawave_user = existing_users[0] try: await api.reset_user_devices(remnawave_user.uuid) - logger.info(f"🔧 Сброшены HWID устройства для {self._format_user_log(user)}") + logger.info(f'🔧 Сброшены HWID устройства для {self._format_user_log(user)}') except Exception as hwid_error: - logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}") + logger.warning(f'⚠️ Не удалось сбросить HWID: {hwid_error}') update_kwargs = dict( uuid=remnawave_user.uuid, @@ -242,7 +231,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id, email=user.email, - user_id=user.id + user_id=user.id, ), active_internal_squads=subscription.connected_squads, ) @@ -264,7 +253,7 @@ class SubscriptionService: ) else: - logger.info(f"🆕 Создаем нового пользователя в панели для {self._format_user_log(user)}") + logger.info(f'🆕 Создаем нового пользователя в панели для {self._format_user_log(user)}') username = settings.format_remnawave_username( full_name=user.full_name, username=user.username, @@ -284,7 +273,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id, email=user.email, - user_id=user.id + user_id=user.id, ), active_internal_squads=subscription.connected_squads, ) @@ -312,53 +301,52 @@ class SubscriptionService: await db.commit() - logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}") - logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}") + logger.info(f'✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}') + logger.info(f'🔗 Ссылка на подписку: {updated_user.subscription_url}') strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY - logger.info(f"📊 Стратегия сброса трафика: {strategy_name}") + logger.info(f'📊 Стратегия сброса трафика: {strategy_name}') return updated_user - + except RemnaWaveAPIError as e: - logger.error(f"Ошибка RemnaWave API: {e}") + logger.error(f'Ошибка RemnaWave API: {e}') return None except Exception as e: - logger.error(f"Ошибка создания RemnaWave пользователя: {e}") + logger.error(f'Ошибка создания RemnaWave пользователя: {e}') return None - + async def update_remnawave_user( self, db: AsyncSession, subscription: Subscription, *, reset_traffic: bool = False, - reset_reason: Optional[str] = None, - ) -> Optional[RemnaWaveUser]: - + reset_reason: str | None = None, + ) -> RemnaWaveUser | None: try: user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: - logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}") + logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}') return None # Загружаем tariff заранее, чтобы избежать lazy loading в async контексте try: - await db.refresh(subscription, ["tariff"]) + await db.refresh(subscription, ['tariff']) except Exception: pass # tariff может быть None или уже загружен current_time = datetime.utcnow() # Определяем актуальный статус для отправки в RemnaWave # НЕ меняем статус подписки здесь - это задача scheduled job - is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date > current_time) + is_actually_active = ( + subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time + ) # Логируем если статус и end_date не согласованы (для отладки) - if (subscription.status == SubscriptionStatus.ACTIVE.value and - subscription.end_date <= current_time): + if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time: logger.warning( - f"⚠️ update_remnawave_user: подписка {subscription.id} имеет статус ACTIVE, " - f"но end_date ({subscription.end_date}) <= now ({current_time}). " - f"Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД." + f'⚠️ update_remnawave_user: подписка {subscription.id} имеет статус ACTIVE, ' + f'но end_date ({subscription.end_date}) <= now ({current_time}). ' + f'Отправляем в RemnaWave как EXPIRED, но НЕ меняем статус в БД.' ) user_tag = self._resolve_user_tag(subscription) @@ -377,7 +365,7 @@ class SubscriptionService: username=user.username, telegram_id=user.telegram_id, email=user.email, - user_id=user.id + user_id=user.id, ), active_internal_squads=subscription.connected_squads, ) @@ -389,7 +377,7 @@ class SubscriptionService: update_kwargs['hwid_device_limit'] = hwid_limit updated_user = await api.update_user(**update_kwargs) - + if reset_traffic: await self._reset_user_traffic( api, @@ -401,60 +389,55 @@ class SubscriptionService: 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 "истёкшим" - logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}") + + status_text = 'активным' if is_actually_active else 'истёкшим' + logger.info(f'✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}') strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY - logger.info(f"📊 Стратегия сброса трафика: {strategy_name}") + logger.info(f'📊 Стратегия сброса трафика: {strategy_name}') return updated_user except RemnaWaveAPIError as e: - logger.error(f"Ошибка RemnaWave API: {e}") + logger.error(f'Ошибка RemnaWave API: {e}') return None except Exception as e: - logger.error(f"Ошибка обновления RemnaWave пользователя: {e}") + logger.error(f'Ошибка обновления RemnaWave пользователя: {e}') return None @staticmethod def _format_user_log(user) -> str: """Форматирует идентификатор пользователя для логов.""" if user.telegram_id: - return f"user {user.telegram_id}" + return f'user {user.telegram_id}' if user.email: - return f"user {user.id} ({user.email})" - return f"user {user.id}" + return f'user {user.id} ({user.email})' + return f'user {user.id}' async def _reset_user_traffic( self, api: RemnaWaveAPI, user_uuid: str, user, # User object вместо telegram_id - reset_reason: Optional[str] = None, + reset_reason: str | None = 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 для {self._format_user_log(user)}{reason_text}" - ) + reason_text = f' ({reset_reason})' if reset_reason else '' + logger.info(f'🔄 Сброшен трафик RemnaWave для {self._format_user_log(user)}{reason_text}') except Exception as exc: - logger.warning( - f"⚠️ Не удалось сбросить трафик RemnaWave для {self._format_user_log(user)}: {exc}" - ) + logger.warning(f'⚠️ Не удалось сбросить трафик RemnaWave для {self._format_user_log(user)}: {exc}') async def disable_remnawave_user(self, user_uuid: str) -> bool: - try: async with self.get_api_client() as api: await api.disable_user(user_uuid) - logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}") + logger.info(f'✅ Отключен RemnaWave пользователь {user_uuid}') return True except Exception as e: - logger.error(f"Ошибка отключения RemnaWave пользователя: {e}") + logger.error(f'Ошибка отключения RemnaWave пользователя: {e}') return False async def enable_remnawave_user(self, user_uuid: str) -> bool: @@ -462,14 +445,14 @@ class SubscriptionService: try: async with self.get_api_client() as api: await api.enable_user(user_uuid) - logger.info(f"✅ Включен RemnaWave пользователь {user_uuid}") + logger.info(f'✅ Включен RemnaWave пользователь {user_uuid}') return True except Exception as e: - logger.error(f"Ошибка включения RemnaWave пользователя: {e}") + logger.error(f'Ошибка включения RemnaWave пользователя: {e}') return False - async def get_remnawave_squads(self) -> Optional[List[dict]]: + async def get_remnawave_squads(self) -> list[dict] | None: """Получить список internal squads из RemnaWave.""" try: async with self.get_api_client() as api: @@ -477,87 +460,78 @@ class SubscriptionService: # Преобразуем в формат для sync_with_remnawave result = [] for squad in squads: - result.append({ - 'uuid': squad.uuid, - 'name': squad.name, - }) - logger.info(f"✅ Получено {len(result)} серверов из RemnaWave") + result.append( + { + 'uuid': squad.uuid, + 'name': squad.name, + } + ) + logger.info(f'✅ Получено {len(result)} серверов из RemnaWave') return result except Exception as e: - logger.error(f"Ошибка получения серверов из RemnaWave: {e}") + logger.error(f'Ошибка получения серверов из RemnaWave: {e}') return None - async def revoke_subscription( - self, - db: AsyncSession, - subscription: Subscription - ) -> Optional[str]: - + async def revoke_subscription(self, db: AsyncSession, subscription: Subscription) -> str | None: try: user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: return None - + async with self.get_api_client() as api: updated_user = await api.revoke_user_subscription(user.remnawave_uuid) - + 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"✅ Обновлена ссылка подписки для {self._format_user_log(user)}") + + logger.info(f'✅ Обновлена ссылка подписки для {self._format_user_log(user)}') return updated_user.subscription_url - + except Exception as e: - logger.error(f"Ошибка обновления ссылки подписки: {e}") + logger.error(f'Ошибка обновления ссылки подписки: {e}') return None - - async def get_subscription_info(self, short_uuid: str) -> Optional[dict]: - + + async def get_subscription_info(self, short_uuid: str) -> dict | None: try: async with self.get_api_client() as api: info = await api.get_subscription_info(short_uuid) return info - + except Exception as e: - logger.error(f"Ошибка получения информации о подписке: {e}") + logger.error(f'Ошибка получения информации о подписке: {e}') return None - - async def sync_subscription_usage( - self, - db: AsyncSession, - subscription: Subscription - ) -> bool: - + + async def sync_subscription_usage(self, db: AsyncSession, subscription: Subscription) -> bool: try: user = await get_user_by_id(db, subscription.user_id) if not user or not user.remnawave_uuid: return False - + async with self.get_api_client() as api: remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) if not remnawave_user: return False - + used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes) subscription.traffic_used_gb = used_gb - + await db.commit() - - logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ") + + logger.debug(f'Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ') return True - + except Exception as e: - logger.error(f"Ошибка синхронизации трафика: {e}") + logger.error(f'Ошибка синхронизации трафика: {e}') return False async def ensure_subscription_synced( self, db: AsyncSession, subscription: Subscription, - ) -> Tuple[bool, Optional[str]]: + ) -> tuple[bool, str | None]: """ Проверяет и синхронизирует подписку с RemnaWave при необходимости. @@ -570,14 +544,11 @@ class SubscriptionService: try: user = await get_user_by_id(db, subscription.user_id) if not user: - logger.error(f"Пользователь не найден для подписки {subscription.id}") - return False, "user_not_found" + logger.error(f'Пользователь не найден для подписки {subscription.id}') + return False, 'user_not_found' # Проверяем, нужна ли синхронизация - needs_sync = ( - not subscription.subscription_url - or not user.remnawave_uuid - ) + needs_sync = not subscription.subscription_url or not user.remnawave_uuid if not needs_sync: # Проверяем, существует ли пользователь в RemnaWave @@ -587,19 +558,19 @@ class SubscriptionService: if not remnawave_user: needs_sync = True logger.warning( - f"Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация" + f'Пользователь {user.remnawave_uuid} не найден в RemnaWave, требуется синхронизация' ) except Exception as check_error: - logger.warning(f"Не удалось проверить пользователя в RemnaWave: {check_error}") + logger.warning(f'Не удалось проверить пользователя в RemnaWave: {check_error}') # Продолжаем, возможно проблема временная if not needs_sync: return True, None logger.info( - f"Синхронизация подписки {subscription.id} с RemnaWave " - f"(subscription_url={bool(subscription.subscription_url)}, " - f"remnawave_uuid={bool(user.remnawave_uuid)})" + f'Синхронизация подписки {subscription.id} с RemnaWave ' + f'(subscription_url={bool(subscription.subscription_url)}, ' + f'remnawave_uuid={bool(user.remnawave_uuid)})' ) # Пытаемся синхронизировать @@ -614,8 +585,7 @@ class SubscriptionService: # Если update не удался (пользователь удалён из RemnaWave) — пробуем создать if not result: logger.warning( - f"Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, " - f"пробуем создать заново" + f'Не удалось обновить пользователя {user.remnawave_uuid} в RemnaWave, пробуем создать заново' ) # Сбрасываем старый UUID, create_remnawave_user установит новый user.remnawave_uuid = None @@ -636,56 +606,54 @@ class SubscriptionService: await db.refresh(subscription) await db.refresh(user) logger.info( - f"Подписка {subscription.id} успешно синхронизирована с RemnaWave. " - f"URL: {subscription.subscription_url}" + f'Подписка {subscription.id} успешно синхронизирована с RemnaWave. ' + f'URL: {subscription.subscription_url}' ) return True, None - else: - logger.error(f"Не удалось синхронизировать подписку {subscription.id} с RemnaWave") - return False, "sync_failed" + logger.error(f'Не удалось синхронизировать подписку {subscription.id} с RemnaWave') + return False, 'sync_failed' except RemnaWaveAPIError as api_error: - logger.error(f"Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}") - return False, "api_error" + logger.error(f'Ошибка RemnaWave API при синхронизации подписки {subscription.id}: {api_error}') + return False, 'api_error' except Exception as e: - logger.error(f"Ошибка синхронизации подписки {subscription.id}: {e}") - return False, "unknown_error" + logger.error(f'Ошибка синхронизации подписки {subscription.id}: {e}') + return False, 'unknown_error' async def calculate_subscription_price( self, period_days: int, traffic_gb: int, - server_squad_ids: List[int], + server_squad_ids: list[int], devices: int, db: AsyncSession, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, - ) -> Tuple[int, List[int]]: - + user: User | None = None, + promo_group: PromoGroup | None = None, + ) -> tuple[int, list[int]]: from app.config import PERIOD_PRICES from app.database.crud.server_squad import get_server_squad_by_id - + if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT: - raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") - + raise ValueError(f'Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}') + base_price_original = PERIOD_PRICES.get(period_days, 0) period_discount_percent = _resolve_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - + promo_group = promo_group or (user.get_primary_promo_group() if user else None) traffic_price = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _resolve_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount = traffic_price * traffic_discount_percent // 100 @@ -696,7 +664,7 @@ class SubscriptionService: servers_discount_percent = _resolve_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) @@ -708,21 +676,19 @@ class SubscriptionService: discounted_server_price = server_price - server_discount server_prices.append(discounted_server_price) total_servers_price += discounted_server_price - log_message = f"Сервер {server.display_name}: {server_price/100}₽" + log_message = f'Сервер {server.display_name}: {server_price / 100}₽' if server_discount > 0: - log_message += ( - f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)" - ) + log_message += f' (скидка {servers_discount_percent}%: -{server_discount / 100}₽ → {discounted_server_price / 100}₽)' logger.debug(log_message) else: server_prices.append(0) - logger.warning(f"Сервер ID {server_id} недоступен") + logger.warning(f'Сервер ID {server_id} недоступен') devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount = devices_price * devices_discount_percent // 100 @@ -730,47 +696,38 @@ class SubscriptionService: total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price - logger.debug("Расчет стоимости новой подписки:") - base_log = f" Период {period_days} дней: {base_price_original/100}₽" + logger.debug('Расчет стоимости новой подписки:') + base_log = f' Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: - base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' logger.debug(base_log) if discounted_traffic_price > 0: - message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽" + message = f' Трафик {traffic_gb} ГБ: {traffic_price / 100}₽' if traffic_discount > 0: - message += ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ → {discounted_traffic_price / 100}₽)' logger.debug(message) if total_servers_price > 0: - message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽" + message = f' Серверы ({len(server_squad_ids)}): {total_servers_price / 100}₽' if servers_discount_percent > 0: - message += ( - f" (скидка {servers_discount_percent}% применяется ко всем серверам)" - ) + message += f' (скидка {servers_discount_percent}% применяется ко всем серверам)' logger.debug(message) if discounted_devices_price > 0: - message = f" Устройства ({devices}): {devices_price/100}₽" + message = f' Устройства ({devices}): {devices_price / 100}₽' if devices_discount > 0: - message += ( - f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ → {discounted_devices_price / 100}₽)' logger.debug(message) - logger.debug(f" ИТОГО: {total_price/100}₽") + logger.debug(f' ИТОГО: {total_price / 100}₽') return total_price, server_prices - + async def calculate_renewal_price( self, subscription: Subscription, period_days: int, db: AsyncSession, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, + user: User | None = None, + promo_group: PromoGroup | None = None, ) -> int: try: from app.config import PERIOD_PRICES @@ -778,7 +735,7 @@ class SubscriptionService: base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: - user = getattr(subscription, "user", None) + user = getattr(subscription, 'user', None) promo_group = promo_group or (user.get_primary_promo_group() if user else None) servers_price, _ = await self.get_countries_price_by_uuids( @@ -790,7 +747,7 @@ class SubscriptionService: servers_discount_percent = _resolve_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) servers_discount = servers_price * servers_discount_percent // 100 @@ -816,7 +773,7 @@ class SubscriptionService: devices_discount_percent = _resolve_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount = devices_price * devices_discount_percent // 100 @@ -831,7 +788,7 @@ class SubscriptionService: traffic_discount_percent = _resolve_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount = traffic_price * traffic_discount_percent // 100 @@ -840,63 +797,51 @@ class SubscriptionService: period_discount_percent = _resolve_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - total_price = ( - base_price - + discounted_servers_price - + discounted_devices_price - + discounted_traffic_price - ) + total_price = base_price + discounted_servers_price + discounted_devices_price + discounted_traffic_price - logger.debug(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):") - base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + logger.debug(f'💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):') + base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: - base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' logger.debug(base_log) if servers_price > 0: - message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽" + message = f' 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price / 100}₽' if servers_discount > 0: message += ( - f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)" + f' (скидка {servers_discount_percent}%: -{servers_discount / 100}₽ от {servers_price / 100}₽)' ) logger.debug(message) if devices_price > 0: - message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}₽" + message = f' 📱 Устройства ({device_limit}): {discounted_devices_price / 100}₽' if devices_discount > 0: message += ( - f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)" + f' (скидка {devices_discount_percent}%: -{devices_discount / 100}₽ от {devices_price / 100}₽)' ) logger.debug(message) if traffic_price > 0: - message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽" + message = f' 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price / 100}₽' if traffic_discount > 0: message += ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)" + f' (скидка {traffic_discount_percent}%: -{traffic_discount / 100}₽ от {traffic_price / 100}₽)' ) logger.debug(message) - logger.debug(f" 💎 ИТОГО: {total_price/100}₽") + logger.debug(f' 💎 ИТОГО: {total_price / 100}₽') return total_price - + except Exception as e: - logger.error(f"Ошибка расчета стоимости продления: {e}") + logger.error(f'Ошибка расчета стоимости продления: {e}') from app.config import PERIOD_PRICES + return PERIOD_PRICES.get(period_days, 0) - async def validate_and_clean_subscription( - self, - db: AsyncSession, - subscription: Subscription, - user: User - ) -> bool: + async def validate_and_clean_subscription(self, db: AsyncSession, subscription: Subscription, user: User) -> bool: try: needs_cleanup = False user_log = self._format_user_log(user) @@ -907,54 +852,61 @@ class SubscriptionService: remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid) if not remnawave_user: - logger.warning(f"⚠️ Пользователь {user_log} имеет UUID {user.remnawave_uuid}, но не найден в панели") + logger.warning( + f'⚠️ Пользователь {user_log} имеет UUID {user.remnawave_uuid}, но не найден в панели' + ) + needs_cleanup = True + # Проверяем telegram_id только если он задан у обоих + elif ( + user.telegram_id + and remnawave_user.telegram_id + and remnawave_user.telegram_id != user.telegram_id + ): + logger.warning( + f'⚠️ Несоответствие telegram_id для {user_log}: panel={remnawave_user.telegram_id}' + ) needs_cleanup = True - else: - # Проверяем telegram_id только если он задан у обоих - if user.telegram_id and remnawave_user.telegram_id and remnawave_user.telegram_id != user.telegram_id: - logger.warning(f"⚠️ Несоответствие telegram_id для {user_log}: panel={remnawave_user.telegram_id}") - needs_cleanup = True except Exception as api_error: - logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}") + logger.error(f'❌ Ошибка проверки пользователя в панели: {api_error}') needs_cleanup = True if subscription.remnawave_short_uuid and not user.remnawave_uuid: - logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid") + logger.warning('⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid') needs_cleanup = True if needs_cleanup: - logger.info(f"🧹 Очищаем мусорные данные подписки для {user_log}") + logger.info(f'🧹 Очищаем мусорные данные подписки для {user_log}') subscription.remnawave_short_uuid = None - subscription.subscription_url = "" - subscription.subscription_crypto_link = "" + subscription.subscription_url = '' + subscription.subscription_crypto_link = '' subscription.connected_squads = [] user.remnawave_uuid = None await db.commit() - logger.info(f"✅ Мусорные данные очищены для {user_log}") + logger.info(f'✅ Мусорные данные очищены для {user_log}') return True except Exception as e: - logger.error(f"❌ Ошибка валидации подписки для {self._format_user_log(user)}: {e}") + logger.error(f'❌ Ошибка валидации подписки для {self._format_user_log(user)}: {e}') await db.rollback() return False - + async def get_countries_price_by_uuids( self, - country_uuids: List[str], + country_uuids: list[str], db: AsyncSession, *, - promo_group_id: Optional[int] = None, - ) -> Tuple[int, List[int]]: + promo_group_id: int | None = None, + ) -> tuple[int, list[int]]: try: from app.database.crud.server_squad import get_server_squad_by_uuid - + total_price = 0 prices_list = [] - + for country_uuid in country_uuids: server = await get_server_squad_by_uuid(db, country_uuid) is_allowed = True @@ -966,66 +918,67 @@ class SubscriptionService: price = server.price_kopeks total_price += price prices_list.append(price) - logger.debug(f"🏷️ Страна {server.display_name}: {price/100}₽") + logger.debug(f'🏷️ Страна {server.display_name}: {price / 100}₽') else: - default_price = 0 + default_price = 0 total_price += default_price prices_list.append(default_price) - logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}₽") - - logger.info(f"💰 Общая стоимость стран: {total_price/100}₽") + logger.warning( + f'⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price / 100}₽' + ) + + logger.info(f'💰 Общая стоимость стран: {total_price / 100}₽') return total_price, prices_list - + except Exception as e: - logger.error(f"Ошибка получения цен стран: {e}") + logger.error(f'Ошибка получения цен стран: {e}') default_prices = [0] * len(country_uuids) return sum(default_prices), default_prices - - async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int: + + async def _get_countries_price(self, country_uuids: list[str], db: AsyncSession) -> int: try: total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db) return total_price except Exception as e: - logger.error(f"Ошибка получения цен стран: {e}") + logger.error(f'Ошибка получения цен стран: {e}') return len(country_uuids) * 1000 async def calculate_subscription_price_with_months( self, period_days: int, traffic_gb: int, - server_squad_ids: List[int], + server_squad_ids: list[int], devices: int, db: AsyncSession, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, - ) -> Tuple[int, List[int]]: - + user: User | None = None, + promo_group: PromoGroup | None = None, + ) -> tuple[int, list[int]]: from app.config import PERIOD_PRICES from app.database.crud.server_squad import get_server_squad_by_id - + if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT: - raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}") - + raise ValueError(f'Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}') + months_in_period = calculate_months_from_days(period_days) - + base_price_original = PERIOD_PRICES.get(period_days, 0) period_discount_percent = _resolve_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 base_price = base_price_original - base_discount_total - + promo_group = promo_group or (user.get_primary_promo_group() if user else None) traffic_price_per_month = settings.get_traffic_price(traffic_gb) traffic_discount_percent = _resolve_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1037,7 +990,7 @@ class SubscriptionService: servers_discount_percent = _resolve_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) @@ -1050,24 +1003,22 @@ class SubscriptionService: server_price_total = discounted_server_per_month * months_in_period server_prices.append(server_price_total) total_servers_price += server_price_total - log_message = ( - f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽" - ) + log_message = f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {months_in_period} мес = {server_price_total / 100}₽' if server_discount_per_month > 0: log_message += ( - f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)" + f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period / 100}₽)' ) logger.debug(log_message) else: server_prices.append(0) - logger.warning(f"Сервер ID {server_id} недоступен") + logger.warning(f'Сервер ID {server_id} недоступен') additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE devices_discount_percent = _resolve_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1076,51 +1027,42 @@ class SubscriptionService: total_price = base_price + total_traffic_price + total_servers_price + total_devices_price - logger.debug(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):") - base_log = f" Период {period_days} дней: {base_price_original/100}₽" + logger.debug(f'Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):') + base_log = f' Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: - base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' logger.debug(base_log) if total_traffic_price > 0: - message = ( - f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" - ) + message = f' Трафик {traffic_gb} ГБ: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽' if traffic_discount_per_month > 0: message += ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)" + f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' ) logger.debug(message) if total_servers_price > 0: - message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽" + message = f' Серверы ({len(server_squad_ids)}): {total_servers_price / 100}₽' if servers_discount_percent > 0: - message += ( - f" (скидка {servers_discount_percent}% применяется ко всем серверам)" - ) + message += f' (скидка {servers_discount_percent}% применяется ко всем серверам)' logger.debug(message) if total_devices_price > 0: - message = ( - f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽" - ) + message = f' Устройства ({additional_devices}): {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽' if devices_discount_per_month > 0: message += ( - f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)" + f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' ) logger.debug(message) - logger.debug(f" ИТОГО: {total_price/100}₽") + logger.debug(f' ИТОГО: {total_price / 100}₽') return total_price, server_prices - + async def calculate_renewal_price_with_months( self, subscription: Subscription, period_days: int, db: AsyncSession, *, - user: Optional[User] = None, - promo_group: Optional[PromoGroup] = None, + user: User | None = None, + promo_group: PromoGroup | None = None, ) -> int: try: from app.config import PERIOD_PRICES @@ -1130,7 +1072,7 @@ class SubscriptionService: base_price_original = PERIOD_PRICES.get(period_days, 0) if user is None: - user = getattr(subscription, "user", None) + user = getattr(subscription, 'user', None) promo_group = promo_group or (user.get_primary_promo_group() if user else None) servers_price_per_month, _ = await self.get_countries_price_by_uuids( @@ -1141,7 +1083,7 @@ class SubscriptionService: servers_discount_percent = _resolve_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100 @@ -1164,7 +1106,7 @@ class SubscriptionService: devices_discount_percent = _resolve_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1180,7 +1122,7 @@ class SubscriptionService: traffic_discount_percent = _resolve_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1190,7 +1132,7 @@ class SubscriptionService: period_discount_percent = _resolve_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount_total = base_price_original * period_discount_percent // 100 @@ -1198,66 +1140,53 @@ class SubscriptionService: total_price = base_price + total_servers_price + total_devices_price + total_traffic_price - logger.debug(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):") - base_log = f" 📅 Период {period_days} дней: {base_price_original/100}₽" + logger.debug( + f'💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):' + ) + base_log = f' 📅 Период {period_days} дней: {base_price_original / 100}₽' if base_discount_total > 0: - base_log += ( - f" → {base_price/100}₽" - f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)" - ) + base_log += f' → {base_price / 100}₽ (скидка {period_discount_percent}%: -{base_discount_total / 100}₽)' logger.debug(base_log) if total_servers_price > 0: - message = ( - f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽" - ) + message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес x {months_in_period} = {total_servers_price / 100}₽' if servers_discount_per_month > 0: - message += ( - f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)" - ) + message += f' (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period / 100}₽)' logger.debug(message) if total_devices_price > 0: - message = ( - f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽" - ) + message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес x {months_in_period} = {total_devices_price / 100}₽' if devices_discount_per_month > 0: - message += ( - f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)" - ) + message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period / 100}₽)' logger.debug(message) if total_traffic_price > 0: - message = ( - f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽" - ) + message = f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес x {months_in_period} = {total_traffic_price / 100}₽' if traffic_discount_per_month > 0: - message += ( - f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)" - ) + message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period / 100}₽)' logger.debug(message) - logger.debug(f" 💎 ИТОГО: {total_price/100}₽") + logger.debug(f' 💎 ИТОГО: {total_price / 100}₽') return total_price - + except Exception as e: - logger.error(f"Ошибка расчета стоимости продления: {e}") + logger.error(f'Ошибка расчета стоимости продления: {e}') from app.config import PERIOD_PRICES + return PERIOD_PRICES.get(period_days, 0) - + async def calculate_addon_price_with_remaining_period( self, subscription: Subscription, additional_traffic_gb: int = 0, additional_devices: int = 0, - additional_server_ids: List[int] = None, - db: AsyncSession = None + additional_server_ids: list[int] = None, + db: AsyncSession = None, ) -> int: - if additional_server_ids is None: additional_server_ids = [] months_to_pay = get_remaining_months(subscription.end_date) period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None - user = getattr(subscription, "user", None) + user = getattr(subscription, 'user', None) promo_group = user.promo_group if user else None total_price = 0 @@ -1267,7 +1196,7 @@ class SubscriptionService: traffic_discount_percent = _resolve_addon_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_hint_days, ) traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100 @@ -1275,13 +1204,12 @@ class SubscriptionService: traffic_total_price = discounted_traffic_per_month * months_to_pay total_price += traffic_total_price message = ( - f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}" - f" = {traffic_total_price/100}₽" + f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес x {months_to_pay}' + f' = {traffic_total_price / 100}₽' ) if traffic_discount_per_month > 0: message += ( - f" (скидка {traffic_discount_percent}%:" - f" -{traffic_discount_per_month * months_to_pay/100}₽)" + f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)' ) logger.info(message) @@ -1290,7 +1218,7 @@ class SubscriptionService: devices_discount_percent = _resolve_addon_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_hint_days, ) devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100 @@ -1298,55 +1226,51 @@ class SubscriptionService: devices_total_price = discounted_devices_per_month * months_to_pay total_price += devices_total_price message = ( - f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}" - f" = {devices_total_price/100}₽" + f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес x {months_to_pay}' + f' = {devices_total_price / 100}₽' ) if devices_discount_per_month > 0: message += ( - f" (скидка {devices_discount_percent}%:" - f" -{devices_discount_per_month * months_to_pay/100}₽)" + f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)' ) logger.info(message) if additional_server_ids and db: for server_id in additional_server_ids: from app.database.crud.server_squad import get_server_squad_by_id + 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_addon_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_hint_days, ) - server_discount_per_month = ( - server_price_per_month * servers_discount_percent // 100 - ) - discounted_server_per_month = ( - server_price_per_month - server_discount_per_month - ) + server_discount_per_month = server_price_per_month * servers_discount_percent // 100 + discounted_server_per_month = server_price_per_month - server_discount_per_month server_total_price = discounted_server_per_month * months_to_pay total_price += server_total_price message = ( - f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}" - f" = {server_total_price/100}₽" + f'Сервер {server.display_name}: {server_price_per_month / 100}₽/мес x {months_to_pay}' + f' = {server_total_price / 100}₽' ) if server_discount_per_month > 0: message += ( - f" (скидка {servers_discount_percent}%:" - f" -{server_discount_per_month * months_to_pay/100}₽)" + f' (скидка {servers_discount_percent}%:' + f' -{server_discount_per_month * months_to_pay / 100}₽)' ) logger.info(message) - logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽") + logger.info(f'Итого доплата за {months_to_pay} мес: {total_price / 100}₽') return total_price - - def _gb_to_bytes(self, gb: Optional[int]) -> int: + + def _gb_to_bytes(self, gb: int | None) -> int: if not gb: # None or 0 return 0 return gb * 1024 * 1024 * 1024 - + def _bytes_to_gb(self, bytes_value: int) -> float: if bytes_value == 0: return 0.0 diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py index 8c488602..d948f255 100644 --- a/app/services/support_settings_service.py +++ b/app/services/support_settings_service.py @@ -1,7 +1,6 @@ import json import logging from pathlib import Path -from typing import Dict from app.config import settings @@ -12,8 +11,8 @@ logger = logging.getLogger(__name__) class SupportSettingsService: """Runtime editable support settings with JSON persistence.""" - _storage_path: Path = Path("data/support_settings.json") - _data: Dict = {} + _storage_path: Path = Path('data/support_settings.json') + _data: dict = {} _loaded: bool = False @classmethod @@ -21,7 +20,7 @@ class SupportSettingsService: try: cls._storage_path.parent.mkdir(parents=True, exist_ok=True) except Exception as e: - logger.error(f"Failed to ensure settings dir: {e}") + logger.error(f'Failed to ensure settings dir: {e}') @classmethod def _load(cls) -> None: @@ -30,11 +29,11 @@ class SupportSettingsService: cls._ensure_dir() try: if cls._storage_path.exists(): - cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8")) + cls._data = json.loads(cls._storage_path.read_text(encoding='utf-8')) else: cls._data = {} except Exception as e: - logger.error(f"Failed to load support settings: {e}") + logger.error(f'Failed to load support settings: {e}') cls._data = {} cls._loaded = True @@ -42,123 +41,123 @@ class SupportSettingsService: def _save(cls) -> bool: cls._ensure_dir() try: - cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8") + cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding='utf-8') return True except Exception as e: - logger.error(f"Failed to save support settings: {e}") + logger.error(f'Failed to save support settings: {e}') return False # Mode @classmethod def get_system_mode(cls) -> str: cls._load() - mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower() - return mode if mode in {"tickets", "contact", "both"} else "both" + mode = (cls._data.get('system_mode') or settings.get_support_system_mode()).strip().lower() + return mode if mode in {'tickets', 'contact', 'both'} else 'both' @classmethod def set_system_mode(cls, mode: str) -> bool: - mode_clean = (mode or "").strip().lower() - if mode_clean not in {"tickets", "contact", "both"}: + mode_clean = (mode or '').strip().lower() + if mode_clean not in {'tickets', 'contact', 'both'}: return False cls._load() - cls._data["system_mode"] = mode_clean + cls._data['system_mode'] = mode_clean return cls._save() # Main menu visibility @classmethod def is_support_menu_enabled(cls) -> bool: cls._load() - if "menu_enabled" in cls._data: - return bool(cls._data["menu_enabled"]) + if 'menu_enabled' in cls._data: + return bool(cls._data['menu_enabled']) return bool(settings.SUPPORT_MENU_ENABLED) @classmethod def set_support_menu_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["menu_enabled"] = bool(enabled) + cls._data['menu_enabled'] = bool(enabled) return cls._save() # Contact vs tickets helpers @classmethod def is_tickets_enabled(cls) -> bool: - return cls.get_system_mode() in {"tickets", "both"} + return cls.get_system_mode() in {'tickets', 'both'} @classmethod def is_contact_enabled(cls) -> bool: - return cls.get_system_mode() in {"contact", "both"} + return cls.get_system_mode() in {'contact', 'both'} # Descriptions (per language) @classmethod def get_support_info_text(cls, language: str) -> str: cls._load() - lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower() - overrides = cls._data.get("support_info_texts") or {} + lang = (language or settings.DEFAULT_LANGUAGE).split('-')[0].lower() + overrides = cls._data.get('support_info_texts') or {} text = overrides.get(lang) if text and isinstance(text, str) and text.strip(): return text # Fallback to dynamic localization default from app.localization.texts import get_texts + return get_texts(lang).SUPPORT_INFO @classmethod def set_support_info_text(cls, language: str, text: str) -> bool: cls._load() - lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower() - texts_map = cls._data.get("support_info_texts") or {} - texts_map[lang] = text or "" - cls._data["support_info_texts"] = texts_map + lang = (language or settings.DEFAULT_LANGUAGE).split('-')[0].lower() + texts_map = cls._data.get('support_info_texts') or {} + texts_map[lang] = text or '' + cls._data['support_info_texts'] = texts_map return cls._save() - # Notifications & SLA @classmethod def get_admin_ticket_notifications_enabled(cls) -> bool: cls._load() - if "admin_ticket_notifications_enabled" in cls._data: - return bool(cls._data["admin_ticket_notifications_enabled"]) + if 'admin_ticket_notifications_enabled' in cls._data: + return bool(cls._data['admin_ticket_notifications_enabled']) # fallback to global admin notifications setting return bool(settings.is_admin_notifications_enabled()) @classmethod def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["admin_ticket_notifications_enabled"] = bool(enabled) + cls._data['admin_ticket_notifications_enabled'] = bool(enabled) return cls._save() @classmethod def get_user_ticket_notifications_enabled(cls) -> bool: cls._load() - if "user_ticket_notifications_enabled" in cls._data: - return bool(cls._data["user_ticket_notifications_enabled"]) + if 'user_ticket_notifications_enabled' in cls._data: + return bool(cls._data['user_ticket_notifications_enabled']) # fallback to global enable notifications - return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) + return bool(getattr(settings, 'ENABLE_NOTIFICATIONS', True)) @classmethod def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["user_ticket_notifications_enabled"] = bool(enabled) + cls._data['user_ticket_notifications_enabled'] = bool(enabled) return cls._save() @classmethod def get_sla_enabled(cls) -> bool: cls._load() - if "ticket_sla_enabled" in cls._data: - return bool(cls._data["ticket_sla_enabled"]) - return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True)) + if 'ticket_sla_enabled' in cls._data: + return bool(cls._data['ticket_sla_enabled']) + return bool(getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True)) @classmethod def set_sla_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["ticket_sla_enabled"] = bool(enabled) + cls._data['ticket_sla_enabled'] = bool(enabled) return cls._save() @classmethod def get_sla_minutes(cls) -> int: cls._load() - minutes = cls._data.get("ticket_sla_minutes") + minutes = cls._data.get('ticket_sla_minutes') if isinstance(minutes, int) and minutes > 0: return minutes - return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5)) + return int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5)) @classmethod def set_sla_minutes(cls, minutes: int) -> bool: @@ -169,14 +168,14 @@ class SupportSettingsService: if minutes_int <= 0: return False cls._load() - cls._data["ticket_sla_minutes"] = minutes_int + cls._data['ticket_sla_minutes'] = minutes_int return cls._save() # Moderators management @classmethod def get_moderators(cls) -> list[int]: cls._load() - raw = cls._data.get("moderators") or [] + raw = cls._data.get('moderators') or [] moderators: list[int] = [] for item in raw: try: @@ -202,7 +201,7 @@ class SupportSettingsService: cls._load() moderators = set(cls.get_moderators()) moderators.add(tid) - cls._data["moderators"] = sorted(moderators) + cls._data['moderators'] = sorted(moderators) return cls._save() @classmethod @@ -215,7 +214,7 @@ class SupportSettingsService: moderators = set(cls.get_moderators()) if tid in moderators: moderators.remove(tid) - cls._data["moderators"] = sorted(moderators) + cls._data['moderators'] = sorted(moderators) return cls._save() return True @@ -224,27 +223,26 @@ class SupportSettingsService: def get_cabinet_user_notifications_enabled(cls) -> bool: """Уведомления юзерам в кабинет о ответе админа на тикет.""" cls._load() - if "cabinet_user_notifications_enabled" in cls._data: - return bool(cls._data["cabinet_user_notifications_enabled"]) + if 'cabinet_user_notifications_enabled' in cls._data: + return bool(cls._data['cabinet_user_notifications_enabled']) return True # По умолчанию включено @classmethod def set_cabinet_user_notifications_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["cabinet_user_notifications_enabled"] = bool(enabled) + cls._data['cabinet_user_notifications_enabled'] = bool(enabled) return cls._save() @classmethod def get_cabinet_admin_notifications_enabled(cls) -> bool: """Уведомления админам в кабинет о новых тикетах.""" cls._load() - if "cabinet_admin_notifications_enabled" in cls._data: - return bool(cls._data["cabinet_admin_notifications_enabled"]) + if 'cabinet_admin_notifications_enabled' in cls._data: + return bool(cls._data['cabinet_admin_notifications_enabled']) return True # По умолчанию включено @classmethod def set_cabinet_admin_notifications_enabled(cls, enabled: bool) -> bool: cls._load() - cls._data["cabinet_admin_notifications_enabled"] = bool(enabled) + cls._data['cabinet_admin_notifications_enabled'] = bool(enabled) return cls._save() - diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 4fe87bc2..1b0c744a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -2,19 +2,17 @@ import hashlib import json import logging from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin - -from app.database.universal_migration import ensure_default_web_api_token +from typing import Any, Optional, Union, get_args, get_origin from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import ( + ENV_OVERRIDE_KEYS, Settings, - settings, refresh_period_prices, refresh_traffic_prices, - ENV_OVERRIDE_KEYS, + settings, ) from app.database.crud.system_setting import ( delete_system_setting, @@ -22,23 +20,24 @@ from app.database.crud.system_setting import ( ) from app.database.database import AsyncSessionLocal from app.database.models import SystemSetting +from app.database.universal_migration import ensure_default_web_api_token logger = logging.getLogger(__name__) def _title_from_key(key: str) -> str: - parts = key.split("_") + parts = key.split('_') if not parts: return key - return " ".join(part.capitalize() for part in parts) + 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] + "…" + return value[: max_len - 1] + '…' @dataclass(slots=True) @@ -46,7 +45,7 @@ class SettingDefinition: key: str category_key: str category_label: str - python_type: Type[Any] + python_type: type[Any] type_label: str is_optional: bool @@ -59,7 +58,7 @@ class SettingDefinition: class ChoiceOption: value: Any label: str - description: Optional[str] = None + description: str | None = None class ReadOnlySettingError(RuntimeError): @@ -67,790 +66,762 @@ class ReadOnlySettingError(RuntimeError): class BotConfigurationService: - EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"} + EXCLUDED_KEYS: set[str] = {'BOT_TOKEN', 'ADMIN_IDS'} - READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} - PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} + READ_ONLY_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} + PLAIN_TEXT_KEYS: set[str] = {'EXTERNAL_ADMIN_TOKEN', 'EXTERNAL_ADMIN_TOKEN_BOT_ID'} - CATEGORY_TITLES: Dict[str, str] = { - "CORE": "🤖 Основные настройки", - "SUPPORT": "💬 Поддержка и тикеты", - "LOCALIZATION": "🌍 Языки интерфейса", - "CHANNEL": "📣 Обязательная подписка", - "TIMEZONE": "🗂 Timezone", - "PAYMENT": "💳 Общие платежные настройки", - "PAYMENT_VERIFICATION": "🕵️ Проверка платежей", - "TELEGRAM": "⭐ Telegram Stars", - "CRYPTOBOT": "🪙 CryptoBot", - "HELEKET": "🪙 Heleket", - "CLOUDPAYMENTS": "💳 CloudPayments", - "FREEKASSA": "💳 Freekassa", - "KASSA_AI": "💳 KassaAI", - "YOOKASSA": "🟣 YooKassa", - "PLATEGA": "💳 {platega_name}", - "TRIBUTE": "🎁 Tribute", - "MULENPAY": "💰 {mulenpay_name}", - "PAL24": "🏦 PAL24 / PayPalych", - "WATA": "💠 Wata", - "EXTERNAL_ADMIN": "🛡️ Внешняя админка", - "SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты", - "SIMPLE_SUBSCRIPTION": "⚡ Простая покупка", - "PERIODS": "📆 Периоды подписок", - "SUBSCRIPTION_PRICES": "💵 Стоимость тарифов", - "TRAFFIC": "📊 Трафик", - "TRAFFIC_PACKAGES": "📦 Пакеты трафика", - "TRIAL": "🎁 Пробный период", - "REFERRAL": "👥 Реферальная программа", - "AUTOPAY": "🔄 Автопродление", - "NOTIFICATIONS": "🔔 Уведомления пользователям", - "ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам", - "ADMIN_REPORTS": "🗂 Автоматические отчеты", - "INTERFACE": "🎨 Интерфейс и брендинг", - "INTERFACE_BRANDING": "🖼️ Брендинг", - "INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку", - "CONNECT_BUTTON": "🚀 Кнопка подключения", - "MINIAPP": "📱 Mini App", - "HAPP": "🅷 Happ", - "SKIP": "⚡ Быстрый старт", - "ADDITIONAL": "📱 Дополнительные приложения", - "DATABASE": "💾 База данных", - "POSTGRES": "🐘 PostgreSQL", - "SQLITE": "🧱 SQLite", - "REDIS": "🧠 Redis", - "REMNAWAVE": "🌐 RemnaWave API", - "SERVER_STATUS": "📊 Статус серверов", - "MONITORING": "📈 Мониторинг", - "MAINTENANCE": "🔧 Обслуживание", - "BACKUP": "💾 Резервные копии", - "VERSION": "🔄 Проверка версий", - "WEB_API": "⚡ Web API", - "WEBHOOK": "🌐 Webhook", - "LOG": "📝 Логирование", - "DEBUG": "🧪 Режим разработки", - "MODERATION": "🛡️ Модерация и фильтры", - "BAN_NOTIFICATIONS": "🚫 Тексты уведомлений о блокировках", + CATEGORY_TITLES: dict[str, str] = { + 'CORE': '🤖 Основные настройки', + 'SUPPORT': '💬 Поддержка и тикеты', + 'LOCALIZATION': '🌍 Языки интерфейса', + 'CHANNEL': '📣 Обязательная подписка', + 'TIMEZONE': '🗂 Timezone', + 'PAYMENT': '💳 Общие платежные настройки', + 'PAYMENT_VERIFICATION': '🕵️ Проверка платежей', + 'TELEGRAM': '⭐ Telegram Stars', + 'CRYPTOBOT': '🪙 CryptoBot', + 'HELEKET': '🪙 Heleket', + 'CLOUDPAYMENTS': '💳 CloudPayments', + 'FREEKASSA': '💳 Freekassa', + 'KASSA_AI': '💳 KassaAI', + 'YOOKASSA': '🟣 YooKassa', + 'PLATEGA': '💳 {platega_name}', + 'TRIBUTE': '🎁 Tribute', + 'MULENPAY': '💰 {mulenpay_name}', + 'PAL24': '🏦 PAL24 / PayPalych', + 'WATA': '💠 Wata', + 'EXTERNAL_ADMIN': '🛡️ Внешняя админка', + 'SUBSCRIPTIONS_CORE': '📅 Подписки и лимиты', + 'SIMPLE_SUBSCRIPTION': '⚡ Простая покупка', + 'PERIODS': '📆 Периоды подписок', + 'SUBSCRIPTION_PRICES': '💵 Стоимость тарифов', + 'TRAFFIC': '📊 Трафик', + 'TRAFFIC_PACKAGES': '📦 Пакеты трафика', + 'TRIAL': '🎁 Пробный период', + 'REFERRAL': '👥 Реферальная программа', + 'AUTOPAY': '🔄 Автопродление', + 'NOTIFICATIONS': '🔔 Уведомления пользователям', + 'ADMIN_NOTIFICATIONS': '📣 Оповещения администраторам', + 'ADMIN_REPORTS': '🗂 Автоматические отчеты', + 'INTERFACE': '🎨 Интерфейс и брендинг', + 'INTERFACE_BRANDING': '🖼️ Брендинг', + 'INTERFACE_SUBSCRIPTION': '🔗 Ссылка на подписку', + 'CONNECT_BUTTON': '🚀 Кнопка подключения', + 'MINIAPP': '📱 Mini App', + 'HAPP': '🅷 Happ', + 'SKIP': '⚡ Быстрый старт', + 'ADDITIONAL': '📱 Дополнительные приложения', + 'DATABASE': '💾 База данных', + 'POSTGRES': '🐘 PostgreSQL', + 'SQLITE': '🧱 SQLite', + 'REDIS': '🧠 Redis', + 'REMNAWAVE': '🌐 RemnaWave API', + 'SERVER_STATUS': '📊 Статус серверов', + 'MONITORING': '📈 Мониторинг', + 'MAINTENANCE': '🔧 Обслуживание', + 'BACKUP': '💾 Резервные копии', + 'VERSION': '🔄 Проверка версий', + 'WEB_API': '⚡ Web API', + 'WEBHOOK': '🌐 Webhook', + 'LOG': '📝 Логирование', + 'DEBUG': '🧪 Режим разработки', + 'MODERATION': '🛡️ Модерация и фильтры', + 'BAN_NOTIFICATIONS': '🚫 Тексты уведомлений о блокировках', } - CATEGORY_DESCRIPTIONS: Dict[str, str] = { - "CORE": "Базовые параметры работы бота и обязательные ссылки.", - "SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.", - "LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.", - "CHANNEL": "Настройки обязательной подписки на канал или группу.", - "TIMEZONE": "Часовой пояс панели и отображение времени.", - "PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.", - "PAYMENT_VERIFICATION": "Автоматическая проверка пополнений и интервал выполнения.", - "YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.", - "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.", - "HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.", - "CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.", - "FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.", - "KASSA_AI": "KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.", - "PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.", - "MULENPAY": "Платежи {mulenpay_name} и параметры магазина.", - "PAL24": "PAL24 / PayPalych подключения и лимиты.", - "TRIBUTE": "Tribute и донат-сервисы.", - "TELEGRAM": "Telegram Stars и их стоимость.", - "WATA": "Wata: токен доступа, тип платежа и пределы сумм.", - "EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.", - "SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.", - "SIMPLE_SUBSCRIPTION": "Параметры упрощённой покупки: период, трафик, устройства и сквады.", - "PERIODS": "Доступные периоды подписок и продлений.", - "SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.", - "TRAFFIC": "Лимиты трафика и стратегии сброса.", - "TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.", - "TRIAL": "Длительность и ограничения пробного периода.", - "REFERRAL": "Бонусы и пороги реферальной программы.", - "AUTOPAY": "Настройки автопродления и минимальный баланс.", - "NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.", - "ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.", - "ADMIN_REPORTS": "Автоматические отчеты для команды.", - "INTERFACE": "Глобальные параметры интерфейса и брендирования.", - "INTERFACE_BRANDING": "Логотип и фирменный стиль.", - "INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.", - "CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.", - "MINIAPP": "Mini App и кастомные ссылки.", - "HAPP": "Интеграция Happ и связанные ссылки.", - "SKIP": "Настройки быстрого старта и гайд по подключению.", - "ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.", - "DATABASE": "Режим работы базы данных и пути до файлов.", - "POSTGRES": "Параметры подключения к PostgreSQL.", - "SQLITE": "Файл SQLite и резервные параметры.", - "REDIS": "Подключение к Redis для кэша.", - "REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.", - "SERVER_STATUS": "Отображение статуса серверов и external URL.", - "MONITORING": "Интервалы мониторинга и хранение логов.", - "MAINTENANCE": "Режим обслуживания, сообщения и интервалы.", - "BACKUP": "Резервное копирование и расписание.", - "VERSION": "Отслеживание обновлений репозитория.", - "WEB_API": "Web API, токены и права доступа.", - "WEBHOOK": "Пути и секреты вебхуков.", - "LOG": "Уровни логирования и ротация.", - "DEBUG": "Отладочные функции и безопасный режим.", - "MODERATION": "Настройки фильтров отображаемых имен и защиты от фишинга.", - "BAN_NOTIFICATIONS": "Тексты уведомлений о блокировках, которые отправляются пользователям.", + CATEGORY_DESCRIPTIONS: dict[str, str] = { + 'CORE': 'Базовые параметры работы бота и обязательные ссылки.', + 'SUPPORT': 'Контакты поддержки, SLA и режимы обработки обращений.', + 'LOCALIZATION': 'Доступные языки, локализация интерфейса и выбор языка.', + 'CHANNEL': 'Настройки обязательной подписки на канал или группу.', + 'TIMEZONE': 'Часовой пояс панели и отображение времени.', + 'PAYMENT': 'Общие тексты платежей, описания чеков и шаблоны.', + 'PAYMENT_VERIFICATION': 'Автоматическая проверка пополнений и интервал выполнения.', + 'YOOKASSA': 'Интеграция с YooKassa: идентификаторы магазина и вебхуки.', + 'CRYPTOBOT': 'CryptoBot и криптоплатежи через Telegram.', + 'HELEKET': 'Heleket: криптоплатежи, ключи мерчанта и вебхуки.', + 'CLOUDPAYMENTS': 'CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.', + 'FREEKASSA': 'Freekassa: ID магазина, API ключ, секретные слова и вебхуки.', + 'KASSA_AI': 'KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.', + 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', + 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', + 'PAL24': 'PAL24 / PayPalych подключения и лимиты.', + 'TRIBUTE': 'Tribute и донат-сервисы.', + 'TELEGRAM': 'Telegram Stars и их стоимость.', + 'WATA': 'Wata: токен доступа, тип платежа и пределы сумм.', + 'EXTERNAL_ADMIN': 'Токен внешней админки для проверки запросов.', + 'SUBSCRIPTIONS_CORE': 'Лимиты устройств, трафика и базовые цены подписок.', + 'SIMPLE_SUBSCRIPTION': 'Параметры упрощённой покупки: период, трафик, устройства и сквады.', + 'PERIODS': 'Доступные периоды подписок и продлений.', + 'SUBSCRIPTION_PRICES': 'Стоимость подписок по периодам в копейках.', + 'TRAFFIC': 'Лимиты трафика и стратегии сброса.', + 'TRAFFIC_PACKAGES': 'Цены пакетов трафика и конфигурация предложений.', + 'TRIAL': 'Длительность и ограничения пробного периода.', + 'REFERRAL': 'Бонусы и пороги реферальной программы.', + 'AUTOPAY': 'Настройки автопродления и минимальный баланс.', + 'NOTIFICATIONS': 'Пользовательские уведомления и кэширование сообщений.', + 'ADMIN_NOTIFICATIONS': 'Оповещения админам о событиях и тикетах.', + 'ADMIN_REPORTS': 'Автоматические отчеты для команды.', + 'INTERFACE': 'Глобальные параметры интерфейса и брендирования.', + 'INTERFACE_BRANDING': 'Логотип и фирменный стиль.', + 'INTERFACE_SUBSCRIPTION': 'Отображение ссылок и кнопок подписок.', + 'CONNECT_BUTTON': 'Поведение кнопки «Подключиться» и miniapp.', + 'MINIAPP': 'Mini App и кастомные ссылки.', + 'HAPP': 'Интеграция Happ и связанные ссылки.', + 'SKIP': 'Настройки быстрого старта и гайд по подключению.', + 'ADDITIONAL': 'Конфигурация app-config.json, deep links и кеша.', + 'DATABASE': 'Режим работы базы данных и пути до файлов.', + 'POSTGRES': 'Параметры подключения к PostgreSQL.', + 'SQLITE': 'Файл SQLite и резервные параметры.', + 'REDIS': 'Подключение к Redis для кэша.', + 'REMNAWAVE': 'Параметры авторизации и интеграция с RemnaWave API.', + 'SERVER_STATUS': 'Отображение статуса серверов и external URL.', + 'MONITORING': 'Интервалы мониторинга и хранение логов.', + 'MAINTENANCE': 'Режим обслуживания, сообщения и интервалы.', + 'BACKUP': 'Резервное копирование и расписание.', + 'VERSION': 'Отслеживание обновлений репозитория.', + 'WEB_API': 'Web API, токены и права доступа.', + 'WEBHOOK': 'Пути и секреты вебхуков.', + 'LOG': 'Уровни логирования и ротация.', + 'DEBUG': 'Отладочные функции и безопасный режим.', + 'MODERATION': 'Настройки фильтров отображаемых имен и защиты от фишинга.', + 'BAN_NOTIFICATIONS': 'Тексты уведомлений о блокировках, которые отправляются пользователям.', } @staticmethod - def _format_dynamic_copy(category_key: Optional[str], value: str) -> str: + def _format_dynamic_copy(category_key: str | None, value: str) -> str: if not value: return value - if category_key == "MULENPAY": + if category_key == 'MULENPAY': return value.format(mulenpay_name=settings.get_mulenpay_display_name()) - if category_key == "PLATEGA": + if category_key == 'PLATEGA': return value.format(platega_name=settings.get_platega_display_name()) return value - CATEGORY_KEY_OVERRIDES: Dict[str, str] = { - "DATABASE_URL": "DATABASE", - "DATABASE_MODE": "DATABASE", - "LOCALES_PATH": "LOCALIZATION", - "CHANNEL_SUB_ID": "CHANNEL", - "CHANNEL_LINK": "CHANNEL", - "CHANNEL_IS_REQUIRED_SUB": "CHANNEL", - "BOT_USERNAME": "CORE", - "DEFAULT_LANGUAGE": "LOCALIZATION", - "AVAILABLE_LANGUAGES": "LOCALIZATION", - "LANGUAGE_SELECTION_ENABLED": "LOCALIZATION", - "DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE", - "DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE", - "MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE", - "PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE", - "DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE", - "DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE", - "BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE", - "SALES_MODE": "SUBSCRIPTIONS_CORE", - "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", - "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", - "PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES", - "TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES", - "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE", - "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE", - "DEFAULT_AUTOPAY_ENABLED": "AUTOPAY", - "DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY", - "MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY", - "TRIAL_WARNING_HOURS": "TRIAL", - "TRIAL_USER_TAG": "TRIAL", - "SUPPORT_USERNAME": "SUPPORT", - "SUPPORT_MENU_ENABLED": "SUPPORT", - "SUPPORT_SYSTEM_MODE": "SUPPORT", - "SUPPORT_TICKET_SLA_ENABLED": "SUPPORT", - "SUPPORT_TICKET_SLA_MINUTES": "SUPPORT", - "SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT", - "SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT", - "ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS", - "ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS", - "ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS", - "ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS", - "ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS", - "ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS", - "ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS", - "ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS", - "PAYMENT_SERVICE_NAME": "PAYMENT", - "PAYMENT_BALANCE_DESCRIPTION": "PAYMENT", - "PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT", - "PAYMENT_BALANCE_TEMPLATE": "PAYMENT", - "PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT", - "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": "PAYMENT", - "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": "PAYMENT", - "SIMPLE_SUBSCRIPTION_ENABLED": "SIMPLE_SUBSCRIPTION", - "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": "SIMPLE_SUBSCRIPTION", - "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": "SIMPLE_SUBSCRIPTION", - "SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION", - "SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION", - "DISABLE_TOPUP_BUTTONS": "PAYMENT", - "SUPPORT_TOPUP_ENABLED": "PAYMENT", - "ENABLE_NOTIFICATIONS": "NOTIFICATIONS", - "NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS", - "NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS", - "MONITORING_LOGS_RETENTION_DAYS": "MONITORING", - "MONITORING_INTERVAL": "MONITORING", - "TRAFFIC_MONITORING_ENABLED": "MONITORING", - "TRAFFIC_MONITORING_INTERVAL_HOURS": "MONITORING", - "TRAFFIC_MONITORED_NODES": "MONITORING", - "TRAFFIC_SNAPSHOT_TTL_HOURS": "MONITORING", - "TRAFFIC_FAST_CHECK_ENABLED": "MONITORING", - "TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": "MONITORING", - "TRAFFIC_FAST_CHECK_THRESHOLD_GB": "MONITORING", - "TRAFFIC_DAILY_CHECK_ENABLED": "MONITORING", - "TRAFFIC_DAILY_CHECK_TIME": "MONITORING", - "TRAFFIC_DAILY_THRESHOLD_GB": "MONITORING", - "TRAFFIC_IGNORED_NODES": "MONITORING", - "TRAFFIC_EXCLUDED_USER_UUIDS": "MONITORING", - "TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": "MONITORING", - "SUSPICIOUS_NOTIFICATIONS_TOPIC_ID": "MONITORING", - "TRAFFIC_CHECK_BATCH_SIZE": "MONITORING", - "TRAFFIC_CHECK_CONCURRENCY": "MONITORING", - "ENABLE_LOGO_MODE": "INTERFACE_BRANDING", - "LOGO_FILE": "INTERFACE_BRANDING", - "HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION", - "MAIN_MENU_MODE": "INTERFACE", - "CONNECT_BUTTON_MODE": "CONNECT_BUTTON", - "MINIAPP_CUSTOM_URL": "CONNECT_BUTTON", - "APP_CONFIG_PATH": "ADDITIONAL", - "ENABLE_DEEP_LINKS": "ADDITIONAL", - "APP_CONFIG_CACHE_TTL": "ADDITIONAL", - "INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE", - "MAINTENANCE_MESSAGE": "MAINTENANCE", - "MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE", - "MAINTENANCE_AUTO_ENABLE": "MAINTENANCE", - "MAINTENANCE_RETRY_ATTEMPTS": "MAINTENANCE", - "WEBHOOK_URL": "WEBHOOK", - "WEBHOOK_SECRET": "WEBHOOK", - "VERSION_CHECK_ENABLED": "VERSION", - "VERSION_CHECK_REPO": "VERSION", - "VERSION_CHECK_INTERVAL_HOURS": "VERSION", - "TELEGRAM_STARS_RATE_RUB": "TELEGRAM", - "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE", - "REMNAWAVE_USER_USERNAME_TEMPLATE": "REMNAWAVE", - "REMNAWAVE_AUTO_SYNC_ENABLED": "REMNAWAVE", - "REMNAWAVE_AUTO_SYNC_TIMES": "REMNAWAVE", - "CABINET_REMNA_SUB_CONFIG": "MINIAPP", + CATEGORY_KEY_OVERRIDES: dict[str, str] = { + 'DATABASE_URL': 'DATABASE', + 'DATABASE_MODE': 'DATABASE', + 'LOCALES_PATH': 'LOCALIZATION', + 'CHANNEL_SUB_ID': 'CHANNEL', + 'CHANNEL_LINK': 'CHANNEL', + 'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL', + 'BOT_USERNAME': 'CORE', + 'DEFAULT_LANGUAGE': 'LOCALIZATION', + 'AVAILABLE_LANGUAGES': 'LOCALIZATION', + 'LANGUAGE_SELECTION_ENABLED': 'LOCALIZATION', + 'DEFAULT_DEVICE_LIMIT': 'SUBSCRIPTIONS_CORE', + 'DEFAULT_TRAFFIC_LIMIT_GB': 'SUBSCRIPTIONS_CORE', + 'MAX_DEVICES_LIMIT': 'SUBSCRIPTIONS_CORE', + 'PRICE_PER_DEVICE': 'SUBSCRIPTIONS_CORE', + 'DEVICES_SELECTION_ENABLED': 'SUBSCRIPTIONS_CORE', + 'DEVICES_SELECTION_DISABLED_AMOUNT': 'SUBSCRIPTIONS_CORE', + 'BASE_SUBSCRIPTION_PRICE': 'SUBSCRIPTIONS_CORE', + 'SALES_MODE': 'SUBSCRIPTIONS_CORE', + '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', + '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', + 'PAID_SUBSCRIPTION_USER_TAG': 'SUBSCRIPTION_PRICES', + 'TRAFFIC_PACKAGES_CONFIG': 'TRAFFIC_PACKAGES', + 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED': 'SUBSCRIPTIONS_CORE', + 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS': 'SUBSCRIPTIONS_CORE', + 'DEFAULT_AUTOPAY_ENABLED': 'AUTOPAY', + 'DEFAULT_AUTOPAY_DAYS_BEFORE': 'AUTOPAY', + 'MIN_BALANCE_FOR_AUTOPAY_KOPEKS': 'AUTOPAY', + 'TRIAL_WARNING_HOURS': 'TRIAL', + 'TRIAL_USER_TAG': 'TRIAL', + 'SUPPORT_USERNAME': 'SUPPORT', + 'SUPPORT_MENU_ENABLED': 'SUPPORT', + 'SUPPORT_SYSTEM_MODE': 'SUPPORT', + 'SUPPORT_TICKET_SLA_ENABLED': 'SUPPORT', + 'SUPPORT_TICKET_SLA_MINUTES': 'SUPPORT', + 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS': 'SUPPORT', + 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES': 'SUPPORT', + 'ADMIN_NOTIFICATIONS_ENABLED': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_CHAT_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID': 'ADMIN_NOTIFICATIONS', + 'ADMIN_REPORTS_ENABLED': 'ADMIN_REPORTS', + 'ADMIN_REPORTS_CHAT_ID': 'ADMIN_REPORTS', + 'ADMIN_REPORTS_TOPIC_ID': 'ADMIN_REPORTS', + 'ADMIN_REPORTS_SEND_TIME': 'ADMIN_REPORTS', + 'PAYMENT_SERVICE_NAME': 'PAYMENT', + 'PAYMENT_BALANCE_DESCRIPTION': 'PAYMENT', + 'PAYMENT_SUBSCRIPTION_DESCRIPTION': 'PAYMENT', + 'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT', + 'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT', + 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT', + 'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': 'PAYMENT', + 'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION', + 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION', + 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION', + 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': 'SIMPLE_SUBSCRIPTION', + 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': 'SIMPLE_SUBSCRIPTION', + 'DISABLE_TOPUP_BUTTONS': 'PAYMENT', + 'SUPPORT_TOPUP_ENABLED': 'PAYMENT', + 'ENABLE_NOTIFICATIONS': 'NOTIFICATIONS', + 'NOTIFICATION_RETRY_ATTEMPTS': 'NOTIFICATIONS', + 'NOTIFICATION_CACHE_HOURS': 'NOTIFICATIONS', + 'MONITORING_LOGS_RETENTION_DAYS': 'MONITORING', + 'MONITORING_INTERVAL': 'MONITORING', + 'TRAFFIC_MONITORING_ENABLED': 'MONITORING', + 'TRAFFIC_MONITORING_INTERVAL_HOURS': 'MONITORING', + 'TRAFFIC_MONITORED_NODES': 'MONITORING', + 'TRAFFIC_SNAPSHOT_TTL_HOURS': 'MONITORING', + 'TRAFFIC_FAST_CHECK_ENABLED': 'MONITORING', + 'TRAFFIC_FAST_CHECK_INTERVAL_MINUTES': 'MONITORING', + 'TRAFFIC_FAST_CHECK_THRESHOLD_GB': 'MONITORING', + 'TRAFFIC_DAILY_CHECK_ENABLED': 'MONITORING', + 'TRAFFIC_DAILY_CHECK_TIME': 'MONITORING', + 'TRAFFIC_DAILY_THRESHOLD_GB': 'MONITORING', + 'TRAFFIC_IGNORED_NODES': 'MONITORING', + 'TRAFFIC_EXCLUDED_USER_UUIDS': 'MONITORING', + 'TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES': 'MONITORING', + 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID': 'MONITORING', + 'TRAFFIC_CHECK_BATCH_SIZE': 'MONITORING', + 'TRAFFIC_CHECK_CONCURRENCY': 'MONITORING', + 'ENABLE_LOGO_MODE': 'INTERFACE_BRANDING', + 'LOGO_FILE': 'INTERFACE_BRANDING', + 'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION', + 'MAIN_MENU_MODE': 'INTERFACE', + 'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON', + 'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON', + 'APP_CONFIG_PATH': 'ADDITIONAL', + 'ENABLE_DEEP_LINKS': 'ADDITIONAL', + 'APP_CONFIG_CACHE_TTL': 'ADDITIONAL', + 'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE', + 'MAINTENANCE_MESSAGE': 'MAINTENANCE', + 'MAINTENANCE_CHECK_INTERVAL': 'MAINTENANCE', + 'MAINTENANCE_AUTO_ENABLE': 'MAINTENANCE', + 'MAINTENANCE_RETRY_ATTEMPTS': 'MAINTENANCE', + 'WEBHOOK_URL': 'WEBHOOK', + 'WEBHOOK_SECRET': 'WEBHOOK', + 'VERSION_CHECK_ENABLED': 'VERSION', + 'VERSION_CHECK_REPO': 'VERSION', + 'VERSION_CHECK_INTERVAL_HOURS': 'VERSION', + 'TELEGRAM_STARS_RATE_RUB': 'TELEGRAM', + 'REMNAWAVE_USER_DESCRIPTION_TEMPLATE': 'REMNAWAVE', + 'REMNAWAVE_USER_USERNAME_TEMPLATE': 'REMNAWAVE', + 'REMNAWAVE_AUTO_SYNC_ENABLED': 'REMNAWAVE', + 'REMNAWAVE_AUTO_SYNC_TIMES': 'REMNAWAVE', + 'CABINET_REMNA_SUB_CONFIG': 'MINIAPP', } - 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", - "HELEKET_": "HELEKET", - "CLOUDPAYMENTS_": "CLOUDPAYMENTS", - "FREEKASSA_": "FREEKASSA", - "KASSA_AI_": "KASSA_AI", - "PLATEGA_": "PLATEGA", - "MULENPAY_": "MULENPAY", - "PAL24_": "PAL24", - "PAYMENT_": "PAYMENT", - "PAYMENT_VERIFICATION_": "PAYMENT_VERIFICATION", - "WATA_": "WATA", - "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", - "SIMPLE_SUBSCRIPTION_": "SIMPLE_SUBSCRIPTION", - "CONNECT_BUTTON_HAPP": "HAPP", - "HAPP_": "HAPP", - "SKIP_": "SKIP", - "MINIAPP_": "MINIAPP", - "MONITORING_": "MONITORING", - "NOTIFICATION_": "NOTIFICATIONS", - "SERVER_STATUS": "SERVER_STATUS", - "MAINTENANCE_": "MAINTENANCE", - "VERSION_CHECK": "VERSION", - "BACKUP_": "BACKUP", - "WEBHOOK_": "WEBHOOK", - "LOG_": "LOG", - "WEB_API_": "WEB_API", - "DEBUG": "DEBUG", - "DISPLAY_NAME_": "MODERATION", - "BAN_MSG_": "BAN_NOTIFICATIONS", + 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', + 'HELEKET_': 'HELEKET', + 'CLOUDPAYMENTS_': 'CLOUDPAYMENTS', + 'FREEKASSA_': 'FREEKASSA', + 'KASSA_AI_': 'KASSA_AI', + 'PLATEGA_': 'PLATEGA', + 'MULENPAY_': 'MULENPAY', + 'PAL24_': 'PAL24', + 'PAYMENT_': 'PAYMENT', + 'PAYMENT_VERIFICATION_': 'PAYMENT_VERIFICATION', + 'WATA_': 'WATA', + 'EXTERNAL_ADMIN_': 'EXTERNAL_ADMIN', + 'SIMPLE_SUBSCRIPTION_': 'SIMPLE_SUBSCRIPTION', + 'CONNECT_BUTTON_HAPP': 'HAPP', + 'HAPP_': 'HAPP', + 'SKIP_': 'SKIP', + 'MINIAPP_': 'MINIAPP', + 'MONITORING_': 'MONITORING', + 'NOTIFICATION_': 'NOTIFICATIONS', + 'SERVER_STATUS': 'SERVER_STATUS', + 'MAINTENANCE_': 'MAINTENANCE', + 'VERSION_CHECK': 'VERSION', + 'BACKUP_': 'BACKUP', + 'WEBHOOK_': 'WEBHOOK', + 'LOG_': 'LOG', + 'WEB_API_': 'WEB_API', + 'DEBUG': 'DEBUG', + 'DISPLAY_NAME_': 'MODERATION', + 'BAN_MSG_': 'BAN_NOTIFICATIONS', } - CHOICES: Dict[str, List[ChoiceOption]] = { - "DATABASE_MODE": [ - ChoiceOption("auto", "🤖 Авто"), - ChoiceOption("postgresql", "🐘 PostgreSQL"), - ChoiceOption("sqlite", "💾 SQLite"), + 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_AUTH_TYPE': [ + ChoiceOption('api_key', '🔑 API Key'), + ChoiceOption('basic_auth', '🧾 Basic Auth'), ], - "REMNAWAVE_USER_DELETE_MODE": [ - ChoiceOption("delete", "🗑 Удалять"), - ChoiceOption("disable", "🚫 Деактивировать"), + 'REMNAWAVE_USER_DELETE_MODE': [ + ChoiceOption('delete', '🗑 Удалять'), + ChoiceOption('disable', '🚫 Деактивировать'), ], - "TRAFFIC_SELECTION_MODE": [ - ChoiceOption("selectable", "📦 Выбор пакетов"), - ChoiceOption("fixed", "📏 Фиксированный лимит"), - ChoiceOption("fixed_with_topup", "📏 Фикс. лимит + докупка"), + 'TRAFFIC_SELECTION_MODE': [ + ChoiceOption('selectable', '📦 Выбор пакетов'), + ChoiceOption('fixed', '📏 Фиксированный лимит'), + ChoiceOption('fixed_with_topup', '📏 Фикс. лимит + докупка'), ], - "DEFAULT_TRAFFIC_RESET_STRATEGY": [ - ChoiceOption("NO_RESET", "♾️ Без сброса"), - ChoiceOption("DAY", "📅 Ежедневно"), - ChoiceOption("WEEK", "🗓 Еженедельно"), - ChoiceOption("MONTH", "📆 Ежемесячно"), + 'DEFAULT_TRAFFIC_RESET_STRATEGY': [ + ChoiceOption('NO_RESET', '♾️ Без сброса'), + ChoiceOption('DAY', '📅 Ежедневно'), + ChoiceOption('WEEK', '🗓 Еженедельно'), + ChoiceOption('MONTH', '📆 Ежемесячно'), ], - "SUPPORT_SYSTEM_MODE": [ - ChoiceOption("tickets", "🎫 Только тикеты"), - ChoiceOption("contact", "💬 Только контакт"), - ChoiceOption("both", "🔁 Оба варианта"), + '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"), + 'CONNECT_BUTTON_MODE': [ + ChoiceOption('guide', '📘 Гайд'), + ChoiceOption('miniapp_subscription', '🧾 Mini App подписка'), + ChoiceOption('miniapp_custom', '🧩 Mini App (ссылка)'), + ChoiceOption('link', '🔗 Прямая ссылка'), + ChoiceOption('happ_cryptolink', '🪙 Happ CryptoLink'), ], - "MAIN_MENU_MODE": [ - ChoiceOption("default", "📋 Полное меню"), - ChoiceOption("text", "📝 Текстовое меню"), + 'MAIN_MENU_MODE': [ + ChoiceOption('default', '📋 Полное меню'), + ChoiceOption('text', '📝 Текстовое меню'), ], - "SALES_MODE": [ - ChoiceOption("classic", "📋 Классический (периоды из .env)"), - ChoiceOption("tariffs", "📦 Тарифы (из кабинета)"), + 'SALES_MODE': [ + ChoiceOption('classic', '📋 Классический (периоды из .env)'), + ChoiceOption('tariffs', '📦 Тарифы (из кабинета)'), ], - "SERVER_STATUS_MODE": [ - ChoiceOption("disabled", "🚫 Отключено"), - ChoiceOption("external_link", "🌐 Внешняя ссылка"), - ChoiceOption("external_link_miniapp", "🧭 Mini App ссылка"), - ChoiceOption("xray", "📊 XRay Checker"), + '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_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_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"), - ChoiceOption(7, "7 — НДС 5%"), - ChoiceOption(8, "8 — НДС 7%"), - ChoiceOption(9, "9 — НДС 5/105"), - ChoiceOption(10, "10 — НДС 7/107"), - ChoiceOption(11, "11 — НДС 22%"), - ChoiceOption(12, "12 — НДС 22/122"), + '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'), + ChoiceOption(7, '7 — НДС 5%'), + ChoiceOption(8, '8 — НДС 7%'), + ChoiceOption(9, '9 — НДС 5/105'), + ChoiceOption(10, '10 — НДС 7/107'), + ChoiceOption(11, '11 — НДС 22%'), + ChoiceOption(12, '12 — НДС 22/122'), ], - "MULENPAY_LANGUAGE": [ - ChoiceOption("ru", "🇷🇺 Русский"), - ChoiceOption("en", "🇬🇧 Английский"), + 'MULENPAY_LANGUAGE': [ + ChoiceOption('ru', '🇷🇺 Русский'), + ChoiceOption('en', '🇬🇧 Английский'), ], - "LOG_LEVEL": [ - ChoiceOption("DEBUG", "🐞 Debug"), - ChoiceOption("INFO", "ℹ️ Info"), - ChoiceOption("WARNING", "⚠️ Warning"), - ChoiceOption("ERROR", "❌ Error"), - ChoiceOption("CRITICAL", "🔥 Critical"), + 'LOG_LEVEL': [ + ChoiceOption('DEBUG', '🐞 Debug'), + ChoiceOption('INFO', 'ℹ️ Info'), + ChoiceOption('WARNING', '⚠️ Warning'), + ChoiceOption('ERROR', '❌ Error'), + ChoiceOption('CRITICAL', '🔥 Critical'), ], } - SETTING_HINTS: Dict[str, Dict[str, str]] = { - "SALES_MODE": { - "description": ( - "Режим продажи подписок. " - "«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). " - "«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами." + SETTING_HINTS: dict[str, dict[str, str]] = { + 'SALES_MODE': { + 'description': ( + 'Режим продажи подписок. ' + '«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). ' + '«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами.' ), - "format": "Выберите один из доступных режимов.", - "example": "tariffs", - "warning": ( - "При смене режима логика покупки подписки полностью меняется. " - "В режиме «Тарифы» пользователи выбирают готовый тарифный план." + 'format': 'Выберите один из доступных режимов.', + 'example': 'tariffs', + 'warning': ( + 'При смене режима логика покупки подписки полностью меняется. ' + 'В режиме «Тарифы» пользователи выбирают готовый тарифный план.' ), }, - "YOOKASSA_ENABLED": { - "description": ( - "Включает оплату через YooKassa. " - "Требует корректных идентификаторов магазина и секретного ключа." + 'YOOKASSA_ENABLED': { + 'description': ( + 'Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.' ), - "format": "Булево значение: выберите \"Включить\" или \"Выключить\".", - "example": "Включено при полностью настроенной интеграции.", - "warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.", - "dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL", + 'format': 'Булево значение: выберите "Включить" или "Выключить".', + 'example': 'Включено при полностью настроенной интеграции.', + 'warning': 'При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.', + 'dependencies': 'YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL', }, - "SIMPLE_SUBSCRIPTION_ENABLED": { - "description": "Показывает в меню пункт с быстрой покупкой подписки.", - "format": "Булево значение.", - "example": "true", - "warning": "Если остались не настроенные параметры, предложение может вести себя некорректно.", + 'SIMPLE_SUBSCRIPTION_ENABLED': { + 'description': 'Показывает в меню пункт с быстрой покупкой подписки.', + 'format': 'Булево значение.', + 'example': 'true', + 'warning': 'Если остались не настроенные параметры, предложение может вести себя некорректно.', }, - "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": { - "description": "Период подписки, который предлагается при быстрой покупке.", - "format": "Выберите один из доступных периодов.", - "example": "30 дн. — 990 ₽", - "warning": "Не забудьте настроить цену периода в блоке «Стоимость тарифов».", + 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': { + 'description': 'Период подписки, который предлагается при быстрой покупке.', + 'format': 'Выберите один из доступных периодов.', + 'example': '30 дн. — 990 ₽', + 'warning': 'Не забудьте настроить цену периода в блоке «Стоимость тарифов».', }, - "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": { - "description": "Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.", - "format": "Выберите число устройств.", - "example": "2 устройства", - "warning": "Значение не должно превышать допустимый лимит в настройках подписок.", + 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': { + 'description': 'Сколько устройств получит пользователь вместе с подпиской по быстрой покупке.', + 'format': 'Выберите число устройств.', + 'example': '2 устройства', + 'warning': 'Значение не должно превышать допустимый лимит в настройках подписок.', }, - "SIMPLE_SUBSCRIPTION_TRAFFIC_GB": { - "description": "Объём трафика, включённый в простую подписку (0 = безлимит).", - "format": "Выберите пакет трафика.", - "example": "Безлимит", + 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': { + 'description': 'Объём трафика, включённый в простую подписку (0 = безлимит).', + 'format': 'Выберите пакет трафика.', + 'example': 'Безлимит', }, - "SIMPLE_SUBSCRIPTION_SQUAD_UUID": { - "description": ( - "Привязка быстрой подписки к конкретному скваду. " - "Оставьте пустым для любого доступного сервера." + 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': { + 'description': ( + 'Привязка быстрой подписки к конкретному скваду. Оставьте пустым для любого доступного сервера.' ), - "format": "Выберите сквад из списка или очистите значение.", - "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba", - "warning": "Убедитесь, что выбранный сквад активен и доступен для подписки.", + 'format': 'Выберите сквад из списка или очистите значение.', + 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba', + 'warning': 'Убедитесь, что выбранный сквад активен и доступен для подписки.', }, - "DEVICES_SELECTION_ENABLED": { - "description": "Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.", - "format": "Булево значение.", - "example": "false", - "warning": "При отключении пользователи не смогут докупать устройства из интерфейса бота.", + 'DEVICES_SELECTION_ENABLED': { + 'description': 'Разрешает пользователям выбирать количество устройств при покупке и продлении подписки.', + 'format': 'Булево значение.', + 'example': 'false', + 'warning': 'При отключении пользователи не смогут докупать устройства из интерфейса бота.', }, - "DEVICES_SELECTION_DISABLED_AMOUNT": { - "description": ( - "Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. " - "Значение 0 отключает назначение устройств." + 'DEVICES_SELECTION_DISABLED_AMOUNT': { + 'description': ( + 'Лимит устройств, который автоматически назначается, когда выбор количества устройств выключен. ' + 'Значение 0 отключает назначение устройств.' ), - "format": "Целое число от 0 и выше.", - "example": "3", - "warning": "При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.", + 'format': 'Целое число от 0 и выше.', + 'example': '3', + 'warning': 'При 0 RemnaWave не получит лимит устройств, пользователям не показываются цифры в интерфейсе.', }, - "CRYPTOBOT_ENABLED": { - "description": "Разрешает принимать криптоплатежи через CryptoBot.", - "format": "Булево значение.", - "example": "Включите после указания токена API и секрета вебхука.", - "warning": "Пустой токен или неверный вебхук приведут к отказам платежей.", - "dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET", + 'CRYPTOBOT_ENABLED': { + 'description': 'Разрешает принимать криптоплатежи через CryptoBot.', + 'format': 'Булево значение.', + 'example': 'Включите после указания токена API и секрета вебхука.', + 'warning': 'Пустой токен или неверный вебхук приведут к отказам платежей.', + 'dependencies': 'CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET', }, - "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED": { - "description": ( - "Запускает фоновую проверку ожидающих пополнений и повторно обращается " - "к платёжным провайдерам без участия администратора." + 'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED': { + 'description': ( + 'Запускает фоновую проверку ожидающих пополнений и повторно обращается ' + 'к платёжным провайдерам без участия администратора.' ), - "format": "Булево значение.", - "example": "Включено, чтобы автоматически перепроверять зависшие платежи.", - "warning": "Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.", + 'format': 'Булево значение.', + 'example': 'Включено, чтобы автоматически перепроверять зависшие платежи.', + 'warning': 'Требует активных интеграций YooKassa, {mulenpay_name}, PayPalych, WATA или CryptoBot.', }, - "PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES": { - "description": ( - "Интервал между автоматическими проверками ожидающих пополнений в минутах." - ), - "format": "Целое число не меньше 1.", - "example": "10", - "warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.", - "dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED", + 'PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES': { + 'description': ('Интервал между автоматическими проверками ожидающих пополнений в минутах.'), + 'format': 'Целое число не меньше 1.', + 'example': '10', + 'warning': 'Слишком малый интервал может привести к частым обращениям к платёжным API.', + 'dependencies': 'PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED', }, - "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": { - "description": ( - "Включает применение базовых скидок на периоды подписок в групповых промо." - ), - "format": "Булево значение.", - "example": "true", - "warning": "Скидки применяются только если указаны корректные пары периодов и процентов.", + 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED': { + 'description': ('Включает применение базовых скидок на периоды подписок в групповых промо.'), + 'format': 'Булево значение.', + 'example': 'true', + 'warning': 'Скидки применяются только если указаны корректные пары периодов и процентов.', }, - "BASE_PROMO_GROUP_PERIOD_DISCOUNTS": { - "description": ( - "Список скидок для групп: каждая пара задаёт дни периода и процент скидки." - ), - "format": "Через запятую пары вида <дней>:<скидка>.", - "example": "30:10,60:20,90:30,180:50,360:65", - "warning": "Некорректные записи будут проигнорированы. Процент ограничен 0-100.", + 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS': { + 'description': ('Список скидок для групп: каждая пара задаёт дни периода и процент скидки.'), + 'format': 'Через запятую пары вида <дней>:<скидка>.', + 'example': '30:10,60:20,90:30,180:50,360:65', + 'warning': 'Некорректные записи будут проигнорированы. Процент ограничен 0-100.', }, - "AUTO_PURCHASE_AFTER_TOPUP_ENABLED": { - "description": ( - "При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения." + 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': { + 'description': ( + 'При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения.' ), - "format": "Булево значение.", - "example": "true", - "warning": ( - "Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена." + 'format': 'Булево значение.', + 'example': 'true', + 'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'), + }, + 'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': { + 'description': ( + 'Включает режим яркого промпта активации подписки после пополнения баланса. ' + 'Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками ' + 'и кнопками для активации/продления подписки или изменения количества устройств.' + ), + 'format': 'Булево значение.', + 'example': 'true', + 'warning': ( + 'При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. ' + 'Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств).' ), }, - "SHOW_ACTIVATION_PROMPT_AFTER_TOPUP": { - "description": ( - "Включает режим яркого промпта активации подписки после пополнения баланса. " - "Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками " - "и кнопками для активации/продления подписки или изменения количества устройств." + 'SUPPORT_TICKET_SLA_MINUTES': { + 'description': 'Лимит времени для ответа модераторов на тикет в минутах.', + 'format': 'Целое число от 1 до 1440.', + 'example': '5', + 'warning': 'Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.', + 'dependencies': 'SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', + }, + 'MAINTENANCE_MODE': { + 'description': 'Переводит бота в режим технического обслуживания и скрывает действия для пользователей.', + 'format': 'Булево значение.', + 'example': 'Включено на время плановых работ.', + 'warning': 'Не забудьте отключить после завершения работ, иначе бот останется недоступен.', + 'dependencies': 'MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL', + }, + 'MAINTENANCE_MONITORING_ENABLED': { + 'description': ('Управляет автоматическим запуском мониторинга панели Remnawave при старте бота.'), + 'format': 'Булево значение.', + 'example': 'false', + 'warning': ('При отключении мониторинг можно запустить вручную из панели администратора.'), + 'dependencies': 'MAINTENANCE_CHECK_INTERVAL', + }, + 'MAINTENANCE_RETRY_ATTEMPTS': { + 'description': ('Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности.'), + 'format': 'Целое число не меньше 1.', + 'example': '3', + 'warning': ( + 'Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний.' ), - "format": "Булево значение.", - "example": "true", - "warning": ( - "При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. " - "Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств)." + 'dependencies': 'MAINTENANCE_CHECK_INTERVAL', + }, + 'DISPLAY_NAME_BANNED_KEYWORDS': { + 'description': ( + 'Список слов и фрагментов, при наличии которых в отображаемом имени пользователь будет заблокирован.' + ), + 'format': 'Перечислите ключевые слова через запятую или с новой строки.', + 'example': 'support, security, служебн', + 'warning': 'Слишком агрессивные фильтры могут блокировать добросовестных пользователей.', + 'dependencies': 'Фильтр отображаемых имен', + }, + 'REMNAWAVE_API_URL': { + 'description': 'Базовый адрес панели RemnaWave, с которой синхронизируется бот.', + 'format': 'Полный URL вида https://panel.example.com.', + 'example': 'https://panel.remnawave.net', + 'warning': 'Недоступный адрес приведет к ошибкам при управлении VPN-учетками.', + 'dependencies': 'REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD', + }, + 'REMNAWAVE_AUTO_SYNC_ENABLED': { + 'description': 'Автоматически запускает синхронизацию пользователей и серверов с панелью RemnaWave.', + 'format': 'Булево значение.', + 'example': 'Включено при корректно настроенных API-ключах.', + 'warning': 'При включении без расписания синхронизация не будет выполнена.', + 'dependencies': 'REMNAWAVE_AUTO_SYNC_TIMES', + }, + 'REMNAWAVE_AUTO_SYNC_TIMES': { + 'description': ('Список времени в формате HH:MM, когда запускается автосинхронизация в течение суток.'), + 'format': 'Перечислите время через запятую или с новой строки (например, 03:00, 15:00).', + 'example': '03:00, 15:00', + 'warning': ( + 'Минимальный интервал между запусками не ограничен, но слишком частые синхронизации нагружают панель.' + ), + 'dependencies': 'REMNAWAVE_AUTO_SYNC_ENABLED', + }, + 'REMNAWAVE_USER_DESCRIPTION_TEMPLATE': { + 'description': ( + 'Шаблон текста, который бот передает в поле Description при создании ' + 'или обновлении пользователя в панели RemnaWave.' + ), + 'format': ('Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}.'), + 'example': 'Bot user: {full_name} {username}', + 'warning': 'Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.', + }, + 'REMNAWAVE_USER_USERNAME_TEMPLATE': { + 'description': ( + 'Шаблон имени пользователя, которое создаётся в панели RemnaWave для телеграм-пользователя.' + ), + 'format': ('Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}.'), + 'example': 'vpn_{username_clean}_{telegram_id}', + 'warning': ( + 'Недопустимые символы автоматически заменяются на подчёркивания. ' + 'Если результат пустой, используется user_{telegram_id}.' ), }, - "SUPPORT_TICKET_SLA_MINUTES": { - "description": "Лимит времени для ответа модераторов на тикет в минутах.", - "format": "Целое число от 1 до 1440.", - "example": "5", - "warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.", - "dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES", + 'EXTERNAL_ADMIN_TOKEN': { + 'description': 'Приватный токен, который использует внешняя админка для проверки запросов.', + 'format': 'Значение генерируется автоматически из username бота и его токена и доступно только для чтения.', + 'example': 'Генерируется автоматически', + 'warning': 'Токен обновится при смене username или токена бота.', + 'dependencies': 'Username телеграм-бота, токен бота', }, - "MAINTENANCE_MODE": { - "description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.", - "format": "Булево значение.", - "example": "Включено на время плановых работ.", - "warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.", - "dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL", + 'EXTERNAL_ADMIN_TOKEN_BOT_ID': { + 'description': 'Идентификатор телеграм-бота, с которым связан токен внешней админки.', + 'format': 'Проставляется автоматически после первого запуска и не редактируется вручную.', + 'example': '123456789', + 'warning': 'Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.', + 'dependencies': 'Результат вызова getMe() в Telegram Bot API', }, - "MAINTENANCE_MONITORING_ENABLED": { - "description": ( - "Управляет автоматическим запуском мониторинга панели Remnawave при старте бота." + 'TRIAL_USER_TAG': { + 'description': ( + 'Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave.' ), - "format": "Булево значение.", - "example": "false", - "warning": ( - "При отключении мониторинг можно запустить вручную из панели администратора." + 'format': 'До 16 символов: заглавные A-Z, цифры и подчёркивание.', + 'example': 'TRIAL_USER', + 'warning': 'Неверный формат будет проигнорирован при создании пользователя.', + 'dependencies': 'Активация триала и включенная интеграция с RemnaWave', + }, + 'PAID_SUBSCRIPTION_USER_TAG': { + 'description': ('Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave.'), + 'format': 'До 16 символов: заглавные A-Z, цифры и подчёркивание.', + 'example': 'PAID_USER', + 'warning': 'Если тег не задан или невалиден, существующий тег не будет изменён.', + 'dependencies': 'Оплата подписки и интеграция с RemnaWave', + }, + 'CABINET_REMNA_SUB_CONFIG': { + 'description': ( + 'UUID конфигурации страницы подписки из RemnaWave. ' + 'Позволяет синхронизировать список приложений напрямую из панели.' ), - "dependencies": "MAINTENANCE_CHECK_INTERVAL", + 'format': 'UUID конфигурации из раздела Subscription Page Configs в RemnaWave.', + 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba', + 'warning': 'Убедитесь, что конфигурация существует в панели и содержит нужные приложения.', + 'dependencies': 'Настроенное подключение к RemnaWave API', }, - "MAINTENANCE_RETRY_ATTEMPTS": { - "description": ( - "Сколько раз повторять проверку панели Remnawave перед фиксацией недоступности." + 'TRAFFIC_MONITORING_ENABLED': { + 'description': ( + 'Включает автоматический мониторинг трафика пользователей. ' + 'Система отслеживает изменения трафика (дельту) и сохраняет snapshot в Redis. ' + 'При превышении порогов отправляются уведомления пользователям и админам.' ), - "format": "Целое число не меньше 1.", - "example": "3", - "warning": ( - "Большие значения увеличивают время реакции на реальные сбои, но помогают избежать ложных срабатываний." + 'format': 'Булево значение.', + 'example': 'true', + 'warning': ( + 'Требует настроенного подключения к Redis. ' + 'При включении будет запущен фоновый мониторинг трафика по расписанию.' ), - "dependencies": "MAINTENANCE_CHECK_INTERVAL", + 'dependencies': 'Redis, TRAFFIC_MONITORING_INTERVAL_HOURS, TRAFFIC_SNAPSHOT_TTL_HOURS', }, - "DISPLAY_NAME_BANNED_KEYWORDS": { - "description": ( - "Список слов и фрагментов, при наличии которых в отображаемом имени " - "пользователь будет заблокирован." + 'TRAFFIC_MONITORING_INTERVAL_HOURS': { + 'description': ( + 'Интервал проверки трафика в часах. ' + 'Каждые N часов система проверяет трафик всех активных пользователей и сравнивает с предыдущим snapshot.' ), - "format": "Перечислите ключевые слова через запятую или с новой строки.", - "example": "support, security, служебн", - "warning": "Слишком агрессивные фильтры могут блокировать добросовестных пользователей.", - "dependencies": "Фильтр отображаемых имен", - }, - "REMNAWAVE_API_URL": { - "description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.", - "format": "Полный URL вида https://panel.example.com.", - "example": "https://panel.remnawave.net", - "warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.", - "dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD", - }, - "REMNAWAVE_AUTO_SYNC_ENABLED": { - "description": "Автоматически запускает синхронизацию пользователей и серверов с панелью RemnaWave.", - "format": "Булево значение.", - "example": "Включено при корректно настроенных API-ключах.", - "warning": "При включении без расписания синхронизация не будет выполнена.", - "dependencies": "REMNAWAVE_AUTO_SYNC_TIMES", - }, - "REMNAWAVE_AUTO_SYNC_TIMES": { - "description": ( - "Список времени в формате HH:MM, когда запускается автосинхронизация " - "в течение суток." + 'format': 'Целое число часов (минимум 1).', + 'example': '24', + 'warning': ( + 'Слишком маленький интервал может создать большую нагрузку на RemnaWave API. ' + 'Рекомендуется 24 часа для ежедневного мониторинга.' ), - "format": "Перечислите время через запятую или с новой строки (например, 03:00, 15:00).", - "example": "03:00, 15:00", - "warning": ( - "Минимальный интервал между запусками не ограничен, но слишком частые " - "синхронизации нагружают панель." + 'dependencies': 'TRAFFIC_MONITORING_ENABLED', + }, + 'TRAFFIC_MONITORED_NODES': { + 'description': ( + 'Список UUID нод для мониторинга трафика через запятую. ' + 'Если пусто - мониторятся все ноды. ' + 'Позволяет ограничить мониторинг только определенными серверами.' ), - "dependencies": "REMNAWAVE_AUTO_SYNC_ENABLED", + 'format': 'UUID через запятую или пусто для всех нод.', + 'example': 'd4aa2b8c-9a36-4f31-93a2-6f07dad05fba, a1b2c3d4-5678-90ab-cdef-1234567890ab', + 'warning': 'UUID должны существовать в RemnaWave, иначе мониторинг не будет работать.', + 'dependencies': 'TRAFFIC_MONITORING_ENABLED', }, - "REMNAWAVE_USER_DESCRIPTION_TEMPLATE": { - "description": ( - "Шаблон текста, который бот передает в поле Description при создании " - "или обновлении пользователя в панели RemnaWave." + 'TRAFFIC_SNAPSHOT_TTL_HOURS': { + 'description': ( + 'Время жизни (TTL) snapshot трафика в Redis в часах. ' + 'Snapshot используется для вычисления дельты (изменения трафика) между проверками. ' + 'После истечения TTL snapshot удаляется и создается новый.' ), - "format": ( - "Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}." + 'format': 'Целое число часов (минимум 1).', + 'example': '24', + 'warning': ( + 'TTL должен быть >= интервала мониторинга. ' + 'Если TTL меньше интервала, snapshot будет удален до следующей проверки.' ), - "example": "Bot user: {full_name} {username}", - "warning": "Плейсхолдер {username} автоматически очищается, если у пользователя нет @username.", + 'dependencies': 'TRAFFIC_MONITORING_ENABLED, Redis', }, - "REMNAWAVE_USER_USERNAME_TEMPLATE": { - "description": ( - "Шаблон имени пользователя, которое создаётся в панели RemnaWave для " - "телеграм-пользователя." - ), - "format": ( - "Доступные плейсхолдеры: {full_name}, {username}, {username_clean}, {telegram_id}." - ), - "example": "vpn_{username_clean}_{telegram_id}", - "warning": ( - "Недопустимые символы автоматически заменяются на подчёркивания. " - "Если результат пустой, используется user_{telegram_id}." + 'TRAFFIC_FAST_CHECK_ENABLED': { + 'description': ( + 'Включает быструю проверку трафика. ' + 'Система сравнивает текущий трафик со snapshot и уведомляет о превышениях дельты.' ), + 'format': 'Булево значение.', + 'example': 'true', + 'warning': 'Требует Redis для хранения snapshot. При отключении проверки не выполняются.', + 'dependencies': 'Redis, TRAFFIC_FAST_CHECK_INTERVAL_MINUTES, TRAFFIC_FAST_CHECK_THRESHOLD_GB', }, - "EXTERNAL_ADMIN_TOKEN": { - "description": "Приватный токен, который использует внешняя админка для проверки запросов.", - "format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.", - "example": "Генерируется автоматически", - "warning": "Токен обновится при смене username или токена бота.", - "dependencies": "Username телеграм-бота, токен бота", + 'TRAFFIC_FAST_CHECK_INTERVAL_MINUTES': { + 'description': 'Интервал быстрой проверки трафика в минутах.', + 'format': 'Целое число минут (минимум 1).', + 'example': '10', + 'warning': 'Слишком малый интервал создаёт нагрузку на Remnawave API.', + 'dependencies': 'TRAFFIC_FAST_CHECK_ENABLED', }, - "EXTERNAL_ADMIN_TOKEN_BOT_ID": { - "description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.", - "format": "Проставляется автоматически после первого запуска и не редактируется вручную.", - "example": "123456789", - "warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.", - "dependencies": "Результат вызова getMe() в Telegram Bot API", + 'TRAFFIC_FAST_CHECK_THRESHOLD_GB': { + 'description': 'Порог дельты трафика в ГБ для быстрой проверки. При превышении отправляется уведомление.', + 'format': 'Число с плавающей точкой.', + 'example': '5.0', + 'warning': 'Слишком низкий порог приведёт к частым уведомлениям.', + 'dependencies': 'TRAFFIC_FAST_CHECK_ENABLED', }, - "TRIAL_USER_TAG": { - "description": ( - "Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave." - ), - "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", - "example": "TRIAL_USER", - "warning": "Неверный формат будет проигнорирован при создании пользователя.", - "dependencies": "Активация триала и включенная интеграция с RemnaWave", + 'TRAFFIC_DAILY_CHECK_ENABLED': { + 'description': 'Включает суточную проверку трафика через bandwidth-stats API.', + 'format': 'Булево значение.', + 'example': 'true', + 'warning': 'Проверка выполняется в указанное время (TRAFFIC_DAILY_CHECK_TIME).', + 'dependencies': 'TRAFFIC_DAILY_CHECK_TIME, TRAFFIC_DAILY_THRESHOLD_GB', }, - "PAID_SUBSCRIPTION_USER_TAG": { - "description": ( - "Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave." - ), - "format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.", - "example": "PAID_USER", - "warning": "Если тег не задан или невалиден, существующий тег не будет изменён.", - "dependencies": "Оплата подписки и интеграция с RemnaWave", + 'TRAFFIC_DAILY_CHECK_TIME': { + 'description': 'Время суточной проверки трафика в формате HH:MM (UTC).', + 'format': 'Строка времени HH:MM.', + 'example': '00:00', + 'warning': 'Время указывается в UTC.', + 'dependencies': 'TRAFFIC_DAILY_CHECK_ENABLED', }, - "CABINET_REMNA_SUB_CONFIG": { - "description": ( - "UUID конфигурации страницы подписки из RemnaWave. " - "Позволяет синхронизировать список приложений напрямую из панели." - ), - "format": "UUID конфигурации из раздела Subscription Page Configs в RemnaWave.", - "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba", - "warning": "Убедитесь, что конфигурация существует в панели и содержит нужные приложения.", - "dependencies": "Настроенное подключение к RemnaWave API", + 'TRAFFIC_DAILY_THRESHOLD_GB': { + 'description': 'Порог суточного трафика в ГБ. При превышении за 24 часа отправляется уведомление.', + 'format': 'Число с плавающей точкой.', + 'example': '50.0', + 'warning': 'Учитывается весь трафик за последние 24 часа.', + 'dependencies': 'TRAFFIC_DAILY_CHECK_ENABLED', }, - "TRAFFIC_MONITORING_ENABLED": { - "description": ( - "Включает автоматический мониторинг трафика пользователей. " - "Система отслеживает изменения трафика (дельту) и сохраняет snapshot в Redis. " - "При превышении порогов отправляются уведомления пользователям и админам." - ), - "format": "Булево значение.", - "example": "true", - "warning": ( - "Требует настроенного подключения к Redis. " - "При включении будет запущен фоновый мониторинг трафика по расписанию." - ), - "dependencies": "Redis, TRAFFIC_MONITORING_INTERVAL_HOURS, TRAFFIC_SNAPSHOT_TTL_HOURS", - }, - "TRAFFIC_MONITORING_INTERVAL_HOURS": { - "description": ( - "Интервал проверки трафика в часах. " - "Каждые N часов система проверяет трафик всех активных пользователей и сравнивает с предыдущим snapshot." - ), - "format": "Целое число часов (минимум 1).", - "example": "24", - "warning": ( - "Слишком маленький интервал может создать большую нагрузку на RemnaWave API. " - "Рекомендуется 24 часа для ежедневного мониторинга." - ), - "dependencies": "TRAFFIC_MONITORING_ENABLED", - }, - "TRAFFIC_MONITORED_NODES": { - "description": ( - "Список UUID нод для мониторинга трафика через запятую. " - "Если пусто - мониторятся все ноды. " - "Позволяет ограничить мониторинг только определенными серверами." - ), - "format": "UUID через запятую или пусто для всех нод.", - "example": "d4aa2b8c-9a36-4f31-93a2-6f07dad05fba, a1b2c3d4-5678-90ab-cdef-1234567890ab", - "warning": "UUID должны существовать в RemnaWave, иначе мониторинг не будет работать.", - "dependencies": "TRAFFIC_MONITORING_ENABLED", - }, - "TRAFFIC_SNAPSHOT_TTL_HOURS": { - "description": ( - "Время жизни (TTL) snapshot трафика в Redis в часах. " - "Snapshot используется для вычисления дельты (изменения трафика) между проверками. " - "После истечения TTL snapshot удаляется и создается новый." - ), - "format": "Целое число часов (минимум 1).", - "example": "24", - "warning": ( - "TTL должен быть >= интервала мониторинга. " - "Если TTL меньше интервала, snapshot будет удален до следующей проверки." - ), - "dependencies": "TRAFFIC_MONITORING_ENABLED, Redis", - }, - "TRAFFIC_FAST_CHECK_ENABLED": { - "description": ( - "Включает быструю проверку трафика. " - "Система сравнивает текущий трафик со snapshot и уведомляет о превышениях дельты." - ), - "format": "Булево значение.", - "example": "true", - "warning": "Требует Redis для хранения snapshot. При отключении проверки не выполняются.", - "dependencies": "Redis, TRAFFIC_FAST_CHECK_INTERVAL_MINUTES, TRAFFIC_FAST_CHECK_THRESHOLD_GB", - }, - "TRAFFIC_FAST_CHECK_INTERVAL_MINUTES": { - "description": "Интервал быстрой проверки трафика в минутах.", - "format": "Целое число минут (минимум 1).", - "example": "10", - "warning": "Слишком малый интервал создаёт нагрузку на Remnawave API.", - "dependencies": "TRAFFIC_FAST_CHECK_ENABLED", - }, - "TRAFFIC_FAST_CHECK_THRESHOLD_GB": { - "description": "Порог дельты трафика в ГБ для быстрой проверки. При превышении отправляется уведомление.", - "format": "Число с плавающей точкой.", - "example": "5.0", - "warning": "Слишком низкий порог приведёт к частым уведомлениям.", - "dependencies": "TRAFFIC_FAST_CHECK_ENABLED", - }, - "TRAFFIC_DAILY_CHECK_ENABLED": { - "description": "Включает суточную проверку трафика через bandwidth-stats API.", - "format": "Булево значение.", - "example": "true", - "warning": "Проверка выполняется в указанное время (TRAFFIC_DAILY_CHECK_TIME).", - "dependencies": "TRAFFIC_DAILY_CHECK_TIME, TRAFFIC_DAILY_THRESHOLD_GB", - }, - "TRAFFIC_DAILY_CHECK_TIME": { - "description": "Время суточной проверки трафика в формате HH:MM (UTC).", - "format": "Строка времени HH:MM.", - "example": "00:00", - "warning": "Время указывается в UTC.", - "dependencies": "TRAFFIC_DAILY_CHECK_ENABLED", - }, - "TRAFFIC_DAILY_THRESHOLD_GB": { - "description": "Порог суточного трафика в ГБ. При превышении за 24 часа отправляется уведомление.", - "format": "Число с плавающей точкой.", - "example": "50.0", - "warning": "Учитывается весь трафик за последние 24 часа.", - "dependencies": "TRAFFIC_DAILY_CHECK_ENABLED", - }, - "TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES": { - "description": "Кулдаун уведомлений по одному пользователю в минутах.", - "format": "Целое число минут.", - "example": "60", - "warning": "Защита от спама уведомлениями по одному и тому же пользователю.", + 'TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES': { + 'description': 'Кулдаун уведомлений по одному пользователю в минутах.', + 'format': 'Целое число минут.', + 'example': '60', + 'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.', }, } @classmethod def get_category_description(cls, category_key: str) -> str: - description = cls.CATEGORY_DESCRIPTIONS.get(category_key, "") + description = cls.CATEGORY_DESCRIPTIONS.get(category_key, '') return cls._format_dynamic_copy(category_key, description) @classmethod @@ -867,54 +838,54 @@ class BotConfigurationService: return key in cls._env_override_keys @classmethod - def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: + def _format_numeric_with_unit(cls, key: str, value: float) -> str | None: if isinstance(value, bool): return None upper_key = key.upper() - if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")): + if any(suffix in upper_key for suffix in ('PRICE', '_KOPEKS', 'AMOUNT')): try: return settings.format_price(int(value)) except Exception: - return f"{value}" - if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key: - return f"{value}%" - if upper_key.endswith("_HOURS"): - return f"{value} ч" - if upper_key.endswith("_MINUTES"): - return f"{value} мин" - if upper_key.endswith("_SECONDS"): - return f"{value} сек" - if upper_key.endswith("_DAYS"): - return f"{value} дн" - if upper_key.endswith("_GB"): - return f"{value} ГБ" - if upper_key.endswith("_MB"): - return f"{value} МБ" + return f'{value}' + if upper_key.endswith('_PERCENT') or 'PERCENT' in upper_key: + return f'{value}%' + if upper_key.endswith('_HOURS'): + return f'{value} ч' + if upper_key.endswith('_MINUTES'): + return f'{value} мин' + if upper_key.endswith('_SECONDS'): + return f'{value} сек' + if upper_key.endswith('_DAYS'): + return f'{value} дн' + if upper_key.endswith('_GB'): + return f'{value} ГБ' + if upper_key.endswith('_MB'): + return f'{value} МБ' return None @classmethod - def _split_comma_values(cls, text: str) -> Optional[List[str]]: - raw = (text or "").strip() - if not raw or "," not in raw: + def _split_comma_values(cls, text: str) -> list[str] | None: + raw = (text or '').strip() + if not raw or ',' not in raw: return None - parts = [segment.strip() for segment in raw.split(",") if segment.strip()] + parts = [segment.strip() for segment in raw.split(',') if segment.strip()] return parts or None @classmethod def format_value_human(cls, key: str, value: Any) -> str: - if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID": + if key == 'SIMPLE_SUBSCRIPTION_SQUAD_UUID': if value is None: - return "Любой доступный" + return 'Любой доступный' if isinstance(value, str): cleaned_value = value.strip() if not cleaned_value: - return "Любой доступный" + return 'Любой доступный' if value is None: - return "—" + return '—' if isinstance(value, bool): - return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО" + return '✅ ВКЛЮЧЕНО' if value else '❌ ВЫКЛЮЧЕНО' if isinstance(value, (int, float)): formatted = cls._format_numeric_with_unit(key, value) @@ -923,18 +894,18 @@ class BotConfigurationService: if isinstance(value, str): cleaned = value.strip() if not cleaned: - return "—" + return '—' if key in cls.PLAIN_TEXT_KEYS: return cleaned - if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")): - return "••••••••" + if any(keyword in key.upper() for keyword in ('TOKEN', 'SECRET', 'PASSWORD', 'KEY')): + return '••••••••' items = cls._split_comma_values(cleaned) if items: - return ", ".join(items) + return ', '.join(items) return cleaned if isinstance(value, (list, tuple, set)): - return ", ".join(str(item) for item in value) + return ', '.join(str(item) for item in value) if isinstance(value, dict): try: @@ -945,45 +916,42 @@ class BotConfigurationService: return str(value) @classmethod - def get_setting_guidance(cls, key: str) -> Dict[str, str]: + def get_setting_guidance(cls, key: str) -> dict[str, str]: definition = cls.get_definition(key) original = cls.get_original_value(key) type_label = definition.type_label hints = dict(cls.SETTING_HINTS.get(key, {})) base_description = ( - hints.get("description") - or f"Параметр {definition.display_name} управляет категорией «{definition.category_label}»." + hints.get('description') + or f'Параметр {definition.display_name} управляет категорией «{definition.category_label}».' ) - base_format = hints.get("format") or ( - "Булево значение (да/нет)." if definition.python_type is bool - else "Введите значение соответствующего типа (число или строку)." + base_format = hints.get('format') or ( + 'Булево значение (да/нет).' + if definition.python_type is bool + else 'Введите значение соответствующего типа (число или строку).' ) - example = hints.get("example") or ( - cls.format_value_human(key, original) if original is not None else "—" - ) - warning = hints.get("warning") or ( - "Неверные значения могут привести к некорректной работе бота." - ) - dependencies = hints.get("dependencies") or definition.category_label + example = hints.get('example') or (cls.format_value_human(key, original) if original is not None else '—') + warning = hints.get('warning') or ('Неверные значения могут привести к некорректной работе бота.') + dependencies = hints.get('dependencies') or definition.category_label return { - "description": base_description, - "format": base_format, - "example": example, - "warning": warning, - "dependencies": dependencies, - "type": type_label, + 'description': base_description, + 'format': base_format, + 'example': example, + 'warning': warning, + 'dependencies': dependencies, + 'type': type_label, } - _definitions: Dict[str, SettingDefinition] = {} - _original_values: Dict[str, Any] = settings.model_dump() - _overrides_raw: Dict[str, Optional[str]] = {} + _definitions: dict[str, SettingDefinition] = {} + _original_values: dict[str, Any] = settings.model_dump() + _overrides_raw: dict[str, str | None] = {} _env_override_keys: set[str] = set(ENV_OVERRIDE_KEYS) - _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]] = {} + _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: @@ -1001,13 +969,13 @@ class BotConfigurationService: category_key = cls._resolve_category_key(key) category_label = cls.CATEGORY_TITLES.get( category_key, - category_key.capitalize() if category_key else "Прочее", + category_key.capitalize() if category_key else 'Прочее', ) category_label = cls._format_dynamic_copy(category_key, category_label) cls._definitions[key] = SettingDefinition( key=key, - category_key=category_key or "other", + category_key=category_key or 'other', category_label=category_label, python_type=python_type, type_label=type_label, @@ -1018,7 +986,6 @@ class BotConfigurationService: 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) @@ -1031,13 +998,13 @@ class BotConfigurationService: if key.startswith(prefix): return category - if "_" not in key: + if '_' not in key: return key.upper() - prefix = key.split("_", 1)[0] + prefix = key.split('_', 1)[0] return prefix.upper() @classmethod - def _normalize_type(cls, annotation: Any) -> Tuple[Type[Any], bool]: + def _normalize_type(cls, annotation: Any) -> tuple[type[Any], bool]: if annotation is None: return str, True @@ -1060,24 +1027,24 @@ class BotConfigurationService: return str, False @classmethod - def _type_to_label(cls, python_type: Type[Any], is_optional: bool) -> str: + 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 + 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]]: + def get_categories(cls) -> list[tuple[str, str, int]]: cls.initialize_definitions() - categories: Dict[str, List[SettingDefinition]] = {} + categories: dict[str, list[SettingDefinition]] = {} for definition in cls._definitions.values(): categories.setdefault(definition.category_key, []).append(definition) - result: List[Tuple[str, str, int]] = [] + 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))) @@ -1086,13 +1053,9 @@ class BotConfigurationService: return result @classmethod - def get_settings_for_category(cls, category_key: str) -> List[SettingDefinition]: + 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 = [definition for definition in cls._definitions.values() if definition.category_key == category_key] filtered.sort(key=lambda definition: definition.key) return filtered @@ -1118,9 +1081,9 @@ class BotConfigurationService: @classmethod def format_value(cls, value: Any) -> str: if value is None: - return "—" + return '—' if isinstance(value, bool): - return "✅ Да" if value else "❌ Нет" + return '✅ Да' if value else '❌ Нет' if isinstance(value, (int, float)): return str(value) if isinstance(value, (list, dict, tuple, set)): @@ -1134,12 +1097,12 @@ class BotConfigurationService: def format_value_for_list(cls, key: str) -> str: value = cls.get_current_value(key) formatted = cls.format_value_human(key, value) - if formatted == "—": + if formatted == '—': return formatted return _truncate(formatted) @classmethod - def get_choice_options(cls, key: str) -> List[ChoiceOption]: + def get_choice_options(cls, key: str) -> list[ChoiceOption]: cls.initialize_definitions() dynamic = cls._get_dynamic_choice_options(key) if dynamic is not None: @@ -1154,21 +1117,21 @@ class BotConfigurationService: cls._choice_token_lookup.pop(key, None) @classmethod - def _get_dynamic_choice_options(cls, key: str) -> Optional[List[ChoiceOption]]: - if key == "SIMPLE_SUBSCRIPTION_PERIOD_DAYS": + def _get_dynamic_choice_options(cls, key: str) -> list[ChoiceOption] | None: + if key == 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': return cls._build_simple_subscription_period_choices() - if key == "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT": + if key == 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': return cls._build_simple_subscription_device_choices() - if key == "SIMPLE_SUBSCRIPTION_TRAFFIC_GB": + if key == 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB': return cls._build_simple_subscription_traffic_choices() return None @staticmethod - def _build_simple_subscription_period_choices() -> List[ChoiceOption]: - raw_periods = str(getattr(settings, "AVAILABLE_SUBSCRIPTION_PERIODS", "") or "") + def _build_simple_subscription_period_choices() -> list[ChoiceOption]: + raw_periods = str(getattr(settings, 'AVAILABLE_SUBSCRIPTION_PERIODS', '') or '') period_values: set[int] = set() - for segment in raw_periods.split(","): + for segment in raw_periods.split(','): segment = segment.strip() if not segment: continue @@ -1179,46 +1142,46 @@ class BotConfigurationService: if period > 0: period_values.add(period) - fallback_period = getattr(settings, "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", 30) or 30 + fallback_period = getattr(settings, 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS', 30) or 30 try: fallback_period = int(fallback_period) except (TypeError, ValueError): fallback_period = 30 period_values.add(max(1, fallback_period)) - options: List[ChoiceOption] = [] + options: list[ChoiceOption] = [] for days in sorted(period_values): - price_attr = f"PRICE_{days}_DAYS" + price_attr = f'PRICE_{days}_DAYS' price_value = getattr(settings, price_attr, None) if not isinstance(price_value, int): price_value = settings.BASE_SUBSCRIPTION_PRICE - label = f"{days} дн." + label = f'{days} дн.' try: if isinstance(price_value, int): - label = f"{label} — {settings.format_price(price_value)}" + label = f'{label} — {settings.format_price(price_value)}' except Exception: - logger.debug("Не удалось форматировать цену для периода %s", days, exc_info=True) + logger.debug('Не удалось форматировать цену для периода %s', days, exc_info=True) options.append(ChoiceOption(days, label)) return options @classmethod - def _build_simple_subscription_device_choices(cls) -> List[ChoiceOption]: - default_limit = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1) or 1 + def _build_simple_subscription_device_choices(cls) -> list[ChoiceOption]: + default_limit = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1) or 1 try: default_limit = int(default_limit) except (TypeError, ValueError): default_limit = 1 - max_limit = getattr(settings, "MAX_DEVICES_LIMIT", default_limit) or default_limit + max_limit = getattr(settings, 'MAX_DEVICES_LIMIT', default_limit) or default_limit try: max_limit = int(max_limit) except (TypeError, ValueError): max_limit = default_limit - current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", default_limit) or default_limit + current_limit = getattr(settings, 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT', default_limit) or default_limit try: current_limit = int(current_limit) except (TypeError, ValueError): @@ -1227,26 +1190,26 @@ class BotConfigurationService: upper_bound = max(default_limit, max_limit, current_limit, 1) upper_bound = min(max(upper_bound, 1), 50) - options: List[ChoiceOption] = [] + options: list[ChoiceOption] = [] for count in range(1, upper_bound + 1): - label = f"{count} {cls._pluralize_devices(count)}" + label = f'{count} {cls._pluralize_devices(count)}' if count == default_limit: - label = f"{label} (по умолчанию)" + label = f'{label} (по умолчанию)' options.append(ChoiceOption(count, label)) return options @staticmethod - def _build_simple_subscription_traffic_choices() -> List[ChoiceOption]: + def _build_simple_subscription_traffic_choices() -> list[ChoiceOption]: try: packages = settings.get_traffic_packages() except Exception as error: - logger.warning("Не удалось получить пакеты трафика: %s", error, exc_info=True) + logger.warning('Не удалось получить пакеты трафика: %s', error, exc_info=True) packages = [] traffic_values: set[int] = {0} for package in packages: - gb_value = package.get("gb") + gb_value = package.get('gb') try: gb = int(gb_value) except (TypeError, ValueError): @@ -1254,7 +1217,7 @@ class BotConfigurationService: if gb >= 0: traffic_values.add(gb) - default_limit = getattr(settings, "DEFAULT_TRAFFIC_LIMIT_GB", 0) or 0 + default_limit = getattr(settings, 'DEFAULT_TRAFFIC_LIMIT_GB', 0) or 0 try: default_limit = int(default_limit) except (TypeError, ValueError): @@ -1262,7 +1225,7 @@ class BotConfigurationService: if default_limit >= 0: traffic_values.add(default_limit) - current_limit = getattr(settings, "SIMPLE_SUBSCRIPTION_TRAFFIC_GB", default_limit) + current_limit = getattr(settings, 'SIMPLE_SUBSCRIPTION_TRAFFIC_GB', default_limit) try: current_limit = int(current_limit) except (TypeError, ValueError): @@ -1270,22 +1233,22 @@ class BotConfigurationService: if current_limit >= 0: traffic_values.add(current_limit) - options: List[ChoiceOption] = [] + options: list[ChoiceOption] = [] for gb in sorted(traffic_values): if gb <= 0: - label = "Безлимит" + label = 'Безлимит' else: - label = f"{gb} ГБ" + label = f'{gb} ГБ' price_label = None for package in packages: try: - package_gb = int(package.get("gb")) + package_gb = int(package.get('gb')) except (TypeError, ValueError): continue if package_gb != gb: continue - price_raw = package.get("price") + price_raw = package.get('price') try: price_value = int(price_raw) if price_value >= 0: @@ -1295,7 +1258,7 @@ class BotConfigurationService: break if price_label: - label = f"{label} — {price_label}" + label = f'{label} — {price_label}' options.append(ChoiceOption(gb, label)) @@ -1307,12 +1270,12 @@ class BotConfigurationService: last_two = count % 100 last_one = count % 10 if 11 <= last_two <= 14: - return "устройств" + return 'устройств' if last_one == 1: - return "устройство" + return 'устройство' if 2 <= last_one <= 4: - return "устройства" - return "устройств" + return 'устройства' + return 'устройств' @classmethod def has_choices(cls, key: str) -> bool: @@ -1329,7 +1292,7 @@ class BotConfigurationService: return cls._token_to_key[token] @classmethod - def get_choice_token(cls, key: str, value: Any) -> Optional[str]: + def get_choice_token(cls, key: str, value: Any) -> str | None: cls.initialize_definitions() cls._ensure_choice_tokens(key) return cls._choice_tokens.get(key, {}).get(value) @@ -1345,12 +1308,12 @@ class BotConfigurationService: if key in cls._callback_tokens: return - base = hashlib.blake2s(key.encode("utf-8"), digest_size=6).hexdigest() + 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] + candidate = f'{base}{suffix}'[:16] counter += 1 cls._callback_tokens[key] = candidate @@ -1362,8 +1325,8 @@ class BotConfigurationService: return options = cls.CHOICES.get(key, []) - value_to_token: Dict[Any, str] = {} - token_to_value: Dict[str, Any] = {} + value_to_token: dict[Any, str] = {} + token_to_value: dict[str, Any] = {} for index, option in enumerate(options): token = cls._encode_base36(index) @@ -1376,15 +1339,15 @@ class BotConfigurationService: @staticmethod def _encode_base36(number: int) -> str: if number < 0: - raise ValueError("number must be non-negative") - alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + raise ValueError('number must be non-negative') + alphabet = '0123456789abcdefghijklmnopqrstuvwxyz' if number == 0: - return "0" + return '0' result = [] while number: number, rem = divmod(number, 36) result.append(alphabet[rem]) - return "".join(reversed(result)) + return ''.join(reversed(result)) @classmethod async def initialize(cls) -> None: @@ -1394,7 +1357,7 @@ class BotConfigurationService: result = await session.execute(select(SystemSetting)) rows = result.scalars().all() - overrides: Dict[str, Optional[str]] = {} + overrides: dict[str, str | None] = {} for row in rows: if row.key in cls._definitions: overrides[row.key] = row.value @@ -1402,14 +1365,14 @@ class BotConfigurationService: for key, raw_value in overrides.items(): if cls._is_env_override(key): logger.debug( - "Пропускаем настройку %s из БД: используется значение из окружения", + 'Пропускаем настройку %s из БД: используется значение из окружения', key, ) continue try: parsed_value = cls.deserialize_value(key, raw_value) except Exception as error: - logger.error("Не удалось применить настройку %s: %s", key, error) + logger.error('Не удалось применить настройку %s: %s', key, error) continue cls._overrides_raw[key] = raw_value @@ -1423,7 +1386,7 @@ class BotConfigurationService: await cls.initialize() @classmethod - def deserialize_value(cls, key: str, raw_value: Optional[str]) -> Any: + def deserialize_value(cls, key: str, raw_value: str | None) -> Any: if raw_value is None: return None @@ -1432,11 +1395,11 @@ class BotConfigurationService: if python_type is bool: value_lower = raw_value.strip().lower() - if value_lower in {"1", "true", "on", "yes", "да"}: + if value_lower in {'1', 'true', 'on', 'yes', 'да'}: return True - if value_lower in {"0", "false", "off", "no", "нет"}: + if value_lower in {'0', 'false', 'off', 'no', 'нет'}: return False - raise ValueError(f"Неверное булево значение: {raw_value}") + raise ValueError(f'Неверное булево значение: {raw_value}') if python_type is int: return int(raw_value) @@ -1447,7 +1410,7 @@ class BotConfigurationService: return raw_value @classmethod - def serialize_value(cls, key: str, value: Any) -> Optional[str]: + def serialize_value(cls, key: str, value: Any) -> str | None: if value is None: return None @@ -1455,7 +1418,7 @@ class BotConfigurationService: python_type = definition.python_type if python_type is bool: - return "true" if value else "false" + return 'true' if value else 'false' if python_type in {int, float}: return str(value) return str(value) @@ -1463,28 +1426,28 @@ class BotConfigurationService: @classmethod def parse_user_value(cls, key: str, user_input: str) -> Any: definition = cls.get_definition(key) - text = (user_input or "").strip() + text = (user_input or '').strip() - if text.lower() in {"отмена", "cancel"}: - raise ValueError("Ввод отменен пользователем") + if text.lower() in {'отмена', 'cancel'}: + raise ValueError('Ввод отменен пользователем') - if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}: + 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"}: + if lowered in {'1', 'true', 'on', 'yes', 'да', 'вкл', 'enable', 'enabled'}: return True - if lowered in {"0", "false", "off", "no", "нет", "выкл", "disable", "disabled"}: + 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(",", ".")) + parsed_value = float(text.replace(',', '.')) else: parsed_value = text @@ -1492,22 +1455,16 @@ class BotConfigurationService: 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 - } + 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}") + 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}") + readable = ', '.join(f'{option.label} ({cls.format_value(option.value)})' for option in choices) + raise ValueError(f'Доступные значения: {readable}') return parsed_value @@ -1521,13 +1478,13 @@ class BotConfigurationService: force: bool = False, ) -> None: if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") + raise ReadOnlySettingError(f'Setting {key} is read-only') raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) if cls._is_env_override(key): logger.info( - "Настройка %s сохранена в БД, но не применена: значение задаётся через окружение", + 'Настройка %s сохранена в БД, но не применена: значение задаётся через окружение', key, ) cls._overrides_raw.pop(key, None) @@ -1535,7 +1492,7 @@ class BotConfigurationService: cls._overrides_raw[key] = raw_value cls._apply_to_settings(key, value) - if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: + if key in {'WEB_API_DEFAULT_TOKEN', 'WEB_API_DEFAULT_TOKEN_NAME'}: await cls._sync_default_web_api_token() @classmethod @@ -1547,62 +1504,62 @@ class BotConfigurationService: force: bool = False, ) -> None: if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") + raise ReadOnlySettingError(f'Setting {key} is read-only') await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) if cls._is_env_override(key): logger.info( - "Настройка %s сброшена в БД, используется значение из окружения", + 'Настройка %s сброшена в БД, используется значение из окружения', key, ) else: original = cls.get_original_value(key) cls._apply_to_settings(key, original) - if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}: + 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: if cls._is_env_override(key): logger.debug( - "Пропуск применения настройки %s: значение задано через окружение", + 'Пропуск применения настройки %s: значение задано через окружение', key, ) return try: setattr(settings, key, value) if key in { - "PRICE_14_DAYS", - "PRICE_30_DAYS", - "PRICE_60_DAYS", - "PRICE_90_DAYS", - "PRICE_180_DAYS", - "PRICE_360_DAYS", + 'PRICE_14_DAYS', + 'PRICE_30_DAYS', + 'PRICE_60_DAYS', + 'PRICE_90_DAYS', + 'PRICE_180_DAYS', + 'PRICE_360_DAYS', }: refresh_period_prices() - elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG": + elif key.startswith('PRICE_TRAFFIC_') or key == 'TRAFFIC_PACKAGES_CONFIG': refresh_traffic_prices() - elif key in {"REMNAWAVE_AUTO_SYNC_ENABLED", "REMNAWAVE_AUTO_SYNC_TIMES"}: + elif key in {'REMNAWAVE_AUTO_SYNC_ENABLED', 'REMNAWAVE_AUTO_SYNC_TIMES'}: try: from app.services.remnawave_sync_service import remnawave_sync_service remnawave_sync_service.schedule_refresh( - run_immediately=(key == "REMNAWAVE_AUTO_SYNC_ENABLED" and bool(value)) + run_immediately=(key == 'REMNAWAVE_AUTO_SYNC_ENABLED' and bool(value)) ) except Exception as error: logger.error( - "Не удалось обновить сервис автосинхронизации RemnaWave: %s", + 'Не удалось обновить сервис автосинхронизации RemnaWave: %s', error, ) elif key in { - "REMNAWAVE_API_URL", - "REMNAWAVE_API_KEY", - "REMNAWAVE_SECRET_KEY", - "REMNAWAVE_USERNAME", - "REMNAWAVE_PASSWORD", - "REMNAWAVE_AUTH_TYPE", + 'REMNAWAVE_API_URL', + 'REMNAWAVE_API_KEY', + 'REMNAWAVE_SECRET_KEY', + 'REMNAWAVE_USERNAME', + 'REMNAWAVE_PASSWORD', + 'REMNAWAVE_AUTH_TYPE', }: try: from app.services.remnawave_sync_service import remnawave_sync_service @@ -1610,41 +1567,41 @@ class BotConfigurationService: remnawave_sync_service.refresh_configuration() except Exception as error: logger.error( - "Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave: %s", + 'Не удалось обновить конфигурацию сервиса автосинхронизации RemnaWave: %s', error, ) except Exception as error: - logger.error("Не удалось применить значение %s=%s: %s", key, value, 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() + 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 после обновления настроек", + 'Не удалось синхронизировать бутстрап токен веб-API после обновления настроек', ) @classmethod - def get_setting_summary(cls, key: str) -> Dict[str, Any]: + 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_human(key, current), - "original": cls.format_value_human(key, original), - "type": definition.type_label, - "category_key": definition.category_key, - "category_label": definition.category_label, - "has_override": has_override, - "is_read_only": cls.is_read_only(key), + 'key': key, + 'name': definition.display_name, + 'current': cls.format_value_human(key, current), + 'original': cls.format_value_human(key, original), + 'type': definition.type_label, + 'category_key': definition.category_key, + 'category_label': definition.category_label, + 'has_override': has_override, + 'is_read_only': cls.is_read_only(key), } diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index 7c48da78..446666a7 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -2,40 +2,42 @@ Сервис для мониторинга трафика пользователей v2 Быстрая проверка текущего трафика + суточная проверка """ + import asyncio import logging from dataclasses import dataclass -from datetime import datetime, timedelta, time -from typing import Dict, List, Optional, Set +from datetime import datetime, time, timedelta + +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.services.admin_notification_service import AdminNotificationService -from app.services.remnawave_service import RemnaWaveService from app.database.crud.user import get_user_by_remnawave_uuid from app.database.database import AsyncSessionLocal +from app.services.admin_notification_service import AdminNotificationService +from app.services.remnawave_service import RemnaWaveService from app.utils.cache import cache, cache_key -from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) # Ключи для хранения snapshot в Redis -TRAFFIC_SNAPSHOT_KEY = "traffic:snapshot" -TRAFFIC_SNAPSHOT_TIME_KEY = "traffic:snapshot:time" -TRAFFIC_NOTIFICATION_CACHE_KEY = "traffic:notifications" +TRAFFIC_SNAPSHOT_KEY = 'traffic:snapshot' +TRAFFIC_SNAPSHOT_TIME_KEY = 'traffic:snapshot:time' +TRAFFIC_NOTIFICATION_CACHE_KEY = 'traffic:notifications' @dataclass class TrafficViolation: """Информация о превышении трафика""" + user_uuid: str - telegram_id: Optional[int] - full_name: Optional[str] - username: Optional[str] + telegram_id: int | None + full_name: str | None + username: str | None used_traffic_gb: float threshold_gb: float - last_node_uuid: Optional[str] - last_node_name: Optional[str] + last_node_uuid: str | None + last_node_name: str | None check_type: str # "fast" или "daily" @@ -52,11 +54,11 @@ class TrafficMonitoringServiceV2: def __init__(self): self.remnawave_service = RemnaWaveService() - self._nodes_cache: Dict[str, str] = {} # {node_uuid: node_name} + self._nodes_cache: dict[str, str] = {} # {node_uuid: node_name} # Fallback на память если Redis недоступен - self._memory_snapshot: Dict[str, float] = {} - self._memory_snapshot_time: Optional[datetime] = None - self._memory_notification_cache: Dict[str, datetime] = {} + self._memory_snapshot: dict[str, float] = {} + self._memory_snapshot_time: datetime | None = None + self._memory_notification_cache: dict[str, datetime] = {} # ============== Настройки ============== @@ -91,16 +93,16 @@ class TrafficMonitoringServiceV2: def get_notification_cooldown_seconds(self) -> int: return settings.TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES * 60 - def get_monitored_nodes(self) -> List[str]: + def get_monitored_nodes(self) -> list[str]: return settings.get_traffic_monitored_nodes() - def get_ignored_nodes(self) -> List[str]: + def get_ignored_nodes(self) -> list[str]: return settings.get_traffic_ignored_nodes() - def get_excluded_user_uuids(self) -> List[str]: + def get_excluded_user_uuids(self) -> list[str]: return settings.get_traffic_excluded_user_uuids() - def get_daily_check_time(self) -> Optional[time]: + def get_daily_check_time(self) -> time | None: return settings.get_traffic_daily_check_time() def get_snapshot_ttl_seconds(self) -> int: @@ -109,7 +111,7 @@ class TrafficMonitoringServiceV2: # ============== Redis операции для snapshot ============== - async def _save_snapshot_to_redis(self, snapshot: Dict[str, float]) -> bool: + async def _save_snapshot_to_redis(self, snapshot: dict[str, float]) -> bool: """Сохраняет snapshot трафика в Redis""" try: # Сохраняем snapshot как JSON @@ -119,20 +121,16 @@ class TrafficMonitoringServiceV2: success = await cache.set(TRAFFIC_SNAPSHOT_KEY, snapshot_data, expire=ttl) if success: # Сохраняем время создания snapshot - await cache.set( - TRAFFIC_SNAPSHOT_TIME_KEY, - datetime.utcnow().isoformat(), - expire=ttl - ) - logger.info(f"📦 Snapshot сохранён в Redis: {len(snapshot)} пользователей, TTL {ttl//3600}ч") + await cache.set(TRAFFIC_SNAPSHOT_TIME_KEY, datetime.utcnow().isoformat(), expire=ttl) + logger.info(f'📦 Snapshot сохранён в Redis: {len(snapshot)} пользователей, TTL {ttl // 3600}ч') else: - logger.warning(f"⚠️ Не удалось сохранить snapshot в Redis") + logger.warning('⚠️ Не удалось сохранить snapshot в Redis') return success except Exception as e: - logger.error(f"❌ Ошибка сохранения snapshot в Redis: {e}") + logger.error(f'❌ Ошибка сохранения snapshot в Redis: {e}') return False - async def _load_snapshot_from_redis(self) -> Optional[Dict[str, float]]: + async def _load_snapshot_from_redis(self) -> dict[str, float] | None: """Загружает snapshot трафика из Redis""" try: snapshot_data = await cache.get(TRAFFIC_SNAPSHOT_KEY) @@ -140,14 +138,14 @@ class TrafficMonitoringServiceV2: if snapshot_data is not None and isinstance(snapshot_data, dict): # Конвертируем обратно в float result = {uuid: float(bytes_val) for uuid, bytes_val in snapshot_data.items()} - logger.debug(f"📦 Snapshot загружен из Redis: {len(result)} пользователей") + logger.debug(f'📦 Snapshot загружен из Redis: {len(result)} пользователей') return result return None except Exception as e: - logger.error(f"❌ Ошибка загрузки snapshot из Redis: {e}") + logger.error(f'❌ Ошибка загрузки snapshot из Redis: {e}') return None - async def _get_snapshot_time_from_redis(self) -> Optional[datetime]: + async def _get_snapshot_time_from_redis(self) -> datetime | None: """Получает время создания snapshot из Redis""" try: time_str = await cache.get(TRAFFIC_SNAPSHOT_TIME_KEY) @@ -155,7 +153,7 @@ class TrafficMonitoringServiceV2: return datetime.fromisoformat(time_str) return None except Exception as e: - logger.error(f"❌ Ошибка получения времени snapshot: {e}") + logger.error(f'❌ Ошибка получения времени snapshot: {e}') return None async def _save_notification_to_redis(self, user_uuid: str) -> bool: @@ -165,10 +163,10 @@ class TrafficMonitoringServiceV2: ttl = 24 * 3600 # 24 часа return await cache.set(key, datetime.utcnow().isoformat(), expire=ttl) except Exception as e: - logger.error(f"❌ Ошибка сохранения уведомления в Redis: {e}") + logger.error(f'❌ Ошибка сохранения уведомления в Redis: {e}') return False - async def _get_notification_time_from_redis(self, user_uuid: str) -> Optional[datetime]: + async def _get_notification_time_from_redis(self, user_uuid: str) -> datetime | None: """Получает время последнего уведомления из Redis""" try: key = cache_key(TRAFFIC_NOTIFICATION_CACHE_KEY, user_uuid) @@ -177,7 +175,7 @@ class TrafficMonitoringServiceV2: return datetime.fromisoformat(time_str) return None except Exception as e: - logger.error(f"❌ Ошибка получения времени уведомления: {e}") + logger.error(f'❌ Ошибка получения времени уведомления: {e}') return None # ============== Работа с нодами ============== @@ -187,11 +185,11 @@ class TrafficMonitoringServiceV2: try: nodes = await self.remnawave_service.get_all_nodes() self._nodes_cache = {node['uuid']: node['name'] for node in nodes if node.get('uuid') and node.get('name')} - logger.debug(f"📋 Загружено {len(self._nodes_cache)} нод в кеш") + logger.debug(f'📋 Загружено {len(self._nodes_cache)} нод в кеш') except Exception as e: - logger.error(f"❌ Ошибка загрузки нод в кеш: {e}") + logger.error(f'❌ Ошибка загрузки нод в кеш: {e}') - def get_node_name(self, node_uuid: Optional[str]) -> Optional[str]: + def get_node_name(self, node_uuid: str | None) -> str | None: """Возвращает название ноды по UUID из кеша""" if not node_uuid: return None @@ -199,7 +197,7 @@ class TrafficMonitoringServiceV2: # ============== Фильтрация по нодам ============== - def should_monitor_node(self, node_uuid: Optional[str]) -> bool: + def should_monitor_node(self, node_uuid: str | None) -> bool: """Проверяет, нужно ли мониторить пользователя с этой ноды""" if not node_uuid: return True # Если нода неизвестна, мониторим @@ -247,18 +245,15 @@ class TrafficMonitoringServiceV2: async def cleanup_notification_cache(self): """Очищает старые записи из памяти (Redis очищается автоматически через TTL)""" now = datetime.utcnow() - expired = [ - uuid for uuid, dt in self._memory_notification_cache.items() - if (now - dt) > timedelta(hours=24) - ] + expired = [uuid for uuid, dt in self._memory_notification_cache.items() if (now - dt) > timedelta(hours=24)] for uuid in expired: del self._memory_notification_cache[uuid] if expired: - logger.debug(f"🧹 Очищено {len(expired)} записей из памяти уведомлений о трафике") + logger.debug(f'🧹 Очищено {len(expired)} записей из памяти уведомлений о трафике') # ============== Получение пользователей ============== - async def get_all_users_with_traffic(self) -> List[Dict]: + async def get_all_users_with_traffic(self) -> list[dict]: """ Получает всех пользователей с их трафиком через батчевые запросы Возвращает список словарей с информацией о пользователях @@ -277,18 +272,18 @@ class TrafficMonitoringServiceV2: break all_users.extend(users) - logger.debug(f"📊 Загружено {len(all_users)} пользователей...") + logger.debug(f'📊 Загружено {len(all_users)} пользователей...') if len(users) < batch_size: break offset += batch_size - logger.info(f"✅ Всего загружено {len(all_users)} пользователей из Remnawave") + logger.info(f'✅ Всего загружено {len(all_users)} пользователей из Remnawave') return all_users except Exception as e: - logger.error(f"❌ Ошибка при получении пользователей: {e}") + logger.error(f'❌ Ошибка при получении пользователей: {e}') return [] # ============== Быстрая проверка ============== @@ -316,7 +311,7 @@ class TrafficMonitoringServiceV2: return float('inf') return (datetime.utcnow() - snapshot_time).total_seconds() / 60 - async def _get_current_snapshot(self) -> Dict[str, float]: + async def _get_current_snapshot(self) -> dict[str, float]: """Получает текущий snapshot (Redis + fallback на память)""" # Пробуем Redis snapshot = await self._load_snapshot_from_redis() @@ -326,7 +321,7 @@ class TrafficMonitoringServiceV2: # Fallback на память return self._memory_snapshot.copy() - async def _save_snapshot(self, snapshot: Dict[str, float]) -> bool: + async def _save_snapshot(self, snapshot: dict[str, float]) -> bool: """Сохраняет snapshot (Redis + fallback на память)""" # Пробуем Redis saved = await self._save_snapshot_to_redis(snapshot) @@ -340,7 +335,7 @@ class TrafficMonitoringServiceV2: # Fallback на память self._memory_snapshot = snapshot.copy() self._memory_snapshot_time = datetime.utcnow() - logger.warning("⚠️ Redis недоступен, snapshot сохранён в память") + logger.warning('⚠️ Redis недоступен, snapshot сохранён в память') return True async def create_initial_snapshot(self) -> int: @@ -354,16 +349,16 @@ class TrafficMonitoringServiceV2: if existing_snapshot is not None: age = await self.get_snapshot_age_minutes() logger.info( - f"📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, " - f"возраст {age:.1f} мин" + f'📦 Найден существующий snapshot в Redis: {len(existing_snapshot)} пользователей, ' + f'возраст {age:.1f} мин' ) return len(existing_snapshot) - logger.info("📸 Создание начального snapshot трафика...") + logger.info('📸 Создание начального snapshot трафика...') start_time = datetime.utcnow() users = await self.get_all_users_with_traffic() - new_snapshot: Dict[str, float] = {} + new_snapshot: dict[str, float] = {} for user in users: try: @@ -378,17 +373,17 @@ class TrafficMonitoringServiceV2: new_snapshot[user.uuid] = current_bytes except Exception as e: - logger.error(f"❌ Ошибка при создании snapshot для {user.uuid}: {e}") + logger.error(f'❌ Ошибка при создании snapshot для {user.uuid}: {e}') # Сохраняем в Redis (с fallback на память) await self._save_snapshot(new_snapshot) elapsed = (datetime.utcnow() - start_time).total_seconds() - logger.info(f"✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей") + logger.info(f'✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей') return len(new_snapshot) - async def run_fast_check(self, bot) -> List[TrafficViolation]: + async def run_fast_check(self, bot) -> list[TrafficViolation]: """ Быстрая проверка трафика с дельтой @@ -412,32 +407,33 @@ class TrafficMonitoringServiceV2: excluded_user_uuids = self.get_excluded_user_uuids() if monitored_nodes: - logger.info(f"🔍 Мониторим только ноды: {monitored_nodes}") + logger.info(f'🔍 Мониторим только ноды: {monitored_nodes}') elif ignored_nodes: - logger.info(f"🚫 Игнорируем ноды: {ignored_nodes}") + logger.info(f'🚫 Игнорируем ноды: {ignored_nodes}') else: - logger.info(f"📊 Мониторим все ноды") + logger.info('📊 Мониторим все ноды') if excluded_user_uuids: - logger.info(f"🚫 Исключены пользователи: {excluded_user_uuids}") + logger.info(f'🚫 Исключены пользователи: {excluded_user_uuids}') if is_first_run: - logger.info("🚀 Первый запуск быстрой проверки — создаём snapshot...") + logger.info('🚀 Первый запуск быстрой проверки — создаём snapshot...') else: age = await self.get_snapshot_age_minutes() - logger.info(f"🚀 Быстрая проверка трафика (snapshot {age:.1f} мин назад, порог {self.get_fast_check_threshold_gb()} ГБ)...") + logger.info( + f'🚀 Быстрая проверка трафика (snapshot {age:.1f} мин назад, порог {self.get_fast_check_threshold_gb()} ГБ)...' + ) - violations: List[TrafficViolation] = [] - threshold_bytes = self.get_fast_check_threshold_gb() * (1024 ** 3) + violations: list[TrafficViolation] = [] + threshold_bytes = self.get_fast_check_threshold_gb() * (1024**3) users = await self.get_all_users_with_traffic() - new_snapshot: Dict[str, float] = {} + new_snapshot: dict[str, float] = {} # Загружаем предыдущий snapshot (из Redis или памяти) previous_snapshot = await self._get_current_snapshot() - logger.info(f"📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})") + logger.info(f'📦 Предыдущий snapshot: {len(previous_snapshot)} пользователей (is_first_run={is_first_run})') - checked_users = 0 users_with_delta = 0 for user in users: @@ -459,7 +455,7 @@ class TrafficMonitoringServiceV2: # Пользователя не было в предыдущем snapshot — пропускаем (новый пользователь) if user.uuid not in previous_snapshot: - logger.debug(f"Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем") + logger.debug(f'Пользователь {user.uuid[:8]} не найден в предыдущем snapshot, пропускаем') continue # Получаем предыдущее значение @@ -471,27 +467,33 @@ class TrafficMonitoringServiceV2: continue # Трафик сбросился или не изменился users_with_delta += 1 - delta_gb = delta_bytes / (1024 ** 3) + delta_gb = delta_bytes / (1024**3) # Проверяем превышение дельты if delta_bytes < threshold_bytes: continue - logger.info(f"⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)") + logger.info( + f'⚠️ Превышение дельты: {user.uuid[:8]}... +{delta_gb:.2f} ГБ (порог {self.get_fast_check_threshold_gb()} ГБ, previous={previous_bytes / (1024**3):.2f} ГБ, current={current_bytes / (1024**3):.2f} ГБ)' + ) # Проверяем исключённых пользователей (служебные/тунельные) if user.uuid.lower() in excluded_user_uuids: - logger.info(f"⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)") + logger.info( + f'⏭️ Пропускаем {user.uuid[:8]}... - пользователь в списке исключений (служебный/тунельный)' + ) continue # Проверяем фильтр по нодам last_node_uuid = user_traffic.last_connected_node_uuid if not self.should_monitor_node(last_node_uuid): - logger.warning(f"⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or 'неизвестна'} не в списке мониторинга") + logger.warning( + f'⏭️ Пропускаем {user.uuid[:8]} - нода {last_node_uuid or "неизвестна"} не в списке мониторинга' + ) continue # Создаём violation - delta_gb = round(delta_bytes / (1024 ** 3), 2) + delta_gb = round(delta_bytes / (1024**3), 2) node_name = self.get_node_name(last_node_uuid) violation = TrafficViolation( user_uuid=user.uuid, @@ -502,28 +504,28 @@ class TrafficMonitoringServiceV2: threshold_gb=self.get_fast_check_threshold_gb(), last_node_uuid=last_node_uuid, last_node_name=node_name, - check_type="fast" + check_type='fast', ) violations.append(violation) except Exception as e: - logger.error(f"❌ Ошибка обработки пользователя {user.uuid}: {e}") + logger.error(f'❌ Ошибка обработки пользователя {user.uuid}: {e}') # Обновляем snapshot (в Redis с fallback на память) await self._save_snapshot(new_snapshot) - logger.info(f"💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей") + logger.info(f'💾 Новый snapshot сохранён: {len(new_snapshot)} пользователей') elapsed = (datetime.utcnow() - start_time).total_seconds() if is_first_run: logger.info( - f"✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей. " - f"Следующая проверка покажет превышения." + f'✅ Snapshot создан за {elapsed:.1f}с: {len(new_snapshot)} пользователей. ' + f'Следующая проверка покажет превышения.' ) else: logger.info( - f"✅ Быстрая проверка завершена за {elapsed:.1f}с: " - f"{len(users)} пользователей, {users_with_delta} с дельтой >0, {len(violations)} превышений" + f'✅ Быстрая проверка завершена за {elapsed:.1f}с: ' + f'{len(users)} пользователей, {users_with_delta} с дельтой >0, {len(violations)} превышений' ) # Отправляем уведомления только если это не первый запуск await self._send_violation_notifications(violations, bot) @@ -532,7 +534,7 @@ class TrafficMonitoringServiceV2: # ============== Суточная проверка ============== - async def run_daily_check(self, bot) -> List[TrafficViolation]: + async def run_daily_check(self, bot) -> list[TrafficViolation]: """ Суточная проверка трафика за последние 24 часа Использует bandwidth-stats API @@ -540,24 +542,24 @@ class TrafficMonitoringServiceV2: if not self.is_daily_check_enabled(): return [] - logger.info("🚀 Запуск суточной проверки трафика...") + logger.info('🚀 Запуск суточной проверки трафика...') start_time = datetime.utcnow() # Загружаем кеш нод для красивых названий в уведомлениях await self._load_nodes_cache() - violations: List[TrafficViolation] = [] - threshold_bytes = self.get_daily_threshold_gb() * (1024 ** 3) + violations: list[TrafficViolation] = [] + threshold_bytes = self.get_daily_threshold_gb() * (1024**3) # Получаем период за последние 24 часа now = datetime.utcnow() - start_date = (now - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S.000Z") - end_date = now.strftime("%Y-%m-%dT%H:%M:%S.999Z") + start_date = (now - timedelta(hours=24)).strftime('%Y-%m-%dT%H:%M:%S.000Z') + end_date = now.strftime('%Y-%m-%dT%H:%M:%S.999Z') users = await self.get_all_users_with_traffic() semaphore = asyncio.Semaphore(self.get_concurrency()) - async def check_user_daily_traffic(user) -> Optional[TrafficViolation]: + async def check_user_daily_traffic(user) -> TrafficViolation | None: async with semaphore: try: if not user.uuid: @@ -587,7 +589,7 @@ class TrafficMonitoringServiceV2: if not self.should_monitor_node(last_node_uuid): return None - used_gb = round(total_bytes / (1024 ** 3), 2) + used_gb = round(total_bytes / (1024**3), 2) node_name = self.get_node_name(last_node_uuid) return TrafficViolation( user_uuid=user.uuid, @@ -598,11 +600,11 @@ class TrafficMonitoringServiceV2: threshold_gb=self.get_daily_threshold_gb(), last_node_uuid=last_node_uuid, last_node_name=node_name, - check_type="daily" + check_type='daily', ) except Exception as e: - logger.error(f"❌ Ошибка суточной проверки для {user.uuid}: {e}") + logger.error(f'❌ Ошибка суточной проверки для {user.uuid}: {e}') return None # Параллельная проверка @@ -615,8 +617,8 @@ class TrafficMonitoringServiceV2: elapsed = (datetime.utcnow() - start_time).total_seconds() logger.info( - f"✅ Суточная проверка завершена за {elapsed:.1f}с: " - f"{len(users)} пользователей, {len(violations)} превышений" + f'✅ Суточная проверка завершена за {elapsed:.1f}с: ' + f'{len(users)} пользователей, {len(violations)} превышений' ) # Отправляем уведомления @@ -626,7 +628,7 @@ class TrafficMonitoringServiceV2: # ============== Уведомления ============== - async def _send_violation_notifications(self, violations: List[TrafficViolation], bot): + async def _send_violation_notifications(self, violations: list[TrafficViolation], bot): """Отправляет уведомления о превышениях""" if not violations or not bot: return @@ -638,74 +640,74 @@ class TrafficMonitoringServiceV2: max_notifications = 10 if len(violations) > max_notifications: logger.warning( - f"⚠️ Слишком много превышений ({len(violations)}), " - f"отправляем только первые {max_notifications}" + f'⚠️ Слишком много превышений ({len(violations)}), отправляем только первые {max_notifications}' ) violations = violations[:max_notifications] for i, violation in enumerate(violations): try: if not await self.should_send_notification(violation.user_uuid): - logger.info(f"⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)") + logger.info( + f'⏭️ Кулдаун для {violation.user_uuid[:8]}... - пропускаем уведомление (кулдаун {self.get_notification_cooldown_seconds() // 60} мин)' + ) continue # Получаем информацию о пользователе из БД - user_info = "" + user_info = '' async with AsyncSessionLocal() as db: db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid) if db_user: - user_id_display = db_user.telegram_id or db_user.email or f"#{db_user.id}" + user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' user_info = ( - f"👤 {db_user.full_name or 'Без имени'}\n" - f"🆔 ID: {user_id_display}\n" + f'👤 {db_user.full_name or "Без имени"}\n🆔 ID: {user_id_display}\n' ) if db_user.username: - user_info += f"📱 Username: @{db_user.username}\n" + user_info += f'📱 Username: @{db_user.username}\n' - if violation.check_type == "fast": - check_type_emoji = "⚡" - check_type_name = "Быстрая проверка" - traffic_label = "За интервал" - elif violation.check_type == "daily": - check_type_emoji = "📅" - check_type_name = "Суточная проверка" - traffic_label = "За 24 часа" + if violation.check_type == 'fast': + check_type_emoji = '⚡' + check_type_name = 'Быстрая проверка' + traffic_label = 'За интервал' + elif violation.check_type == 'daily': + check_type_emoji = '📅' + check_type_name = 'Суточная проверка' + traffic_label = 'За 24 часа' else: - check_type_emoji = "🔍" - check_type_name = "Ручная проверка" - traffic_label = "Использовано" + check_type_emoji = '🔍' + check_type_name = 'Ручная проверка' + traffic_label = 'Использовано' message = ( - f"⚠️ Превышение трафика\n\n" - f"{user_info}" - f"🔑 UUID: {violation.user_uuid}\n\n" - f"{check_type_emoji} {check_type_name}\n" - f"📊 {traffic_label}: {violation.used_traffic_gb} ГБ\n" - f"📈 Порог: {violation.threshold_gb} ГБ\n" - f"🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n" + f'⚠️ Превышение трафика\n\n' + f'{user_info}' + f'🔑 UUID: {violation.user_uuid}\n\n' + f'{check_type_emoji} {check_type_name}\n' + f'📊 {traffic_label}: {violation.used_traffic_gb} ГБ\n' + f'📈 Порог: {violation.threshold_gb} ГБ\n' + f'🚨 Превышение: {violation.used_traffic_gb - violation.threshold_gb:.2f} ГБ\n' ) # Показываем название ноды и UUID if violation.last_node_name: - message += f"\n🖥 Сервер: {violation.last_node_name}" + message += f'\n🖥 Сервер: {violation.last_node_name}' if violation.last_node_uuid: - message += f"\n {violation.last_node_uuid}" + message += f'\n {violation.last_node_uuid}' elif violation.last_node_uuid: - message += f"\n🖥 Сервер: {violation.last_node_uuid}" + message += f'\n🖥 Сервер: {violation.last_node_uuid}' - message += f"\n\n⏰ {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')} UTC" + message += f'\n\n⏰ {datetime.utcnow().strftime("%d.%m.%Y %H:%M:%S")} UTC' await admin_service.send_suspicious_traffic_notification(message, bot, topic_id) await self.record_notification(violation.user_uuid) - logger.info(f"📨 Уведомление отправлено для {violation.user_uuid}") + logger.info(f'📨 Уведомление отправлено для {violation.user_uuid}') # Задержка между отправками (защита от flood) if i < len(violations) - 1: await asyncio.sleep(0.5) except Exception as e: - logger.error(f"❌ Ошибка отправки уведомления для {violation.user_uuid}: {e}") + logger.error(f'❌ Ошибка отправки уведомления для {violation.user_uuid}: {e}') class TrafficMonitoringSchedulerV2: @@ -718,8 +720,8 @@ class TrafficMonitoringSchedulerV2: def __init__(self, service: TrafficMonitoringServiceV2): self.service = service self.bot = None - self._fast_check_task: Optional[asyncio.Task] = None - self._daily_check_task: Optional[asyncio.Task] = None + self._fast_check_task: asyncio.Task | None = None + self._daily_check_task: asyncio.Task | None = None self._is_running = False def set_bot(self, bot): @@ -729,11 +731,11 @@ class TrafficMonitoringSchedulerV2: async def start(self): """Запускает планировщик""" if self._is_running: - logger.warning("Планировщик мониторинга трафика уже запущен") + logger.warning('Планировщик мониторинга трафика уже запущен') return if not self.bot: - logger.error("Бот не установлен для планировщика мониторинга") + logger.error('Бот не установлен для планировщика мониторинга') return self._is_running = True @@ -745,14 +747,14 @@ class TrafficMonitoringSchedulerV2: # Запускаем быструю проверку if self.service.is_fast_check_enabled(): interval = self.service.get_fast_check_interval_seconds() - logger.info(f"🚀 Запуск быстрой проверки трафика каждые {interval // 60} мин") + logger.info(f'🚀 Запуск быстрой проверки трафика каждые {interval // 60} мин') self._fast_check_task = asyncio.create_task(self._run_fast_check_loop(interval)) # Запускаем суточную проверку if self.service.is_daily_check_enabled(): check_time = self.service.get_daily_check_time() if check_time: - logger.info(f"🚀 Запуск суточной проверки трафика в {check_time.strftime('%H:%M')}") + logger.info(f'🚀 Запуск суточной проверки трафика в {check_time.strftime("%H:%M")}') self._daily_check_task = asyncio.create_task(self._run_daily_check_loop(check_time)) async def stop(self): @@ -775,12 +777,12 @@ class TrafficMonitoringSchedulerV2: pass self._daily_check_task = None - logger.info("ℹ️ Планировщик мониторинга трафика остановлен") + logger.info('ℹ️ Планировщик мониторинга трафика остановлен') async def _run_fast_check_loop(self, interval_seconds: int): """Цикл быстрой проверки""" # Сначала ждём интервал (snapshot уже создан в start()) - logger.info(f"⏳ Первая проверка через {interval_seconds // 60} минут...") + logger.info(f'⏳ Первая проверка через {interval_seconds // 60} минут...') await asyncio.sleep(interval_seconds) while self._is_running: @@ -791,7 +793,7 @@ class TrafficMonitoringSchedulerV2: except asyncio.CancelledError: break except Exception as e: - logger.error(f"❌ Ошибка в цикле быстрой проверки: {e}") + logger.error(f'❌ Ошибка в цикле быстрой проверки: {e}') await asyncio.sleep(interval_seconds) async def _run_daily_check_loop(self, check_time: time): @@ -805,7 +807,7 @@ class TrafficMonitoringSchedulerV2: next_run += timedelta(days=1) delay = (next_run - now).total_seconds() - logger.debug(f"⏰ Следующая суточная проверка через {delay / 3600:.1f}ч") + logger.debug(f'⏰ Следующая суточная проверка через {delay / 3600:.1f}ч') await asyncio.sleep(delay) @@ -815,20 +817,21 @@ class TrafficMonitoringSchedulerV2: except asyncio.CancelledError: break except Exception as e: - logger.error(f"❌ Ошибка в цикле суточной проверки: {e}") + logger.error(f'❌ Ошибка в цикле суточной проверки: {e}') await asyncio.sleep(3600) # Ждём час при ошибке - async def run_fast_check_now(self) -> List[TrafficViolation]: + async def run_fast_check_now(self) -> list[TrafficViolation]: """Запускает быструю проверку немедленно""" return await self.service.run_fast_check(self.bot) - async def run_daily_check_now(self) -> List[TrafficViolation]: + async def run_daily_check_now(self) -> list[TrafficViolation]: """Запускает суточную проверку немедленно""" return await self.service.run_daily_check(self.bot) # ============== Обратная совместимость ============== + class TrafficMonitoringService: """Обёртка для обратной совместимости со старым API""" @@ -839,9 +842,9 @@ class TrafficMonitoringService: def is_traffic_monitoring_enabled(self) -> bool: # Используем старый параметр или новые return ( - settings.TRAFFIC_MONITORING_ENABLED or - settings.TRAFFIC_FAST_CHECK_ENABLED or - settings.TRAFFIC_DAILY_CHECK_ENABLED + settings.TRAFFIC_MONITORING_ENABLED + or settings.TRAFFIC_FAST_CHECK_ENABLED + or settings.TRAFFIC_DAILY_CHECK_ENABLED ) def get_traffic_threshold_gb(self) -> float: @@ -851,15 +854,12 @@ class TrafficMonitoringService: return settings.TRAFFIC_THRESHOLD_GB_PER_DAY async def check_user_traffic_threshold( - self, - db: AsyncSession, - user_uuid: str, - user_telegram_id: int = None + self, db: AsyncSession, user_uuid: str, user_telegram_id: int = None ) -> tuple: """Проверяет трафик одного пользователя (для обратной совместимости)""" try: threshold_gb = self.get_traffic_threshold_gb() - threshold_bytes = threshold_gb * (1024 ** 3) + threshold_bytes = threshold_gb * (1024**3) # Получаем пользователя из Remnawave async with self.remnawave_service.get_api_client() as api: @@ -869,29 +869,19 @@ class TrafficMonitoringService: return False, {'total_gb': 0, 'nodes': []} used_bytes = user.user_traffic.used_traffic_bytes or 0 - total_gb = round(used_bytes / (1024 ** 3), 2) + total_gb = round(used_bytes / (1024**3), 2) is_exceeded = used_bytes > threshold_bytes - traffic_info = { - 'total_gb': total_gb, - 'nodes': [], - 'threshold_gb': threshold_gb - } + traffic_info = {'total_gb': total_gb, 'nodes': [], 'threshold_gb': threshold_gb} return is_exceeded, traffic_info except Exception as e: - logger.error(f"Ошибка проверки трафика для {user_uuid}: {e}") + logger.error(f'Ошибка проверки трафика для {user_uuid}: {e}') return False, {'total_gb': 0, 'nodes': []} - async def process_suspicious_traffic( - self, - db: AsyncSession, - user_uuid: str, - traffic_info: dict, - bot - ): + async def process_suspicious_traffic(self, db: AsyncSession, user_uuid: str, traffic_info: dict, bot): """Отправляет уведомление о подозрительном трафике""" violation = TrafficViolation( user_uuid=user_uuid, @@ -902,7 +892,7 @@ class TrafficMonitoringService: threshold_gb=traffic_info.get('threshold_gb', self.get_traffic_threshold_gb()), last_node_uuid=None, last_node_name=None, - check_type="manual" + check_type='manual', ) await self._v2._send_violation_notifications([violation], bot) @@ -942,13 +932,13 @@ class TrafficMonitoringScheduler: if self._v2_service.is_fast_check_enabled(): interval_min = self._v2_service.get_fast_check_interval_seconds() // 60 threshold = self._v2_service.get_fast_check_threshold_gb() - info.append(f"Быстрая: каждые {interval_min} мин, порог {threshold} ГБ") + info.append(f'Быстрая: каждые {interval_min} мин, порог {threshold} ГБ') if self._v2_service.is_daily_check_enabled(): check_time = self._v2_service.get_daily_check_time() threshold = self._v2_service.get_daily_threshold_gb() - time_str = check_time.strftime('%H:%M') if check_time else "00:00" - info.append(f"Суточная: в {time_str}, порог {threshold} ГБ") - return "; ".join(info) if info else "Отключен" + time_str = check_time.strftime('%H:%M') if check_time else '00:00' + info.append(f'Суточная: в {time_str}, порог {threshold} ГБ') + return '; '.join(info) if info else 'Отключен' async def _should_send_notification(self, user_uuid: str) -> bool: """Для обратной совместимости""" diff --git a/app/services/trial_activation_service.py b/app/services/trial_activation_service.py index e335c5a2..4fc2c7bb 100644 --- a/app/services/trial_activation_service.py +++ b/app/services/trial_activation_service.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession @@ -60,7 +59,7 @@ def preview_trial_activation_charge(user: User) -> int: if price_kopeks <= 0: return 0 - balance = int(getattr(user, "balance_kopeks", 0) or 0) + balance = int(getattr(user, 'balance_kopeks', 0) or 0) if balance < price_kopeks: raise TrialPaymentInsufficientFunds(price_kopeks, balance) @@ -71,7 +70,7 @@ async def charge_trial_activation_if_required( db: AsyncSession, user: User, *, - description: Optional[str] = None, + description: str | None = None, ) -> int: """Charges the user's balance if paid trial activation is enabled. @@ -83,7 +82,7 @@ async def charge_trial_activation_if_required( if price_kopeks <= 0: return 0 - charge_description = description or "Активация триальной подписки" + charge_description = description or 'Активация триальной подписки' success = await subtract_user_balance( db, @@ -92,7 +91,7 @@ async def charge_trial_activation_if_required( charge_description, ) if not success: - raise TrialPaymentChargeFailed() + raise TrialPaymentChargeFailed # subtract_user_balance обновляет пользователя, но на всякий случай приводим к int return int(price_kopeks) @@ -103,14 +102,14 @@ async def refund_trial_activation_charge( user: User, amount_kopeks: int, *, - description: Optional[str] = None, + description: str | None = None, ) -> bool: """Refunds a previously charged trial activation amount back to the user.""" if amount_kopeks <= 0: return True - refund_description = description or "Возврат оплаты за активацию триальной подписки" + refund_description = description or 'Возврат оплаты за активацию триальной подписки' success = await add_user_balance( db, @@ -122,9 +121,9 @@ async def refund_trial_activation_charge( if not success: logger.error( - "Failed to refund %s kopeks for user %s during trial activation rollback", + 'Failed to refund %s kopeks for user %s during trial activation rollback', amount_kopeks, - getattr(user, "id", ""), + getattr(user, 'id', ''), ) return success @@ -132,7 +131,7 @@ async def refund_trial_activation_charge( async def rollback_trial_subscription_activation( db: AsyncSession, - subscription: Optional[Subscription], + subscription: Subscription | None, ) -> bool: """Attempts to undo a previously created trial subscription. @@ -148,7 +147,7 @@ async def rollback_trial_subscription_activation( await decrement_subscription_server_counts(db, subscription) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Failed to decrement server counters during trial rollback for %s: %s", + 'Failed to decrement server counters during trial rollback for %s: %s', subscription.user_id, error, ) @@ -158,8 +157,8 @@ async def rollback_trial_subscription_activation( await db.commit() except Exception as error: # pragma: no cover - defensive logging logger.error( - "Failed to remove trial subscription %s after charge failure: %s", - getattr(subscription, "id", ""), + 'Failed to remove trial subscription %s after charge failure: %s', + getattr(subscription, 'id', ''), error, ) await db.rollback() @@ -171,10 +170,10 @@ async def rollback_trial_subscription_activation( async def revert_trial_activation( db: AsyncSession, user: User, - subscription: Optional[Subscription], + subscription: Subscription | None, charged_amount: int, *, - refund_description: Optional[str] = None, + refund_description: str | None = None, ) -> TrialActivationReversionResult: """Rolls back a trial subscription and refunds any charged amount.""" @@ -190,8 +189,8 @@ async def revert_trial_activation( await db.refresh(user) except Exception as error: # pragma: no cover - defensive logging logger.warning( - "Failed to refresh user %s after reverting trial activation: %s", - getattr(user, "id", ""), + 'Failed to refresh user %s after reverting trial activation: %s', + getattr(user, 'id', ''), error, ) diff --git a/app/services/tribute_service.py b/app/services/tribute_service.py index 6440d65b..1a9d9dd2 100644 --- a/app/services/tribute_service.py +++ b/app/services/tribute_service.py @@ -1,17 +1,16 @@ -import logging import json -from typing import Optional, Dict, Any +import logging from datetime import datetime +from typing import Any from aiogram import Bot -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from app.config import settings -from app.database.database import get_db -from app.database.models import Transaction, TransactionType, PaymentMethod -from app.database.crud.transaction import ( - create_transaction, get_transaction_by_external_id, complete_transaction -) +from app.database.crud.transaction import create_transaction, get_transaction_by_external_id from app.database.crud.user import get_user_by_telegram_id +from app.database.database import get_db +from app.database.models import PaymentMethod, TransactionType from app.external.tribute import TributeService as TributeAPI from app.services.payment_service import PaymentService from app.services.subscription_auto_purchase_service import ( @@ -20,130 +19,123 @@ from app.services.subscription_auto_purchase_service import ( ) from app.utils.user_utils import format_referrer_info + logger = logging.getLogger(__name__) class TributeService: - _invoice_messages: Dict[int, Dict[str, int]] = {} - + _invoice_messages: dict[int, dict[str, int]] = {} + def __init__(self, bot: Bot): self.bot = bot self.tribute_api = TributeAPI() @classmethod def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None: - cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id} + cls._invoice_messages[user_id] = {'chat_id': chat_id, 'message_id': message_id} async def _cleanup_invoice_message(self, user_id: int) -> None: invoice_message = self._invoice_messages.pop(user_id, None) - if not invoice_message or not getattr(self, "bot", None): + if not invoice_message or not getattr(self, 'bot', None): return - chat_id = invoice_message.get("chat_id") - message_id = invoice_message.get("message_id") + chat_id = invoice_message.get('chat_id') + message_id = invoice_message.get('message_id') if not chat_id or not message_id: return try: await self.bot.delete_message(chat_id, message_id) except Exception as error: # pragma: no cover - depends on bot rights - logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error) - + logger.warning('Не удалось удалить Tribute счёт %s: %s', message_id, error) + async def create_payment_link( - self, - user_id: int, - amount_kopeks: int, - description: str = "Пополнение баланса" - ) -> Optional[str]: - + self, user_id: int, amount_kopeks: int, description: str = 'Пополнение баланса' + ) -> str | None: if not settings.TRIBUTE_ENABLED: - logger.warning("Tribute платежи отключены") + logger.warning('Tribute платежи отключены') return None - + try: payment_url = await self.tribute_api.create_payment_link( - user_id=user_id, - amount_kopeks=amount_kopeks, - description=description + user_id=user_id, amount_kopeks=amount_kopeks, description=description ) - + if not payment_url: return None - + return payment_url - + except Exception as e: - logger.error(f"Ошибка создания Tribute платежа: {e}") + logger.error(f'Ошибка создания Tribute платежа: {e}') return None - - async def process_webhook( - self, - payload: str - ) -> Dict[str, Any]: - + + async def process_webhook(self, payload: str) -> dict[str, Any]: try: webhook_data = json.loads(payload) except json.JSONDecodeError: - logger.error("Некорректный JSON в Tribute webhook") - return {"status": "error", "reason": "invalid_json"} - - logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}") - + logger.error('Некорректный JSON в Tribute webhook') + return {'status': 'error', 'reason': 'invalid_json'} + + logger.info(f'Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}') + processed_data = await self.tribute_api.process_webhook(webhook_data) if not processed_data: - return {"status": "ignored", "reason": "invalid_data"} - - event_type = processed_data.get("event_type", "payment") - status = processed_data.get("status") - - if event_type == "payment" and status == "paid": + return {'status': 'ignored', 'reason': 'invalid_data'} + + event_type = processed_data.get('event_type', 'payment') + status = processed_data.get('status') + + if event_type == 'payment' and status == 'paid': await self._handle_successful_payment(processed_data) - elif event_type == "payment" and status == "failed": + elif event_type == 'payment' and status == 'failed': await self._handle_failed_payment(processed_data) - elif event_type == "refund": + elif event_type == 'refund': await self._handle_refund(processed_data) - - return {"status": "ok", "event": event_type} - - async def _handle_successful_payment(self, payment_data: Dict[str, Any]): + + return {'status': 'ok', 'event': event_type} + + async def _handle_successful_payment(self, payment_data: dict[str, Any]): try: - user_telegram_id = payment_data["user_id"] - amount_kopeks = payment_data["amount_kopeks"] - payment_id = payment_data["payment_id"] - - logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}") - + user_telegram_id = payment_data['user_id'] + amount_kopeks = payment_data['amount_kopeks'] + payment_id = payment_data['payment_id'] + + logger.info( + f'Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}' + ) + async for session in get_db(): user = await get_user_by_telegram_id(session, user_telegram_id) if not user: - logger.error(f"Пользователь {user_telegram_id} не найден") + logger.error(f'Пользователь {user_telegram_id} не найден') return - - logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп") - + + logger.info(f'Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп') + from app.database.crud.transaction import check_tribute_payment_duplicate - + duplicate_transaction = await check_tribute_payment_duplicate( session, payment_id, amount_kopeks, user_telegram_id ) - + if duplicate_transaction: - logger.warning(f"Найден дубликат платежа в течение 24ч:") - logger.warning(f" Transaction ID: {duplicate_transaction.id}") - logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп") - logger.warning(f" Created: {duplicate_transaction.created_at}") - logger.warning(f" External ID: {duplicate_transaction.external_id}") - logger.warning(f"Платеж игнорирован - это дубликат свежего платежа") + logger.warning('Найден дубликат платежа в течение 24ч:') + logger.warning(f' Transaction ID: {duplicate_transaction.id}') + logger.warning(f' Amount: {duplicate_transaction.amount_kopeks} коп') + logger.warning(f' Created: {duplicate_transaction.created_at}') + logger.warning(f' External ID: {duplicate_transaction.external_id}') + logger.warning('Платеж игнорирован - это дубликат свежего платежа') return - + from app.database.crud.transaction import create_unique_tribute_transaction - + transaction = await create_unique_tribute_transaction( db=session, user_id=user.id, payment_id=payment_id, amount_kopeks=amount_kopeks, - description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})" + description=f'Пополнение через Tribute: {amount_kopeks / 100}₽ (ID: {payment_id})', ) old_balance = user.balance_kopeks @@ -153,17 +145,18 @@ class TributeService: user.updated_at = datetime.utcnow() promo_group = user.get_primary_promo_group() - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) referrer_info = format_referrer_info(user) - topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение" + topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение' await session.commit() try: from app.services.referral_service import process_referral_topup + await process_referral_topup(session, user.id, amount_kopeks, self.bot) except Exception as e: - logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}") + logger.error(f'Ошибка обработки реферального пополнения Tribute: {e}') if was_first_topup and not user.has_made_first_topup: user.has_made_first_topup = True @@ -172,16 +165,16 @@ class TributeService: await session.refresh(user) logger.info( - f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})" + f'✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})' ) - logger.info(f"✅ Создана транзакция ID: {transaction.id}") + logger.info(f'✅ Создана транзакция ID: {transaction.id}') if was_first_topup: - logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}") - + logger.info(f'Отмечен первый топап для пользователя {user_telegram_id}') try: from app.services.admin_notification_service import AdminNotificationService + notification_service = AdminNotificationService(self.bot) await notification_service.send_balance_topup_notification( user, @@ -194,77 +187,76 @@ class TributeService: db=session, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}") + logger.error(f'Ошибка отправки уведомления о Tribute пополнении: {e}') await self._cleanup_invoice_message(user_telegram_id) await self._send_success_notification(user_telegram_id, amount_kopeks) - - logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}") + + logger.info( + f'🎉 Успешно обработан Tribute платеж: {amount_kopeks / 100}₽ для пользователя {user_telegram_id}' + ) break - + except Exception as e: - logger.error(f"⌘ Ошибка обработки успешного Tribute платежа: {e}", exc_info=True) - - async def _handle_failed_payment(self, payment_data: Dict[str, Any]): - + logger.error(f'⌘ Ошибка обработки успешного Tribute платежа: {e}', exc_info=True) + + async def _handle_failed_payment(self, payment_data: dict[str, Any]): try: - user_id = payment_data["user_id"] - payment_id = payment_data["payment_id"] - + user_id = payment_data['user_id'] + payment_id = payment_data['payment_id'] + async for session in get_db(): transaction = await get_transaction_by_external_id( - session, f"donation_{payment_id}", PaymentMethod.TRIBUTE + session, f'donation_{payment_id}', PaymentMethod.TRIBUTE ) - + if transaction: - transaction.description = f"{transaction.description} (платеж отклонен)" + transaction.description = f'{transaction.description} (платеж отклонен)' await session.commit() - + await self._send_failure_notification(user_id) - - logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}") + + logger.info(f'Обработан неудачный Tribute платеж для пользователя {user_id}') break - + except Exception as e: - logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}") - - async def _handle_refund(self, refund_data: Dict[str, Any]): - + logger.error(f'Ошибка обработки неудачного Tribute платежа: {e}') + + async def _handle_refund(self, refund_data: dict[str, Any]): try: - user_id = refund_data["user_id"] - amount_kopeks = refund_data["amount_kopeks"] - payment_id = refund_data["payment_id"] - + user_id = refund_data['user_id'] + amount_kopeks = refund_data['amount_kopeks'] + payment_id = refund_data['payment_id'] + async for session in get_db(): await create_transaction( db=session, user_id=user_id, type=TransactionType.REFUND, - amount_kopeks=-amount_kopeks, - description=f"Возврат Tribute платежа {payment_id}", + amount_kopeks=-amount_kopeks, + description=f'Возврат Tribute платежа {payment_id}', payment_method=PaymentMethod.TRIBUTE, - external_id=f"refund_{payment_id}", - is_completed=True + external_id=f'refund_{payment_id}', + is_completed=True, ) - + user = await get_user_by_telegram_id(session, user_id) if user and user.balance_kopeks >= amount_kopeks: user.balance_kopeks -= amount_kopeks await session.commit() - + await self._send_refund_notification(user_id, amount_kopeks) - - logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}") + + logger.info(f'Обработан возврат Tribute: {amount_kopeks / 100}₽ для пользователя {user_id}') break - + except Exception as e: - logger.error(f"Ошибка обработки возврата Tribute: {e}") - + logger.error(f'Ошибка обработки возврата Tribute: {e}') async def _send_success_notification(self, user_id: int, amount_kopeks: int): # Skip if no telegram_id (email-only user) if not user_id: - logger.debug("Пропуск уведомления Tribute для пользователя без telegram_id") + logger.debug('Пропуск уведомления Tribute для пользователя без telegram_id') return try: @@ -279,26 +271,22 @@ class TributeService: keyboard = await payment_service.build_topup_success_keyboard(user) text = ( - f"✅ **Платеж успешно получен!**\n\n" - f"💰 Сумма: {int(amount_rubles)} ₽\n" - f"💳 Способ оплаты: Tribute\n" - f"🎉 Средства зачислены на баланс!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n" - f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, " - f"подписка будет приобретена автоматически после пополнения баланса.\n\n" - f"Спасибо за оплату! 🙏" + f'✅ **Платеж успешно получен!**\n\n' + f'💰 Сумма: {int(amount_rubles)} ₽\n' + f'💳 Способ оплаты: Tribute\n' + f'🎉 Средства зачислены на баланс!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n' + f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, ' + f'подписка будет приобретена автоматически после пополнения баланса.\n\n' + f'Спасибо за оплату! 🙏' ) - await self.bot.send_message( - user_id, - text, - reply_markup=keyboard, - parse_mode="Markdown" - ) + await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown') # Проверяем наличие сохраненной корзины для возврата к оформлению подписки from app.services.user_cart_service import user_cart_service + has_saved_cart = await user_cart_service.has_user_cart(user.id) auto_purchase_success = False if has_saved_cart: @@ -310,7 +298,7 @@ class TributeService: ) except Exception as auto_error: logger.error( - "Ошибка автоматической покупки подписки для пользователя %s: %s", + 'Ошибка автоматической покупки подписки для пользователя %s: %s', user.id, auto_error, exc_info=True, @@ -328,7 +316,7 @@ class TributeService: ) except Exception as auto_activate_error: logger.error( - "Ошибка умной автоактивации для пользователя %s: %s", + 'Ошибка умной автоактивации для пользователя %s: %s', user.id, auto_activate_error, exc_info=True, @@ -338,128 +326,122 @@ class TributeService: if has_saved_cart and self.bot and not activation_notification_sent and user_id: # Если у пользователя есть сохраненная корзина, # отправляем ему уведомление с кнопкой вернуться к оформлению - from app.localization.texts import get_texts from aiogram import types + from app.localization.texts import get_texts + texts = get_texts(user.language) cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format( total_amount=settings.format_price(amount_kopeks) ) # Создаем клавиатуру с кнопками - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, - callback_data="subscription_resume_checkout" - )], - [types.InlineKeyboardButton( - text="💰 Мой баланс", - callback_data="menu_balance" - )], - [types.InlineKeyboardButton( - text="🏠 Главное меню", - callback_data="back_to_menu" - )] - ]) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT, callback_data='subscription_resume_checkout' + ) + ], + [types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')], + [types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')], + ] + ) await self.bot.send_message( chat_id=user_id, - text=f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n" - f"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. " - f"Обязательно активируйте подписку отдельно!\n\n{cart_message}", - reply_markup=keyboard + text=f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n' + f'⚠️ Важно: Пополнение баланса не активирует подписку автоматически. ' + f'Обязательно активируйте подписку отдельно!\n\n{cart_message}', + reply_markup=keyboard, ) logger.info( - "Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s", + 'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s', user_id, ) except Exception as e: - logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}") + logger.error(f'Ошибка отправки уведомления об успешном платеже: {e}') + async def _send_failure_notification(self, user_id: int): # Skip if no telegram_id (email-only user) if not user_id: - logger.debug("Пропуск уведомления об ошибке Tribute для пользователя без telegram_id") + logger.debug('Пропуск уведомления об ошибке Tribute для пользователя без telegram_id') return try: text = ( - "⌘ **Платеж не прошел**\n\n" - "К сожалению, ваш платеж через Tribute был отклонен.\n\n" - "Возможные причины:\n" - "• Недостаточно средств на карте\n" - "• Технические проблемы банка\n" - "• Превышен лимит операций\n\n" - "Попробуйте еще раз или обратитесь в поддержку." + '⌘ **Платеж не прошел**\n\n' + 'К сожалению, ваш платеж через Tribute был отклонен.\n\n' + 'Возможные причины:\n' + '• Недостаточно средств на карте\n' + '• Технические проблемы банка\n' + '• Превышен лимит операций\n\n' + 'Попробуйте еще раз или обратитесь в поддержку.' ) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")], - [InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")] - ]) - - await self.bot.send_message( - user_id, - text, - reply_markup=keyboard, - parse_mode="Markdown" + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='menu_balance')], + [InlineKeyboardButton(text='💬 Поддержка', callback_data='menu_support')], + ] ) - + + await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown') + except Exception as e: - logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}") - + logger.error(f'Ошибка отправки уведомления о неудачном платеже: {e}') + async def _send_refund_notification(self, user_id: int, amount_kopeks: int): # Skip if no telegram_id (email-only user) if not user_id: - logger.debug("Пропуск уведомления о возврате Tribute для пользователя без telegram_id") + logger.debug('Пропуск уведомления о возврате Tribute для пользователя без telegram_id') return try: amount_rubles = amount_kopeks / 100 text = ( - f"🔄 **Возврат средств**\n\n" - f"💰 Сумма возврата: {int(amount_rubles)} ₽\n" - f"💳 Способ: Tribute\n\n" - f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n" - f"Если у вас есть вопросы, обратитесь в поддержку." + f'🔄 **Возврат средств**\n\n' + f'💰 Сумма возврата: {int(amount_rubles)} ₽\n' + f'💳 Способ: Tribute\n\n' + f'Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n' + f'Если у вас есть вопросы, обратитесь в поддержку.' ) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")], - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ]) - - await self.bot.send_message( - user_id, - text, - reply_markup=keyboard, - parse_mode="Markdown" + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text='💬 Поддержка', callback_data='menu_support')], + [InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')], + ] ) - + + await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown') + except Exception as e: - logger.error(f"Ошибка отправки уведомления о возврате: {e}") - + logger.error(f'Ошибка отправки уведомления о возврате: {e}') + async def force_process_payment( - self, - payment_id: str, - user_id: int, + self, + payment_id: str, + user_id: int, amount_kopeks: int, - description: str = "Принудительная обработка Tribute платежа" + description: str = 'Принудительная обработка Tribute платежа', ) -> bool: - try: - logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}") - + logger.info( + f'🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}' + ) + async for session in get_db(): user = await get_user_by_telegram_id(session, user_id) if not user: - logger.error(f"⌘ Пользователь {user_id} не найден") + logger.error(f'⌘ Пользователь {user_id} не найден') return False - - external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}" - - transaction = await create_transaction( + + external_id = f'force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}' + + await create_transaction( db=session, user_id=user.id, type=TransactionType.DEPOSIT, @@ -467,33 +449,30 @@ class TributeService: description=description, payment_method=PaymentMethod.TRIBUTE, external_id=external_id, - is_completed=True + is_completed=True, ) - + old_balance = user.balance_kopeks user.balance_kopeks += amount_kopeks user.updated_at = datetime.utcnow() - + await session.commit() - - logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп") - + + logger.info(f'💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп') + await self._send_success_notification(user_id, amount_kopeks) - - logger.info(f"✅ Принудительно обработан платеж {payment_id}") + + logger.info(f'✅ Принудительно обработан платеж {payment_id}') return True - + except Exception as e: - logger.error(f"⌘ Ошибка принудительной обработки: {e}", exc_info=True) + logger.error(f'⌘ Ошибка принудительной обработки: {e}', exc_info=True) return False - - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: + + async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: return await self.tribute_api.get_payment_status(payment_id) - + async def create_refund( - self, - payment_id: str, - amount_kopeks: Optional[int] = None, - reason: str = "Возврат по запросу" - ) -> Optional[Dict[str, Any]]: + self, payment_id: str, amount_kopeks: int | None = None, reason: str = 'Возврат по запросу' + ) -> dict[str, Any] | None: return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason) diff --git a/app/services/user_cart_service.py b/app/services/user_cart_service.py index 613ff1c8..f7901be4 100644 --- a/app/services/user_cart_service.py +++ b/app/services/user_cart_service.py @@ -1,11 +1,12 @@ import json import logging -from typing import Optional, Dict, Any +from typing import Any import redis.asyncio as redis from app.config import settings + logger = logging.getLogger(__name__) @@ -18,10 +19,10 @@ class UserCartService: """ def __init__(self): - self._redis_client: Optional[redis.Redis] = None + self._redis_client: redis.Redis | None = None self._initialized: bool = False - def _get_redis_client(self) -> Optional[redis.Redis]: + def _get_redis_client(self) -> redis.Redis | None: """Ленивая инициализация Redis клиента.""" if self._initialized: return self._redis_client @@ -29,17 +30,15 @@ class UserCartService: try: self._redis_client = redis.from_url(settings.REDIS_URL) self._initialized = True - logger.debug("Redis клиент для корзины инициализирован") + logger.debug('Redis клиент для корзины инициализирован') except Exception as e: - logger.warning(f"Не удалось подключиться к Redis для корзины: {e}") + logger.warning(f'Не удалось подключиться к Redis для корзины: {e}') self._redis_client = None self._initialized = True return self._redis_client - async def save_user_cart( - self, user_id: int, cart_data: Dict[str, Any], ttl: Optional[int] = None - ) -> bool: + async def save_user_cart(self, user_id: int, cart_data: dict[str, Any], ttl: int | None = None) -> bool: """ Сохранить корзину пользователя в Redis. @@ -56,17 +55,17 @@ class UserCartService: return False try: - key = f"user_cart:{user_id}" + key = f'user_cart:{user_id}' json_data = json.dumps(cart_data, ensure_ascii=False) effective_ttl = ttl if ttl is not None else settings.CART_TTL_SECONDS await client.setex(key, effective_ttl, json_data) - logger.debug(f"Корзина пользователя {user_id} сохранена в Redis") + logger.debug(f'Корзина пользователя {user_id} сохранена в Redis') return True except Exception as e: - logger.error(f"Ошибка сохранения корзины пользователя {user_id}: {e}") + logger.error(f'Ошибка сохранения корзины пользователя {user_id}: {e}') return False - async def get_user_cart(self, user_id: int) -> Optional[Dict[str, Any]]: + async def get_user_cart(self, user_id: int) -> dict[str, Any] | None: """ Получить корзину пользователя из Redis. @@ -81,15 +80,15 @@ class UserCartService: return None try: - key = f"user_cart:{user_id}" + key = f'user_cart:{user_id}' json_data = await client.get(key) if json_data: cart_data = json.loads(json_data) - logger.debug(f"Корзина пользователя {user_id} загружена из Redis") + logger.debug(f'Корзина пользователя {user_id} загружена из Redis') return cart_data return None except Exception as e: - logger.error(f"Ошибка получения корзины пользователя {user_id}: {e}") + logger.error(f'Ошибка получения корзины пользователя {user_id}: {e}') return None async def delete_user_cart(self, user_id: int) -> bool: @@ -107,13 +106,13 @@ class UserCartService: return False try: - key = f"user_cart:{user_id}" + key = f'user_cart:{user_id}' result = await client.delete(key) if result: - logger.debug(f"Корзина пользователя {user_id} удалена из Redis") + logger.debug(f'Корзина пользователя {user_id} удалена из Redis') return bool(result) except Exception as e: - logger.error(f"Ошибка удаления корзины пользователя {user_id}: {e}") + logger.error(f'Ошибка удаления корзины пользователя {user_id}: {e}') return False async def has_user_cart(self, user_id: int) -> bool: @@ -131,11 +130,11 @@ class UserCartService: return False try: - key = f"user_cart:{user_id}" + key = f'user_cart:{user_id}' exists = await client.exists(key) return bool(exists) except Exception as e: - logger.error(f"Ошибка проверки наличия корзины пользователя {user_id}: {e}") + logger.error(f'Ошибка проверки наличия корзины пользователя {user_id}: {e}') return False diff --git a/app/services/user_service.py b/app/services/user_service.py index 3d90dbbc..05398f9f 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -1,49 +1,74 @@ import logging from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, Tuple -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import delete, select, update, func -from sqlalchemy.orm import selectinload +from typing import Any + from aiogram import Bot, types -from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError -from app.database.crud.user import ( - get_user_by_id, get_user_by_telegram_id, get_users_list, - get_users_count, get_users_statistics, get_inactive_users, - add_user_balance, subtract_user_balance, update_user, delete_user, - get_users_spending_stats, get_referrals -) +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings from app.database.crud.promo_group import get_promo_group_by_id -from app.database.crud.transaction import get_user_transactions_count from app.database.crud.subscription import ( - get_subscription_by_user_id, decrement_subscription_server_counts, + get_subscription_by_user_id, +) +from app.database.crud.transaction import get_user_transactions_count +from app.database.crud.user import ( + add_user_balance, + get_inactive_users, + get_referrals, + get_user_by_id, + get_users_count, + get_users_list, + get_users_spending_stats, + get_users_statistics, + subtract_user_balance, + update_user, ) from app.database.models import ( - User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse, - ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, - CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText, - SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment, - FreekassaPayment, AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod, - TransactionType + AdvertisingCampaign, + AdvertisingCampaignRegistration, + BroadcastHistory, + CryptoBotPayment, + FreekassaPayment, + HeleketPayment, + MulenPayPayment, + Pal24Payment, + PaymentMethod, + PlategaPayment, + PromoCode, + PromoCodeUse, + PromoGroup, + ReferralEarning, + SentNotification, + Subscription, + SubscriptionConversion, + SubscriptionServer, + Transaction, + User, + UserMessage, + UserStatus, + WelcomeText, + YooKassaPayment, ) -from app.config import settings from app.localization.texts import get_texts from app.services.notification_delivery_service import ( - notification_delivery_service, NotificationType, + notification_delivery_service, ) + logger = logging.getLogger(__name__) class UserService: - async def send_topup_success_to_user( self, bot: Bot, user: User, amount_kopeks: int, - subscription: Optional[Subscription] = None, + subscription: Subscription | None = None, ) -> bool: """ Отправляет пользователю уведомление об успешном пополнении баланса. @@ -52,49 +77,47 @@ class UserService: """ texts = get_texts(user.language) - has_active_subscription = ( - subscription is not None - and subscription.status in {"active", "trial"} - ) + has_active_subscription = subscription is not None and subscription.status in {'active', 'trial'} if has_active_subscription: # У пользователя есть активная подписка - обычное сообщение message = ( - f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"Спасибо за использование нашего сервиса! 🎉" + f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n' + f'💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' + f'Спасибо за использование нашего сервиса! 🎉' + ) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [ + types.InlineKeyboardButton( + text=texts.t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', + ) + ] + ] ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend" - )] - ]) else: # НЕТ активной подписки - БОЛЬШОЕ ПРЕДУПРЕЖДЕНИЕ message = ( - f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"{'─' * 25}\n\n" - f"⚠️ ВАЖНО! ⚠️\n\n" - f"🔴 ПОДПИСКА НЕ АКТИВНА!\n\n" - f"Пополнение баланса НЕ активирует подписку автоматически!\n\n" - f"👇 Выберите действие:" + f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n' + f'💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' + f'{"─" * 25}\n\n' + f'⚠️ ВАЖНО! ⚠️\n\n' + f'🔴 ПОДПИСКА НЕ АКТИВНА!\n\n' + f'Пополнение баланса НЕ активирует подписку автоматически!\n\n' + f'👇 Выберите действие:' + ) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text='🚀 АКТИВИРОВАТЬ ПОДПИСКУ', callback_data='subscription_buy')], + [types.InlineKeyboardButton(text='💎 ПРОДЛИТЬ ПОДПИСКУ', callback_data='subscription_extend')], + [ + types.InlineKeyboardButton( + text='📱 ДОБАВИТЬ УСТРОЙСТВА', callback_data='subscription_add_devices' + ) + ], + ] ) - keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ - [types.InlineKeyboardButton( - text="🚀 АКТИВИРОВАТЬ ПОДПИСКУ", - callback_data="subscription_buy" - )], - [types.InlineKeyboardButton( - text="💎 ПРОДЛИТЬ ПОДПИСКУ", - callback_data="subscription_extend" - )], - [types.InlineKeyboardButton( - text="📱 ДОБАВИТЬ УСТРОЙСТВА", - callback_data="subscription_add_devices" - )] - ]) # Use unified notification delivery service return await notification_delivery_service.notify_balance_topup( @@ -106,64 +129,56 @@ class UserService: telegram_markup=keyboard, ) - async def _send_balance_notification( - self, - bot: Bot, - user: User, - amount_kopeks: int, - admin_name: str - ) -> bool: + async def _send_balance_notification(self, bot: Bot, user: User, amount_kopeks: int, admin_name: str) -> bool: """ Отправляет уведомление пользователю о пополнении/списании баланса. Поддерживает как Telegram, так и email-only пользователей. """ if amount_kopeks > 0: # Пополнение - emoji = "💰" - amount_text = f"+{settings.format_price(amount_kopeks)}" + emoji = '💰' + amount_text = f'+{settings.format_price(amount_kopeks)}' message = ( - f"{emoji} Баланс пополнен!\n\n" - f"💵 Сумма: {amount_text}\n" - f"👤 Администратор: {admin_name}\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"Спасибо за использование нашего сервиса! 🎉" + f'{emoji} Баланс пополнен!\n\n' + f'💵 Сумма: {amount_text}\n' + f'👤 Администратор: {admin_name}\n' + f'💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' + f'Спасибо за использование нашего сервиса! 🎉' ) else: # Списание - emoji = "💸" - amount_text = f"-{settings.format_price(abs(amount_kopeks))}" + emoji = '💸' + amount_text = f'-{settings.format_price(abs(amount_kopeks))}' message = ( - f"{emoji} Средства списаны с баланса\n\n" - f"💵 Сумма: {amount_text}\n" - f"👤 Администратор: {admin_name}\n" - f"💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n" - f"Если у вас есть вопросы, обратитесь в поддержку." + f'{emoji} Средства списаны с баланса\n\n' + f'💵 Сумма: {amount_text}\n' + f'👤 Администратор: {admin_name}\n' + f'💳 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' + f'Если у вас есть вопросы, обратитесь в поддержку.' ) keyboard_rows = [] - if getattr(user, "subscription", None) and user.subscription.status in { - "active", - "expired", - "trial", + if getattr(user, 'subscription', None) and user.subscription.status in { + 'active', + 'expired', + 'trial', }: - keyboard_rows.append([ - types.InlineKeyboardButton( - text=get_texts(user.language).t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), - callback_data="subscription_extend", - ) - ]) + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=get_texts(user.language).t('SUBSCRIPTION_EXTEND', '💎 Продлить подписку'), + callback_data='subscription_extend', + ) + ] + ) - reply_markup = ( - types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) - if keyboard_rows - else None - ) + reply_markup = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows) if keyboard_rows else None # Use unified notification delivery service context = { - "amount_kopeks": amount_kopeks, - "new_balance_kopeks": user.balance_kopeks, - "description": f"Администратор: {admin_name}", + 'amount_kopeks': amount_kopeks, + 'new_balance_kopeks': user.balance_kopeks, + 'description': f'Администратор: {admin_name}', } return await notification_delivery_service.send_notification( @@ -174,67 +189,55 @@ class UserService: telegram_message=message, telegram_markup=reply_markup, ) - - async def get_user_profile( - self, - db: AsyncSession, - user_id: int - ) -> Optional[Dict[str, Any]]: + + async def get_user_profile(self, db: AsyncSession, user_id: int) -> dict[str, Any] | None: try: user = await get_user_by_id(db, user_id) if not user: return None - + subscription = await get_subscription_by_user_id(db, user_id) transactions_count = await get_user_transactions_count(db, user_id) - + return { - "user": user, - "subscription": subscription, - "transactions_count": transactions_count, - "is_admin": settings.is_admin(user.telegram_id, user.email), - "registration_days": (datetime.utcnow() - user.created_at).days + 'user': user, + 'subscription': subscription, + 'transactions_count': transactions_count, + 'is_admin': settings.is_admin(user.telegram_id, user.email), + 'registration_days': (datetime.utcnow() - user.created_at).days, } - + except Exception as e: - logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}") + logger.error(f'Ошибка получения профиля пользователя {user_id}: {e}') return None - - async def search_users( - self, - db: AsyncSession, - query: str, - page: int = 1, - limit: int = 20 - ) -> Dict[str, Any]: + + async def search_users(self, db: AsyncSession, query: str, page: int = 1, limit: int = 20) -> dict[str, Any]: try: offset = (page - 1) * limit - - users = await get_users_list( - db, offset=offset, limit=limit, search=query - ) + + users = await get_users_list(db, offset=offset, limit=limit, search=query) total_count = await get_users_count(db, search=query) - + total_pages = (total_count + limit - 1) // limit - + return { - "users": users, - "current_page": page, - "total_pages": total_pages, - "total_count": total_count, - "has_next": page < total_pages, - "has_prev": page > 1 + 'users': users, + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, + 'has_next': page < total_pages, + 'has_prev': page > 1, } - + except Exception as e: - logger.error(f"Ошибка поиска пользователей: {e}") + logger.error(f'Ошибка поиска пользователей: {e}') return { - "users": [], - "current_page": 1, - "total_pages": 1, - "total_count": 0, - "has_next": False, - "has_prev": False + 'users': [], + 'current_page': 1, + 'total_pages': 1, + 'total_count': 0, + 'has_next': False, + 'has_prev': False, } async def get_users_page( @@ -242,16 +245,16 @@ class UserService: db: AsyncSession, page: int = 1, limit: int = 20, - status: Optional[UserStatus] = None, + status: UserStatus | None = None, order_by_balance: bool = False, order_by_traffic: bool = False, order_by_last_activity: bool = False, order_by_total_spent: bool = False, - order_by_purchase_count: bool = False - ) -> Dict[str, Any]: + order_by_purchase_count: bool = False, + ) -> dict[str, Any]: try: offset = (page - 1) * limit - + users = await get_users_list( db, offset=offset, @@ -264,27 +267,27 @@ class UserService: order_by_purchase_count=order_by_purchase_count, ) total_count = await get_users_count(db, status=status) - + total_pages = (total_count + limit - 1) // limit - + return { - "users": users, - "current_page": page, - "total_pages": total_pages, - "total_count": total_count, - "has_next": page < total_pages, - "has_prev": page > 1 + 'users': users, + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, + 'has_next': page < total_pages, + 'has_prev': page > 1, } - + except Exception as e: - logger.error(f"Ошибка получения страницы пользователей: {e}") + logger.error(f'Ошибка получения страницы пользователей: {e}') return { - "users": [], - "current_page": 1, - "total_pages": 1, - "total_count": 0, - "has_next": False, - "has_prev": False + 'users': [], + 'current_page': 1, + 'total_pages': 1, + 'total_count': 0, + 'has_next': False, + 'has_prev': False, } async def get_users_ready_to_renew( @@ -293,7 +296,7 @@ class UserService: min_balance_kopeks: int, page: int = 1, limit: int = 10, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Возвращает пользователей с истекшей подпиской и достаточным балансом.""" try: offset = (page - 1) * limit @@ -318,63 +321,49 @@ class UserService: users = result.scalars().unique().all() count_query = ( - select(func.count(User.id)) - .join(Subscription, Subscription.user_id == User.id) - .where(*base_filters) + select(func.count(User.id)).join(Subscription, Subscription.user_id == User.id).where(*base_filters) ) total_count = (await db.execute(count_query)).scalar() or 0 total_pages = (total_count + limit - 1) // limit if total_count else 0 return { - "users": users, - "current_page": page, - "total_pages": total_pages, - "total_count": total_count, + 'users': users, + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, } except Exception as e: - logger.error(f"Ошибка получения пользователей для продления: {e}") + logger.error(f'Ошибка получения пользователей для продления: {e}') return { - "users": [], - "current_page": 1, - "total_pages": 1, - "total_count": 0, + 'users': [], + 'current_page': 1, + 'total_pages': 1, + 'total_count': 0, } - async def get_user_spending_stats_map( - self, - db: AsyncSession, - user_ids: List[int] - ) -> Dict[int, Dict[str, int]]: + async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]: try: return await get_users_spending_stats(db, user_ids) except Exception as e: - logger.error(f"Ошибка получения статистики трат пользователей: {e}") + logger.error(f'Ошибка получения статистики трат пользователей: {e}') return {} - async def get_users_by_campaign_page( - self, - db: AsyncSession, - page: int = 1, - limit: int = 20 - ) -> Dict[str, Any]: + async def get_users_by_campaign_page(self, db: AsyncSession, page: int = 1, limit: int = 20) -> dict[str, Any]: try: offset = (page - 1) * limit - campaign_ranked = ( - select( - AdvertisingCampaignRegistration.user_id.label("user_id"), - AdvertisingCampaignRegistration.campaign_id.label("campaign_id"), - AdvertisingCampaignRegistration.created_at.label("created_at"), - func.row_number() - .over( - partition_by=AdvertisingCampaignRegistration.user_id, - order_by=AdvertisingCampaignRegistration.created_at.desc(), - ) - .label("rn"), + campaign_ranked = select( + AdvertisingCampaignRegistration.user_id.label('user_id'), + AdvertisingCampaignRegistration.campaign_id.label('campaign_id'), + AdvertisingCampaignRegistration.created_at.label('created_at'), + func.row_number() + .over( + partition_by=AdvertisingCampaignRegistration.user_id, + order_by=AdvertisingCampaignRegistration.created_at.desc(), ) - .cte("campaign_ranked") - ) + .label('rn'), + ).cte('campaign_ranked') latest_campaign = ( select( @@ -389,7 +378,7 @@ class UserService: query = ( select( User, - AdvertisingCampaign.name.label("campaign_name"), + AdvertisingCampaign.name.label('campaign_name'), latest_campaign.c.created_at, ) .join(latest_campaign, latest_campaign.c.user_id == User.id) @@ -411,8 +400,8 @@ class UserService: users = [row[0] for row in rows] campaign_map = { row[0].id: { - "campaign_name": row[1], - "registered_at": row[2], + 'campaign_name': row[1], + 'registered_at': row[2], } for row in rows } @@ -423,25 +412,25 @@ class UserService: total_pages = (total_count + limit - 1) // limit if total_count else 1 return { - "users": users, - "campaigns": campaign_map, - "current_page": page, - "total_pages": total_pages, - "total_count": total_count, - "has_next": page < total_pages, - "has_prev": page > 1, + 'users': users, + 'campaigns': campaign_map, + 'current_page': page, + 'total_pages': total_pages, + 'total_count': total_count, + 'has_next': page < total_pages, + 'has_prev': page > 1, } except Exception as e: - logger.error(f"Ошибка получения пользователей по кампаниям: {e}") + logger.error(f'Ошибка получения пользователей по кампаниям: {e}') return { - "users": [], - "campaigns": {}, - "current_page": 1, - "total_pages": 1, - "total_count": 0, - "has_next": False, - "has_prev": False, + 'users': [], + 'campaigns': {}, + 'current_page': 1, + 'total_pages': 1, + 'total_count': 0, + 'has_next': False, + 'has_prev': False, } async def update_user_balance( @@ -451,8 +440,8 @@ class UserService: amount_kopeks: int, description: str, admin_id: int, - bot: Optional[Bot] = None, - admin_name: Optional[str] = None + bot: Bot | None = None, + admin_name: str | None = None, ) -> bool: try: user = await get_user_by_id(db, user_id) @@ -460,15 +449,12 @@ class UserService: return False # Сохраняем старый баланс для уведомления - old_balance = user.balance_kopeks if amount_kopeks > 0: await add_user_balance( - db, user, amount_kopeks, - description=description, - payment_method=PaymentMethod.MANUAL + db, user, amount_kopeks, description=description, payment_method=PaymentMethod.MANUAL ) - logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽") + logger.info(f'Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks / 100}₽') success = True else: success = await subtract_user_balance( @@ -480,7 +466,7 @@ class UserService: payment_method=PaymentMethod.MANUAL, ) if success: - logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽") + logger.info(f'Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks) / 100}₽') # Отправляем уведомление пользователю, если операция прошла успешно if success and bot: @@ -490,7 +476,7 @@ class UserService: # Получаем имя администратора if not admin_name: admin_user = await get_user_by_id(db, admin_id) - admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}" + admin_name = admin_user.full_name if admin_user else f'Админ #{admin_id}' # Отправляем уведомление (не блокируем операцию если не удалось отправить) await self._send_balance_notification(bot, user, amount_kopeks, admin_name) @@ -498,15 +484,12 @@ class UserService: return success except Exception as e: - logger.error(f"Ошибка изменения баланса пользователя: {e}") + logger.error(f'Ошибка изменения баланса пользователя: {e}') return False async def update_user_promo_group( - self, - db: AsyncSession, - user_id: int, - promo_group_id: int - ) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]: + self, db: AsyncSession, user_id: int, promo_group_id: int + ) -> tuple[bool, User | None, PromoGroup | None, PromoGroup | None]: try: user = await get_user_by_id(db, user_id) if not user: @@ -535,22 +518,22 @@ class UserService: except Exception as e: await db.rollback() - logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}") + logger.error(f'Ошибка обновления промогруппы пользователя {user_id}: {e}') return False, None, None, None async def update_user_referrals( self, db: AsyncSession, user_id: int, - referral_user_ids: List[int], + referral_user_ids: list[int], admin_id: int, - ) -> Tuple[bool, Dict[str, int]]: + ) -> tuple[bool, dict[str, int]]: try: user = await get_user_by_id(db, user_id) if not user: - return False, {"error": "user_not_found"} + return False, {'error': 'user_not_found'} - unique_ids: List[int] = [] + unique_ids: list[int] = [] for referral_id in referral_user_ids: if referral_id == user_id: continue @@ -565,23 +548,15 @@ class UserService: to_add = [rid for rid in unique_ids if rid not in current_ids] if to_assign: - await db.execute( - update(User) - .where(User.id.in_(to_assign)) - .values(referred_by_id=user_id) - ) + await db.execute(update(User).where(User.id.in_(to_assign)).values(referred_by_id=user_id)) if to_remove: - await db.execute( - update(User) - .where(User.id.in_(to_remove)) - .values(referred_by_id=None) - ) + await db.execute(update(User).where(User.id.in_(to_remove)).values(referred_by_id=None)) await db.commit() logger.info( - "Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s", + 'Админ %s обновил рефералов пользователя %s: добавлено %s, удалено %s, всего %s', admin_id, user_id, len(to_add), @@ -590,166 +565,162 @@ class UserService: ) return True, { - "added": len(to_add), - "removed": len(to_remove), - "total": len(unique_ids), + 'added': len(to_add), + 'removed': len(to_remove), + 'total': len(unique_ids), } except Exception as e: await db.rollback() logger.error( - "Ошибка обновления рефералов пользователя %s: %s", + 'Ошибка обновления рефералов пользователя %s: %s', user_id, e, ) - return False, {"error": "update_failed"} + return False, {'error': 'update_failed'} async def block_user( - self, - db: AsyncSession, - user_id: int, - admin_id: int, - reason: str = "Заблокирован администратором" + self, db: AsyncSession, user_id: int, admin_id: int, reason: str = 'Заблокирован администратором' ) -> bool: try: user = await get_user_by_id(db, user_id) if not user: return False - + if user.remnawave_uuid: try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке") + logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке') except Exception as e: - logger.error(f"❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}") - + logger.error(f'❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}') + if user.subscription: from app.database.crud.subscription import deactivate_subscription + await deactivate_subscription(db, user.subscription) - + await update_user(db, user, status=UserStatus.BLOCKED.value) - - logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}") + + logger.info(f'Админ {admin_id} заблокировал пользователя {user_id}: {reason}') return True - + except Exception as e: - logger.error(f"Ошибка блокировки пользователя: {e}") + logger.error(f'Ошибка блокировки пользователя: {e}') return False - - async def unblock_user( - self, - db: AsyncSession, - user_id: int, - admin_id: int - ) -> bool: + + async def unblock_user(self, db: AsyncSession, user_id: int, admin_id: int) -> bool: try: user = await get_user_by_id(db, user_id) if not user: return False - + await update_user(db, user, status=UserStatus.ACTIVE.value) - + if user.subscription: from datetime import datetime + from app.database.models import SubscriptionStatus - + if user.subscription.end_date > datetime.utcnow(): user.subscription.status = SubscriptionStatus.ACTIVE.value await db.commit() await db.refresh(user.subscription) - logger.info(f"🔄 Подписка пользователя {user_id} восстановлена") - + logger.info(f'🔄 Подписка пользователя {user_id} восстановлена') + if user.remnawave_uuid: try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, user.subscription) - logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке") + logger.info( + f'✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке' + ) except Exception as e: - logger.error(f"❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}") + logger.error(f'❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}') else: - logger.info(f"⏰ Подписка пользователя {user_id} истекла, восстановление невозможно") - - logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}") + logger.info(f'⏰ Подписка пользователя {user_id} истекла, восстановление невозможно') + + logger.info(f'Админ {admin_id} разблокировал пользователя {user_id}') return True - + except Exception as e: - logger.error(f"Ошибка разблокировки пользователя: {e}") + logger.error(f'Ошибка разблокировки пользователя: {e}') return False - - async def delete_user_account( - self, - db: AsyncSession, - user_id: int, - admin_id: int - ) -> bool: + + async def delete_user_account(self, db: AsyncSession, user_id: int, admin_id: int) -> bool: try: user = await get_user_by_id(db, user_id) if not user: - logger.warning(f"Пользователь {user_id} не найден для удаления") + logger.warning(f'Пользователь {user_id} не найден для удаления') return False - - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (ID: {user_id_display})") - + + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info(f'🗑️ Начинаем полное удаление пользователя {user_id} (ID: {user_id_display})') + if user.remnawave_uuid: from app.config import settings + delete_mode = settings.get_remnawave_user_delete_mode() - + try: from app.services.remnawave_service import RemnaWaveService + remnawave_service = RemnaWaveService() - - if delete_mode == "delete": + + if delete_mode == 'delete': # Удаляем пользователя из панели Remnawave 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} удален из панели") + logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели') else: - logger.warning(f"⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave") + logger.warning( + f'⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave' + ) else: # Деактивируем пользователя в панели Remnawave from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})") - + logger.info( + f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})' + ) + except Exception as e: - logger.warning(f"⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}") + logger.warning(f'⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}') # Если основное действие не удалось, попытаемся хотя бы деактивировать - if delete_mode == "delete": + if delete_mode == 'delete': try: from app.services.subscription_service import SubscriptionService + subscription_service = SubscriptionService() await subscription_service.disable_remnawave_user(user.remnawave_uuid) - logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback") + logger.info(f'✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback') except Exception as fallback_e: - logger.error(f"❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}") - + logger.error(f'❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}') + try: sent_notifications_result = await db.execute( select(SentNotification).where(SentNotification.user_id == user_id) ) sent_notifications = sent_notifications_result.scalars().all() - + if sent_notifications: - logger.info(f"🔄 Удаляем {len(sent_notifications)} уведомлений") - await db.execute( - delete(SentNotification).where(SentNotification.user_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(sent_notifications)} уведомлений') + await db.execute(delete(SentNotification).where(SentNotification.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления уведомлений: {e}") - + logger.error(f'❌ Ошибка удаления уведомлений: {e}') + try: if user.subscription: subscription_servers_result = await db.execute( - select(SubscriptionServer).where( - SubscriptionServer.subscription_id == user.subscription.id - ) + select(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) subscription_servers = subscription_servers_result.scalars().all() @@ -760,196 +731,144 @@ class UserService: ) if subscription_servers: - logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер") + logger.info(f'🔄 Удаляем {len(subscription_servers)} связей подписка-сервер') await db.execute( - delete(SubscriptionServer).where( - SubscriptionServer.subscription_id == user.subscription.id - ) + delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id) ) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления связей подписка-сервер: {e}") - + logger.error(f'❌ Ошибка удаления связей подписка-сервер: {e}') + try: user_messages_result = await db.execute( - update(UserMessage) - .where(UserMessage.created_by == user_id) - .values(created_by=None) + update(UserMessage).where(UserMessage.created_by == user_id).values(created_by=None) ) if user_messages_result.rowcount > 0: - logger.info(f"🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений") + logger.info(f'🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений') await db.flush() except Exception as e: - logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}") - + logger.error(f'❌ Ошибка обновления пользовательских сообщений: {e}') + try: promocodes_result = await db.execute( - update(PromoCode) - .where(PromoCode.created_by == user_id) - .values(created_by=None) + update(PromoCode).where(PromoCode.created_by == user_id).values(created_by=None) ) if promocodes_result.rowcount > 0: - logger.info(f"🔄 Обновлено {promocodes_result.rowcount} промокодов") + logger.info(f'🔄 Обновлено {promocodes_result.rowcount} промокодов') await db.flush() except Exception as e: - logger.error(f"❌ Ошибка обновления промокодов: {e}") - + logger.error(f'❌ Ошибка обновления промокодов: {e}') + try: welcome_texts_result = await db.execute( - update(WelcomeText) - .where(WelcomeText.created_by == user_id) - .values(created_by=None) + update(WelcomeText).where(WelcomeText.created_by == user_id).values(created_by=None) ) if welcome_texts_result.rowcount > 0: - logger.info(f"🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов") + logger.info(f'🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов') await db.flush() except Exception as e: - logger.error(f"❌ Ошибка обновления приветственных текстов: {e}") - + logger.error(f'❌ Ошибка обновления приветственных текстов: {e}') + try: referrals_result = await db.execute( - update(User) - .where(User.referred_by_id == user_id) - .values(referred_by_id=None) + update(User).where(User.referred_by_id == user_id).values(referred_by_id=None) ) if referrals_result.rowcount > 0: - logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов") + logger.info(f'🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов') await db.flush() except Exception as e: - logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}") - + logger.error(f'❌ Ошибка очистки реферальных ссылок: {e}') + try: - yookassa_result = await db.execute( - select(YooKassaPayment).where(YooKassaPayment.user_id == user_id) - ) + yookassa_result = await db.execute(select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)) yookassa_payments = yookassa_result.scalars().all() - + if yookassa_payments: - logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей") + logger.info(f'🔄 Удаляем {len(yookassa_payments)} YooKassa платежей') await db.execute( - update(YooKassaPayment) - .where(YooKassaPayment.user_id == user_id) - .values(transaction_id=None) + update(YooKassaPayment).where(YooKassaPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id) - ) + await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}") - + logger.error(f'❌ Ошибка удаления YooKassa платежей: {e}') + try: - cryptobot_result = await db.execute( - select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id) - ) + cryptobot_result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)) cryptobot_payments = cryptobot_result.scalars().all() if cryptobot_payments: - logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей") + logger.info(f'🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей') await db.execute( - update(CryptoBotPayment) - .where(CryptoBotPayment.user_id == user_id) - .values(transaction_id=None) + update(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id) - ) + await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}") + logger.error(f'❌ Ошибка удаления CryptoBot платежей: {e}') try: - platega_result = await db.execute( - select(PlategaPayment).where(PlategaPayment.user_id == user_id) - ) + platega_result = await db.execute(select(PlategaPayment).where(PlategaPayment.user_id == user_id)) platega_payments = platega_result.scalars().all() if platega_payments: - logger.info(f"🔄 Удаляем {len(platega_payments)} Platega платежей") + logger.info(f'🔄 Удаляем {len(platega_payments)} Platega платежей') await db.execute( - update(PlategaPayment) - .where(PlategaPayment.user_id == user_id) - .values(transaction_id=None) + update(PlategaPayment).where(PlategaPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(PlategaPayment).where(PlategaPayment.user_id == user_id) - ) + await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления Platega платежей: {e}") + logger.error(f'❌ Ошибка удаления Platega платежей: {e}') try: - mulenpay_result = await db.execute( - select(MulenPayPayment).where(MulenPayPayment.user_id == user_id) - ) + mulenpay_result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)) mulenpay_payments = mulenpay_result.scalars().all() if mulenpay_payments: mulenpay_name = settings.get_mulenpay_display_name() - logger.info( - f"🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей" - ) + logger.info(f'🔄 Удаляем {len(mulenpay_payments)} {mulenpay_name} платежей') await db.execute( - update(MulenPayPayment) - .where(MulenPayPayment.user_id == user_id) - .values(transaction_id=None) + update(MulenPayPayment).where(MulenPayPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id) - ) + await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error( - f"❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}" - ) + logger.error(f'❌ Ошибка удаления {settings.get_mulenpay_display_name()} платежей: {e}') try: - pal24_result = await db.execute( - select(Pal24Payment).where(Pal24Payment.user_id == user_id) - ) + pal24_result = await db.execute(select(Pal24Payment).where(Pal24Payment.user_id == user_id)) pal24_payments = pal24_result.scalars().all() if pal24_payments: - logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей") + logger.info(f'🔄 Удаляем {len(pal24_payments)} Pal24 платежей') await db.execute( - update(Pal24Payment) - .where(Pal24Payment.user_id == user_id) - .values(transaction_id=None) + update(Pal24Payment).where(Pal24Payment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(Pal24Payment).where(Pal24Payment.user_id == user_id) - ) + await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}") + logger.error(f'❌ Ошибка удаления Pal24 платежей: {e}') try: - heleket_result = await db.execute( - select(HeleketPayment).where(HeleketPayment.user_id == user_id) - ) + heleket_result = await db.execute(select(HeleketPayment).where(HeleketPayment.user_id == user_id)) heleket_payments = heleket_result.scalars().all() if heleket_payments: - logger.info( - f"🔄 Удаляем {len(heleket_payments)} Heleket платежей" - ) + logger.info(f'🔄 Удаляем {len(heleket_payments)} Heleket платежей') await db.execute( - update(HeleketPayment) - .where(HeleketPayment.user_id == user_id) - .values(transaction_id=None) + update(HeleketPayment).where(HeleketPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(HeleketPayment).where(HeleketPayment.user_id == user_id) - ) + await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления Heleket платежей: {e}") + logger.error(f'❌ Ошибка удаления Heleket платежей: {e}') # Удаляем Freekassa платежи try: @@ -959,95 +878,77 @@ class UserService: freekassa_payments = freekassa_payments_result.scalars().all() if freekassa_payments: - logger.info(f"🔄 Удаляем {len(freekassa_payments)} Freekassa платежей") + logger.info(f'🔄 Удаляем {len(freekassa_payments)} Freekassa платежей') await db.execute( - update(FreekassaPayment) - .where(FreekassaPayment.user_id == user_id) - .values(transaction_id=None) + update(FreekassaPayment).where(FreekassaPayment.user_id == user_id).values(transaction_id=None) ) await db.flush() - await db.execute( - delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id) - ) + await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления Freekassa платежей: {e}") + logger.error(f'❌ Ошибка удаления Freekassa платежей: {e}') try: - transactions_result = await db.execute( - select(Transaction).where(Transaction.user_id == user_id) - ) + transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id)) transactions = transactions_result.scalars().all() - + if transactions: - logger.info(f"🔄 Удаляем {len(transactions)} транзакций") - await db.execute( - delete(Transaction).where(Transaction.user_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(transactions)} транзакций') + await db.execute(delete(Transaction).where(Transaction.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления транзакций: {e}") - + logger.error(f'❌ Ошибка удаления транзакций: {e}') + try: - promocode_uses_result = await db.execute( - select(PromoCodeUse).where(PromoCodeUse.user_id == user_id) - ) + promocode_uses_result = await db.execute(select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)) promocode_uses = promocode_uses_result.scalars().all() - + if promocode_uses: - logger.info(f"🔄 Удаляем {len(promocode_uses)} использований промокодов") - await db.execute( - delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(promocode_uses)} использований промокодов') + await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления использований промокодов: {e}") - + logger.error(f'❌ Ошибка удаления использований промокодов: {e}') + try: referral_earnings_result = await db.execute( select(ReferralEarning).where(ReferralEarning.user_id == user_id) ) referral_earnings = referral_earnings_result.scalars().all() - + if referral_earnings: - logger.info(f"🔄 Удаляем {len(referral_earnings)} реферальных доходов") - await db.execute( - delete(ReferralEarning).where(ReferralEarning.user_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(referral_earnings)} реферальных доходов') + await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления реферальных доходов: {e}") - + logger.error(f'❌ Ошибка удаления реферальных доходов: {e}') + try: referral_records_result = await db.execute( select(ReferralEarning).where(ReferralEarning.referral_id == user_id) ) referral_records = referral_records_result.scalars().all() - + if referral_records: - logger.info(f"🔄 Удаляем {len(referral_records)} записей о рефералах") - await db.execute( - delete(ReferralEarning).where(ReferralEarning.referral_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(referral_records)} записей о рефералах') + await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления записей о рефералах: {e}") - + logger.error(f'❌ Ошибка удаления записей о рефералах: {e}') + try: conversions_result = await db.execute( select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id) ) conversions = conversions_result.scalars().all() - + if conversions: - logger.info(f"🔄 Удаляем {len(conversions)} записей конверсий") - await db.execute( - delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(conversions)} записей конверсий') + await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления записей конверсий: {e}") - + logger.error(f'❌ Ошибка удаления записей конверсий: {e}') + try: broadcast_history_result = await db.execute( select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id) @@ -1055,13 +956,11 @@ class UserService: broadcast_history = broadcast_history_result.scalars().all() if broadcast_history: - logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок") - await db.execute( - delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id) - ) + logger.info(f'🔄 Удаляем {len(broadcast_history)} записей истории рассылок') + await db.execute(delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления истории рассылок: {e}") + logger.error(f'❌ Ошибка удаления истории рассылок: {e}') try: campaigns_result = await db.execute( @@ -1070,7 +969,7 @@ class UserService: campaigns = campaigns_result.scalars().all() if campaigns: - logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний") + logger.info(f'🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний') await db.execute( update(AdvertisingCampaign) .where(AdvertisingCampaign.created_by == user_id) @@ -1078,157 +977,143 @@ class UserService: ) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}") - + logger.error(f'❌ Ошибка обновления рекламных кампаний: {e}') + try: if user.subscription: - logger.info(f"🔄 Удаляем подписку {user.subscription.id}") - await db.execute( - delete(Subscription).where(Subscription.user_id == user_id) - ) + logger.info(f'🔄 Удаляем подписку {user.subscription.id}') + await db.execute(delete(Subscription).where(Subscription.user_id == user_id)) await db.flush() except Exception as e: - logger.error(f"❌ Ошибка удаления подписки: {e}") - + logger.error(f'❌ Ошибка удаления подписки: {e}') + try: - await db.execute( - delete(User).where(User.id == user_id) - ) + await db.execute(delete(User).where(User.id == user_id)) await db.commit() - logger.info(f"✅ Пользователь {user_id} окончательно удален из базы") + logger.info(f'✅ Пользователь {user_id} окончательно удален из базы') except Exception as e: - logger.error(f"❌ Ошибка финального удаления пользователя: {e}") + logger.error(f'❌ Ошибка финального удаления пользователя: {e}') await db.rollback() return False - - logger.info(f"✅ Пользователь {user_id_display} (ID: {user_id}) полностью удален администратором {admin_id}") + + logger.info( + f'✅ Пользователь {user_id_display} (ID: {user_id}) полностью удален администратором {admin_id}' + ) return True - + except Exception as e: - logger.error(f"❌ Критическая ошибка удаления пользователя {user_id}: {e}") + logger.error(f'❌ Критическая ошибка удаления пользователя {user_id}: {e}') await db.rollback() return False - - async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]: + + async def get_user_statistics(self, db: AsyncSession) -> dict[str, Any]: try: stats = await get_users_statistics(db) return stats - + except Exception as e: - logger.error(f"Ошибка получения статистики пользователей: {e}") + logger.error(f'Ошибка получения статистики пользователей: {e}') return { - "total_users": 0, - "active_users": 0, - "blocked_users": 0, - "new_today": 0, - "new_week": 0, - "new_month": 0 + 'total_users': 0, + 'active_users': 0, + 'blocked_users': 0, + 'new_today': 0, + 'new_week': 0, + 'new_month': 0, } - - async def cleanup_inactive_users( - self, - db: AsyncSession, - months: int = None - ) -> int: + + async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> int: try: if months is None: months = settings.INACTIVE_USER_DELETE_MONTHS - + inactive_users = await get_inactive_users(db, months) deleted_count = 0 - + for user in inactive_users: - success = await self.delete_user_account(db, user.id, 0) + success = await self.delete_user_account(db, user.id, 0) if success: deleted_count += 1 - - logger.info(f"Удалено {deleted_count} неактивных пользователей") + + logger.info(f'Удалено {deleted_count} неактивных пользователей') return deleted_count - + except Exception as e: - logger.error(f"Ошибка очистки неактивных пользователей: {e}") + logger.error(f'Ошибка очистки неактивных пользователей: {e}') return 0 - - async def get_user_activity_summary( - self, - db: AsyncSession, - user_id: int - ) -> Dict[str, Any]: + + async def get_user_activity_summary(self, db: AsyncSession, user_id: int) -> dict[str, Any]: try: user = await get_user_by_id(db, user_id) if not user: return {} - + subscription = await get_subscription_by_user_id(db, user_id) transactions_count = await get_user_transactions_count(db, user_id) - + days_since_registration = (datetime.utcnow() - user.created_at).days - + days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None - + return { - "user_id": user.id, - "telegram_id": user.telegram_id, - "username": user.username, - "full_name": user.full_name, - "status": user.status, - "language": user.language, - "balance_kopeks": user.balance_kopeks, - "registration_date": user.created_at, - "last_activity": user.last_activity, - "days_since_registration": days_since_registration, - "days_since_activity": days_since_activity, - "has_subscription": subscription is not None, - "subscription_active": subscription.is_active if subscription else False, - "subscription_trial": subscription.is_trial if subscription else False, - "transactions_count": transactions_count, - "referrer_id": user.referred_by_id, - "referral_code": user.referral_code + 'user_id': user.id, + 'telegram_id': user.telegram_id, + 'username': user.username, + 'full_name': user.full_name, + 'status': user.status, + 'language': user.language, + 'balance_kopeks': user.balance_kopeks, + 'registration_date': user.created_at, + 'last_activity': user.last_activity, + 'days_since_registration': days_since_registration, + 'days_since_activity': days_since_activity, + 'has_subscription': subscription is not None, + 'subscription_active': subscription.is_active if subscription else False, + 'subscription_trial': subscription.is_trial if subscription else False, + 'transactions_count': transactions_count, + 'referrer_id': user.referred_by_id, + 'referral_code': user.referral_code, } - + except Exception as e: - logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}") + logger.error(f'Ошибка получения сводки активности пользователя {user_id}: {e}') return {} - - async def get_users_by_criteria( - self, - db: AsyncSession, - criteria: Dict[str, Any] - ) -> List[User]: + + async def get_users_by_criteria(self, db: AsyncSession, criteria: dict[str, Any]) -> list[User]: try: status = criteria.get('status') - has_subscription = criteria.get('has_subscription') - is_trial = criteria.get('is_trial') + criteria.get('has_subscription') + criteria.get('is_trial') min_balance = criteria.get('min_balance', 0) max_balance = criteria.get('max_balance') days_inactive = criteria.get('days_inactive') - + registered_after = criteria.get('registered_after') registered_before = criteria.get('registered_before') - + users = await get_users_list(db, offset=0, limit=10000, status=status) - + filtered_users = [] for user in users: if user.balance_kopeks < min_balance: continue if max_balance and user.balance_kopeks > max_balance: continue - + if registered_after and user.created_at < registered_after: continue if registered_before and user.created_at > registered_before: continue - + if days_inactive and user.last_activity: inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive) if user.last_activity > inactive_threshold: continue - + filtered_users.append(user) - + return filtered_users - + except Exception as e: - logger.error(f"Ошибка получения пользователей по критериям: {e}") + logger.error(f'Ошибка получения пользователей по критериям: {e}') return [] diff --git a/app/services/version_service.py b/app/services/version_service.py index ee68a4d7..f95d11ff 100644 --- a/app/services/version_service.py +++ b/app/services/version_service.py @@ -1,13 +1,14 @@ import asyncio import logging +import re from datetime import datetime, timedelta -from typing import Dict, List, Optional, Tuple + import aiohttp from packaging import version -import re from app.config import settings + logger = logging.getLogger(__name__) @@ -19,35 +20,35 @@ class VersionInfo: self.body = body self.prerelease = prerelease self.is_dev = 'dev' in tag_name.lower() - + @property def clean_version(self) -> str: return re.sub(r'^v', '', self.tag_name) - + @property def version_obj(self): try: clean_ver = self.clean_version if 'dev' in clean_ver: base_ver = clean_ver.split('-dev')[0] - return version.parse(f"{base_ver}.dev") + return version.parse(f'{base_ver}.dev') return version.parse(clean_ver) except Exception: - return version.parse("0.0.0") - + return version.parse('0.0.0') + @property def formatted_date(self) -> str: return self.published_at.strftime('%d.%m.%Y %H:%M') - + @property def short_description(self) -> str: if not self.body: - return "Без описания" - + return 'Без описания' + description = self.body.strip() if len(description) > 350: - description = description[:347] + "..." - + description = description[:347] + '...' + return description @@ -57,158 +58,153 @@ class VersionService: self.repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot') self.enabled = getattr(settings, 'VERSION_CHECK_ENABLED', True) self.current_version = self._get_current_version() - self.cache_ttl = 3600 - self._cache: Dict = {} - self._last_check: Optional[datetime] = None + self.cache_ttl = 3600 + self._cache: dict = {} + self._last_check: datetime | None = None self._notification_service = None - + async def get_latest_stable_version(self) -> str: try: - url = f"https://api.github.com/repos/{self.repo}/releases/latest" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - data = await response.json() - return data['tag_name'] + url = f'https://api.github.com/repos/{self.repo}/releases/latest' + async with aiohttp.ClientSession() as session, session.get(url) as response: + if response.status == 200: + data = await response.json() + return data['tag_name'] except Exception: pass - return "UNKNOW" - + return 'UNKNOW' + def _get_current_version(self) -> str: import os - + current = os.getenv('VERSION', '').strip() - + if current: if '-' in current and current.startswith('v'): - base_version = current.split('-')[0] - if base_version.count('.') == 2: + base_version = current.split('-')[0] + if base_version.count('.') == 2: return base_version return current - - return "UNKNOW" - + + return 'UNKNOW' + def set_notification_service(self, notification_service): self._notification_service = notification_service - - async def check_for_updates(self, force: bool = False) -> Tuple[bool, List[VersionInfo]]: + + async def check_for_updates(self, force: bool = False) -> tuple[bool, list[VersionInfo]]: if not self.enabled: return False, [] - + try: releases = await self._fetch_releases(force) if not releases: return False, [] - + current_ver = self._parse_version(self.current_version) newer_releases = [] - + for release in releases: release_ver = release.version_obj if release_ver > current_ver: newer_releases.append(release) - + newer_releases.sort(key=lambda x: x.version_obj, reverse=True) - + has_updates = len(newer_releases) > 0 - + if has_updates and not force: await self._send_update_notification(newer_releases) - + return has_updates, newer_releases - + except Exception as e: - logger.error(f"Ошибка проверки обновлений: {e}") + logger.error(f'Ошибка проверки обновлений: {e}') return False, [] - - async def _fetch_releases(self, force: bool = False) -> List[VersionInfo]: + + async def _fetch_releases(self, force: bool = False) -> list[VersionInfo]: if not force and self._cache and self._last_check: if datetime.now() - self._last_check < timedelta(seconds=self.cache_ttl): return self._cache.get('releases', []) - - url = f"https://api.github.com/repos/{self.repo}/releases" - + + url = f'https://api.github.com/repos/{self.repo}/releases' + try: timeout = aiohttp.ClientTimeout(total=10) - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.get(url) as response: - if response.status == 200: - data = await response.json() - releases = [] - - for release_data in data[:20]: - release = VersionInfo( - tag_name=release_data['tag_name'], - published_at=release_data['published_at'], - name=release_data['name'], - body=release_data['body'] or '', - prerelease=release_data['prerelease'] - ) - releases.append(release) - - self._cache['releases'] = releases - self._last_check = datetime.now() - - logger.info(f"Получено {len(releases)} релизов из GitHub") - return releases - else: - logger.warning(f"GitHub API вернул статус {response.status}") - return [] - - except asyncio.TimeoutError: - logger.warning("Таймаут при запросе к GitHub API") + async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response: + if response.status == 200: + data = await response.json() + releases = [] + + for release_data in data[:20]: + release = VersionInfo( + tag_name=release_data['tag_name'], + published_at=release_data['published_at'], + name=release_data['name'], + body=release_data['body'] or '', + prerelease=release_data['prerelease'], + ) + releases.append(release) + + self._cache['releases'] = releases + self._last_check = datetime.now() + + logger.info(f'Получено {len(releases)} релизов из GitHub') + return releases + logger.warning(f'GitHub API вернул статус {response.status}') + return [] + + except TimeoutError: + logger.warning('Таймаут при запросе к GitHub API') return [] except Exception as e: - logger.error(f"Ошибка запроса к GitHub API: {e}") + logger.error(f'Ошибка запроса к GitHub API: {e}') return [] - + def _parse_version(self, version_str: str): try: clean_ver = re.sub(r'^v', '', version_str) if 'dev' in clean_ver: base_ver = clean_ver.split('-dev')[0] - return version.parse(f"{base_ver}.dev") - if 'unknow' in clean_ver.lower(): - return version.parse("0.0.0") + return version.parse(f'{base_ver}.dev') + if 'unknow' in clean_ver.lower(): + return version.parse('0.0.0') return version.parse(clean_ver) except Exception: - return version.parse("0.0.0") - - async def _send_update_notification(self, newer_releases: List[VersionInfo]): + return version.parse('0.0.0') + + async def _send_update_notification(self, newer_releases: list[VersionInfo]): if not self._notification_service or not newer_releases: return - + try: latest_version = newer_releases[0] - cache_key = f"notified_{latest_version.tag_name}" - + cache_key = f'notified_{latest_version.tag_name}' + if self._cache.get(cache_key): return - + await self._notification_service.send_version_update_notification( - current_version=self.current_version, - latest_version=latest_version, - total_updates=len(newer_releases) + current_version=self.current_version, latest_version=latest_version, total_updates=len(newer_releases) ) - + self._cache[cache_key] = True - + except Exception as e: - logger.error(f"Ошибка отправки уведомления об обновлении: {e}") - - async def get_version_info(self) -> Dict: + logger.error(f'Ошибка отправки уведомления об обновлении: {e}') + + async def get_version_info(self) -> dict: try: has_updates, newer_releases = await self.check_for_updates() all_releases = await self._fetch_releases() - + current_release = None current_ver = self._parse_version(self.current_version) - + for release in all_releases: if release.version_obj == current_ver: current_release = release break - + return { 'current_version': self.current_version, 'current_release': current_release, @@ -216,11 +212,11 @@ class VersionService: 'newer_releases': newer_releases[:5], 'total_newer': len(newer_releases), 'last_check': self._last_check, - 'repo_url': f"https://github.com/{self.repo}" + 'repo_url': f'https://github.com/{self.repo}', } - + except Exception as e: - logger.error(f"Ошибка получения информации о версиях: {e}") + logger.error(f'Ошибка получения информации о версиях: {e}') return { 'current_version': self.current_version, 'current_release': None, @@ -228,40 +224,40 @@ class VersionService: 'newer_releases': [], 'total_newer': 0, 'last_check': None, - 'repo_url': f"https://github.com/{self.repo}", - 'error': str(e) + 'repo_url': f'https://github.com/{self.repo}', + 'error': str(e), } - + async def start_periodic_check(self): if not self.enabled: - logger.info("Проверка версий отключена") + logger.info('Проверка версий отключена') return - - logger.info(f"Запуск периодической проверки обновлений для {self.repo}") - logger.info(f"Текущая версия: {self.current_version}") - + + logger.info(f'Запуск периодической проверки обновлений для {self.repo}') + logger.info(f'Текущая версия: {self.current_version}') + while True: try: - await asyncio.sleep(3600) + await asyncio.sleep(3600) await self.check_for_updates() - + except asyncio.CancelledError: - logger.info("Остановка проверки обновлений") + logger.info('Остановка проверки обновлений') break except Exception as e: - logger.error(f"Ошибка в периодической проверке обновлений: {e}") - await asyncio.sleep(300) - + logger.error(f'Ошибка в периодической проверке обновлений: {e}') + await asyncio.sleep(300) + def format_version_display(self, version_info: VersionInfo) -> str: - status_icon = "" + status_icon = '' if version_info.prerelease: - status_icon = "🧪" + status_icon = '🧪' elif version_info.is_dev: - status_icon = "🔧" + status_icon = '🔧' else: - status_icon = "📦" - - return f"{status_icon} {version_info.tag_name}" + status_icon = '📦' + + return f'{status_icon} {version_info.tag_name}' version_service = VersionService() diff --git a/app/services/wata_service.py b/app/services/wata_service.py index 03bd385d..493e5638 100644 --- a/app/services/wata_service.py +++ b/app/services/wata_service.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Optional +from datetime import UTC, datetime, timedelta +from typing import Any import aiohttp from app.config import settings + logger = logging.getLogger(__name__) @@ -23,31 +24,27 @@ class WataService: def __init__( self, *, - base_url: Optional[str] = None, - access_token: Optional[str] = None, - request_timeout: Optional[int] = None, + base_url: str | None = None, + access_token: str | None = None, + request_timeout: int | None = None, ) -> None: - self.base_url = (base_url or settings.WATA_BASE_URL or "").rstrip("/") + self.base_url = (base_url or settings.WATA_BASE_URL or '').rstrip('/') self.access_token = access_token or settings.WATA_ACCESS_TOKEN self.request_timeout = request_timeout or int(settings.WATA_REQUEST_TIMEOUT) @property def is_configured(self) -> bool: - return bool( - settings.is_wata_enabled() - and self.base_url - and self.access_token - ) + return bool(settings.is_wata_enabled() and self.base_url and self.access_token) def _build_url(self, path: str) -> str: - return f"{self.base_url}/{path.lstrip('/')}" + return f'{self.base_url}/{path.lstrip("/")}' - def _build_headers(self) -> Dict[str, str]: + def _build_headers(self) -> dict[str, str]: if not self.access_token: - raise WataAPIError("WATA access token is not configured") + raise WataAPIError('WATA access token is not configured') return { - "Authorization": f"Bearer {self.access_token}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {self.access_token}', + 'Content-Type': 'application/json', } async def _request( @@ -55,46 +52,44 @@ class WataService: method: str, path: str, *, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: if not self.is_configured: - raise WataAPIError("WATA service is not configured") + raise WataAPIError('WATA service is not configured') url = self._build_url(path) timeout = aiohttp.ClientTimeout(total=self.request_timeout) try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.request( + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.request( method, url, json=json, params=params, headers=self._build_headers(), - ) as response: - response_text = await response.text() - if response.status >= 400: - logger.error( - "WATA API error %s: %s", response.status, response_text - ) - raise WataAPIError( - f"WATA API returned status {response.status}: {response_text}" - ) + ) as response, + ): + response_text = await response.text() + if response.status >= 400: + logger.error('WATA API error %s: %s', response.status, response_text) + raise WataAPIError(f'WATA API returned status {response.status}: {response_text}') - if not response_text: - return {} + if not response_text: + return {} - try: - data = await response.json() - except aiohttp.ContentTypeError as error: - logger.error("WATA API returned non-JSON response: %s", error) - raise WataAPIError("WATA API returned invalid JSON") from error + try: + data = await response.json() + except aiohttp.ContentTypeError as error: + logger.error('WATA API returned non-JSON response: %s', error) + raise WataAPIError('WATA API returned invalid JSON') from error - return data + return data except aiohttp.ClientError as error: - logger.error("Error communicating with WATA API: %s", error) - raise WataAPIError("Failed to communicate with WATA API") from error + logger.error('Error communicating with WATA API: %s', error) + raise WataAPIError('Failed to communicate with WATA API') from error @staticmethod def _amount_from_kopeks(amount_kopeks: int) -> float: @@ -103,23 +98,23 @@ class WataService: @staticmethod def _format_datetime(value: datetime) -> str: if value.tzinfo is None: - aware = value.replace(tzinfo=timezone.utc) + aware = value.replace(tzinfo=UTC) else: - aware = value.astimezone(timezone.utc) - return aware.isoformat().replace("+00:00", "Z") + aware = value.astimezone(UTC) + return aware.isoformat().replace('+00:00', 'Z') @staticmethod - def _parse_datetime(raw: Optional[str]) -> Optional[datetime]: + def _parse_datetime(raw: str | None) -> datetime | None: if not raw: return None try: - normalized = raw.replace("Z", "+00:00") + normalized = raw.replace('Z', '+00:00') parsed = datetime.fromisoformat(normalized) if parsed.tzinfo is None: return parsed - return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed.astimezone(UTC).replace(tzinfo=None) except (ValueError, TypeError): - logger.debug("Failed to parse WATA datetime: %s", raw) + logger.debug('Failed to parse WATA datetime: %s', raw) return None async def create_payment_link( @@ -129,26 +124,26 @@ class WataService: currency: str, description: str, order_id: str, - success_url: Optional[str] = None, - fail_url: Optional[str] = None, - link_type: Optional[str] = None, - expiration_minutes: Optional[int] = None, + success_url: str | None = None, + fail_url: str | None = None, + link_type: str | None = None, + expiration_minutes: int | None = None, allow_arbitrary_amount: bool = False, - arbitrary_amount_prompts: Optional[list[int]] = None, - ) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "amount": self._amount_from_kopeks(amount_kopeks), - "currency": currency, - "description": description, - "orderId": order_id, + arbitrary_amount_prompts: list[int] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + 'amount': self._amount_from_kopeks(amount_kopeks), + 'currency': currency, + 'description': description, + 'orderId': order_id, } - payload["type"] = link_type or settings.WATA_PAYMENT_TYPE or "OneTime" + payload['type'] = link_type or settings.WATA_PAYMENT_TYPE or 'OneTime' if success_url or settings.WATA_SUCCESS_REDIRECT_URL: - payload["successRedirectUrl"] = success_url or settings.WATA_SUCCESS_REDIRECT_URL + payload['successRedirectUrl'] = success_url or settings.WATA_SUCCESS_REDIRECT_URL if fail_url or settings.WATA_FAIL_REDIRECT_URL: - payload["failRedirectUrl"] = fail_url or settings.WATA_FAIL_REDIRECT_URL + payload['failRedirectUrl'] = fail_url or settings.WATA_FAIL_REDIRECT_URL if expiration_minutes is None: ttl = settings.WATA_LINK_TTL_MINUTES @@ -156,52 +151,50 @@ class WataService: if expiration_minutes: expiration_time = datetime.utcnow() + timedelta(minutes=expiration_minutes) - payload["expirationDateTime"] = self._format_datetime(expiration_time) + payload['expirationDateTime'] = self._format_datetime(expiration_time) if allow_arbitrary_amount: - payload["isArbitraryAmountAllowed"] = True + payload['isArbitraryAmountAllowed'] = True if arbitrary_amount_prompts: - payload["arbitraryAmountPrompts"] = arbitrary_amount_prompts + payload['arbitraryAmountPrompts'] = arbitrary_amount_prompts logger.info( - "Создаем WATA платежную ссылку: order_id=%s, amount=%s %s", + 'Создаем WATA платежную ссылку: order_id=%s, amount=%s %s', order_id, - payload["amount"], + payload['amount'], currency, ) - response = await self._request("POST", "/links", json=payload) - logger.debug("WATA create link response: %s", response) + response = await self._request('POST', '/links', json=payload) + logger.debug('WATA create link response: %s', response) return response - async def get_payment_link(self, payment_link_id: str) -> Dict[str, Any]: - logger.debug("Запрашиваем WATA ссылку %s", payment_link_id) - return await self._request("GET", f"/links/{payment_link_id}") + async def get_payment_link(self, payment_link_id: str) -> dict[str, Any]: + logger.debug('Запрашиваем WATA ссылку %s', payment_link_id) + return await self._request('GET', f'/links/{payment_link_id}') async def search_transactions( self, *, - order_id: Optional[str] = None, - payment_link_id: Optional[str] = None, - status: Optional[str] = None, + order_id: str | None = None, + payment_link_id: str | None = None, + status: str | None = None, limit: int = 5, - ) -> Dict[str, Any]: - params: Dict[str, Any] = { - "skipCount": 0, - "maxResultCount": max(1, min(limit, 1000)), + ) -> dict[str, Any]: + params: dict[str, Any] = { + 'skipCount': 0, + 'maxResultCount': max(1, min(limit, 1000)), } if order_id: - params["orderId"] = order_id + params['orderId'] = order_id if status: - params["statuses"] = status + params['statuses'] = status if payment_link_id: - params["paymentLinkId"] = payment_link_id + params['paymentLinkId'] = payment_link_id - logger.debug( - "Ищем WATA транзакции: order_id=%s, payment_link_id=%s", order_id, payment_link_id - ) - return await self._request("GET", "/transactions", params=params) + logger.debug('Ищем WATA транзакции: order_id=%s, payment_link_id=%s', order_id, payment_link_id) + return await self._request('GET', '/transactions', params=params) - async def get_transaction(self, transaction_id: str) -> Dict[str, Any]: - logger.debug("Получаем WATA транзакцию %s", transaction_id) - return await self._request("GET", f"/transactions/{transaction_id}") + async def get_transaction(self, transaction_id: str) -> dict[str, Any]: + logger.debug('Получаем WATA транзакцию %s', transaction_id) + return await self._request('GET', f'/transactions/{transaction_id}') diff --git a/app/services/web_api_token_service.py b/app/services/web_api_token_service.py index 6beb06fd..b46dc943 100644 --- a/app/services/web_api_token_service.py +++ b/app/services/web_api_token_service.py @@ -2,7 +2,6 @@ from __future__ import annotations import secrets from datetime import datetime -from typing import Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession @@ -17,7 +16,7 @@ class WebApiTokenService: """Сервис для управления токенами административного веб-API.""" def __init__(self): - self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256" + 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] @@ -27,20 +26,20 @@ class WebApiTokenService: db: AsyncSession, token_value: str, *, - remote_ip: Optional[str] = None, - ) -> Optional[WebApiToken]: + remote_ip: str | None = None, + ) -> WebApiToken | None: normalized_value = token_value.strip() if not normalized_value: return None - async def _load_token(value: str) -> Optional[WebApiToken]: + async def _load_token(value: str) -> WebApiToken | None: 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() + 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) @@ -62,11 +61,11 @@ class WebApiTokenService: 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]: + description: str | None = None, + expires_at: datetime | None = None, + created_by: str | None = None, + token_value: str | None = None, + ) -> tuple[str, WebApiToken]: plain_token = token_value or generate_api_token() token_hash = self.hash_token(plain_token) diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index 3e545603..af7221ab 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -6,7 +6,7 @@ import hmac import json import logging from dataclasses import dataclass -from typing import Any, Optional +from typing import Any import aiohttp from sqlalchemy.ext.asyncio import AsyncSession @@ -17,6 +17,7 @@ from app.database.crud.webhook import ( update_webhook_stats, ) + logger = logging.getLogger(__name__) @@ -28,16 +29,16 @@ class DeliveryResult: event_type: str payload: dict[str, Any] status: str - response_status: Optional[int] = None - response_body: Optional[str] = None - error_message: Optional[str] = None + response_status: int | None = None + response_body: str | None = None + error_message: str | None = None class WebhookService: """Сервис для отправки webhooks.""" def __init__(self) -> None: - self._session: Optional[aiohttp.ClientSession] = None + self._session: aiohttp.ClientSession | None = None async def _get_session(self) -> aiohttp.ClientSession: """Получить или создать HTTP сессию.""" @@ -54,8 +55,8 @@ class WebhookService: def _sign_payload(self, payload: str, secret: str) -> str: """Подписать payload с помощью секрета.""" return hmac.new( - secret.encode("utf-8"), - payload.encode("utf-8"), + secret.encode('utf-8'), + payload.encode('utf-8'), hashlib.sha256, ).hexdigest() @@ -69,20 +70,17 @@ class WebhookService: webhooks = await get_active_webhooks_for_event(db, event_type) if not webhooks: - logger.debug("No active webhooks for event type: %s", event_type) + logger.debug('No active webhooks for event type: %s', event_type) return # Выполняем HTTP запросы параллельно (без операций с БД) - tasks = [ - self._deliver_webhook_http(webhook, event_type, payload) - for webhook in webhooks - ] + tasks = [self._deliver_webhook_http(webhook, event_type, payload) for webhook in webhooks] results = await asyncio.gather(*tasks, return_exceptions=True) # Записываем результаты в БД последовательно (избегаем concurrent session access) for result in results: if isinstance(result, Exception): - logger.exception("Unexpected error during webhook delivery: %s", result) + logger.exception('Unexpected error during webhook delivery: %s', result) continue if isinstance(result, DeliveryResult): await self._record_result(db, result) @@ -96,15 +94,15 @@ class WebhookService: """Выполнить HTTP доставку webhook (без операций с БД).""" payload_json = json.dumps(payload, default=str, ensure_ascii=False) headers = { - "Content-Type": "application/json", - "X-Webhook-Event": event_type, - "X-Webhook-Id": str(webhook.id), + 'Content-Type': 'application/json', + 'X-Webhook-Event': event_type, + 'X-Webhook-Id': str(webhook.id), } # Добавляем подпись, если есть секрет if webhook.secret: signature = self._sign_payload(payload_json, webhook.secret) - headers["X-Webhook-Signature"] = f"sha256={signature}" + headers['X-Webhook-Signature'] = f'sha256={signature}' try: session = await self._get_session() @@ -116,12 +114,12 @@ class WebhookService: response_body = await response.text() # Ограничиваем размер ответа для хранения if len(response_body) > 1000: - response_body = response_body[:1000] + "... (truncated)" + response_body = response_body[:1000] + '... (truncated)' - status = "success" if 200 <= response.status < 300 else "failed" + status = 'success' if 200 <= response.status < 300 else 'failed' error_message = None - if status == "failed": - error_message = f"HTTP {response.status}: {response_body[:500]}" + if status == 'failed': + error_message = f'HTTP {response.status}: {response_body[:500]}' return DeliveryResult( webhook=webhook, @@ -133,13 +131,13 @@ class WebhookService: error_message=error_message, ) - except asyncio.TimeoutError: + except TimeoutError: return DeliveryResult( webhook=webhook, event_type=event_type, payload=payload, - status="failed", - error_message="Request timeout", + status='failed', + error_message='Request timeout', ) except Exception as error: @@ -147,7 +145,7 @@ class WebhookService: webhook=webhook, event_type=event_type, payload=payload, - status="failed", + status='failed', error_message=str(error), ) @@ -165,23 +163,23 @@ class WebhookService: error_message=result.error_message, ) - await update_webhook_stats(db, result.webhook, result.status == "success") + await update_webhook_stats(db, result.webhook, result.status == 'success') - if result.status == "success": + if result.status == 'success': logger.info( - "Webhook %s delivered successfully to %s", + 'Webhook %s delivered successfully to %s', result.webhook.id, result.webhook.url, ) else: logger.warning( - "Webhook %s delivery failed: %s", + 'Webhook %s delivery failed: %s', result.webhook.id, result.error_message, ) except Exception as error: logger.exception( - "Failed to record webhook delivery result for %s: %s", + 'Failed to record webhook delivery result for %s: %s', result.webhook.id, error, ) @@ -189,4 +187,3 @@ class WebhookService: # Глобальный экземпляр сервиса webhook_service = WebhookService() - diff --git a/app/services/wheel_service.py b/app/services/wheel_service.py index 44086889..cd7de244 100644 --- a/app/services/wheel_service.py +++ b/app/services/wheel_service.py @@ -1,64 +1,65 @@ """ Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом. """ + import logging import random import secrets from dataclasses import dataclass from datetime import datetime, timedelta -from decimal import Decimal, ROUND_HALF_UP -from typing import Optional, List, Tuple, Dict, Any +from decimal import Decimal +from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database.models import ( - User, - Subscription, - WheelConfig, - WheelPrize, - WheelSpin, - WheelPrizeType, - WheelSpinPaymentType, - PromoCode, - PromoCodeType, -) +from app.database.crud.subscription import get_subscription_by_user_id +from app.database.crud.user import add_user_balance from app.database.crud.wheel import ( - get_or_create_wheel_config, - get_wheel_prizes, - get_user_spins_today, create_wheel_spin, - mark_spin_applied, + get_or_create_wheel_config, + get_user_spins_today, + get_wheel_prizes, get_wheel_statistics, ) -from app.database.crud.user import add_user_balance -from app.database.crud.subscription import get_subscription_by_user_id +from app.database.models import ( + PromoCode, + PromoCodeType, + User, + WheelConfig, + WheelPrize, + WheelPrizeType, + WheelSpinPaymentType, +) from app.services.subscription_service import SubscriptionService + logger = logging.getLogger(__name__) @dataclass class SpinResult: """Результат спина колеса.""" + success: bool - prize_id: Optional[int] = None - prize_type: Optional[str] = None + prize_id: int | None = None + prize_type: str | None = None prize_value: int = 0 - prize_display_name: str = "" - emoji: str = "🎁" - color: str = "#3B82F6" + prize_display_name: str = '' + emoji: str = '🎁' + color: str = '#3B82F6' rotation_degrees: float = 0.0 - message: str = "" - promocode: Optional[str] = None - error: Optional[str] = None + message: str = '' + promocode: str | None = None + error: str | None = None @dataclass class SpinAvailability: """Доступность спина для пользователя.""" + can_spin: bool - reason: Optional[str] = None + reason: str | None = None spins_remaining_today: int = 0 can_pay_stars: bool = False can_pay_days: bool = False @@ -74,11 +75,7 @@ class FortuneWheelService: def __init__(self): pass - async def check_availability( - self, - db: AsyncSession, - user: User - ) -> SpinAvailability: + async def check_availability(self, db: AsyncSession, user: User) -> SpinAvailability: """Проверить доступность спина для пользователя.""" config = await get_or_create_wheel_config(db) @@ -86,7 +83,7 @@ class FortuneWheelService: if not config.is_enabled: return SpinAvailability( can_spin=False, - reason="wheel_disabled", + reason='wheel_disabled', ) # Проверяем лимит спинов @@ -96,7 +93,7 @@ class FortuneWheelService: if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit: return SpinAvailability( can_spin=False, - reason="daily_limit_reached", + reason='daily_limit_reached', spins_remaining_today=0, ) @@ -125,9 +122,9 @@ class FortuneWheelService: if not can_pay_stars and not can_pay_days: # Определяем причину - reason = "no_payment_method_available" + reason = 'no_payment_method_available' if config.spin_cost_stars_enabled and user.balance_kopeks < required_balance_kopeks: - reason = "insufficient_balance" + reason = 'insufficient_balance' return SpinAvailability( can_spin=False, @@ -146,7 +143,7 @@ class FortuneWheelService: if not prizes: return SpinAvailability( can_spin=False, - reason="no_prizes_configured", + reason='no_prizes_configured', ) return SpinAvailability( @@ -161,11 +158,8 @@ class FortuneWheelService: ) def calculate_prize_probabilities( - self, - config: WheelConfig, - prizes: List[WheelPrize], - spin_cost_kopeks: int - ) -> List[Tuple[WheelPrize, float]]: + self, config: WheelConfig, prizes: list[WheelPrize], spin_cost_kopeks: int + ) -> list[tuple[WheelPrize, float]]: """ Рассчитать вероятности выпадения призов на основе RTP. @@ -216,10 +210,7 @@ class FortuneWheelService: # Нормализуем веса авто-призов до remaining_prob total_weight = sum(w[1] for w in weights) - auto_probabilities = [ - (prize, (weight / total_weight) * remaining_prob) - for prize, weight in weights - ] + auto_probabilities = [(prize, (weight / total_weight) * remaining_prob) for prize, weight in weights] # Объединяем result = manual_prizes + auto_probabilities @@ -231,13 +222,10 @@ class FortuneWheelService: return result - def _select_prize( - self, - prizes_with_probabilities: List[Tuple[WheelPrize, float]] - ) -> WheelPrize: + def _select_prize(self, prizes_with_probabilities: list[tuple[WheelPrize, float]]) -> WheelPrize: """Выбрать приз на основе вероятностей.""" if not prizes_with_probabilities: - raise ValueError("No prizes to select from") + raise ValueError('No prizes to select from') rand = random.random() cumulative = 0.0 @@ -250,11 +238,7 @@ class FortuneWheelService: # Fallback на последний приз return prizes_with_probabilities[-1][0] - def _calculate_rotation( - self, - prizes: List[WheelPrize], - selected_prize: WheelPrize - ) -> float: + def _calculate_rotation(self, prizes: list[WheelPrize], selected_prize: WheelPrize) -> float: """ Рассчитать угол поворота колеса для анимации. Возвращает градусы для CSS transform. @@ -263,10 +247,7 @@ class FortuneWheelService: return 0.0 # Находим индекс выбранного приза - prize_index = next( - (i for i, p in enumerate(prizes) if p.id == selected_prize.id), - 0 - ) + prize_index = next((i for i, p in enumerate(prizes) if p.id == selected_prize.id), 0) # Угол одного сектора sector_angle = 360 / len(prizes) @@ -285,12 +266,7 @@ class FortuneWheelService: return full_rotations + stop_angle - async def _process_stars_payment( - self, - db: AsyncSession, - user: User, - config: WheelConfig - ) -> int: + async def _process_stars_payment(self, db: AsyncSession, user: User, config: WheelConfig) -> int: """ Обработать оплату Stars (списание эквивалента с баланса). Возвращает стоимость в копейках. @@ -301,20 +277,15 @@ class FortuneWheelService: kopeks = int(rubles * 100) if user.balance_kopeks < kopeks: - raise ValueError("Недостаточно средств на балансе") + raise ValueError('Недостаточно средств на балансе') # Списываем с баланса user.balance_kopeks -= kopeks - logger.info(f"💫 Списано {kopeks/100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}") + logger.info(f'💫 Списано {kopeks / 100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}') return kopeks - async def _process_days_payment( - self, - db: AsyncSession, - user: User, - config: WheelConfig - ) -> int: + async def _process_days_payment(self, db: AsyncSession, user: User, config: WheelConfig) -> int: """ Обработать оплату днями подписки. Возвращает эквивалент в копейках. @@ -322,10 +293,10 @@ class FortuneWheelService: subscription = await get_subscription_by_user_id(db, user.id) if not subscription or not subscription.is_active: - raise ValueError("Нет активной подписки") + raise ValueError('Нет активной подписки') if subscription.days_left < config.min_subscription_days_for_day_payment + config.spin_cost_days: - raise ValueError("Недостаточно дней подписки") + raise ValueError('Недостаточно дней подписки') # Уменьшаем end_date subscription.end_date -= timedelta(days=config.spin_cost_days) @@ -334,29 +305,24 @@ class FortuneWheelService: # Оцениваем стоимость в копейках (для статистики) # Берем цену 30-дневного периода и делим на 30 from app.config import PERIOD_PRICES + price_30_days = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) or 19900 daily_price = price_30_days / 30 kopeks = int(daily_price * config.spin_cost_days) - logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}") + logger.info(f'📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}') # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f"✅ Списание дней синхронизировано с RemnaWave для user_id={user.id}") + logger.info(f'✅ Списание дней синхронизировано с RemnaWave для user_id={user.id}') except Exception as e: - logger.error(f"⚠️ Ошибка синхронизации списания дней с RemnaWave: {e}") + logger.error(f'⚠️ Ошибка синхронизации списания дней с RemnaWave: {e}') return kopeks - async def _apply_prize( - self, - db: AsyncSession, - user: User, - prize: WheelPrize, - config: WheelConfig - ) -> Optional[str]: + async def _apply_prize(self, db: AsyncSession, user: User, prize: WheelPrize, config: WheelConfig) -> str | None: """ Применить приз к пользователю. Возвращает промокод (если приз - промокод), иначе None. @@ -364,17 +330,19 @@ class FortuneWheelService: prize_type = prize.prize_type if prize_type == WheelPrizeType.NOTHING.value: - logger.info(f"🎰 Пустой приз для user_id={user.id}") + logger.info(f'🎰 Пустой приз для user_id={user.id}') return None if prize_type == WheelPrizeType.BALANCE_BONUS.value: # Пополнение баланса await add_user_balance( - db, user, prize.prize_value, - description=f"Выигрыш в колесе удачи: {prize.prize_value/100:.2f}₽", + db, + user, + prize.prize_value, + description=f'Выигрыш в колесе удачи: {prize.prize_value / 100:.2f}₽', create_transaction=True, ) - logger.info(f"💰 Начислено {prize.prize_value/100:.2f}₽ на баланс user_id={user.id}") + logger.info(f'💰 Начислено {prize.prize_value / 100:.2f}₽ на баланс user_id={user.id}') return None if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value: @@ -395,40 +363,48 @@ class FortuneWheelService: if daily_price > 0: balance_bonus = prize.prize_value * daily_price await add_user_balance( - db, user, balance_bonus, - description=f"Выигрыш в колесе удачи: {prize.prize_value} дней → {balance_bonus/100:.2f}₽", + db, + user, + balance_bonus, + description=f'Выигрыш в колесе удачи: {prize.prize_value} дней → {balance_bonus / 100:.2f}₽', create_transaction=True, ) - logger.info(f"💰 Суточный тариф: {prize.prize_value} дней конвертированы в {balance_bonus/100:.2f}₽ для user_id={user.id}") + logger.info( + f'💰 Суточный тариф: {prize.prize_value} дней конвертированы в {balance_bonus / 100:.2f}₽ для user_id={user.id}' + ) else: # Если нет цены - используем prize_value_kopeks await add_user_balance( - db, user, prize.prize_value_kopeks, - description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)", + db, + user, + prize.prize_value_kopeks, + description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)', create_transaction=True, ) - logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}") + logger.info(f'💰 Дни конвертированы в баланс для user_id={user.id}') else: # Обычная подписка - добавляем дни и синхронизируем с RemnaWave subscription.end_date += timedelta(days=prize.prize_value) subscription.updated_at = datetime.utcnow() - logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}") + logger.info(f'📅 Начислено {prize.prize_value} дней подписки user_id={user.id}') # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f"✅ Синхронизировано с RemnaWave для user_id={user.id}") + logger.info(f'✅ Синхронизировано с RemnaWave для user_id={user.id}') except Exception as e: - logger.error(f"⚠️ Ошибка синхронизации с RemnaWave: {e}") + logger.error(f'⚠️ Ошибка синхронизации с RemnaWave: {e}') else: # Если нет подписки - начисляем на баланс эквивалент await add_user_balance( - db, user, prize.prize_value_kopeks, - description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)", + db, + user, + prize.prize_value_kopeks, + description=f'Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)', create_transaction=True, ) - logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}") + logger.info(f'💰 Дни конвертированы в баланс для user_id={user.id}') return None if prize_type == WheelPrizeType.TRAFFIC_GB.value: @@ -437,20 +413,22 @@ class FortuneWheelService: if subscription and subscription.traffic_limit_gb > 0: subscription.traffic_limit_gb += prize.prize_value subscription.updated_at = datetime.utcnow() - logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}") + logger.info(f'📊 Начислено {prize.prize_value}GB трафика user_id={user.id}') # Синхронизируем с RemnaWave try: subscription_service = SubscriptionService() await subscription_service.update_remnawave_user(db, subscription) - logger.info(f"✅ Трафик синхронизирован с RemnaWave для user_id={user.id}") + logger.info(f'✅ Трафик синхронизирован с RemnaWave для user_id={user.id}') except Exception as e: - logger.error(f"⚠️ Ошибка синхронизации трафика с RemnaWave: {e}") + logger.error(f'⚠️ Ошибка синхронизации трафика с RemnaWave: {e}') else: # Если безлимит или нет подписки - на баланс await add_user_balance( - db, user, prize.prize_value_kopeks, - description=f"Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)", + db, + user, + prize.prize_value_kopeks, + description=f'Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)', create_transaction=True, ) return None @@ -458,21 +436,17 @@ class FortuneWheelService: if prize_type == WheelPrizeType.PROMOCODE.value: # Генерация промокода promocode = await self._generate_prize_promocode(db, user, prize, config) - logger.info(f"🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}") + logger.info(f'🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}') return promocode.code return None async def _generate_prize_promocode( - self, - db: AsyncSession, - user: User, - prize: WheelPrize, - config: WheelConfig + self, db: AsyncSession, user: User, prize: WheelPrize, config: WheelConfig ) -> PromoCode: """Сгенерировать уникальный промокод для приза.""" # Генерируем уникальный код - code = f"{config.promo_prefix}{secrets.token_hex(4).upper()}" + code = f'{config.promo_prefix}{secrets.token_hex(4).upper()}' # Определяем тип промокода if prize.promo_subscription_days > 0: @@ -496,12 +470,7 @@ class FortuneWheelService: return promocode - async def spin( - self, - db: AsyncSession, - user: User, - payment_type: str - ) -> SpinResult: + async def spin(self, db: AsyncSession, user: User, payment_type: str) -> SpinResult: """ Выполнить спин колеса. @@ -529,8 +498,8 @@ class FortuneWheelService: if not prizes: return SpinResult( success=False, - error="no_prizes", - message="Призы не настроены", + error='no_prizes', + message='Призы не настроены', ) # 2. Обрабатываем оплату @@ -538,8 +507,8 @@ class FortuneWheelService: if not availability.can_pay_stars: return SpinResult( success=False, - error="cannot_pay_stars", - message="Оплата Stars недоступна", + error='cannot_pay_stars', + message='Оплата Stars недоступна', ) payment_amount = config.spin_cost_stars payment_value_kopeks = await self._process_stars_payment(db, user, config) @@ -547,16 +516,16 @@ class FortuneWheelService: if not availability.can_pay_days: return SpinResult( success=False, - error="cannot_pay_days", - message="Оплата днями подписки недоступна", + error='cannot_pay_days', + message='Оплата днями подписки недоступна', ) payment_amount = config.spin_cost_days payment_value_kopeks = await self._process_days_payment(db, user, config) else: return SpinResult( success=False, - error="invalid_payment_type", - message="Неверный способ оплаты", + error='invalid_payment_type', + message='Неверный способ оплаты', ) # 3. Рассчитываем вероятности и выбираем приз @@ -571,15 +540,13 @@ class FortuneWheelService: promocode_id = None if generated_promocode: # Получаем ID промокода - result = await db.execute( - f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'" - ) + result = await db.execute(f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'") row = result.fetchone() if row: promocode_id = row[0] # 6. Создаем запись спина - spin = await create_wheel_spin( + await create_wheel_spin( db=db, user_id=user.id, prize_id=selected_prize.id, @@ -616,68 +583,64 @@ class FortuneWheelService: await db.rollback() return SpinResult( success=False, - error="payment_error", + error='payment_error', message=str(e), ) except Exception as e: await db.rollback() - logger.exception(f"Ошибка спина колеса для user_id={user.id}: {e}") + logger.exception(f'Ошибка спина колеса для user_id={user.id}: {e}') return SpinResult( success=False, - error="internal_error", - message="Произошла ошибка, попробуйте позже", + error='internal_error', + message='Произошла ошибка, попробуйте позже', ) - def _get_error_message(self, reason: Optional[str]) -> str: + def _get_error_message(self, reason: str | None) -> str: """Получить человекочитаемое сообщение об ошибке.""" messages = { - "wheel_disabled": "Колесо удачи временно недоступно", - "daily_limit_reached": "Вы достигли лимита спинов на сегодня", - "no_payment_method_available": "Нет доступных способов оплаты", - "no_prizes_configured": "Призы еще не настроены", - "insufficient_balance": "Недостаточно средств на балансе. Пополните баланс для оплаты спина.", + 'wheel_disabled': 'Колесо удачи временно недоступно', + 'daily_limit_reached': 'Вы достигли лимита спинов на сегодня', + 'no_payment_method_available': 'Нет доступных способов оплаты', + 'no_prizes_configured': 'Призы еще не настроены', + 'insufficient_balance': 'Недостаточно средств на балансе. Пополните баланс для оплаты спина.', } - return messages.get(reason, "Произошла ошибка") + return messages.get(reason, 'Произошла ошибка') - def _get_prize_message(self, prize: WheelPrize, promocode: Optional[str]) -> str: + def _get_prize_message(self, prize: WheelPrize, promocode: str | None) -> str: """Сформировать сообщение о выигрыше.""" prize_type = prize.prize_type if prize_type == WheelPrizeType.NOTHING.value: - return "К сожалению, в этот раз не повезло. Попробуйте еще!" + return 'К сожалению, в этот раз не повезло. Попробуйте еще!' if prize_type == WheelPrizeType.BALANCE_BONUS.value: - return f"Поздравляем! Вы выиграли {prize.prize_value/100:.0f}₽ на баланс!" + return f'Поздравляем! Вы выиграли {prize.prize_value / 100:.0f}₽ на баланс!' if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value: days_word = self._pluralize_days(prize.prize_value) - return f"Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!" + return f'Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!' if prize_type == WheelPrizeType.TRAFFIC_GB.value: - return f"Поздравляем! Вы выиграли {prize.prize_value}GB трафика!" + return f'Поздравляем! Вы выиграли {prize.prize_value}GB трафика!' if prize_type == WheelPrizeType.PROMOCODE.value: - return f"Поздравляем! Ваш промокод: {promocode}" + return f'Поздравляем! Ваш промокод: {promocode}' - return "Поздравляем с выигрышем!" + return 'Поздравляем с выигрышем!' def _pluralize_days(self, n: int) -> str: """Склонение слова 'день'.""" if 11 <= n % 100 <= 19: - return "дней" - elif n % 10 == 1: - return "день" - elif 2 <= n % 10 <= 4: - return "дня" - else: - return "дней" + return 'дней' + if n % 10 == 1: + return 'день' + if 2 <= n % 10 <= 4: + return 'дня' + return 'дней' async def get_statistics( - self, - db: AsyncSession, - date_from: Optional[datetime] = None, - date_to: Optional[datetime] = None - ) -> Dict[str, Any]: + self, db: AsyncSession, date_from: datetime | None = None, date_to: datetime | None = None + ) -> dict[str, Any]: """Получить статистику колеса.""" return await get_wheel_statistics(db, date_from, date_to) diff --git a/app/services/yookassa_service.py b/app/services/yookassa_service.py index 3a451014..11231cbe 100644 --- a/app/services/yookassa_service.py +++ b/app/services/yookassa_service.py @@ -1,25 +1,26 @@ -import uuid -import logging import asyncio -from typing import Optional, Dict, Any, List +import logging +import uuid +from typing import Any from yookassa import Configuration, Payment as YooKassaPayment -from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder from yookassa.domain.common.confirmation_type import ConfirmationType +from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder from app.config import settings + logger = logging.getLogger(__name__) class YooKassaService: - - def __init__(self, - shop_id: Optional[str] = None, - secret_key: Optional[str] = None, - configured_return_url: Optional[str] = None, - bot_username_for_default_return: Optional[str] = None): - + def __init__( + self, + shop_id: str | None = None, + secret_key: str | None = None, + configured_return_url: str | None = None, + bot_username_for_default_return: str | None = None, + ): shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None) secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None) configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None) @@ -28,101 +29,92 @@ class YooKassaService: if not shop_id or not secret_key: logger.warning( - "YooKassa SHOP_ID или SECRET_KEY не настроены в settings. " - "Функционал платежей будет ОТКЛЮЧЕН.") + 'YooKassa SHOP_ID или SECRET_KEY не настроены в settings. Функционал платежей будет ОТКЛЮЧЕН.' + ) else: try: Configuration.configure(shop_id, secret_key) self.configured = True - logger.info( - f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...") + logger.info(f'YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...') except Exception as error: logger.error( - "Ошибка конфигурации YooKassa SDK: %s", + 'Ошибка конфигурации YooKassa SDK: %s', error, exc_info=True, ) self.configured = False if not self.configured: - self.return_url = "https://t.me/" + self.return_url = 'https://t.me/' logger.warning( - "YooKassa не активна, используем заглушку return_url: %s", + 'YooKassa не активна, используем заглушку return_url: %s', self.return_url, ) elif configured_return_url: self.return_url = configured_return_url elif bot_username_for_default_return: - self.return_url = f"https://t.me/{bot_username_for_default_return}" - logger.info( - f"YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}") + self.return_url = f'https://t.me/{bot_username_for_default_return}' + logger.info(f'YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}') else: - self.return_url = "https://t.me/" + self.return_url = 'https://t.me/' logger.warning( - f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. " - f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.") + f'КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. ' + f'Используем заглушку: {self.return_url}. Платежи могут работать некорректно.' + ) - logger.info(f"YooKassa Service return_url: {self.return_url}") + logger.info(f'YooKassa Service return_url: {self.return_url}') async def create_payment( - self, - amount: float, - currency: str, - description: str, - metadata: Dict[str, Any], - receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]: + self, + amount: float, + currency: str, + description: str, + metadata: dict[str, Any], + receipt_email: str | None = None, + receipt_phone: str | None = None, + ) -> dict[str, Any] | None: """Создает платеж в YooKassa""" if not self.configured: - logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.") + logger.error('YooKassa не сконфигурирован. Невозможно создать платеж.') return None customer_contact_for_receipt = {} if receipt_email: - customer_contact_for_receipt["email"] = receipt_email + customer_contact_for_receipt['email'] = receipt_email elif receipt_phone: - customer_contact_for_receipt["phone"] = receipt_phone + customer_contact_for_receipt['phone'] = receipt_phone elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: - customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL else: logger.error( - "КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.") + 'КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.' + ) return { - "error": True, - "internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию." + 'error': True, + 'internal_message': 'Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию.', } try: builder = PaymentRequestBuilder() - builder.set_amount({ - "value": str(round(amount, 2)), - "currency": currency.upper() - }) + builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()}) builder.set_capture(True) - builder.set_confirmation({ - "type": ConfirmationType.REDIRECT, - "return_url": self.return_url - }) + builder.set_confirmation({'type': ConfirmationType.REDIRECT, 'return_url': self.return_url}) builder.set_description(description) builder.set_metadata(metadata) - receipt_items_list: List[Dict[str, Any]] = [{ - "description": description[:128], - "quantity": "1.00", - "amount": { - "value": str(round(amount, 2)), - "currency": currency.upper() - }, - "vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), - "payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), - "payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service') - }] + receipt_items_list: list[dict[str, Any]] = [ + { + 'description': description[:128], + 'quantity': '1.00', + 'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()}, + 'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), + 'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), + 'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'), + } + ] - receipt_data_dict: Dict[str, Any] = { - "customer": customer_contact_for_receipt, - "items": receipt_items_list - } + receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list} builder.set_receipt(receipt_data_dict) @@ -130,61 +122,66 @@ class YooKassaService: payment_request = builder.build() logger.info( - f"Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). " - f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}") + f'Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). ' + f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + ) loop = asyncio.get_running_loop() response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key)) + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( - f"Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}") + f'Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}' + ) return { - "id": response.id, - "confirmation_url": response.confirmation.confirmation_url if response.confirmation else None, - "status": response.status, - "metadata": response.metadata, - "amount_value": float(response.amount.value), - "amount_currency": response.amount.currency, - "idempotence_key_used": idempotence_key, - "paid": response.paid, - "refundable": response.refundable, - "created_at": response.created_at.isoformat() if hasattr( - response.created_at, 'isoformat') else str(response.created_at), - "description_from_yk": response.description, - "test_mode": response.test if hasattr(response, 'test') else None + 'id': response.id, + 'confirmation_url': response.confirmation.confirmation_url if response.confirmation else None, + 'status': response.status, + 'metadata': response.metadata, + 'amount_value': float(response.amount.value), + 'amount_currency': response.amount.currency, + 'idempotence_key_used': idempotence_key, + 'paid': response.paid, + 'refundable': response.refundable, + 'created_at': response.created_at.isoformat() + if hasattr(response.created_at, 'isoformat') + else str(response.created_at), + 'description_from_yk': response.description, + 'test_mode': response.test if hasattr(response, 'test') else None, } except Exception as e: - logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True) + logger.error(f'Ошибка создания платежа YooKassa: {e}', exc_info=True) return None async def create_sbp_payment( - self, - amount: float, - currency: str, - description: str, - metadata: Dict[str, Any], - receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]: - + self, + amount: float, + currency: str, + description: str, + metadata: dict[str, Any], + receipt_email: str | None = None, + receipt_phone: str | None = None, + ) -> dict[str, Any] | None: if not self.configured: - logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.") + logger.error('YooKassa не сконфигурирован. Невозможно создать платеж через СБП.') return None customer_contact_for_receipt = {} if receipt_email: - customer_contact_for_receipt["email"] = receipt_email + customer_contact_for_receipt['email'] = receipt_email elif receipt_phone: - customer_contact_for_receipt["phone"] = receipt_phone + customer_contact_for_receipt['phone'] = receipt_phone elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL: - customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL + customer_contact_for_receipt['email'] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL else: logger.error( - "КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.") + 'КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.' + ) return { - "error": True, - "internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию." + 'error': True, + 'internal_message': 'Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию.', } try: @@ -192,43 +189,31 @@ class YooKassaService: # Это позволит получить QR-код для пользователя builder = PaymentRequestBuilder() - builder.set_amount({ - "value": str(round(amount, 2)), - "currency": currency.upper() - }) + builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()}) builder.set_capture(True) # Устанавливаем подтверждение через redirect для получения вебхуков - builder.set_confirmation({ - "type": "redirect", - "return_url": self.return_url - }) + builder.set_confirmation({'type': 'redirect', 'return_url': self.return_url}) builder.set_description(description) builder.set_metadata(metadata) - builder.set_payment_method_data({ - "type": "sbp" - }) + builder.set_payment_method_data({'type': 'sbp'}) - receipt_items_list: List[Dict[str, Any]] = [{ - "description": description[:128], - "quantity": "1.00", - "amount": { - "value": str(round(amount, 2)), - "currency": currency.upper() - }, - "vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), - "payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), - "payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service') - }] + receipt_items_list: list[dict[str, Any]] = [ + { + 'description': description[:128], + 'quantity': '1.00', + 'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()}, + 'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), + 'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), + 'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'), + } + ] - receipt_data_dict: Dict[str, Any] = { - "customer": customer_contact_for_receipt, - "items": receipt_items_list - } + receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list} builder.set_receipt(receipt_data_dict) @@ -238,90 +223,85 @@ class YooKassaService: logger.info( f"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: {idempotence_key}). " - f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}") + f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + ) loop = asyncio.get_running_loop() response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key)) + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( - f"Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}") + f'Ответ YooKassa Payment.create (СБП, qr): ID={response.id}, Status={response.status}, Paid={response.paid}' + ) # Возвращаем данные платежа с QR-подтверждением # Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа return { - "id": response.id, - "qr_confirmation_data": response.confirmation.confirmation_data if response.confirmation and hasattr(response.confirmation, 'confirmation_data') else None, - "confirmation_url": response.confirmation.confirmation_url if response.confirmation and hasattr(response.confirmation, 'confirmation_url') else None, - "status": response.status, - "metadata": response.metadata, - "amount_value": float(response.amount.value), - "amount_currency": response.amount.currency, - "idempotence_key_used": idempotence_key, - "paid": response.paid, - "refundable": response.refundable, - "created_at": response.created_at.isoformat() if hasattr( - response.created_at, 'isoformat') else str(response.created_at), - "description_from_yk": response.description, - "test_mode": response.test if hasattr(response, 'test') else None + 'id': response.id, + 'qr_confirmation_data': response.confirmation.confirmation_data + if response.confirmation and hasattr(response.confirmation, 'confirmation_data') + else None, + 'confirmation_url': response.confirmation.confirmation_url + if response.confirmation and hasattr(response.confirmation, 'confirmation_url') + else None, + 'status': response.status, + 'metadata': response.metadata, + 'amount_value': float(response.amount.value), + 'amount_currency': response.amount.currency, + 'idempotence_key_used': idempotence_key, + 'paid': response.paid, + 'refundable': response.refundable, + 'created_at': response.created_at.isoformat() + if hasattr(response.created_at, 'isoformat') + else str(response.created_at), + 'description_from_yk': response.description, + 'test_mode': response.test if hasattr(response, 'test') else None, } except Exception as e: - logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True) + logger.error(f'Ошибка создания платежа YooKassa СБП: {e}', exc_info=True) return None async def _create_sbp_payment_with_confirmation_type( - self, - amount: float, - currency: str, - description: str, - metadata: Dict[str, Any], - customer_contact_for_receipt: Dict[str, str], - confirmation_type: str) -> Optional[Dict[str, Any]]: + self, + amount: float, + currency: str, + description: str, + metadata: dict[str, Any], + customer_contact_for_receipt: dict[str, str], + confirmation_type: str, + ) -> dict[str, Any] | None: """Создает SBP платеж с указанным типом подтверждения""" try: builder = PaymentRequestBuilder() - builder.set_amount({ - "value": str(round(amount, 2)), - "currency": currency.upper() - }) + builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()}) builder.set_capture(True) - if confirmation_type == "qr": - builder.set_confirmation({ - "type": "qr" - }) + if confirmation_type == 'qr': + builder.set_confirmation({'type': 'qr'}) else: # redirect - builder.set_confirmation({ - "type": "redirect", - "return_url": self.return_url - }) + builder.set_confirmation({'type': 'redirect', 'return_url': self.return_url}) builder.set_description(description) builder.set_metadata(metadata) - builder.set_payment_method_data({ - "type": "sbp" - }) + builder.set_payment_method_data({'type': 'sbp'}) - receipt_items_list: List[Dict[str, Any]] = [{ - "description": description[:128], - "quantity": "1.00", - "amount": { - "value": str(round(amount, 2)), - "currency": currency.upper() - }, - "vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), - "payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), - "payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service') - }] + receipt_items_list: list[dict[str, Any]] = [ + { + 'description': description[:128], + 'quantity': '1.00', + 'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()}, + 'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)), + 'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'), + 'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'), + } + ] - receipt_data_dict: Dict[str, Any] = { - "customer": customer_contact_for_receipt, - "items": receipt_items_list - } + receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list} builder.set_receipt(receipt_data_dict) @@ -331,83 +311,86 @@ class YooKassaService: logger.info( f"Создание платежа YooKassa СБП с подтверждением '{confirmation_type}' (Idempotence-Key: {idempotence_key}). " - f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}") + f'Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}' + ) loop = asyncio.get_running_loop() response = await loop.run_in_executor( - None, lambda: YooKassaPayment.create(payment_request, idempotence_key)) + None, lambda: YooKassaPayment.create(payment_request, idempotence_key) + ) logger.info( - f"Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}") + f'Ответ YooKassa Payment.create (СБП, {confirmation_type}): ID={response.id}, Status={response.status}, Paid={response.paid}' + ) result = { - "id": response.id, - "status": response.status, - "metadata": response.metadata, - "amount_value": float(response.amount.value), - "amount_currency": response.amount.currency, - "idempotence_key_used": idempotence_key, - "paid": response.paid, - "refundable": response.refundable, - "created_at": response.created_at.isoformat() if hasattr( - response.created_at, 'isoformat') else str(response.created_at), - "description_from_yk": response.description, - "test_mode": response.test if hasattr(response, 'test') else None + 'id': response.id, + 'status': response.status, + 'metadata': response.metadata, + 'amount_value': float(response.amount.value), + 'amount_currency': response.amount.currency, + 'idempotence_key_used': idempotence_key, + 'paid': response.paid, + 'refundable': response.refundable, + 'created_at': response.created_at.isoformat() + if hasattr(response.created_at, 'isoformat') + else str(response.created_at), + 'description_from_yk': response.description, + 'test_mode': response.test if hasattr(response, 'test') else None, } # Добавляем данные подтверждения в зависимости от типа - if confirmation_type == "qr": + if confirmation_type == 'qr': if response.confirmation and hasattr(response.confirmation, 'confirmation_data'): - result["confirmation_data"] = response.confirmation.confirmation_data - else: # redirect - if response.confirmation and hasattr(response.confirmation, 'confirmation_url'): - result["confirmation_url"] = response.confirmation.confirmation_url + result['confirmation_data'] = response.confirmation.confirmation_data + elif response.confirmation and hasattr(response.confirmation, 'confirmation_url'): + result['confirmation_url'] = response.confirmation.confirmation_url return result except Exception as e: - logger.error(f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True) + logger.error( + f"Ошибка создания платежа YooKassa СБП с подтверждением '{confirmation_type}': {e}", exc_info=True + ) return None - async def get_payment_info( - self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]: - + async def get_payment_info(self, payment_id_in_yookassa: str) -> dict[str, Any] | None: if not self.configured: - logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.") + logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.') return None try: - logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}") + logger.info(f'Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}') loop = asyncio.get_running_loop() - payment_info_yk = await loop.run_in_executor( - None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) + payment_info_yk = await loop.run_in_executor(None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa)) if payment_info_yk: logger.info( - f"Информация о платеже YooKassa {payment_id_in_yookassa}: " - f"Status={payment_info_yk.status}, Paid={payment_info_yk.paid}") + f'Информация о платеже YooKassa {payment_id_in_yookassa}: ' + f'Status={payment_info_yk.status}, Paid={payment_info_yk.paid}' + ) return { - "id": payment_info_yk.id, - "status": payment_info_yk.status, - "paid": payment_info_yk.paid, - "amount_value": float(payment_info_yk.amount.value), - "amount_currency": payment_info_yk.amount.currency, - "metadata": payment_info_yk.metadata, - "description": payment_info_yk.description, - "refundable": payment_info_yk.refundable, - "created_at": payment_info_yk.created_at.isoformat() if hasattr( - payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at), - "captured_at": payment_info_yk.captured_at.isoformat() - if payment_info_yk.captured_at and hasattr( - payment_info_yk.captured_at, 'isoformat') else None, - "payment_method_type": payment_info_yk.payment_method.type - if payment_info_yk.payment_method else None, - "test_mode": payment_info_yk.test if hasattr(payment_info_yk, 'test') else None + 'id': payment_info_yk.id, + 'status': payment_info_yk.status, + 'paid': payment_info_yk.paid, + 'amount_value': float(payment_info_yk.amount.value), + 'amount_currency': payment_info_yk.amount.currency, + 'metadata': payment_info_yk.metadata, + 'description': payment_info_yk.description, + 'refundable': payment_info_yk.refundable, + 'created_at': payment_info_yk.created_at.isoformat() + if hasattr(payment_info_yk.created_at, 'isoformat') + else str(payment_info_yk.created_at), + 'captured_at': payment_info_yk.captured_at.isoformat() + if payment_info_yk.captured_at and hasattr(payment_info_yk.captured_at, 'isoformat') + else None, + 'payment_method_type': payment_info_yk.payment_method.type + if payment_info_yk.payment_method + else None, + 'test_mode': payment_info_yk.test if hasattr(payment_info_yk, 'test') else None, } - else: - logger.warning(f"Платеж не найден в YooKassa ID: {payment_id_in_yookassa}") - return None - except Exception as e: - logger.error(f"Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}", - exc_info=True) + logger.warning(f'Платеж не найден в YooKassa ID: {payment_id_in_yookassa}') + return None + except Exception as e: + logger.error(f'Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}', exc_info=True) return None diff --git a/app/states.py b/app/states.py index f06dbe3f..c567f40e 100644 --- a/app/states.py +++ b/app/states.py @@ -1,11 +1,13 @@ from aiogram.fsm.state import State, StatesGroup + class RegistrationStates(StatesGroup): waiting_for_language = State() waiting_for_rules_accept = State() waiting_for_privacy_policy_accept = State() waiting_for_referral_code = State() + class SubscriptionStates(StatesGroup): selecting_period = State() selecting_traffic = State() @@ -28,6 +30,7 @@ class SubscriptionStates(StatesGroup): selecting_custom_traffic = State() confirming_custom_purchase = State() + class BalanceStates(StatesGroup): waiting_for_amount = State() waiting_for_pal24_method = State() @@ -40,8 +43,8 @@ class PromoCodeStates(StatesGroup): waiting_for_code = State() waiting_for_referral_code = State() + class AdminStates(StatesGroup): - waiting_for_user_search = State() waiting_for_bulk_ban_list = State() sending_user_message = State() @@ -114,7 +117,7 @@ class AdminStates(StatesGroup): editing_referral_contest_summary_times = State() editing_daily_contest_field = State() editing_daily_contest_value = State() - + editing_squad_price = State() editing_traffic_price = State() editing_device_price = State() @@ -153,7 +156,7 @@ class AdminStates(StatesGroup): editing_welcome_text = State() editing_pinned_message = State() confirming_pinned_broadcast = State() - waiting_for_message_buttons = "waiting_for_message_buttons" + waiting_for_message_buttons = 'waiting_for_message_buttons' editing_promo_offer_message = State() editing_promo_offer_button = State() @@ -164,7 +167,7 @@ class AdminStates(StatesGroup): editing_promo_offer_squads = State() selecting_promo_offer_user = State() searching_promo_offer_user = State() - + # Состояния для отслеживания источника перехода viewing_user_from_balance_list = State() viewing_user_from_traffic_list = State() @@ -202,15 +205,18 @@ class AdminStates(StatesGroup): class SupportStates(StatesGroup): waiting_for_message = State() + class TicketStates(StatesGroup): waiting_for_title = State() waiting_for_message = State() waiting_for_reply = State() + class AdminTicketStates(StatesGroup): waiting_for_reply = State() waiting_for_block_duration = State() + class SupportSettingsStates(StatesGroup): waiting_for_desc = State() @@ -224,14 +230,17 @@ class BotConfigStates(StatesGroup): class PricingStates(StatesGroup): waiting_for_value = State() + class AutoPayStates(StatesGroup): setting_autopay_days = State() confirming_autopay_toggle = State() + class SquadCreateStates(StatesGroup): waiting_for_name = State() selecting_inbounds = State() + class SquadRenameStates(StatesGroup): waiting_for_new_name = State() @@ -245,6 +254,7 @@ class SquadMigrationStates(StatesGroup): class RemnaWaveSyncStates(StatesGroup): waiting_for_schedule = State() + class ContestStates(StatesGroup): waiting_for_answer = State() diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 740348dd..d6313d0e 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,13 +1,14 @@ from .pricing_utils import ( calculate_months_from_days, - get_remaining_months, calculate_prorated_price, - format_period_description + format_period_description, + get_remaining_months, ) + __all__ = [ 'calculate_months_from_days', - 'get_remaining_months', 'calculate_prorated_price', - 'format_period_description' + 'format_period_description', + 'get_remaining_months', ] diff --git a/app/utils/cache.py b/app/utils/cache.py index 1f3c4209..75a82e68 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -1,75 +1,66 @@ import json import logging -from typing import Any, Optional, Union -from datetime import datetime, timedelta +from datetime import timedelta +from typing import Any + import redis.asyncio as redis from app.config import settings + logger = logging.getLogger(__name__) class CacheService: - def __init__(self): - self.redis_client: Optional[redis.Redis] = None + self.redis_client: redis.Redis | None = None self._connected = False - + async def connect(self): try: self.redis_client = redis.from_url(settings.REDIS_URL) await self.redis_client.ping() self._connected = True - logger.info("✅ Подключение к Redis кешу установлено") + logger.info('✅ Подключение к Redis кешу установлено') except Exception as e: - logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}") + logger.warning(f'⚠️ Не удалось подключиться к Redis: {e}') self._connected = False - + async def disconnect(self): if self.redis_client: await self.redis_client.close() self._connected = False - - async def get(self, key: str) -> Optional[Any]: + + async def get(self, key: str) -> Any | None: if not self._connected: return None - + try: value = await self.redis_client.get(key) if value: return json.loads(value) return None except Exception as e: - logger.error(f"Ошибка получения из кеша {key}: {e}") + logger.error(f'Ошибка получения из кеша {key}: {e}') return None - - async def set( - self, - key: str, - value: Any, - expire: Union[int, timedelta] = None - ) -> bool: + + async def set(self, key: str, value: Any, expire: int | timedelta = None) -> bool: if not self._connected: return False - + try: serialized_value = json.dumps(value, default=str) - + if isinstance(expire, timedelta): expire = int(expire.total_seconds()) - + await self.redis_client.set(key, serialized_value, ex=expire) return True except Exception as e: - logger.error(f"Ошибка записи в кеш {key}: {e}") + logger.error(f'Ошибка записи в кеш {key}: {e}') return False - - async def setnx( - self, - key: str, - value: Any, - expire: Union[int, timedelta] = None - ) -> bool: + + async def setnx(self, key: str, value: Any, expire: int | timedelta = None) -> bool: """Атомарная операция SET IF NOT EXISTS. Устанавливает значение только если ключ не существует. @@ -88,7 +79,7 @@ class CacheService: result = await self.redis_client.set(key, serialized_value, ex=expire, nx=True) return result is True except Exception as e: - logger.error(f"Ошибка setnx в кеш {key}: {e}") + logger.error(f'Ошибка setnx в кеш {key}: {e}') return False async def delete(self, key: str) -> bool: @@ -99,7 +90,7 @@ class CacheService: deleted = await self.redis_client.delete(key) return deleted > 0 except Exception as e: - logger.error(f"Ошибка удаления из кеша {key}: {e}") + logger.error(f'Ошибка удаления из кеша {key}: {e}') return False async def delete_pattern(self, pattern: str) -> int: @@ -114,76 +105,76 @@ class CacheService: deleted = await self.redis_client.delete(*keys) return int(deleted) except Exception as e: - logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}") + logger.error(f'Ошибка удаления ключей по шаблону {pattern}: {e}') return 0 - + async def exists(self, key: str) -> bool: if not self._connected: return False - + try: return await self.redis_client.exists(key) except Exception as e: - logger.error(f"Ошибка проверки существования в кеше {key}: {e}") + logger.error(f'Ошибка проверки существования в кеше {key}: {e}') return False - + async def expire(self, key: str, seconds: int) -> bool: if not self._connected: return False - + try: return await self.redis_client.expire(key, seconds) except Exception as e: - logger.error(f"Ошибка установки TTL для {key}: {e}") + logger.error(f'Ошибка установки TTL для {key}: {e}') return False - - async def get_keys(self, pattern: str = "*") -> list: + + async def get_keys(self, pattern: str = '*') -> list: if not self._connected: return [] - + try: keys = await self.redis_client.keys(pattern) return [key.decode() if isinstance(key, bytes) else key for key in keys] except Exception as e: - logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}") + logger.error(f'Ошибка получения ключей по паттерну {pattern}: {e}') return [] - + async def flush_all(self) -> bool: if not self._connected: return False - + try: await self.redis_client.flushall() - logger.info("🗑️ Кеш полностью очищен") + logger.info('🗑️ Кеш полностью очищен') return True except Exception as e: - logger.error(f"Ошибка очистки кеша: {e}") + logger.error(f'Ошибка очистки кеша: {e}') return False - - async def increment(self, key: str, amount: int = 1) -> Optional[int]: + + async def increment(self, key: str, amount: int = 1) -> int | None: if not self._connected: return None - + try: return await self.redis_client.incrby(key, amount) except Exception as e: - logger.error(f"Ошибка инкремента {key}: {e}") + logger.error(f'Ошибка инкремента {key}: {e}') return None - + async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool: if not self._connected: return False - + try: await self.redis_client.hset(name, mapping=mapping) if expire: await self.redis_client.expire(name, expire) return True except Exception as e: - logger.error(f"Ошибка записи хеша {name}: {e}") + logger.error(f'Ошибка записи хеша {name}: {e}') return False - - async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]: + + async def get_hash(self, name: str, key: str = None) -> dict | str | None: if not self._connected: return None @@ -191,11 +182,10 @@ class CacheService: if key: value = await self.redis_client.hget(name, key) return value.decode() if value else None - else: - hash_data = await self.redis_client.hgetall(name) - return {k.decode(): v.decode() for k, v in hash_data.items()} + hash_data = await self.redis_client.hgetall(name) + return {k.decode(): v.decode() for k, v in hash_data.items()} except Exception as e: - logger.error(f"Ошибка получения хеша {name}: {e}") + logger.error(f'Ошибка получения хеша {name}: {e}') return None async def lpush(self, key: str, value: Any) -> bool: @@ -208,10 +198,10 @@ class CacheService: await self.redis_client.lpush(key, serialized) return True except Exception as e: - logger.error(f"Ошибка добавления в очередь {key}: {e}") + logger.error(f'Ошибка добавления в очередь {key}: {e}') return False - async def rpop(self, key: str) -> Optional[Any]: + async def rpop(self, key: str) -> Any | None: """Извлечь элемент из конца списка (FIFO очередь).""" if not self._connected: return None @@ -222,7 +212,7 @@ class CacheService: return json.loads(value) return None except Exception as e: - logger.error(f"Ошибка извлечения из очереди {key}: {e}") + logger.error(f'Ошибка извлечения из очереди {key}: {e}') return None async def llen(self, key: str) -> int: @@ -233,7 +223,7 @@ class CacheService: try: return await self.redis_client.llen(key) except Exception as e: - logger.error(f"Ошибка получения длины очереди {key}: {e}") + logger.error(f'Ошибка получения длины очереди {key}: {e}') return 0 async def lrange(self, key: str, start: int = 0, end: int = -1) -> list: @@ -245,7 +235,7 @@ class CacheService: items = await self.redis_client.lrange(key, start, end) return [json.loads(item) for item in items] except Exception as e: - logger.error(f"Ошибка чтения очереди {key}: {e}") + logger.error(f'Ошибка чтения очереди {key}: {e}') return [] @@ -253,7 +243,7 @@ cache = CacheService() def cache_key(*parts) -> str: - return ":".join(str(part) for part in parts) + return ':'.join(str(part) for part in parts) async def cached_function(key: str, expire: int = 300): @@ -262,100 +252,93 @@ async def cached_function(key: str, expire: int = 300): cache_result = await cache.get(key) if cache_result is not None: return cache_result - + result = await func(*args, **kwargs) await cache.set(key, result, expire) return result - + return wrapper + return decorator class UserCache: - @staticmethod - async def get_user_data(user_id: int) -> Optional[dict]: - key = cache_key("user", user_id) + async def get_user_data(user_id: int) -> dict | None: + key = cache_key('user', user_id) return await cache.get(key) - + @staticmethod async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool: - key = cache_key("user", user_id) + key = cache_key('user', user_id) return await cache.set(key, data, expire) - + @staticmethod async def delete_user_data(user_id: int) -> bool: - key = cache_key("user", user_id) + key = cache_key('user', user_id) return await cache.delete(key) - + @staticmethod - async def get_user_session(user_id: int, session_key: str) -> Optional[Any]: - key = cache_key("session", user_id, session_key) + async def get_user_session(user_id: int, session_key: str) -> Any | None: + key = cache_key('session', user_id, session_key) return await cache.get(key) - + @staticmethod - async def set_user_session( - user_id: int, - session_key: str, - data: Any, - expire: int = 1800 - ) -> bool: - key = cache_key("session", user_id, session_key) + async def set_user_session(user_id: int, session_key: str, data: Any, expire: int = 1800) -> bool: + key = cache_key('session', user_id, session_key) return await cache.set(key, data, expire) @staticmethod async def delete_user_session(user_id: int, session_key: str) -> bool: - key = cache_key("session", user_id, session_key) + key = cache_key('session', user_id, session_key) return await cache.delete(key) class SystemCache: - @staticmethod - async def get_system_stats() -> Optional[dict]: - return await cache.get("system:stats") - + async def get_system_stats() -> dict | None: + return await cache.get('system:stats') + @staticmethod async def set_system_stats(stats: dict, expire: int = 300) -> bool: - return await cache.set("system:stats", stats, expire) - + return await cache.set('system:stats', stats, expire) + @staticmethod - async def get_nodes_status() -> Optional[list]: - return await cache.get("remnawave:nodes") - + async def get_nodes_status() -> list | None: + return await cache.get('remnawave:nodes') + @staticmethod async def set_nodes_status(nodes: list, expire: int = 60) -> bool: - return await cache.set("remnawave:nodes", nodes, expire) - + return await cache.set('remnawave:nodes', nodes, expire) + @staticmethod - async def get_daily_stats(date: str) -> Optional[dict]: - key = cache_key("stats", "daily", date) + async def get_daily_stats(date: str) -> dict | None: + key = cache_key('stats', 'daily', date) return await cache.get(key) - + @staticmethod async def set_daily_stats(date: str, stats: dict) -> bool: - key = cache_key("stats", "daily", date) + key = cache_key('stats', 'daily', date) return await cache.set(key, stats, 86400) # 24 часа class RateLimitCache: - @staticmethod async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool: - key = cache_key("rate_limit", user_id, action) + key = cache_key('rate_limit', user_id, action) current = await cache.get(key) - + if current is None: await cache.set(key, 1, window) return False - + if current >= limit: return True - + await cache.increment(key) return False - + @staticmethod async def reset_rate_limit(user_id: int, action: str) -> bool: - key = cache_key("rate_limit", user_id, action) - return await cache.delete(key) \ No newline at end of file + key = cache_key('rate_limit', user_id, action) + return await cache.delete(key) diff --git a/app/utils/check_reg_process.py b/app/utils/check_reg_process.py index 8a8efc45..58e845c8 100644 --- a/app/utils/check_reg_process.py +++ b/app/utils/check_reg_process.py @@ -1,38 +1,34 @@ -from typing import Optional - -from aiogram.fsm.context import FSMContext -from aiogram.types import TelegramObject, Message, CallbackQuery +from aiogram.types import CallbackQuery, TelegramObject from app.states import RegistrationStates -def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool: +def is_registration_process(event: TelegramObject, current_state: str | None) -> bool: registration_states = [ RegistrationStates.waiting_for_language.state, RegistrationStates.waiting_for_rules_accept.state, RegistrationStates.waiting_for_privacy_policy_accept.state, - RegistrationStates.waiting_for_referral_code.state + RegistrationStates.waiting_for_referral_code.state, ] registration_callbacks = [ - "rules_accept", - "rules_decline", - "privacy_policy_accept", - "privacy_policy_decline", - "referral_skip" + 'rules_accept', + 'rules_decline', + 'privacy_policy_accept', + 'privacy_policy_decline', + 'referral_skip', ] - language_select_prefix = "language_select:" + language_select_prefix = 'language_select:' if current_state in registration_states: return True - - if (isinstance(event, CallbackQuery) + + if ( + isinstance(event, CallbackQuery) and event.data - and ( - event.data in registration_callbacks - or event.data.startswith(language_select_prefix) - )): + and (event.data in registration_callbacks or event.data.startswith(language_select_prefix)) + ): return True return False diff --git a/app/utils/currency_converter.py b/app/utils/currency_converter.py index 8152b1c1..2645dfb2 100644 --- a/app/utils/currency_converter.py +++ b/app/utils/currency_converter.py @@ -1,69 +1,67 @@ import logging +from datetime import datetime + import aiohttp -import asyncio -from typing import Optional -from datetime import datetime, timedelta + logger = logging.getLogger(__name__) + class CurrencyConverter: - def __init__(self): self._cache = {} self._cache_ttl = 3600 # 1 час self._last_update = {} - + async def get_usd_to_rub_rate(self) -> float: """Получает курс USD/RUB с кешированием""" - - cache_key = "USD_RUB" + + cache_key = 'USD_RUB' now = datetime.utcnow() - + # Проверяем кеш - if (cache_key in self._cache and - cache_key in self._last_update and - (now - self._last_update[cache_key]).seconds < self._cache_ttl): + if ( + cache_key in self._cache + and cache_key in self._last_update + and (now - self._last_update[cache_key]).seconds < self._cache_ttl + ): return self._cache[cache_key] - + # Получаем новый курс rate = await self._fetch_exchange_rate() - + if rate: self._cache[cache_key] = rate self._last_update[cache_key] = now - logger.info(f"Обновлен курс USD/RUB: {rate}") + logger.info(f'Обновлен курс USD/RUB: {rate}') return rate - + # Возвращаем из кеша если API недоступен if cache_key in self._cache: - logger.warning("API курсов недоступен, используем кешированный курс") + logger.warning('API курсов недоступен, используем кешированный курс') return self._cache[cache_key] - + # Fallback курс - logger.warning("Используем fallback курс USD/RUB: 95") + logger.warning('Используем fallback курс USD/RUB: 95') return 95.0 - - async def _fetch_exchange_rate(self) -> Optional[float]: + + async def _fetch_exchange_rate(self) -> float | None: """Получает курс с нескольких источников""" - - sources = [ - self._fetch_from_cbr, - self._fetch_from_exchangerate_api, - self._fetch_from_fixer - ] - + + sources = [self._fetch_from_cbr, self._fetch_from_exchangerate_api, self._fetch_from_fixer] + for source in sources: try: rate = await source() if rate and 50 < rate < 200: # Разумные границы курса return rate except Exception as e: - logger.debug(f"Ошибка получения курса из {source.__name__}: {e}") + logger.debug(f'Ошибка получения курса из {source.__name__}: {e}') continue - + return None - - async def _fetch_from_cbr(self) -> Optional[float]: + + async def _fetch_from_cbr(self) -> float | None: """Получает курс с сайта ЦБ РФ""" try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: @@ -73,10 +71,10 @@ class CurrencyConverter: usd_rate = data['Valute']['USD']['Value'] return float(usd_rate) except Exception as e: - logger.debug(f"Ошибка получения курса ЦБ: {e}") + logger.debug(f'Ошибка получения курса ЦБ: {e}') return None - - async def _fetch_from_exchangerate_api(self) -> Optional[float]: + + async def _fetch_from_exchangerate_api(self) -> float | None: """Получает курс с exchangerate-api.com""" try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: @@ -86,15 +84,17 @@ class CurrencyConverter: rub_rate = data['rates']['RUB'] return float(rub_rate) except Exception as e: - logger.debug(f"Ошибка получения курса exchangerate-api: {e}") + logger.debug(f'Ошибка получения курса exchangerate-api: {e}') return None - - async def _fetch_from_fixer(self) -> Optional[float]: + + async def _fetch_from_fixer(self) -> float | None: """Получает курс с fixer.io (бесплатный план)""" try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: # Используем бесплатный endpoint (EUR base) - async with session.get('https://api.fixer.io/latest?access_key=YOUR_API_KEY&symbols=USD,RUB') as response: + async with session.get( + 'https://api.fixer.io/latest?access_key=YOUR_API_KEY&symbols=USD,RUB' + ) as response: if response.status == 200: data = await response.json() if data.get('success'): @@ -104,18 +104,19 @@ class CurrencyConverter: usd_rub = rub_eur / usd_eur return float(usd_rub) except Exception as e: - logger.debug(f"Ошибка получения курса fixer: {e}") + logger.debug(f'Ошибка получения курса fixer: {e}') return None - + async def usd_to_rub(self, usd_amount: float) -> float: """Конвертирует USD в RUB""" rate = await self.get_usd_to_rub_rate() return usd_amount * rate - + async def rub_to_usd(self, rub_amount: float) -> float: """Конвертирует RUB в USD""" rate = await self.get_usd_to_rub_rate() return rub_amount / rate + # Глобальный экземпляр currency_converter = CurrencyConverter() diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 3c4a67d7..b5ceb517 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -1,47 +1,45 @@ -import logging import functools -from typing import Callable, Any +import logging +from collections.abc import Callable +from typing import Any + from aiogram import types -from aiogram.fsm.context import FSMContext from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext from app.config import settings from app.localization.texts import get_texts + logger = logging.getLogger(__name__) def admin_required(func: Callable) -> Callable: - @functools.wraps(func) - async def wrapper( - event: types.Update, - *args, - **kwargs - ) -> Any: + async def wrapper(event: types.Update, *args, **kwargs) -> Any: user = None if isinstance(event, (types.Message, types.CallbackQuery)): user = event.from_user - + if not user or not settings.is_admin(user.id): texts = get_texts() - + try: if isinstance(event, types.Message): await event.answer(texts.ACCESS_DENIED) elif isinstance(event, types.CallbackQuery): await event.answer(texts.ACCESS_DENIED, show_alert=True) except TelegramBadRequest as e: - if "query is too old" in str(e).lower(): - logger.warning(f"Попытка ответить на устаревший callback query от {user.id if user else 'Unknown'}") + if 'query is too old' in str(e).lower(): + logger.warning(f'Попытка ответить на устаревший callback query от {user.id if user else "Unknown"}') else: raise - - logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}") - return - + + logger.warning(f'Попытка доступа к админской функции от {user.id if user else "Unknown"}') + return None + return await func(event, *args, **kwargs) - + return wrapper @@ -50,55 +48,57 @@ def auth_required(func: Callable) -> Callable: Простая проверка на наличие пользователя в апдейте. Middleware уже подтягивает db_user, но здесь страхуемся от вызовов без from_user. """ + @functools.wraps(func) async def wrapper(event: types.Update, *args, **kwargs) -> Any: user = None if isinstance(event, (types.Message, types.CallbackQuery)): user = event.from_user if not user: - logger.warning("auth_required: нет from_user, пропускаем") - return + logger.warning('auth_required: нет from_user, пропускаем') + return None return await func(event, *args, **kwargs) + return wrapper def error_handler(func: Callable) -> Callable: - @functools.wraps(func) async def wrapper(*args, **kwargs) -> Any: try: return await func(*args, **kwargs) except TelegramBadRequest as e: error_message = str(e).lower() - - if "query is too old" in error_message or "query id is invalid" in error_message: + + if 'query is too old' in error_message or 'query id is invalid' in error_message: event = _extract_event(args) if event and isinstance(event, types.CallbackQuery): - user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}" + user_info = ( + f'@{event.from_user.username}' if event.from_user.username else f'ID:{event.from_user.id}' + ) logger.warning(f"🕐 Игнорируем устаревший callback '{event.data}' от {user_info} в {func.__name__}") else: - logger.warning(f"🕐 Игнорируем устаревший запрос в {func.__name__}: {e}") + logger.warning(f'🕐 Игнорируем устаревший запрос в {func.__name__}: {e}') return None - - elif "message is not modified" in error_message: - logger.debug(f"📝 Сообщение не изменено в {func.__name__}") + + if 'message is not modified' in error_message: + logger.debug(f'📝 Сообщение не изменено в {func.__name__}') event = _extract_event(args) if event and isinstance(event, types.CallbackQuery): try: await event.answer() except TelegramBadRequest as answer_error: - if "query is too old" not in str(answer_error).lower(): - logger.error(f"Ошибка при ответе на callback: {answer_error}") + if 'query is too old' not in str(answer_error).lower(): + logger.error(f'Ошибка при ответе на callback: {answer_error}') return None - - else: - logger.error(f"Telegram API error в {func.__name__}: {e}") - await _send_error_message(args, kwargs, e) - - except Exception as e: - logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True) + + logger.error(f'Telegram API error в {func.__name__}: {e}') await _send_error_message(args, kwargs, e) - + + except Exception as e: + logger.error(f'Ошибка в {func.__name__}: {e}', exc_info=True) + await _send_error_message(args, kwargs, e) + return wrapper @@ -113,73 +113,59 @@ async def _send_error_message(args, kwargs, original_error): try: event = _extract_event(args) db_user = kwargs.get('db_user') - + if not event: return - + texts = get_texts(db_user.language if db_user else 'ru') - + if isinstance(event, types.Message): await event.answer(texts.ERROR) elif isinstance(event, types.CallbackQuery): await event.answer(texts.ERROR, show_alert=True) - + except TelegramBadRequest as e: - if "query is too old" in str(e).lower(): - logger.warning("Не удалось отправить сообщение об ошибке - callback query устарел") + if 'query is too old' in str(e).lower(): + logger.warning('Не удалось отправить сообщение об ошибке - callback query устарел') else: - logger.error(f"Ошибка при отправке сообщения об ошибке: {e}") + logger.error(f'Ошибка при отправке сообщения об ошибке: {e}') except Exception as e: - logger.error(f"Критическая ошибка при отправке сообщения об ошибке: {e}") + logger.error(f'Критическая ошибка при отправке сообщения об ошибке: {e}') def state_cleanup(func: Callable) -> Callable: - @functools.wraps(func) async def wrapper(*args, **kwargs) -> Any: state = kwargs.get('state') - + try: return await func(*args, **kwargs) except Exception as e: if state and isinstance(state, FSMContext): await state.clear() raise e - + return wrapper def typing_action(func: Callable) -> Callable: - @functools.wraps(func) - async def wrapper( - event: types.Update, - *args, - **kwargs - ) -> Any: + async def wrapper(event: types.Update, *args, **kwargs) -> Any: if isinstance(event, types.Message): try: - await event.bot.send_chat_action( - chat_id=event.chat.id, - action="typing" - ) + await event.bot.send_chat_action(chat_id=event.chat.id, action='typing') except Exception as e: - logger.warning(f"Не удалось отправить typing action: {e}") - + logger.warning(f'Не удалось отправить typing action: {e}') + return await func(event, *args, **kwargs) - + return wrapper def rate_limit(rate: float = 1.0, key: str = None): def decorator(func: Callable) -> Callable: - @functools.wraps(func) - async def wrapper( - event: types.Update, - *args, - **kwargs - ) -> Any: + async def wrapper(event: types.Update, *args, **kwargs) -> Any: return await func(event, *args, **kwargs) return wrapper @@ -209,53 +195,34 @@ def modem_available(for_enable: bool = False, for_disable: bool = False): @modem_available(for_enable=True) async def handle_modem_enable(callback, db_user, db): ... """ - def decorator(func: Callable) -> Callable: + def decorator(func: Callable) -> Callable: @functools.wraps(func) - async def wrapper( - event: types.Update, - *args, - **kwargs - ) -> Any: + async def wrapper(event: types.Update, *args, **kwargs) -> Any: db_user = kwargs.get('db_user') if not db_user: - logger.warning("modem_available: нет db_user в kwargs") - return + logger.warning('modem_available: нет db_user в kwargs') + return None - from app.services.modem_service import get_modem_service, ModemError + from app.services.modem_service import ModemError, get_modem_service service = get_modem_service() - result = service.check_availability( - db_user, - for_enable=for_enable, - for_disable=for_disable - ) + result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable) if not result.available: texts = get_texts(db_user.language if db_user else 'ru') error_messages = { ModemError.NO_SUBSCRIPTION: texts.t( - "MODEM_PAID_ONLY", - "Модем доступен только для платных подписок" + 'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок' ), ModemError.TRIAL_SUBSCRIPTION: texts.t( - "MODEM_PAID_ONLY", - "Модем доступен только для платных подписок" - ), - ModemError.MODEM_DISABLED: texts.t( - "MODEM_DISABLED", - "Функция модема отключена" - ), - ModemError.ALREADY_ENABLED: texts.t( - "MODEM_ALREADY_ENABLED", - "Модем уже подключен" - ), - ModemError.NOT_ENABLED: texts.t( - "MODEM_NOT_ENABLED", - "Модем не подключен" + 'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок' ), + ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'), + ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'), + ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'), } error_text = error_messages.get(result.error, texts.ERROR) @@ -266,7 +233,7 @@ def modem_available(for_enable: bool = False, for_disable: bool = False): elif isinstance(event, types.Message): await event.answer(error_text) except TelegramBadRequest as e: - if "query is too old" not in str(e).lower(): + if 'query is too old' not in str(e).lower(): raise return None diff --git a/app/utils/formatters.py b/app/utils/formatters.py index c98d2f65..3ee01038 100644 --- a/app/utils/formatters.py +++ b/app/utils/formatters.py @@ -1,237 +1,221 @@ -from datetime import datetime, timedelta -from typing import Union, Optional +from datetime import datetime -def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str: +def format_datetime(dt: datetime | str, format_str: str = '%d.%m.%Y %H:%M') -> str: if isinstance(dt, str): - if dt == "now" or dt == "": + if dt == 'now' or dt == '': dt = datetime.now() else: try: dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) except (ValueError, AttributeError): dt = datetime.now() - + return dt.strftime(format_str) -def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str: +def format_date(dt: datetime | str, format_str: str = '%d.%m.%Y') -> str: if isinstance(dt, str): - if dt == "now" or dt == "": + if dt == 'now' or dt == '': dt = datetime.now() else: try: dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) except (ValueError, AttributeError): dt = datetime.now() - + return dt.strftime(format_str) -def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str: +def format_time_ago(dt: datetime | str, language: str = 'ru') -> str: if isinstance(dt, str): - if dt == "now" or dt == "": + if dt == 'now' or dt == '': dt = datetime.now() else: try: dt = datetime.fromisoformat(dt.replace('Z', '+00:00')) except (ValueError, AttributeError): dt = datetime.now() - + now = datetime.utcnow() diff = now - dt - language_code = (language or "ru").split("-")[0].lower() + language_code = (language or 'ru').split('-')[0].lower() if diff.days > 0: if diff.days == 1: - return "yesterday" if language_code == "en" else "вчера" + return 'yesterday' if language_code == 'en' else 'вчера' if diff.days < 7: value = diff.days - if language_code == "en": - suffix = "day" if value == 1 else "days" - return f"{value} {suffix} ago" - return f"{value} дн. назад" + if language_code == 'en': + suffix = 'day' if value == 1 else 'days' + return f'{value} {suffix} ago' + return f'{value} дн. назад' if diff.days < 30: value = diff.days // 7 - if language_code == "en": - suffix = "week" if value == 1 else "weeks" - return f"{value} {suffix} ago" - return f"{value} нед. назад" + if language_code == 'en': + suffix = 'week' if value == 1 else 'weeks' + return f'{value} {suffix} ago' + return f'{value} нед. назад' if diff.days < 365: value = diff.days // 30 - if language_code == "en": - suffix = "month" if value == 1 else "months" - return f"{value} {suffix} ago" - return f"{value} мес. назад" + if language_code == 'en': + suffix = 'month' if value == 1 else 'months' + return f'{value} {suffix} ago' + return f'{value} мес. назад' value = diff.days // 365 - if language_code == "en": - suffix = "year" if value == 1 else "years" - return f"{value} {suffix} ago" - return f"{value} г. назад" + if language_code == 'en': + suffix = 'year' if value == 1 else 'years' + return f'{value} {suffix} ago' + return f'{value} г. назад' if diff.seconds > 3600: value = diff.seconds // 3600 - if language_code == "en": - suffix = "hour" if value == 1 else "hours" - return f"{value} {suffix} ago" - return f"{value} ч. назад" + if language_code == 'en': + suffix = 'hour' if value == 1 else 'hours' + return f'{value} {suffix} ago' + return f'{value} ч. назад' if diff.seconds > 60: value = diff.seconds // 60 - if language_code == "en": - suffix = "minute" if value == 1 else "minutes" - return f"{value} {suffix} ago" - return f"{value} мин. назад" + if language_code == 'en': + suffix = 'minute' if value == 1 else 'minutes' + return f'{value} {suffix} ago' + return f'{value} мин. назад' - return "just now" if language_code == "en" else "только что" + return 'just now' if language_code == 'en' else 'только что' + + +def format_days_declension(days: int, language: str = 'ru') -> str: + if language != 'ru': + return f'{days} day{"s" if days != 1 else ""}' -def format_days_declension(days: int, language: str = "ru") -> str: - if language != "ru": - return f"{days} day{'s' if days != 1 else ''}" - if days % 10 == 1 and days % 100 != 11: - return f"{days} день" - elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]: - return f"{days} дня" - else: - return f"{days} дней" + return f'{days} день' + if days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]: + return f'{days} дня' + return f'{days} дней' def format_duration(seconds: int) -> str: if seconds < 60: - return f"{seconds} сек." - + return f'{seconds} сек.' + minutes = seconds // 60 if minutes < 60: - return f"{minutes} мин." - + return f'{minutes} мин.' + hours = minutes // 60 if hours < 24: - return f"{hours} ч." - + return f'{hours} ч.' + days = hours // 24 - return f"{days} дн." + return f'{days} дн.' def format_bytes(bytes_value: int) -> str: if bytes_value == 0: - return "0 B" - - units = ["B", "KB", "MB", "GB", "TB"] + return '0 B' + + units = ['B', 'KB', 'MB', 'GB', 'TB'] size = float(bytes_value) unit_index = 0 - + while size >= 1024 and unit_index < len(units) - 1: size /= 1024 unit_index += 1 - + if size == int(size): - return f"{int(size)} {units[unit_index]}" - else: - return f"{size:.1f} {units[unit_index]}" + return f'{int(size)} {units[unit_index]}' + return f'{size:.1f} {units[unit_index]}' def format_percentage(value: float, decimals: int = 1) -> str: - return f"{value:.{decimals}f}%" + return f'{value:.{decimals}f}%' -def format_number(number: Union[int, float], separator: str = " ") -> str: +def format_number(number: float, separator: str = ' ') -> str: if isinstance(number, float): integer_part = int(number) decimal_part = number - integer_part - - formatted_integer = f"{integer_part:,}".replace(",", separator) - + + formatted_integer = f'{integer_part:,}'.replace(',', separator) + if decimal_part > 0: - return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}" - else: - return formatted_integer - else: - return f"{number:,}".replace(",", separator) + return f'{formatted_integer}.{decimal_part:.2f}'.split('.')[0] + f'.{str(decimal_part).split(".")[1][:2]}' + return formatted_integer + return f'{number:,}'.replace(',', separator) def format_price_range(min_price: int, max_price: int) -> str: from app.config import settings - + min_formatted = settings.format_price(min_price) max_formatted = settings.format_price(max_price) - + if min_price == max_price: return min_formatted - else: - return f"{min_formatted} - {max_formatted}" + return f'{min_formatted} - {max_formatted}' -def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str: +def truncate_text(text: str, max_length: int = 100, suffix: str = '...') -> str: if len(text) <= max_length: return text - - return text[:max_length - len(suffix)] + suffix + + return text[: max_length - len(suffix)] + suffix -def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str: +def format_username(username: str | None, user_id: int, full_name: str | None = None) -> str: if full_name: return full_name - elif username: - return f"@{username}" - else: - return f"ID{user_id}" + if username: + return f'@{username}' + return f'ID{user_id}' -def format_subscription_status( - is_active: bool, - is_trial: bool, - end_date: Union[datetime, str], - language: str = "ru" -) -> str: - +def format_subscription_status(is_active: bool, is_trial: bool, end_date: datetime | str, language: str = 'ru') -> str: if isinstance(end_date, str): try: end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00')) except (ValueError, AttributeError): end_date = datetime.now() - + if not is_active: - return "❌ Неактивна" if language == "ru" else "❌ Inactive" - + return '❌ Неактивна' if language == 'ru' else '❌ Inactive' + if is_trial: - status = "🎁 Тестовая" if language == "ru" else "🎁 Trial" + status = '🎁 Тестовая' if language == 'ru' else '🎁 Trial' else: - status = "✅ Активна" if language == "ru" else "✅ Active" - + status = '✅ Активна' if language == 'ru' else '✅ Active' + now = datetime.utcnow() if end_date > now: days_left = (end_date - now).days if days_left > 0: - status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)" + status += f' ({days_left} дн.)' if language == 'ru' else f' ({days_left} days)' else: hours_left = (end_date - now).seconds // 3600 - status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)" + status += f' ({hours_left} ч.)' if language == 'ru' else f' ({hours_left} hrs)' else: - status = "⏰ Истекла" if language == "ru" else "⏰ Expired" - + status = '⏰ Истекла' if language == 'ru' else '⏰ Expired' + return status -def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str: - - if limit_gb == 0: - if language == "ru": - return f"{used_gb:.1f} ГБ / ∞" - else: - return f"{used_gb:.1f} GB / ∞" - +def format_traffic_usage(used_gb: float, limit_gb: int, language: str = 'ru') -> str: + if limit_gb == 0: + if language == 'ru': + return f'{used_gb:.1f} ГБ / ∞' + return f'{used_gb:.1f} GB / ∞' + percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0 - - if language == "ru": - return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)" - else: - return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)" + + if language == 'ru': + return f'{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)' + return f'{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)' -def format_boolean(value: bool, language: str = "ru") -> str: - if language == "ru": - return "✅ Да" if value else "❌ Нет" - else: - return "✅ Yes" if value else "❌ No" +def format_boolean(value: bool, language: str = 'ru') -> str: + if language == 'ru': + return '✅ Да' if value else '❌ Нет' + return '✅ Yes' if value else '❌ No' diff --git a/app/utils/log_handlers.py b/app/utils/log_handlers.py index baee8d89..2b177cef 100644 --- a/app/utils/log_handlers.py +++ b/app/utils/log_handlers.py @@ -9,7 +9,6 @@ from __future__ import annotations import logging -from typing import Optional class LevelFilterHandler(logging.Handler): @@ -31,8 +30,8 @@ class LevelFilterHandler(logging.Handler): self, filename: str, min_level: int, - max_level: Optional[int] = None, - encoding: str = "utf-8", + max_level: int | None = None, + encoding: str = 'utf-8', ): super().__init__(level=min_level) self.min_level = min_level @@ -69,22 +68,22 @@ class PaymentLogFilter(logging.Filter): """ PAYMENT_MODULES = ( - "app.payments", - "app.services.payment", - "app.services.yookassa_service", - "app.services.tribute_service", - "app.services.mulenpay_service", - "app.services.cloudpayments_service", - "app.services.platega_service", - "app.services.pal24_service", - "app.services.wata_service", - "app.external.cryptobot", - "app.external.heleket", - "app.external.tribute", - "app.external.yookassa_webhook", - "app.external.pal24_webhook", - "app.external.wata_webhook", - "app.external.heleket_webhook", + 'app.payments', + 'app.services.payment', + 'app.services.yookassa_service', + 'app.services.tribute_service', + 'app.services.mulenpay_service', + 'app.services.cloudpayments_service', + 'app.services.platega_service', + 'app.services.pal24_service', + 'app.services.wata_service', + 'app.external.cryptobot', + 'app.external.heleket', + 'app.external.tribute', + 'app.external.yookassa_webhook', + 'app.external.pal24_webhook', + 'app.external.wata_webhook', + 'app.external.heleket_webhook', ) def filter(self, record: logging.LogRecord) -> bool: @@ -103,6 +102,4 @@ class ExcludePaymentFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: """Пропустить записи НЕ из платежных модулей.""" - return not any( - record.name.startswith(module) for module in self.PAYMENT_MODULES - ) + return not any(record.name.startswith(module) for module in self.PAYMENT_MODULES) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index c022ea2d..e075febe 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict +from typing import Any from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InaccessibleMessage, InputMediaPhoto, Message @@ -7,20 +7,21 @@ from aiogram.types import FSInputFile, InaccessibleMessage, InputMediaPhoto, Mes from app.config import settings from app.localization.texts import get_texts + LOGO_PATH = Path(settings.LOGO_FILE) -_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED" +_PRIVACY_RESTRICTED_CODE = 'BUTTON_USER_PRIVACY_RESTRICTED' _TOPIC_REQUIRED_ERRORS = ( - "topic must be specified", - "TOPIC_CLOSED", - "TOPIC_DELETED", - "FORUM_CLOSED", + 'topic must be specified', + 'TOPIC_CLOSED', + 'TOPIC_DELETED', + 'FORUM_CLOSED', ) def is_qr_message(message: Message) -> bool: if isinstance(message, InaccessibleMessage): return False - return bool(message.caption and message.caption.startswith("\U0001F517 Ваша реферальная ссылка")) + return bool(message.caption and message.caption.startswith('\U0001f517 Ваша реферальная ссылка')) _original_answer = Message.answer @@ -30,7 +31,7 @@ _original_edit_text = Message.edit_text def _get_language(message: Message) -> str | None: try: user = message.from_user - if user and getattr(user, "language_code", None): + if user and getattr(user, 'language_code', None): return user.language_code except AttributeError: pass @@ -38,22 +39,22 @@ def _get_language(message: Message) -> str | None: def _default_privacy_hint(language: str | None) -> str: - if language and language.lower().startswith("en"): + if language and language.lower().startswith('en'): return ( - "⚠️ Telegram blocked the contact request button because of your privacy settings. " - "Please allow sharing your contact information or send the required details manually." + '⚠️ Telegram blocked the contact request button because of your privacy settings. ' + 'Please allow sharing your contact information or send the required details manually.' ) return ( - "⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. " - "Разрешите отправку контакта в настройках Telegram или отправьте данные вручную." + '⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. ' + 'Разрешите отправку контакта в настройках Telegram или отправьте данные вручную.' ) def append_privacy_hint(text: str | None, language: str | None) -> str: - base_text = text or "" + base_text = text or '' try: hint = get_texts(language).t( - "PRIVACY_RESTRICTED_BUTTON_HINT", + 'PRIVACY_RESTRICTED_BUTTON_HINT', default=_default_privacy_hint(language), ) except Exception: @@ -67,13 +68,13 @@ def append_privacy_hint(text: str | None, language: str | None) -> str: return base_text if base_text: - return f"{base_text}\n\n{hint}" + return f'{base_text}\n\n{hint}' return hint -def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]: - safe_kwargs: Dict[str, Any] = dict(kwargs or {}) - safe_kwargs.pop("reply_markup", None) +def prepare_privacy_safe_kwargs(kwargs: dict[str, Any] | None = None) -> dict[str, Any]: + safe_kwargs: dict[str, Any] = dict(kwargs or {}) + safe_kwargs.pop('reply_markup', None) return safe_kwargs @@ -81,7 +82,7 @@ def is_privacy_restricted_error(error: Exception) -> bool: if not isinstance(error, TelegramBadRequest): return False - message = getattr(error, "message", "") or "" + message = getattr(error, 'message', '') or '' description = str(error) return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description @@ -164,19 +165,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): pass # Всегда используем логотип если включен режим логотипа, # кроме специальных случаев (QR сообщения) - if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self): - media = FSInputFile(LOGO_PATH) - elif is_qr_message(self) and LOGO_PATH.exists(): + if (settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self)) or ( + is_qr_message(self) and LOGO_PATH.exists() + ): media = FSInputFile(LOGO_PATH) else: media = self.photo[-1].file_id - media_kwargs = {"media": media, "caption": text} + media_kwargs = {'media': media, 'caption': text} edit_kwargs = dict(kwargs) - if "parse_mode" in edit_kwargs: - _pm = edit_kwargs.pop("parse_mode") - media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML" + if 'parse_mode' in edit_kwargs: + _pm = edit_kwargs.pop('parse_mode') + media_kwargs['parse_mode'] = _pm if _pm is not None else 'HTML' else: - media_kwargs["parse_mode"] = "HTML" + media_kwargs['parse_mode'] = 'HTML' try: return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs) except TelegramBadRequest as error: @@ -212,7 +213,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): except TelegramBadRequest as error: if is_topic_required_error(error): return None - if "MESSAGE_ID_INVALID" in str(error) or "message to edit not found" in str(error).lower(): + if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower(): # Сообщение удалено или недоступно — просто игнорируем return None raise @@ -223,4 +224,3 @@ def patch_message_methods(): return Message.answer = _answer_with_photo Message.edit_text = _edit_with_photo - diff --git a/app/utils/miniapp_buttons.py b/app/utils/miniapp_buttons.py index e2dc1aa8..c3b5592f 100644 --- a/app/utils/miniapp_buttons.py +++ b/app/utils/miniapp_buttons.py @@ -4,7 +4,7 @@ from aiogram.types import InlineKeyboardButton from app.config import settings -DEFAULT_UNAVAILABLE_CALLBACK = "menu_profile_unavailable" +DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable' def build_miniapp_or_callback_button( diff --git a/app/utils/pagination.py b/app/utils/pagination.py index b70dc5cc..aab8b8c0 100644 --- a/app/utils/pagination.py +++ b/app/utils/pagination.py @@ -1,18 +1,12 @@ -from typing import List, TypeVar, Generic, Dict, Any from math import ceil +from typing import Any, TypeVar + T = TypeVar('T') -class PaginationResult(Generic[T]): - - def __init__( - self, - items: List[T], - total_count: int, - page: int, - per_page: int - ): +class PaginationResult[T]: + def __init__(self, items: list[T], total_count: int, page: int, per_page: int): self.items = items self.total_count = total_count self.page = page @@ -24,59 +18,42 @@ class PaginationResult(Generic[T]): self.next_page = page + 1 if self.has_next else None -def paginate_list( - items: List[T], - page: int = 1, - per_page: int = 10 -) -> PaginationResult[T]: +def paginate_list[T](items: list[T], page: int = 1, per_page: int = 10) -> PaginationResult[T]: total_count = len(items) - + start_index = (page - 1) * per_page end_index = start_index + per_page - + page_items = items[start_index:end_index] - - return PaginationResult( - items=page_items, - total_count=total_count, - page=page, - per_page=per_page - ) + + return PaginationResult(items=page_items, total_count=total_count, page=page, per_page=per_page) -def get_pagination_info( - total_count: int, - page: int = 1, - per_page: int = 10 -) -> Dict[str, Any]: +def get_pagination_info(total_count: int, page: int = 1, per_page: int = 10) -> dict[str, Any]: total_pages = ceil(total_count / per_page) if per_page > 0 else 1 - + return { - "total_count": total_count, - "page": page, - "per_page": per_page, - "total_pages": total_pages, - "has_prev": page > 1, - "has_next": page < total_pages, - "prev_page": page - 1 if page > 1 else None, - "next_page": page + 1 if page < total_pages else None, - "offset": (page - 1) * per_page + 'total_count': total_count, + 'page': page, + 'per_page': per_page, + 'total_pages': total_pages, + 'has_prev': page > 1, + 'has_next': page < total_pages, + 'prev_page': page - 1 if page > 1 else None, + 'next_page': page + 1 if page < total_pages else None, + 'offset': (page - 1) * per_page, } -def get_page_numbers( - current_page: int, - total_pages: int, - max_visible: int = 5 -) -> List[int]: +def get_page_numbers(current_page: int, total_pages: int, max_visible: int = 5) -> list[int]: if total_pages <= max_visible: return list(range(1, total_pages + 1)) - + half_visible = max_visible // 2 start_page = max(1, current_page - half_visible) end_page = min(total_pages, start_page + max_visible - 1) - + if end_page - start_page + 1 < max_visible: start_page = max(1, end_page - max_visible + 1) - - return list(range(start_page, end_page + 1)) \ No newline at end of file + + return list(range(start_page, end_page + 1)) diff --git a/app/utils/payment_logger.py b/app/utils/payment_logger.py index ff742a96..4ab5f5eb 100644 --- a/app/utils/payment_logger.py +++ b/app/utils/payment_logger.py @@ -13,15 +13,15 @@ from __future__ import annotations import logging -from typing import Optional + # Выделенный логгер для всех платежных операций -payment_logger = logging.getLogger("app.payments") +payment_logger = logging.getLogger('app.payments') def configure_payment_logger( handler: logging.Handler, - formatter: Optional[logging.Formatter] = None, + formatter: logging.Formatter | None = None, level: int = logging.INFO, ) -> None: """Настроить payment_logger с указанным хэндлером. diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 90041362..bc7a45b8 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -1,117 +1,133 @@ -from typing import List, Dict, Tuple - from app.config import settings from app.localization.texts import get_texts -def get_available_payment_methods() -> List[Dict[str, str]]: + +def get_available_payment_methods() -> list[dict[str, str]]: """ Возвращает список доступных способов оплаты с их настройками """ methods = [] - - if settings.TELEGRAM_STARS_ENABLED: - methods.append({ - "id": "stars", - "name": "Telegram Stars", - "icon": "⭐", - "description": "быстро и удобно", - "callback": "topup_stars" - }) - - if settings.is_yookassa_enabled(): - if getattr(settings, "YOOKASSA_SBP_ENABLED", False): - methods.append({ - "id": "yookassa_sbp", - "name": "СБП (YooKassa)", - "icon": "🏦", - "description": "моментальная оплата по QR", - "callback": "topup_yookassa_sbp", - }) - methods.append({ - "id": "yookassa", - "name": "Банковская карта", - "icon": "💳", - "description": "через YooKassa", - "callback": "topup_yookassa", - }) - + if settings.TELEGRAM_STARS_ENABLED: + methods.append( + { + 'id': 'stars', + 'name': 'Telegram Stars', + 'icon': '⭐', + 'description': 'быстро и удобно', + 'callback': 'topup_stars', + } + ) + + if settings.is_yookassa_enabled(): + if getattr(settings, 'YOOKASSA_SBP_ENABLED', False): + methods.append( + { + 'id': 'yookassa_sbp', + 'name': 'СБП (YooKassa)', + 'icon': '🏦', + 'description': 'моментальная оплата по QR', + 'callback': 'topup_yookassa_sbp', + } + ) + + methods.append( + { + 'id': 'yookassa', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': 'через YooKassa', + 'callback': 'topup_yookassa', + } + ) + if settings.TRIBUTE_ENABLED: - methods.append({ - "id": "tribute", - "name": "Банковская карта", - "icon": "💳", - "description": "через Tribute", - "callback": "topup_tribute" - }) + methods.append( + { + 'id': 'tribute', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': 'через Tribute', + 'callback': 'topup_tribute', + } + ) if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() - methods.append({ - "id": "mulenpay", - "name": "Банковская карта", - "icon": "💳", - "description": f"через {mulenpay_name}", - "callback": "topup_mulenpay" - }) + methods.append( + { + 'id': 'mulenpay', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': f'через {mulenpay_name}', + 'callback': 'topup_mulenpay', + } + ) if settings.is_wata_enabled(): - methods.append({ - "id": "wata", - "name": "Банковская карта", - "icon": "💳", - "description": "через WATA", - "callback": "topup_wata" - }) + methods.append( + { + 'id': 'wata', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': 'через WATA', + 'callback': 'topup_wata', + } + ) if settings.is_pal24_enabled(): - methods.append({ - "id": "pal24", - "name": "СБП", - "icon": "🏦", - "description": "через PayPalych", - "callback": "topup_pal24" - }) + methods.append( + {'id': 'pal24', 'name': 'СБП', 'icon': '🏦', 'description': 'через PayPalych', 'callback': 'topup_pal24'} + ) if settings.is_cryptobot_enabled(): - methods.append({ - "id": "cryptobot", - "name": "Криптовалюта", - "icon": "🪙", - "description": "через CryptoBot", - "callback": "topup_cryptobot" - }) + methods.append( + { + 'id': 'cryptobot', + 'name': 'Криптовалюта', + 'icon': '🪙', + 'description': 'через CryptoBot', + 'callback': 'topup_cryptobot', + } + ) if settings.is_heleket_enabled(): - methods.append({ - "id": "heleket", - "name": "Криптовалюта", - "icon": "🪙", - "description": "через Heleket", - "callback": "topup_heleket" - }) + methods.append( + { + 'id': 'heleket', + 'name': 'Криптовалюта', + 'icon': '🪙', + 'description': 'через Heleket', + 'callback': 'topup_heleket', + } + ) if settings.is_platega_enabled() and settings.get_platega_active_methods(): platega_name = settings.get_platega_display_name() - methods.append({ - "id": "platega", - "name": "Банковская карта", - "icon": "💳", - "description": f"через {platega_name} (карты + СБП)", - "callback": "topup_platega", - }) + methods.append( + { + 'id': 'platega', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': f'через {platega_name} (карты + СБП)', + 'callback': 'topup_platega', + } + ) if settings.is_support_topup_enabled(): - methods.append({ - "id": "support", - "name": "Через поддержку", - "icon": "🛠️", - "description": "другие способы", - "callback": "topup_support" - }) - + methods.append( + { + 'id': 'support', + 'name': 'Через поддержку', + 'icon': '🛠️', + 'description': 'другие способы', + 'callback': 'topup_support', + } + ) + return methods + def get_payment_methods_text(language: str) -> str: """ Генерирует текст с описанием доступных способов оплаты @@ -121,7 +137,7 @@ def get_payment_methods_text(language: str) -> str: if not methods: return texts.t( - "PAYMENT_METHODS_NONE_AVAILABLE", + 'PAYMENT_METHODS_NONE_AVAILABLE', """💳 Способы пополнения баланса ⚠️ В данный момент способы оплаты временно недоступны. @@ -130,9 +146,9 @@ def get_payment_methods_text(language: str) -> str: Выберите способ пополнения:""", ) - if len(methods) == 1 and methods[0]["id"] == "support": + if len(methods) == 1 and methods[0]['id'] == 'support': return texts.t( - "PAYMENT_METHODS_ONLY_SUPPORT", + 'PAYMENT_METHODS_ONLY_SUPPORT', """💳 Способы пополнения баланса ⚠️ В данный момент автоматические способы оплаты временно недоступны. @@ -141,89 +157,97 @@ def get_payment_methods_text(language: str) -> str: Выберите способ пополнения:""", ) - text = texts.t( - "PAYMENT_METHODS_TITLE", - "💳 Способы пополнения баланса", - ) + "\n\n" - text += texts.t( - "PAYMENT_METHODS_PROMPT", - "Выберите удобный для вас способ оплаты:", - ) + "\n\n" + text = ( + texts.t( + 'PAYMENT_METHODS_TITLE', + '💳 Способы пополнения баланса', + ) + + '\n\n' + ) + text += ( + texts.t( + 'PAYMENT_METHODS_PROMPT', + 'Выберите удобный для вас способ оплаты:', + ) + + '\n\n' + ) for method in methods: method_id = method['id'].upper() name = texts.t( - f"PAYMENT_METHOD_{method_id}_NAME", - f"{method['icon']} {method['name']}", + f'PAYMENT_METHOD_{method_id}_NAME', + f'{method["icon"]} {method["name"]}', ) description = texts.t( - f"PAYMENT_METHOD_{method_id}_DESCRIPTION", + f'PAYMENT_METHOD_{method_id}_DESCRIPTION', method['description'], ) - if method_id == "MULENPAY": + if method_id == 'MULENPAY': mulenpay_name = settings.get_mulenpay_display_name() mulenpay_name_html = settings.get_mulenpay_display_name_html() name = name.format(mulenpay_name=mulenpay_name_html) description = description.format(mulenpay_name=mulenpay_name) - elif method_id == "PLATEGA": + elif method_id == 'PLATEGA': platega_name = settings.get_platega_display_name() platega_name_html = settings.get_platega_display_name_html() name = name.format(platega_name=platega_name_html) description = description.format(platega_name=platega_name) - text += f"{name} - {description}\n" + text += f'{name} - {description}\n' - text += "\n" + texts.t( - "PAYMENT_METHODS_FOOTER", - "Выберите способ пополнения:", + text += '\n' + texts.t( + 'PAYMENT_METHODS_FOOTER', + 'Выберите способ пополнения:', ) return text + def is_payment_method_available(method_id: str) -> bool: """ Проверяет, доступен ли конкретный способ оплаты """ - if method_id == "stars": + if method_id == 'stars': return settings.TELEGRAM_STARS_ENABLED - elif method_id == "yookassa": + if method_id == 'yookassa': return settings.is_yookassa_enabled() - elif method_id == "tribute": + if method_id == 'tribute': return settings.TRIBUTE_ENABLED - elif method_id == "mulenpay": + if method_id == 'mulenpay': return settings.is_mulenpay_enabled() - elif method_id == "wata": + if method_id == 'wata': return settings.is_wata_enabled() - elif method_id == "pal24": + if method_id == 'pal24': return settings.is_pal24_enabled() - elif method_id == "cryptobot": + if method_id == 'cryptobot': return settings.is_cryptobot_enabled() - elif method_id == "heleket": + if method_id == 'heleket': return settings.is_heleket_enabled() - elif method_id == "platega": + if method_id == 'platega': return settings.is_platega_enabled() and bool(settings.get_platega_active_methods()) - elif method_id == "support": + if method_id == 'support': return settings.is_support_topup_enabled() - else: - return False + return False -def get_payment_method_status() -> Dict[str, bool]: + +def get_payment_method_status() -> dict[str, bool]: """ Возвращает статус всех способов оплаты """ return { - "stars": settings.TELEGRAM_STARS_ENABLED, - "yookassa": settings.is_yookassa_enabled(), - "tribute": settings.TRIBUTE_ENABLED, - "mulenpay": settings.is_mulenpay_enabled(), - "wata": settings.is_wata_enabled(), - "pal24": settings.is_pal24_enabled(), - "cryptobot": settings.is_cryptobot_enabled(), - "heleket": settings.is_heleket_enabled(), - "platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()), - "support": settings.is_support_topup_enabled() + 'stars': settings.TELEGRAM_STARS_ENABLED, + 'yookassa': settings.is_yookassa_enabled(), + 'tribute': settings.TRIBUTE_ENABLED, + 'mulenpay': settings.is_mulenpay_enabled(), + 'wata': settings.is_wata_enabled(), + 'pal24': settings.is_pal24_enabled(), + 'cryptobot': settings.is_cryptobot_enabled(), + 'heleket': settings.is_heleket_enabled(), + 'platega': settings.is_platega_enabled() and bool(settings.get_platega_active_methods()), + 'support': settings.is_support_topup_enabled(), } + def get_enabled_payment_methods_count() -> int: """ Возвращает количество включенных способов оплаты (не считая поддержку) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 41aaa758..2c99b5d5 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -6,6 +6,7 @@ from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError from aiogram.types import FSInputFile, InaccessibleMessage, InputMediaPhoto from app.config import settings + from .message_patch import ( LOGO_PATH, append_privacy_hint, @@ -14,6 +15,7 @@ from .message_patch import ( prepare_privacy_safe_kwargs, ) + logger = logging.getLogger(__name__) MAX_RETRIES = 3 @@ -29,7 +31,7 @@ def _resolve_media(message: types.Message): if settings.ENABLE_LOGO_MODE and not is_qr_message(message): return FSInputFile(LOGO_PATH) # Только если режим логотипа выключен, используем фото из сообщения - elif message.photo: + if message.photo: return message.photo[-1].file_id return FSInputFile(LOGO_PATH) @@ -37,7 +39,7 @@ def _resolve_media(message: types.Message): def _get_language(callback: types.CallbackQuery) -> str | None: try: user = callback.from_user - if user and getattr(user, "language_code", None): + if user and getattr(user, 'language_code', None): return user.language_code except AttributeError: pass @@ -47,9 +49,9 @@ def _get_language(callback: types.CallbackQuery) -> str | None: def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: - kwargs["parse_mode"] = parse_mode + kwargs['parse_mode'] = parse_mode if keyboard is not None: - kwargs["reply_markup"] = keyboard + kwargs['reply_markup'] = keyboard return kwargs @@ -67,7 +69,7 @@ async def _answer_text( caption = append_privacy_hint(caption, language) kwargs = prepare_privacy_safe_kwargs(kwargs) - kwargs.setdefault("parse_mode", parse_mode or "HTML") + kwargs.setdefault('parse_mode', parse_mode or 'HTML') await callback.message.answer( caption, @@ -79,11 +81,11 @@ async def edit_or_answer_photo( callback: types.CallbackQuery, caption: str, keyboard: types.InlineKeyboardMarkup, - parse_mode: str | None = "HTML", + parse_mode: str | None = 'HTML', *, force_text: bool = False, ) -> None: - resolved_parse_mode = parse_mode or "HTML" + resolved_parse_mode = parse_mode or 'HTML' # Если сообщение недоступно, отправляем новое сообщение if isinstance(callback.message, InaccessibleMessage): @@ -102,7 +104,7 @@ async def edit_or_answer_photo( parse_mode=resolved_parse_mode, ) except Exception as e: - logger.warning("Не удалось отправить новое сообщение для InaccessibleMessage: %s", e) + logger.warning('Не удалось отправить новое сообщение для InaccessibleMessage: %s', e) try: await callback.message.answer( caption, @@ -149,27 +151,23 @@ async def edit_or_answer_photo( for attempt in range(MAX_RETRIES): try: await callback.message.edit_media( - InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")), + InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or 'HTML')), reply_markup=keyboard, ) return # Успешно — выходим except TelegramNetworkError as net_error: if attempt < MAX_RETRIES - 1: - logger.warning( - "Сетевая ошибка edit_media (попытка %d/%d): %s", - attempt + 1, MAX_RETRIES, net_error - ) + logger.warning('Сетевая ошибка edit_media (попытка %d/%d): %s', attempt + 1, MAX_RETRIES, net_error) await asyncio.sleep(RETRY_DELAY * (attempt + 1)) continue - else: - logger.error("Сетевая ошибка edit_media после %d попыток: %s", MAX_RETRIES, net_error) - # После всех попыток — фоллбек на текст - try: - await callback.message.delete() - except Exception: - pass - await _answer_text(callback, caption, keyboard, resolved_parse_mode) - return + logger.error('Сетевая ошибка edit_media после %d попыток: %s', MAX_RETRIES, net_error) + # После всех попыток — фоллбек на текст + try: + await callback.message.delete() + except Exception: + pass + await _answer_text(callback, caption, keyboard, resolved_parse_mode) + return except TelegramBadRequest as error: if is_privacy_restricted_error(error): try: diff --git a/app/utils/price_display.py b/app/utils/price_display.py index 06747be5..ceb471af 100644 --- a/app/utils/price_display.py +++ b/app/utils/price_display.py @@ -7,12 +7,12 @@ This module provides a centralized way to: - Ensure uniform discount display throughout the application """ -from dataclasses import dataclass -from typing import Optional import logging +from dataclasses import dataclass -from app.database.models import User from app.config import settings +from app.database.models import User + logger = logging.getLogger(__name__) @@ -36,12 +36,7 @@ class PriceInfo: return self.base_price - self.final_price -def calculate_user_price( - user: Optional[User], - base_price: int, - period_days: int, - category: str = "period" -) -> PriceInfo: +def calculate_user_price(user: User | None, base_price: int, period_days: int, category: str = 'period') -> PriceInfo: """ Calculate final price for a user with all applicable discounts. @@ -56,12 +51,12 @@ def calculate_user_price( Example: >>> user = get_user_from_db(123) - >>> price_info = calculate_user_price(user, 100000, 30, "period") - >>> print(f"{price_info.base_price} -> {price_info.final_price} ({price_info.discount_percent}%)") + >>> price_info = calculate_user_price(user, 100000, 30, 'period') + >>> print(f'{price_info.base_price} -> {price_info.final_price} ({price_info.discount_percent}%)') 100000 -> 80000 (20%) >>> # For base pricing (no user) - >>> price_info = calculate_user_price(None, 100000, 30, "period") + >>> price_info = calculate_user_price(None, 100000, 30, 'period') >>> # Uses BASE_PROMO_GROUP_PERIOD_DISCOUNTS from settings """ if not base_price or base_price <= 0: @@ -76,9 +71,9 @@ def calculate_user_price( discount_percent = settings.get_base_promo_group_period_discount(period_days) logger.debug( - f"calculate_user_price: user={user.telegram_id if user else 'None'}, " - f"base_price={base_price}, period_days={period_days}, category={category}, " - f"discount_percent={discount_percent}" + f'calculate_user_price: user={user.telegram_id if user else "None"}, ' + f'base_price={base_price}, period_days={period_days}, category={category}, ' + f'discount_percent={discount_percent}' ) if discount_percent <= 0: @@ -89,24 +84,16 @@ def calculate_user_price( final_price = base_price - discount_value logger.debug( - f"Calculated price for user {user.telegram_id if user else 'None'}: " - f"{base_price} -> {final_price} (-{discount_percent}%) " - f"[category={category}, period={period_days}]" + f'Calculated price for user {user.telegram_id if user else "None"}: ' + f'{base_price} -> {final_price} (-{discount_percent}%) ' + f'[category={category}, period={period_days}]' ) - return PriceInfo( - base_price=base_price, - final_price=final_price, - discount_percent=discount_percent - ) + return PriceInfo(base_price=base_price, final_price=final_price, discount_percent=discount_percent) def format_price_button( - period_label: str, - price_info: PriceInfo, - format_price_func, - emphasize: bool = False, - add_exclamation: bool = True + period_label: str, price_info: PriceInfo, format_price_func, emphasize: bool = False, add_exclamation: bool = True ) -> str: """ Format a price button text with unified discount display. @@ -136,31 +123,27 @@ def format_price_button( """ # Format button text differently if final price is 0 if price_info.final_price == 0: - button_text = f"📅 {period_label}" + button_text = f'📅 {period_label}' elif price_info.has_discount: - exclamation = "!" if add_exclamation else "" + exclamation = '!' if add_exclamation else '' button_text = ( - f"📅 {period_label} - " - f"{format_price_func(price_info.base_price)} ➜ " - f"{format_price_func(price_info.final_price)} " - f"(-{price_info.discount_percent}%){exclamation}" + f'📅 {period_label} - ' + f'{format_price_func(price_info.base_price)} ➜ ' + f'{format_price_func(price_info.final_price)} ' + f'(-{price_info.discount_percent}%){exclamation}' ) else: - button_text = f"📅 {period_label} - {format_price_func(price_info.final_price)}" + button_text = f'📅 {period_label} - {format_price_func(price_info.final_price)}' # Add emphasis for best deals if emphasize: - button_text = f"🔥 {button_text} 🔥" + button_text = f'🔥 {button_text} 🔥' - logger.debug(f"Formatted button: {button_text}") + logger.debug(f'Formatted button: {button_text}') return button_text -def format_price_text( - period_label: str, - price_info: PriceInfo, - format_price_func -) -> str: +def format_price_text(period_label: str, price_info: PriceInfo, format_price_func) -> str: """ Format a price for message text (not button) with unified discount display. @@ -178,17 +161,16 @@ def format_price_text( Without discount: "📅 30 дней - 990₽" - + With zero price: "📅 30 дней" """ if price_info.final_price == 0: - return f"📅 {period_label}" - elif price_info.has_discount: + return f'📅 {period_label}' + if price_info.has_discount: return ( - f"📅 {period_label} - " - f"{format_price_func(price_info.base_price)} ➜ " - f"{format_price_func(price_info.final_price)}" + f'📅 {period_label} - ' + f'{format_price_func(price_info.base_price)} ➜ ' + f'{format_price_func(price_info.final_price)}' ) - else: - return f"📅 {period_label} - {format_price_func(price_info.final_price)}" + return f'📅 {period_label} - {format_price_func(price_info.final_price)}' diff --git a/app/utils/pricing_utils.py b/app/utils/pricing_utils.py index a27d62fb..5a7476af 100644 --- a/app/utils/pricing_utils.py +++ b/app/utils/pricing_utils.py @@ -1,13 +1,15 @@ -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING import logging +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, Optional from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings + if TYPE_CHECKING: # pragma: no cover - from app.database.models import User, PromoGroup + from app.database.models import PromoGroup, User logger = logging.getLogger(__name__) @@ -19,37 +21,35 @@ def calculate_months_from_days(days: int) -> int: def get_remaining_months(end_date: datetime) -> int: current_time = datetime.utcnow() if end_date <= current_time: - return 1 - + return 1 + remaining_days = (end_date - current_time).days return max(1, round(remaining_days / 30)) -def calculate_period_multiplier(period_days: int) -> Tuple[int, float]: +def calculate_period_multiplier(period_days: int) -> tuple[int, float]: exact_months = period_days / 30 months_count = max(1, round(exact_months)) - - logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета") - + + logger.debug(f'Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета') + return months_count, exact_months -def calculate_prorated_price( - monthly_price: int, - end_date: datetime, - min_charge_months: int = 1 -) -> Tuple[int, int]: +def calculate_prorated_price(monthly_price: int, end_date: datetime, min_charge_months: int = 1) -> tuple[int, int]: months_remaining = get_remaining_months(end_date) months_to_charge = max(min_charge_months, months_remaining) - + total_price = monthly_price * months_to_charge - - logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}₽") - + + logger.debug( + f'Расчет пропорциональной цены: {monthly_price / 100}₽/мес × {months_to_charge} мес = {total_price / 100}₽' + ) + return total_price, months_to_charge -def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: +def apply_percentage_discount(amount: int, percent: int) -> tuple[int, int]: if amount <= 0 or percent <= 0: return amount, 0 @@ -65,7 +65,7 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: discount_value = amount - discounted_amount logger.debug( - "Применена скидка %s%%: %s → %s (скидка %s)", + 'Применена скидка %s%%: %s → %s (скидка %s)', clamped_percent, amount, discounted_amount, @@ -76,11 +76,11 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]: def resolve_discount_percent( - user: Optional["User"], - promo_group: Optional["PromoGroup"], + user: Optional['User'], + promo_group: Optional['PromoGroup'], category: str, *, - period_days: Optional[int] = None, + period_days: int | None = None, ) -> int: """Определяет размер скидки для указанной категории.""" @@ -98,18 +98,18 @@ def resolve_discount_percent( async def compute_simple_subscription_price( db: AsyncSession, - params: Dict[str, Any], + params: dict[str, Any], *, - user: Optional["User"] = None, - resolved_squad_uuids: Optional[Sequence[str]] = None, -) -> Tuple[int, Dict[str, Any]]: + user: Optional['User'] = None, + resolved_squad_uuids: Sequence[str] | None = None, +) -> tuple[int, dict[str, Any]]: """Вычисляет стоимость простой подписки с учетом всех доплат и скидок.""" - period_days = int(params.get("period_days", 30) or 30) - attr_name = f"PRICE_{period_days}_DAYS" + period_days = int(params.get('period_days', 30) or 30) + attr_name = f'PRICE_{period_days}_DAYS' base_price_original = getattr(settings, attr_name, settings.BASE_SUBSCRIPTION_PRICE) - traffic_limit_raw = params.get("traffic_limit_gb") + traffic_limit_raw = params.get('traffic_limit_gb') try: traffic_limit = int(traffic_limit_raw) if traffic_limit_raw is not None else None except (TypeError, ValueError): # pragma: no cover - defensive conversion @@ -121,7 +121,7 @@ async def compute_simple_subscription_price( else: traffic_price_original = settings.get_traffic_price(traffic_limit) - device_limit_raw = params.get("device_limit", settings.DEFAULT_DEVICE_LIMIT) + device_limit_raw = params.get('device_limit', settings.DEFAULT_DEVICE_LIMIT) try: device_limit = int(device_limit_raw) except (TypeError, ValueError): # pragma: no cover - defensive conversion @@ -129,10 +129,10 @@ async def compute_simple_subscription_price( additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT) devices_price_original = additional_devices * settings.PRICE_PER_DEVICE - promo_group: Optional["PromoGroup"] = params.get("promo_group") + promo_group: PromoGroup | None = params.get('promo_group') if promo_group is None: - promo_group_id = params.get("promo_group_id") + promo_group_id = params.get('promo_group_id') if promo_group_id: from app.database.crud.promo_group import get_promo_group_by_id @@ -144,42 +144,42 @@ async def compute_simple_subscription_price( period_discount_percent = resolve_discount_percent( user, promo_group, - "period", + 'period', period_days=period_days, ) base_discount = base_price_original * period_discount_percent // 100 - discounted_base = base_price_original - base_discount + base_price_original - base_discount traffic_discount_percent = resolve_discount_percent( user, promo_group, - "traffic", + 'traffic', period_days=period_days, ) traffic_discount = traffic_price_original * traffic_discount_percent // 100 - discounted_traffic = traffic_price_original - traffic_discount + traffic_price_original - traffic_discount devices_discount_percent = resolve_discount_percent( user, promo_group, - "devices", + 'devices', period_days=period_days, ) devices_discount = devices_price_original * devices_discount_percent // 100 - discounted_devices = devices_price_original - devices_discount + devices_price_original - devices_discount servers_discount_percent = resolve_discount_percent( user, promo_group, - "servers", + 'servers', period_days=period_days, ) - resolved_uuids: List[str] = [] + resolved_uuids: list[str] = [] if resolved_squad_uuids: resolved_uuids.extend([uuid for uuid in resolved_squad_uuids if uuid]) else: - raw_squad = params.get("squad_uuid") + raw_squad = params.get('squad_uuid') if isinstance(raw_squad, (list, tuple, set)): resolved_uuids.extend([str(uuid) for uuid in raw_squad if uuid]) elif raw_squad: @@ -187,7 +187,7 @@ async def compute_simple_subscription_price( from app.database.crud.server_squad import get_server_squad_by_uuid - server_breakdown: List[Dict[str, Any]] = [] + server_breakdown: list[dict[str, Any]] = [] servers_price_original = 0 servers_discount_total = 0 @@ -195,36 +195,36 @@ async def compute_simple_subscription_price( server = await get_server_squad_by_uuid(db, squad_uuid) if not server: logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s", + 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_NOT_FOUND | squad=%s', squad_uuid, ) server_breakdown.append( { - "uuid": squad_uuid, - "name": None, - "available": False, - "original_price": 0, - "discount": 0, - "final_price": 0, + 'uuid': squad_uuid, + 'name': None, + 'available': False, + 'original_price': 0, + 'discount': 0, + 'final_price': 0, } ) continue if not server.is_available or server.is_full: logger.warning( - "SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s", + 'SIMPLE_SUBSCRIPTION_PRICE_SERVER_UNAVAILABLE | squad=%s | available=%s | full=%s', squad_uuid, server.is_available, server.is_full, ) server_breakdown.append( { - "uuid": squad_uuid, - "name": server.display_name, - "available": False, - "original_price": 0, - "discount": 0, - "final_price": 0, + 'uuid': squad_uuid, + 'name': server.display_name, + 'available': False, + 'original_price': 0, + 'discount': 0, + 'final_price': 0, } ) continue @@ -238,104 +238,93 @@ async def compute_simple_subscription_price( server_breakdown.append( { - "uuid": squad_uuid, - "name": server.display_name, - "available": True, - "original_price": original_price, - "discount": discount_value, - "final_price": final_price, + 'uuid': squad_uuid, + 'name': server.display_name, + 'available': True, + 'original_price': original_price, + 'discount': discount_value, + 'final_price': final_price, } ) total_before_discount = ( - base_price_original - + traffic_price_original - + devices_price_original - + servers_price_original + base_price_original + traffic_price_original + devices_price_original + servers_price_original ) - total_discount = ( - base_discount - + traffic_discount - + devices_discount - + servers_discount_total - ) + total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total total_price = max(0, total_before_discount - total_discount) breakdown = { - "base_price": base_price_original, - "base_discount": base_discount, - "traffic_price": traffic_price_original, - "traffic_discount": traffic_discount, - "devices_price": devices_price_original, - "devices_discount": devices_discount, - "servers_price": servers_price_original, - "servers_discount": servers_discount_total, - "servers_final": sum(item["final_price"] for item in server_breakdown), - "server_details": server_breakdown, - "total_before_discount": total_before_discount, - "total_discount": total_discount, - "resolved_squad_uuids": resolved_uuids, - "applied_promo_group_id": getattr(promo_group, "id", None) if promo_group else None, - "period_discount_percent": period_discount_percent, - "traffic_discount_percent": traffic_discount_percent, - "devices_discount_percent": devices_discount_percent, - "servers_discount_percent": servers_discount_percent, + 'base_price': base_price_original, + 'base_discount': base_discount, + 'traffic_price': traffic_price_original, + 'traffic_discount': traffic_discount, + 'devices_price': devices_price_original, + 'devices_discount': devices_discount, + 'servers_price': servers_price_original, + 'servers_discount': servers_discount_total, + 'servers_final': sum(item['final_price'] for item in server_breakdown), + 'server_details': server_breakdown, + 'total_before_discount': total_before_discount, + 'total_discount': total_discount, + 'resolved_squad_uuids': resolved_uuids, + 'applied_promo_group_id': getattr(promo_group, 'id', None) if promo_group else None, + 'period_discount_percent': period_discount_percent, + 'traffic_discount_percent': traffic_discount_percent, + 'devices_discount_percent': devices_discount_percent, + 'servers_discount_percent': servers_discount_percent, } return total_price, breakdown -def format_period_description(days: int, language: str = "ru") -> str: +def format_period_description(days: int, language: str = 'ru') -> str: months = calculate_months_from_days(days) - - if language == "ru": + + if language == 'ru': if days == 14: - return "14 дней" + return '14 дней' if days == 30: - return "1 месяц" - elif days == 60: - return "2 месяца" - elif days == 90: - return "3 месяца" - elif days == 180: - return "6 месяцев" - elif days == 360: - return "12 месяцев" - else: - month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев") - return f"{days} дней ({months} {month_word})" - else: - if days == 14: - return "14 days" - month_word = "month" if months == 1 else "months" - return f"{days} days ({months} {month_word})" + return '1 месяц' + if days == 60: + return '2 месяца' + if days == 90: + return '3 месяца' + if days == 180: + return '6 месяцев' + if days == 360: + return '12 месяцев' + month_word = 'месяц' if months == 1 else ('месяца' if 2 <= months <= 4 else 'месяцев') + return f'{days} дней ({months} {month_word})' + if days == 14: + return '14 days' + month_word = 'month' if months == 1 else 'months' + return f'{days} days ({months} {month_word})' -def validate_pricing_calculation( - base_price: int, - monthly_additions: int, - months: int, - total_calculated: int -) -> bool: +def validate_pricing_calculation(base_price: int, monthly_additions: int, months: int, total_calculated: int) -> bool: expected_total = base_price + (monthly_additions * months) is_valid = expected_total == total_calculated - + if not is_valid: - logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}₽") - logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}₽ × {months} мес") - + logger.warning( + f'Несоответствие в расчете цены: ожидалось {expected_total / 100}₽, получено {total_calculated / 100}₽' + ) + logger.warning( + f'Детали: базовая цена {base_price / 100}₽ + месячные дополнения {monthly_additions / 100}₽ × {months} мес' + ) + return is_valid STANDARD_PERIODS = { - 14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"}, - 30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"}, - 60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"}, - 90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"}, - 180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"}, - 360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"}, + 14: {'months': 0.5, 'display_ru': '2 недели', 'display_en': '2 weeks'}, + 30: {'months': 1, 'display_ru': '1 месяц', 'display_en': '1 month'}, + 60: {'months': 2, 'display_ru': '2 месяца', 'display_en': '2 months'}, + 90: {'months': 3, 'display_ru': '3 месяца', 'display_en': '3 months'}, + 180: {'months': 6, 'display_ru': '6 месяцев', 'display_en': '6 months'}, + 360: {'months': 12, 'display_ru': '1 год', 'display_en': '1 year'}, } diff --git a/app/utils/promo_offer.py b/app/utils/promo_offer.py index 98c3cf23..81fac66d 100644 --- a/app/utils/promo_offer.py +++ b/app/utils/promo_offer.py @@ -2,8 +2,8 @@ from __future__ import annotations import html import math +from collections.abc import Sequence from datetime import datetime -from typing import Optional, Sequence from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,19 +16,19 @@ from app.database.models import ServerSquad, SubscriptionTemporaryAccess, User def _escape_format_braces(text: str) -> str: """Escape braces so str.format treats them as literals.""" - return text.replace("{", "{{").replace("}", "}}") + return text.replace('{', '{{').replace('}', '}}') -def get_user_active_promo_discount_percent(user: Optional[User]) -> int: +def get_user_active_promo_discount_percent(user: User | None) -> int: if not user: return 0 try: - percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): return 0 - expires_at = getattr(user, "promo_offer_discount_expires_at", None) + expires_at = getattr(user, 'promo_offer_discount_expires_at', None) if expires_at and expires_at <= datetime.utcnow(): return 0 @@ -40,19 +40,19 @@ def _format_time_left(seconds_left: int, language: str) -> str: days, remainder_minutes = divmod(total_minutes, 60 * 24) hours, minutes = divmod(remainder_minutes, 60) - language_code = (language or "ru").split("-")[0].lower() - if language_code == "en": - day_label, hour_label, minute_label = "d", "h", "m" + language_code = (language or 'ru').split('-')[0].lower() + if language_code == 'en': + day_label, hour_label, minute_label = 'd', 'h', 'm' else: - day_label, hour_label, minute_label = "д", "ч", "м" + day_label, hour_label, minute_label = 'д', 'ч', 'м' parts: list[str] = [] if days: - parts.append(f"{days}{day_label}") + parts.append(f'{days}{day_label}') if hours or days: - parts.append(f"{hours}{hour_label}") - parts.append(f"{minutes}{minute_label}") - return " ".join(parts) + parts.append(f'{hours}{hour_label}') + parts.append(f'{minutes}{minute_label}') + return ' '.join(parts) def _build_progress_bar(seconds_left: int, total_seconds: int) -> str: @@ -66,15 +66,15 @@ def _build_progress_bar(seconds_left: int, total_seconds: int) -> str: if filled_segments == 0 and seconds_left > 0: filled_segments = 1 - return f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]" + return f'[{"█" * filled_segments}{"░" * (bar_length - filled_segments)}]' async def build_promo_offer_timer_line( db: AsyncSession, user: User, texts, -) -> Optional[str]: - expires_at = getattr(user, "promo_offer_discount_expires_at", None) +) -> str | None: + expires_at = getattr(user, 'promo_offer_discount_expires_at', None) if not expires_at: return None @@ -86,26 +86,23 @@ async def build_promo_offer_timer_line( if seconds_left <= 0: return None - total_seconds: Optional[int] = None - source = getattr(user, "promo_offer_discount_source", None) + total_seconds: int | None = None + source = getattr(user, 'promo_offer_discount_source', None) try: offer = await get_latest_claimed_offer_for_user(db, user.id, source) except Exception: offer = None - if offer and getattr(offer, "claimed_at", None): + if offer and getattr(offer, 'claimed_at', None): total_seconds = int((expires_at - offer.claimed_at).total_seconds()) if total_seconds <= 0: total_seconds = None if total_seconds is None and offer: - extra_data = getattr(offer, "extra_data", None) + extra_data = getattr(offer, 'extra_data', None) if isinstance(extra_data, dict): - raw_duration = ( - extra_data.get("active_discount_hours") - or extra_data.get("duration_hours") - ) + raw_duration = extra_data.get('active_discount_hours') or extra_data.get('duration_hours') else: raw_duration = None try: @@ -118,11 +115,11 @@ async def build_promo_offer_timer_line( total_seconds = seconds_left bar = _build_progress_bar(seconds_left, total_seconds) - time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru")) + time_left_text = _format_time_left(seconds_left, getattr(texts, 'language', 'ru')) template = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_TIMER", - "⏳ Discount active for {time_left}\n{bar}", + 'SUBSCRIPTION_PROMO_DISCOUNT_TIMER', + '⏳ Discount active for {time_left}\n{bar}', ) return template.format(bar=bar, time_left=time_left_text) @@ -131,8 +128,8 @@ async def build_promo_offer_hint( db: AsyncSession, user: User, texts, - percent: Optional[int] = None, -) -> Optional[str]: + percent: int | None = None, +) -> str | None: if percent is None: percent = get_user_active_promo_discount_percent(user) @@ -140,13 +137,13 @@ async def build_promo_offer_hint( return None base_hint = texts.t( - "SUBSCRIPTION_PROMO_DISCOUNT_HINT", - "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.", + 'SUBSCRIPTION_PROMO_DISCOUNT_HINT', + '⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.', ).format(percent=percent) timer_line = await build_promo_offer_timer_line(db, user, texts) if timer_line: - return f"{base_hint}\n{timer_line}" + return f'{base_hint}\n{timer_line}' return base_hint @@ -155,12 +152,12 @@ async def build_test_access_hint( db: AsyncSession, user: User, texts, -) -> Optional[str]: - subscription = getattr(user, "subscription", None) +) -> str | None: + subscription = getattr(user, 'subscription', None) if not subscription: return None - subscription_id = getattr(subscription, "id", None) + subscription_id = getattr(subscription, 'id', None) if not subscription_id: return None @@ -171,16 +168,14 @@ async def build_test_access_hint( .options(selectinload(SubscriptionTemporaryAccess.offer)) .where( SubscriptionTemporaryAccess.subscription_id == subscription_id, - SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.is_active == True, SubscriptionTemporaryAccess.expires_at > now, ) .order_by(SubscriptionTemporaryAccess.expires_at.desc()) ) entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all() - active_entries = [ - entry for entry in entries if entry.expires_at and entry.expires_at > now - ] + active_entries = [entry for entry in entries if entry.expires_at and entry.expires_at > now] if not active_entries: return None @@ -189,10 +184,10 @@ async def build_test_access_hint( if seconds_left <= 0: return None - total_seconds: Optional[int] = None + total_seconds: int | None = None for entry in active_entries: offer = entry.offer - claimed_at = getattr(offer, "claimed_at", None) if offer else None + claimed_at = getattr(offer, 'claimed_at', None) if offer else None if claimed_at: total = int((entry.expires_at - claimed_at).total_seconds()) if total > 0 and (total_seconds is None or total > total_seconds): @@ -202,12 +197,12 @@ async def build_test_access_hint( total_seconds = seconds_left bar = _build_progress_bar(seconds_left, total_seconds) - time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru")) + time_left_text = _format_time_left(seconds_left, getattr(texts, 'language', 'ru')) unique_squad_uuids: list[str] = [] seen_squads: set[str] = set() for entry in active_entries: - squad_uuid = getattr(entry, "squad_uuid", None) + squad_uuid = getattr(entry, 'squad_uuid', None) if squad_uuid and squad_uuid not in seen_squads: seen_squads.add(squad_uuid) unique_squad_uuids.append(squad_uuid) @@ -220,9 +215,7 @@ async def build_test_access_hint( ) ) names_map = { - squad_uuid: html.escape(display_name) - for squad_uuid, display_name in squads_result.all() - if display_name + squad_uuid: html.escape(display_name) for squad_uuid, display_name in squads_result.all() if display_name } for squad_uuid in unique_squad_uuids: if squad_uuid in names_map: @@ -231,26 +224,22 @@ async def build_test_access_hint( squad_display_names.append(html.escape(squad_uuid)) if squad_display_names: - servers_display = ", ".join(squad_display_names) + servers_display = ', '.join(squad_display_names) elif unique_squad_uuids: - servers_display = ", ".join( - html.escape(squad_uuid) for squad_uuid in unique_squad_uuids - ) + servers_display = ', '.join(html.escape(squad_uuid) for squad_uuid in unique_squad_uuids) else: servers_display = str(len(active_entries)) header_template = texts.t( - "MAIN_MENU_TEST_ACCESS_HEADER", - "🧪 Test servers active: {servers}", + 'MAIN_MENU_TEST_ACCESS_HEADER', + '🧪 Test servers active: {servers}', ) timer_template = texts.t( - "MAIN_MENU_TEST_ACCESS_TIMER", - "⏳ Access active for {time_left}\n{bar}", + 'MAIN_MENU_TEST_ACCESS_TIMER', + '⏳ Access active for {time_left}\n{bar}', ) - header = header_template.format( - servers=_escape_format_braces(servers_display) - ) + header = header_template.format(servers=_escape_format_braces(servers_display)) timer_line = timer_template.format(time_left=time_left_text, bar=bar) - return f"{header}\n{timer_line}" + return f'{header}\n{timer_line}' diff --git a/app/utils/security.py b/app/utils/security.py index a2fe469d..37e0946e 100644 --- a/app/utils/security.py +++ b/app/utils/security.py @@ -1,4 +1,5 @@ """Утилиты безопасности и генерации ключей.""" + from __future__ import annotations import hashlib @@ -6,17 +7,17 @@ import secrets from typing import Literal -HashAlgorithm = Literal["sha256", "sha384", "sha512"] +HashAlgorithm = Literal['sha256', 'sha384', 'sha512'] -def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str: +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}") + 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() + return digest(token.encode('utf-8')).hexdigest() def generate_api_token(length: int = 48) -> str: @@ -25,4 +26,4 @@ def generate_api_token(length: int = 48) -> str: return secrets.token_urlsafe(length) -__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"] +__all__ = ['HashAlgorithm', 'generate_api_token', 'hash_api_token'] diff --git a/app/utils/startup_timeline.py b/app/utils/startup_timeline.py index 3d37215a..8cb9191e 100644 --- a/app/utils/startup_timeline.py +++ b/app/utils/startup_timeline.py @@ -1,8 +1,9 @@ import platform import time +from collections.abc import Iterable, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Any, Iterable, List, Optional, Sequence, Tuple +from typing import Any @dataclass @@ -17,57 +18,55 @@ class StepRecord: class StageHandle: def __init__( self, - timeline: "StartupTimeline", + timeline: 'StartupTimeline', title: str, icon: str, - success_message: Optional[str], + success_message: str | None, ) -> None: self.timeline = timeline self.title = title self.icon = icon - self.message = success_message or "" - self.status_icon = "✅" - self.status_label = "Готово" + self.message = success_message or '' + self.status_icon = '✅' + self.status_label = 'Готово' self._explicit_status = False - def success(self, message: Optional[str] = None) -> None: + def success(self, message: str | None = None) -> None: if message is not None: self.message = message - self.status_icon = "✅" - self.status_label = "Готово" + self.status_icon = '✅' + self.status_label = 'Готово' self._explicit_status = True def warning(self, message: str) -> None: - self.status_icon = "⚠️" - self.status_label = "Предупреждение" + self.status_icon = '⚠️' + self.status_label = 'Предупреждение' self.message = message self._explicit_status = True def skip(self, message: str) -> None: - self.status_icon = "⏭️" - self.status_label = "Пропущено" + self.status_icon = '⏭️' + self.status_label = 'Пропущено' self.message = message self._explicit_status = True def failure(self, message: str) -> None: - self.status_icon = "❌" - self.status_label = "Ошибка" + self.status_icon = '❌' + self.status_label = 'Ошибка' self.message = message self._explicit_status = True - def log(self, message: str, icon: str = "•") -> None: - self.timeline.logger.info(f"┃ {icon} {message}") + def log(self, message: str, icon: str = '•') -> None: + self.timeline.logger.info(f'┃ {icon} {message}') class StartupTimeline: def __init__(self, logger: Any, app_name: str) -> None: self.logger = logger self.app_name = app_name - self.steps: List[StepRecord] = [] + self.steps: list[StepRecord] = [] - def _record_step( - self, title: str, icon: str, status_label: str, message: str, duration: float - ) -> None: + def _record_step(self, title: str, icon: str, status_label: str, message: str, duration: float) -> None: self.steps.append( StepRecord( title=title, @@ -78,33 +77,33 @@ class StartupTimeline: ) ) - def log_banner(self, metadata: Optional[Sequence[Tuple[str, Any]]] = None) -> None: - title_text = f"🚀 {self.app_name}" - subtitle_parts = [f"Python {platform.python_version()}"] + def log_banner(self, metadata: Sequence[tuple[str, Any]] | None = None) -> None: + title_text = f'🚀 {self.app_name}' + subtitle_parts = [f'Python {platform.python_version()}'] if metadata: for key, value in metadata: - subtitle_parts.append(f"{key}: {value}") - subtitle_text = " | ".join(subtitle_parts) + subtitle_parts.append(f'{key}: {value}') + subtitle_text = ' | '.join(subtitle_parts) width = max(len(title_text), len(subtitle_text)) - border = "╔" + "═" * (width + 2) + "╗" + border = '╔' + '═' * (width + 2) + '╗' self.logger.info(border) - self.logger.info("║ " + title_text.ljust(width) + " ║") - self.logger.info("║ " + subtitle_text.ljust(width) + " ║") - self.logger.info("╚" + "═" * (width + 2) + "╝") + self.logger.info('║ ' + title_text.ljust(width) + ' ║') + self.logger.info('║ ' + subtitle_text.ljust(width) + ' ║') + self.logger.info('╚' + '═' * (width + 2) + '╝') - def log_section(self, title: str, lines: Iterable[str], icon: str = "📄") -> None: - items = [f"{icon} {title}"] + [f"• {line}" for line in lines] + def log_section(self, title: str, lines: Iterable[str], icon: str = '📄') -> None: + items = [f'{icon} {title}'] + [f'• {line}' for line in lines] width = max(len(item) for item in items) - top = "┌ " + "─" * width + " ┐" - middle = "├ " + "─" * width + " ┤" - bottom = "└ " + "─" * width + " ┘" + top = '┌ ' + '─' * width + ' ┐' + middle = '├ ' + '─' * width + ' ┤' + bottom = '└ ' + '─' * width + ' ┘' self.logger.info(top) - self.logger.info("│ " + items[0].ljust(width) + " │") + self.logger.info('│ ' + items[0].ljust(width) + ' │') self.logger.info(middle) for item in items[1:]: - self.logger.info("│ " + item.ljust(width) + " │") + self.logger.info('│ ' + item.ljust(width) + ' │') self.logger.info(bottom) def add_manual_step( @@ -114,22 +113,22 @@ class StartupTimeline: status_label: str, message: str, ) -> None: - self.logger.info(f"┏ {icon} {title}") - self.logger.info(f"┗ {icon} {title} — {status_label}: {message}") + self.logger.info(f'┏ {icon} {title}') + self.logger.info(f'┗ {icon} {title} — {status_label}: {message}') self._record_step(title, icon, status_label, message, 0.0) @asynccontextmanager async def stage( self, title: str, - icon: str = "⚙️", - description: Optional[str] = None, - success_message: Optional[str] = "Готово", + icon: str = '⚙️', + description: str | None = None, + success_message: str | None = 'Готово', ): if description: - self.logger.info(f"┏ {icon} {title} — {description}") + self.logger.info(f'┏ {icon} {title} — {description}') else: - self.logger.info(f"┏ {icon} {title}") + self.logger.info(f'┏ {icon} {title}') handle = StageHandle(self, title, icon, success_message) start_time = time.perf_counter() @@ -138,15 +137,13 @@ class StartupTimeline: except Exception as exc: message = str(exc) handle.failure(message) - self.logger.exception(f"┣ ❌ {title} — ошибка: {message}") + self.logger.exception(f'┣ ❌ {title} — ошибка: {message}') raise finally: duration = time.perf_counter() - start_time if not handle._explicit_status: - handle.success(handle.message or "Готово") - self.logger.info( - f"┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]" - ) + handle.success(handle.message or 'Готово') + self.logger.info(f'┗ {handle.status_icon} {title} — {handle.message} [{duration:.2f}s]') self._record_step( title=title, icon=handle.status_icon, @@ -161,24 +158,20 @@ class StartupTimeline: lines = [] for step in self.steps: - base = ( - f"{step.icon} {step.title} — {step.status_label}" - f" [{step.duration:.2f}s]" - ) + base = f'{step.icon} {step.title} — {step.status_label} [{step.duration:.2f}s]' if step.message: - base += f" :: {step.message}" + base += f' :: {step.message}' lines.append(base) width = max(len(line) for line in lines) - border_top = "┏" + "━" * (width + 2) + "┓" - border_mid = "┣" + "━" * (width + 2) + "┫" - border_bottom = "┗" + "━" * (width + 2) + "┛" - title = "РЕЗЮМЕ ЗАПУСКА" + border_top = '┏' + '━' * (width + 2) + '┓' + border_mid = '┣' + '━' * (width + 2) + '┫' + border_bottom = '┗' + '━' * (width + 2) + '┛' + title = 'РЕЗЮМЕ ЗАПУСКА' self.logger.info(border_top) - self.logger.info("┃ " + title.center(width) + " ┃") + self.logger.info('┃ ' + title.center(width) + ' ┃') self.logger.info(border_mid) for line in lines: - self.logger.info("┃ " + line.ljust(width) + " ┃") + self.logger.info('┃ ' + line.ljust(width) + ' ┃') self.logger.info(border_bottom) - diff --git a/app/utils/subscription_utils.py b/app/utils/subscription_utils.py index 55f8ab8c..b679c960 100644 --- a/app/utils/subscription_utils.py +++ b/app/utils/subscription_utils.py @@ -1,49 +1,47 @@ import logging from datetime import datetime -from typing import Optional from urllib.parse import quote, urlparse, urlunparse -from sqlalchemy import select, delete, func + +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Subscription, User + from app.config import settings +from app.database.models import Subscription + logger = logging.getLogger(__name__) -async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]: +async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Subscription | None: result = await db.execute( - select(Subscription) - .where(Subscription.user_id == user_id) - .order_by(Subscription.created_at.desc()) + select(Subscription).where(Subscription.user_id == user_id).order_by(Subscription.created_at.desc()) ) subscriptions = result.scalars().all() - + if len(subscriptions) <= 1: return subscriptions[0] if subscriptions else None - + latest_subscription = subscriptions[0] old_subscriptions = subscriptions[1:] - - logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.") - + + logger.warning( + f'🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.' + ) + for old_sub in old_subscriptions: await db.delete(old_sub) - logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}") - + logger.info(f'🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}') + await db.commit() await db.refresh(latest_subscription) - - logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}") + + logger.info(f'✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}') return latest_subscription -async def update_or_create_subscription( - db: AsyncSession, - user_id: int, - **subscription_data -) -> Subscription: +async def update_or_create_subscription(db: AsyncSession, user_id: int, **subscription_data) -> Subscription: existing_subscription = await ensure_single_subscription(db, user_id) - + if existing_subscription: for key, value in subscription_data.items(): if hasattr(existing_subscription, key): @@ -53,84 +51,69 @@ async def update_or_create_subscription( await db.commit() await db.refresh(existing_subscription) - logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}") + logger.info(f'🔄 Обновлена существующая подписка ID {existing_subscription.id}') return existing_subscription - else: - subscription_defaults = dict(subscription_data) - autopay_enabled = subscription_defaults.pop( - "autopay_enabled", None - ) - autopay_days_before = subscription_defaults.pop( - "autopay_days_before", None - ) + subscription_defaults = dict(subscription_data) + autopay_enabled = subscription_defaults.pop('autopay_enabled', None) + autopay_days_before = subscription_defaults.pop('autopay_days_before', None) - new_subscription = Subscription( - user_id=user_id, - autopay_enabled=( - settings.is_autopay_enabled_by_default() - if autopay_enabled is None - else autopay_enabled - ), - autopay_days_before=( - settings.DEFAULT_AUTOPAY_DAYS_BEFORE - if autopay_days_before is None - else autopay_days_before - ), - **subscription_defaults - ) - - db.add(new_subscription) - await db.commit() - await db.refresh(new_subscription) - - logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}") - return new_subscription + new_subscription = Subscription( + user_id=user_id, + autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled), + autopay_days_before=( + settings.DEFAULT_AUTOPAY_DAYS_BEFORE if autopay_days_before is None else autopay_days_before + ), + **subscription_defaults, + ) + + db.add(new_subscription) + await db.commit() + await db.refresh(new_subscription) + + logger.info(f'🆕 Создана новая подписка ID {new_subscription.id}') + return new_subscription async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int: result = await db.execute( - select(Subscription.user_id) - .group_by(Subscription.user_id) - .having(func.count(Subscription.id) > 1) + select(Subscription.user_id).group_by(Subscription.user_id).having(func.count(Subscription.id) > 1) ) users_with_duplicates = result.scalars().all() - + total_deleted = 0 - + for user_id in users_with_duplicates: subscriptions_result = await db.execute( - select(Subscription) - .where(Subscription.user_id == user_id) - .order_by(Subscription.created_at.desc()) + select(Subscription).where(Subscription.user_id == user_id).order_by(Subscription.created_at.desc()) ) subscriptions = subscriptions_result.scalars().all() - + for old_subscription in subscriptions[1:]: await db.delete(old_subscription) total_deleted += 1 - logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}") - + logger.info(f'🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}') + await db.commit() - logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок") + logger.info(f'🧹 Очищено {total_deleted} дублирующихся подписок') return total_deleted -def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]: +def get_display_subscription_link(subscription: Subscription | None) -> str | None: if not subscription: return None - base_link = getattr(subscription, "subscription_url", None) + base_link = getattr(subscription, 'subscription_url', None) if settings.is_happ_cryptolink_mode(): - crypto_link = getattr(subscription, "subscription_crypto_link", None) + 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]: +def get_happ_cryptolink_redirect_link(subscription_link: str | None) -> str | None: if not subscription_link: return None @@ -138,12 +121,12 @@ def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optio if not template: return None - encoded_link = quote(subscription_link, safe="") + encoded_link = quote(subscription_link, safe='') replacements = { - "{subscription_link}": encoded_link, - "{link}": encoded_link, - "{subscription_link_raw}": subscription_link, - "{link_raw}": subscription_link, + '{subscription_link}': encoded_link, + '{link}': encoded_link, + '{subscription_link_raw}': subscription_link, + '{link_raw}': subscription_link, } replaced = False @@ -155,28 +138,28 @@ def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optio if replaced: return template - if template.endswith(("=", "?", "&")): - return f"{template}{encoded_link}" + if template.endswith(('=', '?', '&')): + return f'{template}{encoded_link}' - return f"{template}{encoded_link}" + return f'{template}{encoded_link}' -def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]: +def convert_subscription_link_to_happ_scheme(subscription_link: str | None) -> str | None: if not subscription_link: return None parsed_link = urlparse(subscription_link) - if parsed_link.scheme.lower() == "happ": + if parsed_link.scheme.lower() == 'happ': return subscription_link if not parsed_link.scheme: return subscription_link - return urlunparse(parsed_link._replace(scheme="happ")) + return urlunparse(parsed_link._replace(scheme='happ')) -def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[int]: +def resolve_hwid_device_limit(subscription: Subscription | None) -> int | None: """Return a device limit value for RemnaWave payloads when selection is enabled.""" if subscription is None: @@ -189,7 +172,7 @@ def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[ # Если forced_limit не задан, используем device_limit из подписки # чтобы при смене тарифа лимит устройств обновлялся в панели - limit = getattr(subscription, "device_limit", None) + limit = getattr(subscription, 'device_limit', None) if limit is None or limit <= 0: return None @@ -197,8 +180,8 @@ def resolve_hwid_device_limit(subscription: Optional[Subscription]) -> Optional[ def resolve_hwid_device_limit_for_payload( - subscription: Optional[Subscription], -) -> Optional[int]: + subscription: Subscription | None, +) -> int | None: """Return the device limit that should be sent to RemnaWave APIs. When device selection is disabled and no explicit override is configured, @@ -214,7 +197,7 @@ def resolve_hwid_device_limit_for_payload( if subscription is None: return None - fallback_limit = getattr(subscription, "device_limit", None) + fallback_limit = getattr(subscription, 'device_limit', None) if fallback_limit is None or fallback_limit <= 0: return None @@ -225,10 +208,10 @@ def resolve_simple_subscription_device_limit() -> int: """Return the effective device limit for simple subscription flows.""" if settings.is_devices_selection_enabled(): - return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0) + return int(getattr(settings, 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT', 0) or 0) forced_limit = settings.get_disabled_mode_device_limit() if forced_limit is not None: return forced_limit - return int(getattr(settings, "SIMPLE_SUBSCRIPTION_DEVICE_LIMIT", 0) or 0) + return int(getattr(settings, 'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT', 0) or 0) diff --git a/app/utils/telegram_webapp.py b/app/utils/telegram_webapp.py index 43d74d5c..62c55660 100644 --- a/app/utils/telegram_webapp.py +++ b/app/utils/telegram_webapp.py @@ -6,7 +6,7 @@ import hashlib import hmac import json import time -from typing import Any, Dict +from typing import Any from urllib.parse import parse_qsl @@ -19,7 +19,7 @@ def parse_webapp_init_data( bot_token: str, *, max_age_seconds: int = 86400, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Validate and parse Telegram WebApp init data. Args: @@ -35,57 +35,54 @@ def parse_webapp_init_data( """ if not init_data: - raise TelegramWebAppAuthError("Missing init data") + raise TelegramWebAppAuthError('Missing init data') if not bot_token: - raise TelegramWebAppAuthError("Bot token is not configured") + raise TelegramWebAppAuthError('Bot token is not configured') parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True) - data: Dict[str, Any] = {key: value for key, value in parsed_pairs} + data: dict[str, Any] = {key: value for key, value in parsed_pairs} - received_hash = data.pop("hash", None) + received_hash = data.pop('hash', None) if not received_hash: - raise TelegramWebAppAuthError("Missing init data signature") + raise TelegramWebAppAuthError('Missing init data signature') - data_check_string = "\n".join( - f"{key}={value}" for key, value in sorted(data.items()) - ) + data_check_string = '\n'.join(f'{key}={value}' for key, value in sorted(data.items())) secret_key = hmac.new( - key=b"WebAppData", - msg=bot_token.encode("utf-8"), + key=b'WebAppData', + msg=bot_token.encode('utf-8'), digestmod=hashlib.sha256, ).digest() computed_hash = hmac.new( key=secret_key, - msg=data_check_string.encode("utf-8"), + msg=data_check_string.encode('utf-8'), digestmod=hashlib.sha256, ).hexdigest() if not hmac.compare_digest(computed_hash, received_hash): - raise TelegramWebAppAuthError("Invalid init data signature") + raise TelegramWebAppAuthError('Invalid init data signature') - auth_date_raw = data.get("auth_date") + auth_date_raw = data.get('auth_date') if auth_date_raw is not None: try: auth_date = int(auth_date_raw) except (TypeError, ValueError): - raise TelegramWebAppAuthError("Invalid auth_date value") from None + raise TelegramWebAppAuthError('Invalid auth_date value') from None if max_age_seconds and auth_date: current_ts = int(time.time()) if current_ts - auth_date > max_age_seconds: - raise TelegramWebAppAuthError("Init data is too old") + raise TelegramWebAppAuthError('Init data is too old') - data["auth_date"] = auth_date + data['auth_date'] = auth_date - user_payload = data.get("user") + user_payload = data.get('user') if user_payload is not None: try: - data["user"] = json.loads(user_payload) + data['user'] = json.loads(user_payload) except json.JSONDecodeError as error: - raise TelegramWebAppAuthError("Invalid user payload") from error + raise TelegramWebAppAuthError('Invalid user payload') from error return data - diff --git a/app/utils/timezone.py b/app/utils/timezone.py index 83af1aa1..715c7522 100644 --- a/app/utils/timezone.py +++ b/app/utils/timezone.py @@ -3,13 +3,13 @@ from __future__ import annotations import logging -from datetime import datetime, timezone as dt_timezone +from datetime import UTC, datetime from functools import lru_cache -from typing import Optional from zoneinfo import ZoneInfo from app.config import settings + logger = logging.getLogger(__name__) @@ -31,23 +31,23 @@ def get_local_timezone() -> ZoneInfo: tz_name, exc, ) - return ZoneInfo("UTC") + return ZoneInfo('UTC') -def to_local_datetime(dt: Optional[datetime]) -> Optional[datetime]: +def to_local_datetime(dt: datetime | None) -> datetime | None: """Convert a datetime value to the configured local timezone.""" if dt is None: return None - aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=dt_timezone.utc) + aware_dt = dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) return aware_dt.astimezone(get_local_timezone()) def format_local_datetime( - dt: Optional[datetime], - fmt: str = "%Y-%m-%d %H:%M:%S %Z", - na_placeholder: str = "N/A", + dt: datetime | None, + fmt: str = '%Y-%m-%d %H:%M:%S %Z', + na_placeholder: str = 'N/A', ) -> str: """Format a datetime value in the configured local timezone.""" @@ -60,7 +60,7 @@ def format_local_datetime( class TimezoneAwareFormatter(logging.Formatter): """Logging formatter that renders timestamps in the configured timezone.""" - def __init__(self, *args, timezone_name: Optional[str] = None, **kwargs): + def __init__(self, *args, timezone_name: str | None = None, **kwargs): super().__init__(*args, **kwargs) if timezone_name: try: @@ -71,12 +71,12 @@ class TimezoneAwareFormatter(logging.Formatter): timezone_name, exc, ) - self._timezone = ZoneInfo("UTC") + self._timezone = ZoneInfo('UTC') else: self._timezone = get_local_timezone() - def formatTime(self, record, datefmt=None): # noqa: N802 - inherited method name + def formatTime(self, record, datefmt=None): dt = datetime.fromtimestamp(record.created, tz=self._timezone) if datefmt: return dt.strftime(datefmt) - return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3] + return dt.strftime('%Y-%m-%d %H:%M:%S,%f')[:-3] diff --git a/app/utils/user_utils.py b/app/utils/user_utils.py index 89de0f3f..5913d046 100644 --- a/app/utils/user_utils.py +++ b/app/utils/user_utils.py @@ -1,15 +1,15 @@ import logging import secrets import string -import logging from datetime import datetime, timedelta -from typing import Optional, Dict, List -from sqlalchemy import select, func, and_, update + +from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.config import settings -from app.database.models import User, ReferralEarning, Transaction, TransactionType +from app.database.models import ReferralEarning, Transaction, TransactionType, User + logger = logging.getLogger(__name__) @@ -17,61 +17,61 @@ logger = logging.getLogger(__name__) def format_referrer_info(user: User) -> str: """Return formatted referrer info for admin notifications.""" - referred_by_id = getattr(user, "referred_by_id", None) + referred_by_id = getattr(user, 'referred_by_id', None) if not referred_by_id: - return "Нет" + return 'Нет' try: # Проверяем, является ли referrer обычным объектом или InstrumentedList - referrer = getattr(user, "referrer", None) - + referrer = getattr(user, 'referrer', None) + # Если referrer это InstrumentedList или None, то возвращаем информацию по ID if referrer is None: - return f"ID {referred_by_id} (не найден)" - + return f'ID {referred_by_id} (не найден)' + # Пытаемся получить атрибуты referrer, если они доступны - referrer_username = getattr(referrer, "username", None) - referrer_telegram_id = getattr(referrer, "telegram_id", None) - + referrer_username = getattr(referrer, 'username', None) + referrer_telegram_id = getattr(referrer, 'telegram_id', None) + if referrer_username: - return f"@{referrer_username} (ID: {referred_by_id})" - - return f"ID {referrer_telegram_id or referred_by_id}" - + return f'@{referrer_username} (ID: {referred_by_id})' + + return f'ID {referrer_telegram_id or referred_by_id}' + except (AttributeError, TypeError): # Если возникла ошибка при обращении к атрибутам, просто возвращаем ID - return f"ID {referred_by_id} (ошибка загрузки)" + return f'ID {referred_by_id} (ошибка загрузки)' async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str: max_attempts = 10 - + for _ in range(max_attempts): - code = f"ref{''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}" - - result = await db.execute( - select(User).where(User.referral_code == code) - ) + code = f'ref{"".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}' + + result = await db.execute(select(User).where(User.referral_code == code)) if not result.scalar_one_or_none(): return code - + timestamp = str(int(datetime.utcnow().timestamp()))[-6:] - return f"ref{timestamp}" + return f'ref{timestamp}' def get_effective_referral_commission_percent(user: User) -> int: """Возвращает индивидуальный процент комиссии пользователя или дефолтное значение.""" - percent = getattr(user, "referral_commission_percent", None) + percent = getattr(user, 'referral_commission_percent', None) if percent is None: percent = settings.REFERRAL_COMMISSION_PERCENT if percent < 0 or percent > 100: - user_id_display = getattr(user, "telegram_id", None) or getattr(user, "email", None) or f"#{getattr(user, 'id', 'unknown')}" + user_id_display = ( + getattr(user, 'telegram_id', None) or getattr(user, 'email', None) or f'#{getattr(user, "id", "unknown")}' + ) logger.error( - "❌ Некорректный процент комиссии для пользователя %s: %s", + '❌ Некорректный процент комиссии для пользователя %s: %s', user_id_display, percent, ) @@ -83,63 +83,49 @@ def get_effective_referral_commission_percent(user: User) -> int: async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool: try: if user.has_had_paid_subscription: - logger.debug(f"Пользователь {user.id} уже отмечен как имевший платную подписку") + logger.debug(f'Пользователь {user.id} уже отмечен как имевший платную подписку') return True - + await db.execute( - update(User) - .where(User.id == user.id) - .values( - has_had_paid_subscription=True, - updated_at=datetime.utcnow() - ) + update(User).where(User.id == user.id).values(has_had_paid_subscription=True, updated_at=datetime.utcnow()) ) - + await db.commit() - logger.info(f"✅ Пользователь {user.id} отмечен как имевший платную подписку") + logger.info(f'✅ Пользователь {user.id} отмечен как имевший платную подписку') return True - + except Exception as e: - logger.error(f"Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}") + logger.error(f'Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}') try: await db.rollback() except Exception as rollback_error: - logger.error(f"Ошибка отката транзакции: {rollback_error}") + logger.error(f'Ошибка отката транзакции: {rollback_error}') return False -async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict: +async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict: try: - invited_count_result = await db.execute( - select(func.count(User.id)).where(User.referred_by_id == user_id) - ) + invited_count_result = await db.execute(select(func.count(User.id)).where(User.referred_by_id == user_id)) invited_count = invited_count_result.scalar() or 0 - - referrals_result = await db.execute( - select(User).where(User.referred_by_id == user_id) - ) + + referrals_result = await db.execute(select(User).where(User.referred_by_id == user_id)) referrals = referrals_result.scalars().all() - + paid_referrals_count = sum(1 for ref in referrals if ref.has_made_first_topup) - + total_earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where(ReferralEarning.user_id == user_id) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.user_id == user_id) ) total_earned_kopeks = total_earnings_result.scalar() or 0 - + month_ago = datetime.utcnow() - timedelta(days=30) month_earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where( - and_( - ReferralEarning.user_id == user_id, - ReferralEarning.created_at >= month_ago - ) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + and_(ReferralEarning.user_id == user_id, ReferralEarning.created_at >= month_ago) ) ) month_earned_kopeks = month_earnings_result.scalar() or 0 - + recent_earnings_result = await db.execute( select(ReferralEarning) .options(selectinload(ReferralEarning.referral)) @@ -148,39 +134,38 @@ async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict: .limit(5) ) recent_earnings_raw = recent_earnings_result.scalars().all() - + recent_earnings = [] for earning in recent_earnings_raw: if earning.referral: - recent_earnings.append({ - 'amount_kopeks': earning.amount_kopeks, - 'reason': earning.reason, - 'referral_name': earning.referral.full_name, - 'created_at': earning.created_at - }) - + recent_earnings.append( + { + 'amount_kopeks': earning.amount_kopeks, + 'reason': earning.reason, + 'referral_name': earning.referral.full_name, + 'created_at': earning.created_at, + } + ) + earnings_by_type = {} earnings_by_type_result = await db.execute( select( ReferralEarning.reason, func.count(ReferralEarning.id).label('count'), - func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount') + func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount'), ) .where(ReferralEarning.user_id == user_id) .group_by(ReferralEarning.reason) ) - + for row in earnings_by_type_result: - earnings_by_type[row.reason] = { - 'count': row.count, - 'total_amount_kopeks': row.total_amount - } - + earnings_by_type[row.reason] = {'count': row.count, 'total_amount_kopeks': row.total_amount} + active_referrals_count = 0 for referral in referrals: if referral.last_activity and referral.last_activity >= month_ago: active_referrals_count += 1 - + return { 'invited_count': invited_count, 'paid_referrals_count': paid_referrals_count, @@ -189,11 +174,11 @@ async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict: 'month_earned_kopeks': month_earned_kopeks, 'recent_earnings': recent_earnings, 'earnings_by_type': earnings_by_type, - 'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1) + 'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1), } - + except Exception as e: - logger.error(f"Ошибка получения статистики рефералов для пользователя {user_id}: {e}") + logger.error(f'Ошибка получения статистики рефералов для пользователя {user_id}: {e}') return { 'invited_count': 0, 'paid_referrals_count': 0, @@ -202,11 +187,11 @@ async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict: 'month_earned_kopeks': 0, 'recent_earnings': [], 'earnings_by_type': {}, - 'conversion_rate': 0.0 + 'conversion_rate': 0.0, } -async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> Dict: +async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> dict: try: referrals_result = await db.execute( select(User) @@ -216,141 +201,121 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int .limit(limit) ) referrals = referrals_result.scalars().all() - - total_count_result = await db.execute( - select(func.count(User.id)).where(User.referred_by_id == user_id) - ) + + total_count_result = await db.execute(select(func.count(User.id)).where(User.referred_by_id == user_id)) total_count = total_count_result.scalar() or 0 - + detailed_referrals = [] for referral in referrals: earnings_result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where( - and_( - ReferralEarning.user_id == user_id, - ReferralEarning.referral_id == referral.id - ) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + and_(ReferralEarning.user_id == user_id, ReferralEarning.referral_id == referral.id) ) ) total_earned_from_referral = earnings_result.scalar() or 0 - + topups_result = await db.execute( - select(func.count(Transaction.id)) - .where( + select(func.count(Transaction.id)).where( and_( Transaction.user_id == referral.id, Transaction.type == TransactionType.DEPOSIT.value, - Transaction.is_completed.is_(True) + Transaction.is_completed.is_(True), ) ) ) topups_count = topups_result.scalar() or 0 - + days_since_registration = (datetime.utcnow() - referral.created_at).days - + days_since_activity = None if referral.last_activity: days_since_activity = (datetime.utcnow() - referral.last_activity).days - - detailed_referrals.append({ - 'id': referral.id, - 'telegram_id': referral.telegram_id, - 'full_name': referral.full_name, - 'username': referral.username, - 'created_at': referral.created_at, - 'last_activity': referral.last_activity, - 'has_made_first_topup': referral.has_made_first_topup, - 'balance_kopeks': referral.balance_kopeks, - 'total_earned_kopeks': total_earned_from_referral, - 'topups_count': topups_count, - 'days_since_registration': days_since_registration, - 'days_since_activity': days_since_activity, - 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive' - }) - + + detailed_referrals.append( + { + 'id': referral.id, + 'telegram_id': referral.telegram_id, + 'full_name': referral.full_name, + 'username': referral.username, + 'created_at': referral.created_at, + 'last_activity': referral.last_activity, + 'has_made_first_topup': referral.has_made_first_topup, + 'balance_kopeks': referral.balance_kopeks, + 'total_earned_kopeks': total_earned_from_referral, + 'topups_count': topups_count, + 'days_since_registration': days_since_registration, + 'days_since_activity': days_since_activity, + 'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive', + } + ) + return { 'referrals': detailed_referrals, 'total_count': total_count, 'has_next': offset + limit < total_count, 'has_prev': offset > 0, 'current_page': (offset // limit) + 1, - 'total_pages': (total_count + limit - 1) // limit + 'total_pages': (total_count + limit - 1) // limit, } - + except Exception as e: - logger.error(f"Ошибка получения списка рефералов для пользователя {user_id}: {e}") + logger.error(f'Ошибка получения списка рефералов для пользователя {user_id}: {e}') return { 'referrals': [], 'total_count': 0, 'has_next': False, 'has_prev': False, 'current_page': 1, - 'total_pages': 1 + 'total_pages': 1, } -async def get_referral_analytics(db: AsyncSession, user_id: int) -> Dict: +async def get_referral_analytics(db: AsyncSession, user_id: int) -> dict: try: now = datetime.utcnow() periods = { 'today': now.replace(hour=0, minute=0, second=0, microsecond=0), 'week': now - timedelta(days=7), 'month': now - timedelta(days=30), - 'quarter': now - timedelta(days=90) + 'quarter': now - timedelta(days=90), } - + earnings_by_period = {} for period_name, start_date in periods.items(): result = await db.execute( - select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)) - .where( - and_( - ReferralEarning.user_id == user_id, - ReferralEarning.created_at >= start_date - ) + select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where( + and_(ReferralEarning.user_id == user_id, ReferralEarning.created_at >= start_date) ) ) earnings_by_period[period_name] = result.scalar() or 0 - + top_referrals_result = await db.execute( select( ReferralEarning.referral_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earned'), - func.count(ReferralEarning.id).label('earnings_count') + func.count(ReferralEarning.id).label('earnings_count'), ) .where(ReferralEarning.user_id == user_id) .group_by(ReferralEarning.referral_id) .order_by(func.sum(ReferralEarning.amount_kopeks).desc()) .limit(5) ) - + top_referrals = [] for row in top_referrals_result: - referral_result = await db.execute( - select(User).where(User.id == row.referral_id) - ) + referral_result = await db.execute(select(User).where(User.id == row.referral_id)) referral = referral_result.scalar_one_or_none() if referral: - top_referrals.append({ - 'referral_name': referral.full_name, - 'total_earned_kopeks': row.total_earned, - 'earnings_count': row.earnings_count - }) - - return { - 'earnings_by_period': earnings_by_period, - 'top_referrals': top_referrals - } - + top_referrals.append( + { + 'referral_name': referral.full_name, + 'total_earned_kopeks': row.total_earned, + 'earnings_count': row.earnings_count, + } + ) + + return {'earnings_by_period': earnings_by_period, 'top_referrals': top_referrals} + except Exception as e: - logger.error(f"Ошибка получения аналитики рефералов для пользователя {user_id}: {e}") - return { - 'earnings_by_period': { - 'today': 0, - 'week': 0, - 'month': 0, - 'quarter': 0 - }, - 'top_referrals': [] - } + logger.error(f'Ошибка получения аналитики рефералов для пользователя {user_id}: {e}') + return {'earnings_by_period': {'today': 0, 'week': 0, 'month': 0, 'quarter': 0}, 'top_referrals': []} diff --git a/app/utils/validators.py b/app/utils/validators.py index fe65c273..15022ef0 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -1,25 +1,27 @@ import re -from typing import Optional, Union, Tuple from datetime import datetime -import html + ALLOWED_HTML_TAGS = { - 'b', 'strong', # жирный - 'i', 'em', # курсив - 'u', 'ins', # подчёркнутый - 's', 'strike', 'del', # зачёркнутый - 'code', # моноширинный - 'pre', # блок кода - 'a', # ссылка - 'blockquote', # цитата - 'tg-spoiler', # спойлер - 'tg-emoji', # кастомный эмодзи - 'span', # для class="tg-spoiler" + 'b', + 'strong', # жирный + 'i', + 'em', # курсив + 'u', + 'ins', # подчёркнутый + 's', + 'strike', + 'del', # зачёркнутый + 'code', # моноширинный + 'pre', # блок кода + 'a', # ссылка + 'blockquote', # цитата + 'tg-spoiler', # спойлер + 'tg-emoji', # кастомный эмодзи + 'span', # для class="tg-spoiler" } -SELF_CLOSING_TAGS = { - 'br', 'hr', 'img' -} +SELF_CLOSING_TAGS = {'br', 'hr', 'img'} def validate_email(email: str) -> bool: @@ -47,7 +49,7 @@ def validate_promocode(code: str) -> bool: return code.replace('_', '').replace('-', '').isalnum() -def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]: +def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> float | None: try: amount = float(amount_str.replace(',', '.')) if min_amount <= amount <= max_amount: @@ -57,7 +59,7 @@ def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = return None -def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]: +def validate_positive_integer(value: str | int, max_value: int = None) -> int | None: try: num = int(value) if num > 0 and (max_value is None or num <= max_value): @@ -67,7 +69,7 @@ def validate_positive_integer(value: Union[str, int], max_value: int = None) -> return None -def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]: +def validate_date_string(date_str: str, date_format: str = '%Y-%m-%d') -> datetime | None: try: return datetime.strptime(date_str, date_format) except ValueError: @@ -84,36 +86,29 @@ def validate_uuid(uuid_str: str) -> bool: return re.match(pattern, uuid_str.lower()) is not None -def validate_traffic_amount(traffic_str: str) -> Optional[int]: +def validate_traffic_amount(traffic_str: str) -> int | None: traffic_str = traffic_str.upper().strip() - + if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '∞']: return 0 - - units = { - 'MB': 1, - 'GB': 1024, - 'TB': 1024 * 1024, - 'МБ': 1, - 'ГБ': 1024, - 'ТБ': 1024 * 1024 - } - + + units = {'MB': 1, 'GB': 1024, 'TB': 1024 * 1024, 'МБ': 1, 'ГБ': 1024, 'ТБ': 1024 * 1024} + for unit, multiplier in units.items(): if traffic_str.endswith(unit): try: - value = float(traffic_str[:-len(unit)].strip()) + value = float(traffic_str[: -len(unit)].strip()) return int(value * multiplier) except ValueError: break - + try: return int(float(traffic_str)) except ValueError: return None -def validate_subscription_period(days: Union[str, int]) -> Optional[int]: +def validate_subscription_period(days: str | int) -> int | None: try: days_int = int(days) if 1 <= days_int <= 3650: @@ -151,14 +146,13 @@ def sanitize_html(text: str) -> str: pattern = rf'(<)(/?{tag}\b)([^>]*?)(>)' def replace_tag(match): - opening = match.group(1) # < + match.group(1) # < full_tag_content = match.group(2) # /?tagname attrs_part = match.group(3) # атрибуты (без >) - closing = match.group(4) # > + match.group(4) # > # Убираем начальный пробел, если есть - if attrs_part.startswith(' '): - attrs_part = attrs_part[1:] + attrs_part = attrs_part.removeprefix(' ') # Формируем результат if attrs_part: @@ -166,32 +160,26 @@ def sanitize_html(text: str) -> str: # Не разворачиваем < и > внутри атрибутов, чтобы избежать XSS processed_attrs = attrs_part.replace('"', '"').replace(''', "'") return f'<{full_tag_content} {processed_attrs}>' - else: - return f'<{full_tag_content}>' + return f'<{full_tag_content}>' text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE) return text -def sanitize_telegram_name(name: Optional[str]) -> Optional[str]: +def sanitize_telegram_name(name: str | None) -> str | None: """Санитизация Telegram-имени для безопасной вставки в HTML и хранения. Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги. """ if not name: return name try: - return ( - name.replace('<', '‹') - .replace('>', '›') - .replace('&', '&') - .strip() - ) + return name.replace('<', '‹').replace('>', '›').replace('&', '&').strip() except Exception: return name -def validate_device_count(count: Union[str, int]) -> Optional[int]: +def validate_device_count(count: str | int) -> int | None: try: count_int = int(count) if 1 <= count_int <= 10: @@ -204,75 +192,75 @@ def validate_device_count(count: Union[str, int]) -> Optional[int]: def validate_referral_code(code: str) -> bool: if not code: return False - + if code.startswith('ref') and len(code) > 3: user_id_part = code[3:] return user_id_part.isdigit() - + return validate_promocode(code) -def validate_html_tags(text: str) -> Tuple[bool, str]: +def validate_html_tags(text: str) -> tuple[bool, str]: if not text: - return True, "" - + return True, '' + tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>' tags = re.findall(tag_pattern, text) - + for is_closing, tag_name in tags: tag_name_lower = tag_name.lower() - + if tag_name_lower not in ALLOWED_HTML_TAGS and tag_name_lower not in SELF_CLOSING_TAGS: - return False, f"Неподдерживаемый тег: <{tag_name}>" - + return False, f'Неподдерживаемый тег: <{tag_name}>' + return validate_html_structure(text) -def validate_html_structure(text: str) -> Tuple[bool, str]: +def validate_html_structure(text: str) -> tuple[bool, str]: tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?/?>' - + matches = re.finditer(tag_pattern, text) tag_stack = [] - + for match in matches: full_tag = match.group(0) is_closing = bool(match.group(1)) tag_name = match.group(2).lower() - + if full_tag.endswith('/>') or tag_name in SELF_CLOSING_TAGS: continue - + if not is_closing: tag_stack.append(tag_name) else: if not tag_stack: - return False, f"Закрывающий тег без открывающего: " - + return False, f'Закрывающий тег без открывающего: ' + last_tag = tag_stack.pop() if last_tag != tag_name: - return False, f"Неправильная вложенность тегов: ожидался , найден " - + return False, f'Неправильная вложенность тегов: ожидался , найден ' + if tag_stack: - return False, f"Незакрытый тег: <{tag_stack[-1]}>" - - return True, "" + return False, f'Незакрытый тег: <{tag_stack[-1]}>' + + return True, '' def fix_html_tags(text: str) -> str: if not text: return text - + fixes = [ (r']+)>', r''), (r'<(br|hr|img[^>]*?)>', r'<\1 />'), (r'<<([^>]+)>>', r'<\1>'), (r'<\s+([^>]+)\s+>', r'<\1>'), ] - + result = text for pattern, replacement in fixes: result = re.sub(pattern, replacement, result, flags=re.IGNORECASE) - + return result @@ -302,21 +290,20 @@ def get_html_help_text() -> str: <b>жирный <i>курсив</i></b>""" -def validate_rules_content(text: str) -> Tuple[bool, str, Optional[str]]: +def validate_rules_content(text: str) -> tuple[bool, str, str | None]: if not text or not text.strip(): - return False, "Текст правил не может быть пустым", None - + return False, 'Текст правил не может быть пустым', None + if len(text) > 4000: - return False, f"Текст слишком длинный: {len(text)} символов (максимум 4000)", None - + return False, f'Текст слишком длинный: {len(text)} символов (максимум 4000)', None + is_valid_html, html_error = validate_html_tags(text) if not is_valid_html: fixed_text = fix_html_tags(text) fixed_is_valid, _ = validate_html_tags(fixed_text) - + if fixed_is_valid and fixed_text != text: return False, html_error, fixed_text - else: - return False, html_error, None - - return True, "", None + return False, html_error, None + + return True, '', None diff --git a/app/webapi/__init__.py b/app/webapi/__init__.py index 39ce0c40..c85a4c98 100644 --- a/app/webapi/__init__.py +++ b/app/webapi/__init__.py @@ -1,5 +1,7 @@ """Пакет административного веб-API.""" + from .app import create_web_api_app from .server import WebAPIServer -__all__ = ["create_web_api_app", "WebAPIServer"] + +__all__ = ['WebAPIServer', 'create_web_api_app'] diff --git a/app/webapi/app.py b/app/webapi/app.py index d1eba6e5..cdd4f7fd 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -3,174 +3,169 @@ from __future__ import annotations from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +# Cabinet (Personal Account) routes +from app.cabinet.routes import router as cabinet_router from app.config import settings from app.webapi.docs import add_redoc_endpoint from .middleware import RequestLoggingMiddleware from .routes import ( - broadcasts, backups, ban_notifications, + broadcasts, campaigns, config, + contests, health, + logs, main_menu_buttons, media, menu_layout, miniapp, + pages, partners, pinned_messages, polls, - promocodes, promo_groups, promo_offers, - user_messages, - welcome_texts, - pages, + promocodes, remnawave, - contests, servers, - subscription_events, stats, + subscription_events, subscriptions, tickets, tokens, transactions, + user_messages, users, - logs, webhooks, websocket, + welcome_texts, ) -# Cabinet (Personal Account) routes -from app.cabinet.routes import router as cabinet_router - OPENAPI_TAGS = [ { - "name": "health", - "description": "Мониторинг состояния административного API и связанных сервисов.", + 'name': 'health', + 'description': 'Мониторинг состояния административного API и связанных сервисов.', }, { - "name": "stats", - "description": "Сводные показатели по пользователям, подпискам и платежам.", + 'name': 'stats', + 'description': 'Сводные показатели по пользователям, подпискам и платежам.', }, { - "name": "settings", - "description": "Получение и изменение конфигурации бота из административной панели.", + 'name': 'settings', + 'description': 'Получение и изменение конфигурации бота из административной панели.', }, { - "name": "main-menu", - "description": "Управление кнопками и сообщениями главного меню Telegram-бота.", + 'name': 'main-menu', + 'description': 'Управление кнопками и сообщениями главного меню Telegram-бота.', }, { - "name": "menu-layout", - "description": "API конструктор меню: управление расположением и настройками кнопок.", + 'name': 'menu-layout', + 'description': 'API конструктор меню: управление расположением и настройками кнопок.', }, { - "name": "welcome-texts", - "description": "Создание, редактирование и управление приветственными текстами.", + 'name': 'welcome-texts', + 'description': 'Создание, редактирование и управление приветственными текстами.', }, { - "name": "users", - "description": "Управление пользователями, балансом и статусами подписок.", + 'name': 'users', + 'description': 'Управление пользователями, балансом и статусами подписок.', }, { - "name": "subscriptions", - "description": "Создание, продление и настройка подписок бота.", + 'name': 'subscriptions', + 'description': 'Создание, продление и настройка подписок бота.', }, { - "name": "support", - "description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.", + 'name': 'support', + 'description': 'Работа с тикетами поддержки, приоритетами и ограничениями на ответы.', }, { - "name": "transactions", - "description": "История финансовых операций и пополнений баланса.", + 'name': 'transactions', + 'description': 'История финансовых операций и пополнений баланса.', }, { - "name": "promo-groups", - "description": "Создание и управление промо-группами и их участниками.", + 'name': 'promo-groups', + 'description': 'Создание и управление промо-группами и их участниками.', }, { - "name": "servers", - "description": ( - "Управление серверами RemnaWave, их доступностью, промогруппами и " - "ручная синхронизация данных.", + 'name': 'servers', + 'description': ( + 'Управление серверами RemnaWave, их доступностью, промогруппами и ручная синхронизация данных.', ), }, { - "name": "promo-offers", - "description": "Управление промо-предложениями, шаблонами и журналом событий.", + 'name': 'promo-offers', + 'description': 'Управление промо-предложениями, шаблонами и журналом событий.', }, { - "name": "logs", - "description": ( - "Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл." + 'name': 'logs', + 'description': ('Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл.'), + }, + { + 'name': 'auth', + 'description': 'Управление токенами доступа к административному API.', + }, + { + 'name': 'remnawave', + 'description': ( + 'Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией ' + 'данных между ботом и панелью.' ), }, { - "name": "auth", - "description": "Управление токенами доступа к административному API.", + 'name': 'media', + 'description': 'Загрузка файлов в Telegram и получение ссылок на медиа.', }, { - "name": "remnawave", - "description": ( - "Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией " - "данных между ботом и панелью." + 'name': 'miniapp', + 'description': 'Endpoint для Telegram Mini App с информацией о подписке пользователя.', + }, + { + 'name': 'partners', + 'description': 'Просмотр участников реферальной программы, их доходов и рефералов.', + }, + { + 'name': 'polls', + 'description': 'Создание опросов, удаление, статистика и ответы пользователей.', + }, + { + 'name': 'pages', + 'description': 'Управление контентом публичных страниц: оферта, политика, FAQ и правила.', + }, + { + 'name': 'notifications', + 'description': ( + 'Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, ' + 'пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и ' + 'сменах промогрупп пользователей для административной панели.' ), }, { - "name": "media", - "description": "Загрузка файлов в Telegram и получение ссылок на медиа.", + 'name': 'contests', + 'description': 'Управление конкурсами: реферальными и ежедневными играми/раундами.', }, { - "name": "miniapp", - "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", + 'name': 'webhooks', + 'description': 'Управление webhooks для подписки на события системы (пользователи, платежи, тикеты).', }, { - "name": "partners", - "description": "Просмотр участников реферальной программы, их доходов и рефералов.", + 'name': 'websocket', + 'description': 'WebSocket подключения для real-time обновлений дашборда и уведомлений.', }, { - "name": "polls", - "description": "Создание опросов, удаление, статистика и ответы пользователей.", - }, - { - "name": "pages", - "description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.", - }, - { - "name": "notifications", - "description": ( - "Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, " - "пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и " - "сменах промогрупп пользователей для административной панели." + 'name': 'pinned-messages', + 'description': ( + 'Управление закреплёнными сообщениями: создание, обновление, рассылка и настройка показа при /start.' ), }, { - "name": "contests", - "description": "Управление конкурсами: реферальными и ежедневными играми/раундами.", - }, - { - "name": "webhooks", - "description": "Управление webhooks для подписки на события системы (пользователи, платежи, тикеты).", - }, - { - "name": "websocket", - "description": "WebSocket подключения для real-time обновлений дашборда и уведомлений.", - }, - { - "name": "pinned-messages", - "description": ( - "Управление закреплёнными сообщениями: создание, обновление, рассылка и " - "настройка показа при /start." - ), - }, - { - "name": "ban-notifications", - "description": ( - "Эндпоинты для приема уведомлений от системы мониторинга ban (Banhammer). " - "Позволяет отправлять уведомления пользователям о блокировке и разблокировке." + 'name': 'ban-notifications', + 'description': ( + 'Эндпоинты для приема уведомлений от системы мониторинга ban (Banhammer). ' + 'Позволяет отправлять уведомления пользователям о блокировке и разблокировке.' ), }, ] @@ -183,16 +178,16 @@ def create_web_api_app() -> FastAPI: app = FastAPI( title=settings.WEB_API_TITLE, version=settings.WEB_API_VERSION, - docs_url=docs_config.get("docs_url"), + docs_url=docs_config.get('docs_url'), redoc_url=None, - openapi_url=docs_config.get("openapi_url"), - swagger_ui_parameters={"persistAuthorization": True}, + openapi_url=docs_config.get('openapi_url'), + swagger_ui_parameters={'persistAuthorization': True}, ) add_redoc_endpoint( app, - redoc_url=docs_config.get("redoc_url"), - openapi_url=docs_config.get("openapi_url"), + redoc_url=docs_config.get('redoc_url'), + openapi_url=docs_config.get('openapi_url'), title=settings.WEB_API_TITLE, ) @@ -202,74 +197,74 @@ def create_web_api_app() -> FastAPI: app.add_middleware( CORSMiddleware, - allow_origins=["*"] if "*" in all_origins else all_origins, + allow_origins=['*'] if '*' in all_origins else all_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + 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(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"]) - app.include_router(servers.router, prefix="/servers", tags=["servers"]) - app.include_router(contests.router, prefix="/contests", tags=["contests"]) + 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(promo_offers.router, prefix='/promo-offers', tags=['promo-offers']) + app.include_router(servers.router, prefix='/servers', tags=['servers']) + app.include_router(contests.router, prefix='/contests', tags=['contests']) app.include_router( main_menu_buttons.router, - prefix="/main-menu/buttons", - tags=["main-menu"], + prefix='/main-menu/buttons', + tags=['main-menu'], ) app.include_router( menu_layout.router, - prefix="/menu-layout", - tags=["menu-layout"], + prefix='/menu-layout', + tags=['menu-layout'], ) app.include_router( user_messages.router, - prefix="/main-menu/messages", - tags=["main-menu"], + prefix='/main-menu/messages', + tags=['main-menu'], ) app.include_router( welcome_texts.router, - prefix="/welcome-texts", - tags=["welcome-texts"], + prefix='/welcome-texts', + tags=['welcome-texts'], ) - app.include_router(pages.router, prefix="/pages", tags=["pages"]) - 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"]) - app.include_router(media.router, tags=["media"]) - app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) - app.include_router(partners.router, prefix="/partners", tags=["partners"]) - app.include_router(polls.router, prefix="/polls", tags=["polls"]) - app.include_router(logs.router, prefix="/logs", tags=["logs"]) + app.include_router(pages.router, prefix='/pages', tags=['pages']) + 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']) + app.include_router(media.router, tags=['media']) + app.include_router(miniapp.router, prefix='/miniapp', tags=['miniapp']) + app.include_router(partners.router, prefix='/partners', tags=['partners']) + app.include_router(polls.router, prefix='/polls', tags=['polls']) + app.include_router(logs.router, prefix='/logs', tags=['logs']) app.include_router( pinned_messages.router, - prefix="/pinned-messages", - tags=["pinned-messages"], + prefix='/pinned-messages', + tags=['pinned-messages'], ) app.include_router( subscription_events.router, - prefix="/notifications/subscriptions", - tags=["notifications"], + prefix='/notifications/subscriptions', + tags=['notifications'], ) - app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) - app.include_router(websocket.router, tags=["websocket"]) + app.include_router(webhooks.router, prefix='/webhooks', tags=['webhooks']) + app.include_router(websocket.router, tags=['websocket']) app.include_router( ban_notifications.router, - prefix="/ban-notifications", - tags=["ban-notifications"], + prefix='/ban-notifications', + tags=['ban-notifications'], ) # Cabinet (Personal Account) routes diff --git a/app/webapi/background/backup_tasks.py b/app/webapi/background/backup_tasks.py index 6034a678..fd05f8e2 100644 --- a/app/webapi/background/backup_tasks.py +++ b/app/webapi/background/backup_tasks.py @@ -4,7 +4,6 @@ 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 @@ -12,10 +11,10 @@ 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 + status: str = 'queued' + message: str | None = None + file_path: str | None = None + created_by: int | None = None created_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow) @@ -25,7 +24,7 @@ class BackupTaskManager: self._tasks: dict[str, BackupTaskState] = {} self._lock = asyncio.Lock() - async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState: + async def enqueue(self, *, created_by: int | None) -> BackupTaskState: task_id = uuid.uuid4().hex state = BackupTaskState(task_id=task_id, created_by=created_by) @@ -36,23 +35,21 @@ class BackupTaskManager: return state async def _run_task(self, state: BackupTaskState) -> None: - state.status = "running" + state.status = 'running' state.updated_at = datetime.utcnow() try: - success, message, file_path = await backup_service.create_backup( - created_by=state.created_by - ) + 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}" + state.status = 'completed' if success else 'failed' + except Exception as exc: + 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 def get(self, task_id: str) -> BackupTaskState | None: async with self._lock: return self._tasks.get(task_id) @@ -61,11 +58,7 @@ class BackupTaskManager: states = list(self._tasks.values()) if active_only: - return [ - state - for state in states - if state.status in {"queued", "running"} - ] + return [state for state in states if state.status in {'queued', 'running'}] return states diff --git a/app/webapi/dependencies.py b/app/webapi/dependencies.py index 2d2ea1a0..cd659d76 100644 --- a/app/webapi/dependencies.py +++ b/app/webapi/dependencies.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import APIKeyHeader @@ -11,10 +11,10 @@ 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) +api_key_header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False) -async def get_db_session() -> AsyncGenerator[AsyncSession, None]: +async def get_db_session() -> AsyncGenerator[AsyncSession]: async with AsyncSessionLocal() as session: try: yield session @@ -30,16 +30,16 @@ async def require_api_token( api_key = api_key_header if not api_key: - authorization = request.headers.get("Authorization") + authorization = request.headers.get('Authorization') if authorization: - scheme, _, credentials = authorization.partition(" ") - if scheme.lower() == "bearer" and credentials: + 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", + detail='Missing API key', ) token = await web_api_token_service.authenticate( @@ -52,7 +52,7 @@ async def require_api_token( await db.rollback() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired API key", + detail='Invalid or expired API key', ) await db.commit() diff --git a/app/webapi/docs.py b/app/webapi/docs.py index 26eeeaf2..f6c7b1bc 100644 --- a/app/webapi/docs.py +++ b/app/webapi/docs.py @@ -21,13 +21,13 @@ def add_redoc_endpoint( return for route in app.router.routes: - if getattr(route, "path", None) == redoc_url: + if getattr(route, 'path', None) == redoc_url: return @app.get(redoc_url, include_in_schema=False) async def redoc_html(): # pragma: no cover - template rendering return get_redoc_html( openapi_url=openapi_url, - title=f"{title or app.title} - ReDoc", - redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js", + title=f'{title or app.title} - ReDoc', + redoc_js_url='https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js', ) diff --git a/app/webapi/middleware.py b/app/webapi/middleware.py index 9a8c89bb..1010d798 100644 --- a/app/webapi/middleware.py +++ b/app/webapi/middleware.py @@ -2,12 +2,13 @@ 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") +logger = logging.getLogger('web_api') class RequestLoggingMiddleware(BaseHTTPMiddleware): @@ -21,9 +22,9 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): return response finally: duration_ms = (monotonic() - start) * 1000 - status = response.status_code if response else "error" + status = response.status_code if response else 'error' logger.debug( - "%s %s -> %s (%.2f ms)", + '%s %s -> %s (%.2f ms)', request.method, request.url.path, status, diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index f3426197..5c19b8da 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -1,53 +1,54 @@ from . import ( config, + contests, health, + logs, main_menu_buttons, media, miniapp, + pages, partners, pinned_messages, polls, - promo_offers, - user_messages, - welcome_texts, - pages, promo_groups, - servers, + promo_offers, remnawave, - contests, - subscription_events, + servers, stats, + subscription_events, subscriptions, tickets, tokens, transactions, + user_messages, users, - logs, + welcome_texts, ) + __all__ = [ - "config", - "health", - "main_menu_buttons", - "media", - "miniapp", - "partners", - "pinned_messages", - "polls", - "promo_offers", - "user_messages", - "welcome_texts", - "pages", - "promo_groups", - "servers", - "remnawave", - "contests", - "subscription_events", - "stats", - "subscriptions", - "tickets", - "tokens", - "transactions", - "users", - "logs", + 'config', + 'contests', + 'health', + 'logs', + 'main_menu_buttons', + 'media', + 'miniapp', + 'pages', + 'partners', + 'pinned_messages', + 'polls', + 'promo_groups', + 'promo_offers', + 'remnawave', + 'servers', + 'stats', + 'subscription_events', + 'subscriptions', + 'tickets', + 'tokens', + 'transactions', + 'user_messages', + 'users', + 'welcome_texts', ] diff --git a/app/webapi/routes/backups.py b/app/webapi/routes/backups.py index 2d681f7c..ef8aacef 100644 --- a/app/webapi/routes/backups.py +++ b/app/webapi/routes/backups.py @@ -1,11 +1,10 @@ from __future__ import annotations -import os from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any -from fastapi import APIRouter, HTTPException, Query, Security, UploadFile, File, status +from fastapi import APIRouter, File, HTTPException, Query, Security, UploadFile, status from fastapi.responses import FileResponse from app.services.backup_service import backup_service @@ -28,19 +27,19 @@ from ..schemas.backups import ( router = APIRouter() -def _parse_datetime(value: Optional[str]) -> Optional[datetime]: +def _parse_datetime(value: str | None) -> datetime | None: if not value: return None try: - if value.endswith("Z"): - value = value.replace("Z", "+00:00") + 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]: +def _to_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): @@ -48,52 +47,52 @@ def _to_int(value: Any) -> Optional[int]: 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") + 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")) + created_by = _to_int(raw.get('created_by')) return BackupInfo( - filename=str(raw.get("filename")), - filepath=str(raw.get("filepath")), + 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)), + 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"), + 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="Запустить создание резервной копии", + summary='Запустить создание резервной копии', ) async def create_backup_endpoint( token: Any = Security(require_api_token), ) -> BackupCreateResponse: - created_by = getattr(token, "id", None) + 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="Список резервных копий", + summary='Список резервных копий', ) async def list_backups( _: Any = Security(require_api_token), @@ -115,9 +114,9 @@ async def list_backups( @router.get( - "/status/{task_id}", + '/status/{task_id}', response_model=BackupStatusResponse, - summary="Статус создания резервной копии", + summary='Статус создания резервной копии', ) async def get_backup_status( task_id: str, @@ -125,7 +124,7 @@ async def get_backup_status( ) -> BackupStatusResponse: state = await backup_task_manager.get(task_id) if not state: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Task not found') return BackupStatusResponse( task_id=state.task_id, @@ -139,13 +138,13 @@ async def get_backup_status( @router.get( - "/tasks", + '/tasks', response_model=BackupTaskListResponse, - summary="Список фоновых задач бекапов", + summary='Список фоновых задач бекапов', ) async def list_backup_tasks( _: Any = Security(require_api_token), - active_only: bool = Query(False, description="Вернуть только активные задачи"), + active_only: bool = Query(False, description='Вернуть только активные задачи'), ) -> BackupTaskListResponse: states = await backup_task_manager.list(active_only=active_only) @@ -166,12 +165,12 @@ async def list_backup_tasks( @router.get( - "/download/{filename:path}", - summary="Скачать файл резервной копии", + '/download/{filename:path}', + summary='Скачать файл резервной копии', responses={ 200: { - "content": {"application/octet-stream": {}}, - "description": "Файл резервной копии", + 'content': {'application/octet-stream': {}}, + 'description': 'Файл резервной копии', } }, ) @@ -182,27 +181,27 @@ async def download_backup( backup_path = backup_service.backup_dir / filename if not backup_path.exists(): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Backup file not found') if not backup_path.is_file(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid backup path") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid backup path') resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + raise HTTPException(status.HTTP_403_FORBIDDEN, 'Access denied') return FileResponse( path=str(backup_path), filename=filename, - media_type="application/octet-stream", + media_type='application/octet-stream', ) @router.post( - "/restore/{filename:path}", + '/restore/{filename:path}', response_model=BackupRestoreResponse, - summary="Восстановить из резервной копии", + summary='Восстановить из резервной копии', ) async def restore_backup( filename: str, @@ -212,17 +211,14 @@ async def restore_backup( backup_path = backup_service.backup_dir / filename if not backup_path.exists(): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Backup file not found') resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + raise HTTPException(status.HTTP_403_FORBIDDEN, 'Access denied') - success, message = await backup_service.restore_backup( - str(backup_path), - clear_existing=payload.clear_existing - ) + success, message = await backup_service.restore_backup(str(backup_path), clear_existing=payload.clear_existing) return BackupRestoreResponse( success=success, @@ -231,45 +227,39 @@ async def restore_backup( @router.post( - "/upload", + '/upload', response_model=BackupRestoreResponse, - summary="Загрузить и восстановить из файла резервной копии", + summary='Загрузить и восстановить из файла резервной копии', ) async def upload_and_restore_backup( - file: UploadFile = File(..., description="Файл резервной копии (.tar.gz, .json, .json.gz)"), - clear_existing: bool = Query(False, description="Очистить существующие данные"), + file: UploadFile = File(..., description='Файл резервной копии (.tar.gz, .json, .json.gz)'), + clear_existing: bool = Query(False, description='Очистить существующие данные'), _: Any = Security(require_api_token), ) -> BackupRestoreResponse: if not file.filename: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Filename is required") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Filename is required') safe_filename = Path(file.filename).name if not safe_filename or safe_filename in ('.', '..'): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid filename") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid filename') allowed_extensions = ('.tar.gz', '.json', '.json.gz', '.tar') if not any(safe_filename.endswith(ext) for ext in allowed_extensions): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, f'Invalid file type. Allowed: {", ".join(allowed_extensions)}') - temp_path = backup_service.backup_dir / f"uploaded_{safe_filename}" + temp_path = backup_service.backup_dir / f'uploaded_{safe_filename}' resolved_path = temp_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Invalid file path") + raise HTTPException(status.HTTP_403_FORBIDDEN, 'Invalid file path') try: content = await file.read() with open(temp_path, 'wb') as f: f.write(content) - success, message = await backup_service.restore_backup( - str(temp_path), - clear_existing=clear_existing - ) + success, message = await backup_service.restore_backup(str(temp_path), clear_existing=clear_existing) return BackupRestoreResponse( success=success, @@ -285,9 +275,9 @@ async def upload_and_restore_backup( @router.delete( - "/{filename:path}", + '/{filename:path}', response_model=BackupDeleteResponse, - summary="Удалить резервную копию", + summary='Удалить резервную копию', ) async def delete_backup( filename: str, @@ -298,7 +288,7 @@ async def delete_backup( resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") + raise HTTPException(status.HTTP_403_FORBIDDEN, 'Access denied') success, message = await backup_service.delete_backup(filename) diff --git a/app/webapi/routes/ban_notifications.py b/app/webapi/routes/ban_notifications.py index 20960a58..bb43b977 100644 --- a/app/webapi/routes/ban_notifications.py +++ b/app/webapi/routes/ban_notifications.py @@ -1,6 +1,7 @@ """ API эндпоинты для приема уведомлений от ban системы """ + from __future__ import annotations import logging @@ -8,12 +9,12 @@ import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession +from app.services.ban_notification_service import ban_notification_service from app.webapi.dependencies import get_db_session, require_api_token from app.webapi.schemas.ban_notifications import ( BanNotificationRequest, BanNotificationResponse, ) -from app.services.ban_notification_service import ban_notification_service logger = logging.getLogger(__name__) @@ -22,12 +23,12 @@ router = APIRouter() @router.post( - "/send", + '/send', response_model=BanNotificationResponse, - summary="Отправить уведомление от ban системы", + summary='Отправить уведомление от ban системы', description=( - "Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. " - "Поддерживает уведомления о блокировке, разблокировке и предупреждения." + 'Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. ' + 'Поддерживает уведомления о блокировке, разблокировке и предупреждения.' ), ) async def send_ban_notification( @@ -46,16 +47,16 @@ async def send_ban_notification( """ logger.info( f"Получен запрос на отправку уведомления типа '{request.notification_type}' " - f"для пользователя {request.username} ({request.user_identifier}), " - f"node_name={request.node_name!r}" + f'для пользователя {request.username} ({request.user_identifier}), ' + f'node_name={request.node_name!r}' ) try: - if request.notification_type == "punishment": + if request.notification_type == 'punishment': if request.ip_count is None or request.limit is None or request.ban_minutes is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes" + detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes", ) success, message, telegram_id = await ban_notification_service.send_punishment_notification( @@ -68,18 +69,17 @@ async def send_ban_notification( node_name=request.node_name, ) - elif request.notification_type == "enabled": + elif request.notification_type == 'enabled': success, message, telegram_id = await ban_notification_service.send_enabled_notification( db=db, user_identifier=request.user_identifier, username=request.username, ) - elif request.notification_type == "warning": + elif request.notification_type == 'warning': if not request.warning_message: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Для типа 'warning' требуется поле: warning_message" + status_code=status.HTTP_400_BAD_REQUEST, detail="Для типа 'warning' требуется поле: warning_message" ) success, message, telegram_id = await ban_notification_service.send_warning_notification( @@ -89,11 +89,11 @@ async def send_ban_notification( warning_message=request.warning_message, ) - elif request.notification_type == "network_wifi": + elif request.notification_type == 'network_wifi': if request.ban_minutes is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Для типа 'network_wifi' требуется поле: ban_minutes" + detail="Для типа 'network_wifi' требуется поле: ban_minutes", ) success, message, telegram_id = await ban_notification_service.send_network_wifi_notification( @@ -105,11 +105,11 @@ async def send_ban_notification( node_name=request.node_name, ) - elif request.notification_type == "network_mobile": + elif request.notification_type == 'network_mobile': if request.ban_minutes is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Для типа 'network_mobile' требуется поле: ban_minutes" + detail="Для типа 'network_mobile' требуется поле: ban_minutes", ) success, message, telegram_id = await ban_notification_service.send_network_mobile_notification( @@ -124,21 +124,15 @@ async def send_ban_notification( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Неизвестный тип уведомления: {request.notification_type}" + detail=f'Неизвестный тип уведомления: {request.notification_type}', ) - return BanNotificationResponse( - success=success, - message=message, - telegram_id=telegram_id, - sent=success - ) + return BanNotificationResponse(success=success, message=message, telegram_id=telegram_id, sent=success) except HTTPException: raise except Exception as e: - logger.exception(f"Ошибка при отправке уведомления: {e}") + logger.exception(f'Ошибка при отправке уведомления: {e}') raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Внутренняя ошибка сервера: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Внутренняя ошибка сервера: {e!s}' ) diff --git a/app/webapi/routes/broadcasts.py b/app/webapi/routes/broadcasts.py index d60a52e3..60e21a0e 100644 --- a/app/webapi/routes/broadcasts.py +++ b/app/webapi/routes/broadcasts.py @@ -45,7 +45,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse: ) -@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED) async def create_broadcast( payload: BroadcastCreateRequest, token: Any = Depends(require_api_token), @@ -53,7 +53,7 @@ async def create_broadcast( ) -> BroadcastResponse: message_text = payload.message_text.strip() if not message_text: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Message text must not be empty') media_payload = payload.media @@ -67,9 +67,9 @@ async def create_broadcast( total_count=0, sent_count=0, failed_count=0, - status="queued", + status='queued', admin_id=None, - admin_name=getattr(token, "name", None) or getattr(token, "created_by", None), + admin_name=getattr(token, 'name', None) or getattr(token, 'created_by', None), ) db.add(broadcast) await db.commit() @@ -88,7 +88,7 @@ async def create_broadcast( message_text=message_text, selected_buttons=payload.selected_buttons, media=media_config, - initiator_name=getattr(token, "name", None) or getattr(token, "created_by", None), + initiator_name=getattr(token, 'name', None) or getattr(token, 'created_by', None), ) await broadcast_service.start_broadcast(broadcast.id, config) @@ -97,7 +97,7 @@ async def create_broadcast( return _serialize_broadcast(broadcast) -@router.get("", response_model=BroadcastListResponse) +@router.get('', response_model=BroadcastListResponse) async def list_broadcasts( _: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -107,10 +107,7 @@ async def list_broadcasts( 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) + select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit) ) broadcasts = result.scalars().all() @@ -122,7 +119,7 @@ async def list_broadcasts( ) -@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse) +@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse) async def stop_broadcast( broadcast_id: int, _: Any = Depends(require_api_token), @@ -130,17 +127,17 @@ async def stop_broadcast( ) -> BroadcastResponse: broadcast = await db.get(BroadcastHistory, broadcast_id) if not broadcast: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Broadcast not found") + 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") + 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" + broadcast.status = 'cancelling' else: - broadcast.status = "cancelled" + broadcast.status = 'cancelled' broadcast.completed_at = datetime.utcnow() await db.commit() diff --git a/app/webapi/routes/campaigns.py b/app/webapi/routes/campaigns.py index 6effd306..9360773d 100644 --- a/app/webapi/routes/campaigns.py +++ b/app/webapi/routes/campaigns.py @@ -29,8 +29,8 @@ router = APIRouter() def _serialize_campaign(campaign) -> CampaignResponse: registrations_attr = None - if isinstance(getattr(campaign, "__dict__", None), dict): - registrations_attr = campaign.__dict__.get("registrations") + if isinstance(getattr(campaign, '__dict__', None), dict): + registrations_attr = campaign.__dict__.get('registrations') registrations = registrations_attr or [] squads = list(campaign.subscription_squads or []) @@ -62,17 +62,17 @@ def _serialize_campaign(campaign) -> CampaignResponse: @router.post( - "", + '', response_model=CampaignResponse, status_code=status.HTTP_201_CREATED, - summary="Создать рекламную кампанию", + 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) + created_by = getattr(token, 'id', None) try: campaign = await create_campaign( @@ -94,23 +94,23 @@ async def create_campaign_endpoint( await db.rollback() raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Campaign with this start_parameter already exists", + 'Campaign with this start_parameter already exists', ) from exc return _serialize_campaign(campaign) @router.get( - "", + '', response_model=CampaignListResponse, - summary="Список рекламных кампаний", + 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="Включать неактивные кампании"), + 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( @@ -129,9 +129,9 @@ async def list_campaigns( @router.delete( - "/{campaign_id}", + '/{campaign_id}', status_code=status.HTTP_204_NO_CONTENT, - summary="Удалить рекламную кампанию", + summary='Удалить рекламную кампанию', ) async def delete_campaign_endpoint( campaign_id: int, @@ -140,16 +140,15 @@ async def delete_campaign_endpoint( ): campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Campaign not found') await delete_campaign(db, campaign) - return None @router.patch( - "/{campaign_id}", + '/{campaign_id}', response_model=CampaignResponse, - summary="Обновить рекламную кампанию", + summary='Обновить рекламную кампанию', ) async def update_campaign_endpoint( campaign_id: int, @@ -159,7 +158,7 @@ async def update_campaign_endpoint( ) -> CampaignResponse: campaign = await get_campaign_by_id(db, campaign_id) if not campaign: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Campaign not found') update_fields = payload.dict(exclude_unset=True) if not update_fields: @@ -169,10 +168,10 @@ async def update_campaign_endpoint( campaign = await update_campaign(db, campaign, **update_fields) except IntegrityError as exc: await db.rollback() - if "start_parameter" in str(exc.orig): + if 'start_parameter' in str(exc.orig): raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Campaign with this start_parameter already exists", + 'Campaign with this start_parameter already exists', ) from exc raise diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 46965da4..d4345ea0 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +19,7 @@ from ..schemas.config import ( SettingUpdateRequest, ) + router = APIRouter() @@ -28,7 +29,7 @@ def _coerce_value(key: str, value: Any) -> Any: if value is None: if definition.is_optional: return None - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required') python_type = definition.python_type @@ -38,14 +39,14 @@ def _coerce_value(key: str, value: Any) -> Any: normalized = value elif isinstance(value, str): lowered = value.strip().lower() - if lowered in {"true", "1", "yes", "on", "да"}: + if lowered in {'true', '1', 'yes', 'on', 'да'}: normalized = True - elif lowered in {"false", "0", "no", "off", "нет"}: + elif lowered in {'false', '0', 'no', 'off', 'нет'}: normalized = False else: - raise ValueError("invalid bool") + raise ValueError('invalid bool') else: - raise ValueError("invalid bool") + raise ValueError('invalid bool') elif python_type is int: normalized = int(value) @@ -54,16 +55,16 @@ def _coerce_value(key: str, value: Any) -> Any: else: normalized = str(value) except ValueError: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None + 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) + 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}", + detail=f'Value must be one of: {readable}', ) return normalized @@ -102,21 +103,18 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe ) -@router.get("/categories", response_model=list[SettingCategorySummary]) +@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 - ] + return [SettingCategorySummary(key=key, label=label, items=count) for key, label, count in categories] -@router.get("", response_model=list[SettingDefinition]) +@router.get('', response_model=list[SettingDefinition]) async def list_settings( _: object = Security(require_api_token), - category: Optional[str] = Query(default=None, alias="category_key"), + category: str | None = Query(default=None, alias='category_key'), ) -> list[SettingDefinition]: items: list[SettingDefinition] = [] if category: @@ -131,7 +129,7 @@ async def list_settings( return items -@router.get("/{key}", response_model=SettingDefinition) +@router.get('/{key}', response_model=SettingDefinition) async def get_setting( key: str, _: object = Security(require_api_token), @@ -139,12 +137,12 @@ async def get_setting( 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 + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error return _serialize_definition(definition) -@router.put("/{key}", response_model=SettingDefinition) +@router.put('/{key}', response_model=SettingDefinition) async def update_setting( key: str, payload: SettingUpdateRequest, @@ -154,7 +152,7 @@ async def update_setting( try: definition = bot_configuration_service.get_definition(key) except KeyError as error: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error value = _coerce_value(key, payload.value) try: @@ -166,7 +164,7 @@ async def update_setting( return _serialize_definition(definition) -@router.delete("/{key}", response_model=SettingDefinition) +@router.delete('/{key}', response_model=SettingDefinition) async def reset_setting( key: str, _: object = Security(require_api_token), @@ -175,7 +173,7 @@ async def reset_setting( try: definition = bot_configuration_service.get_definition(key) except KeyError as error: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error try: await bot_configuration_service.reset_value(db, key) diff --git a/app/webapi/routes/contests.py b/app/webapi/routes/contests.py index 0c7f96fe..b949c695 100644 --- a/app/webapi/routes/contests.py +++ b/app/webapi/routes/contests.py @@ -1,10 +1,10 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone, time -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime, time, timedelta +from typing import Any from zoneinfo import ZoneInfo -from fastapi import APIRouter, Depends, HTTPException, Query, Security, status, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased, selectinload @@ -15,12 +15,13 @@ from app.database.crud.contest import ( finish_round, get_active_round_by_template, get_active_rounds, + get_template_by_id, list_templates, update_template_fields, - get_template_by_id, ) from app.database.crud.referral_contest import ( create_referral_contest, + delete_referral_contest, get_contest_events_count, get_contest_leaderboard, get_referral_contest, @@ -28,7 +29,6 @@ from app.database.crud.referral_contest import ( list_referral_contests, toggle_referral_contest, update_referral_contest, - delete_referral_contest, ) from app.database.models import ( ContestAttempt, @@ -50,30 +50,30 @@ from app.webapi.schemas.contests import ( ContestTemplateResponse, ContestTemplateUpdateRequest, ReferralContestCreateRequest, - ReferralContestDetailResponse, ReferralContestDetailedStatsResponse, + ReferralContestDetailResponse, ReferralContestEventListResponse, ReferralContestEventResponse, ReferralContestEventUser, ReferralContestLeaderboardItem, ReferralContestListResponse, - ReferralContestParticipant, ReferralContestResponse, ReferralContestUpdateRequest, StartRoundRequest, ) + router = APIRouter() # --------- Helpers ---------- -def _to_utc_naive(dt: datetime, tz_name: Optional[str] = None) -> datetime: +def _to_utc_naive(dt: datetime, tz_name: str | None = None) -> datetime: tz = ZoneInfo(tz_name or settings.TIMEZONE) if dt.tzinfo is None: dt = dt.replace(tzinfo=tz) - return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.astimezone(UTC).replace(tzinfo=None) def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse: @@ -101,7 +101,7 @@ def _serialize_round(round_obj: ContestRound) -> ContestRoundResponse: return ContestRoundResponse( id=round_obj.id, template_id=round_obj.template_id, - template_slug=tpl.slug if tpl else "", + template_slug=tpl.slug if tpl else '', template_name=tpl.name if tpl else None, starts_at=round_obj.starts_at, ends_at=round_obj.ends_at, @@ -153,22 +153,22 @@ def _serialize_referral_contest(contest: ReferralContest) -> ReferralContestResp ) -def _parse_times_str(times_str: Optional[str]) -> List[time]: +def _parse_times_str(times_str: str | None) -> list[time]: if not times_str: return [] - parsed: List[time] = [] - for part in times_str.split(","): + parsed: list[time] = [] + for part in times_str.split(','): part = part.strip() if not part: continue try: - parsed.append(datetime.strptime(part, "%H:%M").time()) + parsed.append(datetime.strptime(part, '%H:%M').time()) except Exception: continue return parsed -def _primary_time(times_str: Optional[str], fallback: Optional[time]) -> time: +def _primary_time(times_str: str | None, fallback: time | None) -> time: parsed = _parse_times_str(times_str) if parsed: return parsed[0] @@ -221,12 +221,12 @@ def _serialize_event( @router.get( - "/daily/templates", + '/daily/templates', response_model=ContestTemplateListResponse, - tags=["contests"], + tags=['contests'], ) async def list_daily_templates( - enabled_only: bool = Query(False, description="Показывать только включенные игры"), + enabled_only: bool = Query(False, description='Показывать только включенные игры'), _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ContestTemplateListResponse: @@ -235,9 +235,9 @@ async def list_daily_templates( @router.get( - "/daily/templates/{template_id}", + '/daily/templates/{template_id}', response_model=ContestTemplateResponse, - tags=["contests"], + tags=['contests'], ) async def get_daily_template( template_id: int, @@ -246,14 +246,14 @@ async def get_daily_template( ) -> ContestTemplateResponse: tpl = await get_template_by_id(db, template_id) if not tpl: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found') return _serialize_template(tpl) @router.patch( - "/daily/templates/{template_id}", + '/daily/templates/{template_id}', response_model=ContestTemplateResponse, - tags=["contests"], + tags=['contests'], ) async def update_daily_template( template_id: int, @@ -263,7 +263,7 @@ async def update_daily_template( ) -> ContestTemplateResponse: tpl = await get_template_by_id(db, template_id) if not tpl: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found') update_fields = payload.model_dump(exclude_none=True) if not update_fields: @@ -274,10 +274,10 @@ async def update_daily_template( @router.post( - "/daily/templates/{template_id}/start-round", + '/daily/templates/{template_id}/start-round', response_model=ContestRoundResponse, status_code=status.HTTP_201_CREATED, - tags=["contests"], + tags=['contests'], ) async def start_round_now( template_id: int, @@ -287,7 +287,7 @@ async def start_round_now( ) -> ContestRoundResponse: tpl = await get_template_by_id(db, template_id) if not tpl: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found') if not tpl.is_enabled: tpl = await update_template_fields(db, tpl, is_enabled=True) @@ -296,7 +296,7 @@ async def start_round_now( if existing and not payload.force: raise HTTPException( status.HTTP_409_CONFLICT, - "Active round already exists for this template. Set force=true to start a new one.", + 'Active round already exists for this template. Set force=true to start a new one.', ) if existing and payload.force: await finish_round(db, existing) @@ -311,7 +311,7 @@ async def start_round_now( starts_at = _to_utc_naive(starts_at, settings.TIMEZONE) ends_at = _to_utc_naive(ends_at, settings.TIMEZONE) - round_payload: Dict[str, Any] = payload.payload or contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] + round_payload: dict[str, Any] = payload.payload or contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] round_obj = await create_round( db, @@ -330,19 +330,19 @@ async def start_round_now( @router.get( - "/daily/rounds", + '/daily/rounds', response_model=ContestRoundListResponse, - tags=["contests"], + tags=['contests'], ) async def list_rounds( - status_filter: str = Query("active", regex="^(active|finished|any)$"), - template_id: Optional[int] = Query(None), + status_filter: str = Query('active', regex='^(active|finished|any)$'), + template_id: int | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ContestRoundListResponse: - if status_filter == "active": + if status_filter == 'active': rounds = await get_active_rounds(db) if template_id: rounds = [r for r in rounds if r.template_id == template_id] @@ -355,13 +355,9 @@ async def list_rounds( offset=offset, ) - query = ( - select(ContestRound) - .options(selectinload(ContestRound.template)) - .order_by(ContestRound.starts_at.desc()) - ) + query = select(ContestRound).options(selectinload(ContestRound.template)).order_by(ContestRound.starts_at.desc()) count_query = select(func.count(ContestRound.id)) - if status_filter != "any": + if status_filter != 'any': query = query.where(ContestRound.status == status_filter) count_query = count_query.where(ContestRound.status == status_filter) if template_id: @@ -381,9 +377,9 @@ async def list_rounds( @router.get( - "/daily/rounds/{round_id}", + '/daily/rounds/{round_id}', response_model=ContestRoundResponse, - tags=["contests"], + tags=['contests'], ) async def get_round( round_id: int, @@ -391,20 +387,18 @@ async def get_round( db: AsyncSession = Depends(get_db_session), ) -> ContestRoundResponse: result = await db.execute( - select(ContestRound) - .options(selectinload(ContestRound.template)) - .where(ContestRound.id == round_id) + select(ContestRound).options(selectinload(ContestRound.template)).where(ContestRound.id == round_id) ) round_obj = result.scalar_one_or_none() if not round_obj: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Round not found') return _serialize_round(round_obj) @router.post( - "/daily/rounds/{round_id}/finish", + '/daily/rounds/{round_id}/finish', response_model=ContestRoundResponse, - tags=["contests"], + tags=['contests'], ) async def finish_round_now( round_id: int, @@ -412,26 +406,24 @@ async def finish_round_now( db: AsyncSession = Depends(get_db_session), ) -> ContestRoundResponse: result = await db.execute( - select(ContestRound) - .options(selectinload(ContestRound.template)) - .where(ContestRound.id == round_id) + select(ContestRound).options(selectinload(ContestRound.template)).where(ContestRound.id == round_id) ) round_obj = result.scalar_one_or_none() if not round_obj: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Round not found") - if round_obj.status != "finished": + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Round not found') + if round_obj.status != 'finished': round_obj = await finish_round(db, round_obj) return _serialize_round(round_obj) @router.get( - "/daily/rounds/{round_id}/attempts", + '/daily/rounds/{round_id}/attempts', response_model=ContestAttemptListResponse, - tags=["contests"], + tags=['contests'], ) async def list_attempts( round_id: int, - winners_only: bool = Query(False, description="Вернуть только победителей"), + winners_only: bool = Query(False, description='Вернуть только победителей'), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), _: Any = Security(require_api_token), @@ -441,9 +433,7 @@ async def list_attempts( if winners_only: conditions.append(ContestAttempt.is_winner.is_(True)) - total = await db.scalar( - select(func.count(ContestAttempt.id)).where(and_(*conditions)) - ) or 0 + total = await db.scalar(select(func.count(ContestAttempt.id)).where(and_(*conditions))) or 0 query = ( select(ContestAttempt, User) @@ -468,12 +458,12 @@ async def list_attempts( @router.get( - "/referral", + '/referral', response_model=ReferralContestListResponse, - tags=["contests"], + tags=['contests'], ) async def list_referral( - contest_type: Optional[str] = Query(None), + contest_type: str | None = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), _: Any = Security(require_api_token), @@ -495,10 +485,10 @@ async def list_referral( @router.post( - "/contests/referral", + '/contests/referral', response_model=ReferralContestResponse, status_code=status.HTTP_201_CREATED, - tags=["contests"], + tags=['contests'], ) async def create_referral( payload: ReferralContestCreateRequest, @@ -508,7 +498,7 @@ async def create_referral( start_at = _to_utc_naive(payload.start_at, payload.timezone) end_at = _to_utc_naive(payload.end_at, payload.timezone) if end_at <= start_at: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'end_at must be after start_at') summary_time = _primary_time(payload.daily_summary_times, payload.daily_summary_time) @@ -533,9 +523,9 @@ async def create_referral( @router.get( - "/referral/{contest_id}", + '/referral/{contest_id}', response_model=ReferralContestDetailResponse, - tags=["contests"], + tags=['contests'], ) async def get_referral( contest_id: int, @@ -545,7 +535,7 @@ async def get_referral( ) -> ReferralContestDetailResponse: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') total_events = await get_contest_events_count(db, contest.id) leaderboard_rows = await get_contest_leaderboard(db, contest.id, limit=leaderboard_limit) @@ -559,9 +549,9 @@ async def get_referral( @router.patch( - "/referral/{contest_id}", + '/referral/{contest_id}', response_model=ReferralContestResponse, - tags=["contests"], + tags=['contests'], ) async def update_referral( contest_id: int, @@ -571,24 +561,26 @@ async def update_referral( ) -> ReferralContestResponse: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') fields = payload.model_dump(exclude_none=True) - if "start_at" in fields: - fields["start_at"] = _to_utc_naive(fields["start_at"], fields.get("timezone") or contest.timezone) - if "end_at" in fields: - fields["end_at"] = _to_utc_naive(fields["end_at"], fields.get("timezone") or contest.timezone) - if "daily_summary_times" in fields: - fields["daily_summary_time"] = _primary_time(fields["daily_summary_times"], fields.get("daily_summary_time") or contest.daily_summary_time) - elif "daily_summary_time" in fields: + if 'start_at' in fields: + fields['start_at'] = _to_utc_naive(fields['start_at'], fields.get('timezone') or contest.timezone) + if 'end_at' in fields: + fields['end_at'] = _to_utc_naive(fields['end_at'], fields.get('timezone') or contest.timezone) + if 'daily_summary_times' in fields: + fields['daily_summary_time'] = _primary_time( + fields['daily_summary_times'], fields.get('daily_summary_time') or contest.daily_summary_time + ) + elif 'daily_summary_time' in fields: # ensure type is time (pydantic provides time) pass - new_start = fields.get("start_at", contest.start_at) - new_end = fields.get("end_at", contest.end_at) + new_start = fields.get('start_at', contest.start_at) + new_end = fields.get('end_at', contest.end_at) if new_end <= new_start: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "end_at must be after start_at") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'end_at must be after start_at') if fields: contest = await update_referral_contest(db, contest, **fields) @@ -597,50 +589,50 @@ async def update_referral( @router.post( - "/referral/{contest_id}/toggle", + '/referral/{contest_id}/toggle', response_model=ReferralContestResponse, - tags=["contests"], + tags=['contests'], ) async def toggle_referral( contest_id: int, - is_active: bool = Query(..., description="Активировать или остановить конкурс"), + is_active: bool = Query(..., description='Активировать или остановить конкурс'), _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ReferralContestResponse: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') contest = await toggle_referral_contest(db, contest, is_active) return _serialize_referral_contest(contest) @router.delete( - "/referral/{contest_id}", + '/referral/{contest_id}', status_code=status.HTTP_200_OK, - tags=["contests"], + tags=['contests'], ) async def delete_referral( contest_id: int, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> Dict[str, str]: +) -> dict[str, str]: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') now_utc = datetime.utcnow() if contest.is_active or contest.end_at > now_utc: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Можно удалять только завершённые конкурсы", + 'Можно удалять только завершённые конкурсы', ) await delete_referral_contest(db, contest) - return {"status": "deleted"} + return {'status': 'deleted'} @router.get( - "/referral/{contest_id}/events", + '/referral/{contest_id}/events', response_model=ReferralContestEventListResponse, - tags=["contests"], + tags=['contests'], ) async def list_referral_events( contest_id: int, @@ -651,15 +643,13 @@ async def list_referral_events( ) -> ReferralContestEventListResponse: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') referrer_user = aliased(User) referral_user = aliased(User) base_conditions = [ReferralContestEvent.contest_id == contest_id] - total = await db.scalar( - select(func.count(ReferralContestEvent.id)).where(and_(*base_conditions)) - ) or 0 + total = await db.scalar(select(func.count(ReferralContestEvent.id)).where(and_(*base_conditions))) or 0 query = ( select(ReferralContestEvent, referrer_user, referral_user) @@ -682,9 +672,9 @@ async def list_referral_events( @router.get( - "/referral/{contest_id}/detailed-stats", + '/referral/{contest_id}/detailed-stats', response_model=ReferralContestDetailedStatsResponse, - tags=["contests"], + tags=['contests'], ) async def get_referral_detailed_stats( contest_id: int, @@ -693,8 +683,9 @@ async def get_referral_detailed_stats( ) -> ReferralContestDetailedStatsResponse: contest = await get_referral_contest(db, contest_id) if not contest: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Contest not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Contest not found') from app.services.referral_contest_service import referral_contest_service + stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) return ReferralContestDetailedStatsResponse(**stats) diff --git a/app/webapi/routes/health.py b/app/webapi/routes/health.py index 0be67d40..7923a541 100644 --- a/app/webapi/routes/health.py +++ b/app/webapi/routes/health.py @@ -9,13 +9,14 @@ 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) +@router.get('/health', tags=['health'], response_model=HealthCheckResponse) async def health_check(_: object = Security(require_api_token)) -> HealthCheckResponse: return HealthCheckResponse( - status="ok", + status='ok', api_version=settings.WEB_API_VERSION, bot_version=version_service.current_version, features=HealthFeatureFlags( @@ -27,14 +28,14 @@ async def health_check(_: object = Security(require_api_token)) -> HealthCheckRe ) -@router.get("/health/database", tags=["health"]) +@router.get('/health/database', tags=['health']) async def database_health(_: object = Security(require_api_token)) -> dict: """Детальная информация о состоянии базы данных.""" return await db_manager.health_check() -@router.get("/metrics/pool", tags=["health"]) +@router.get('/metrics/pool', tags=['health']) async def pool_metrics(_: object = Security(require_api_token)) -> dict: """Метрики пула подключений к базе данных.""" diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py index 4e275348..92d363cb 100644 --- a/app/webapi/routes/logs.py +++ b/app/webapi/routes/logs.py @@ -1,10 +1,11 @@ """Маршруты административного API для просмотра логов.""" + from __future__ import annotations import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security from fastapi.concurrency import run_in_threadpool @@ -23,10 +24,11 @@ from ..schemas.logs import ( SupportAuditActionsResponse, SupportAuditLogEntry, SupportAuditLogListResponse, - SystemLogPreviewResponse, SystemLogFullResponse, + SystemLogPreviewResponse, ) + router = APIRouter() logger = logging.getLogger(__name__) @@ -43,29 +45,29 @@ def _resolve_system_log_path() -> Path: return path -async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]: +async def _read_system_log(path: Path) -> tuple[str, int, float | None]: def _read() -> tuple[str, int, float]: - content = path.read_text(encoding="utf-8", errors="ignore") + content = path.read_text(encoding='utf-8', errors='ignore') stats = path.stat() return content, stats.st_size, stats.st_mtime return await run_in_threadpool(_read) -def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]: +def _format_timestamp(timestamp: float | None) -> datetime | None: if timestamp is None: return None - return datetime.fromtimestamp(timestamp, tz=timezone.utc) + return datetime.fromtimestamp(timestamp, tz=UTC) -@router.get("/system", response_model=SystemLogPreviewResponse) +@router.get('/system', response_model=SystemLogPreviewResponse) async def get_system_log_preview( _: Any = Security(require_api_token), preview_limit: int = Query( SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT, ge=500, le=SYSTEM_LOG_PREVIEW_LIMIT_MAX, - description="Количество символов предпросмотра от конца файла", + description='Количество символов предпросмотра от конца файла', ), ) -> SystemLogPreviewResponse: """Получить предпросмотр системного лог-файла бота.""" @@ -79,32 +81,32 @@ async def get_system_log_preview( updated_at=None, size_bytes=0, size_chars=0, - preview="", + preview='', preview_chars=0, preview_truncated=False, - download_url="/logs/system/download", + download_url='/logs/system/download', ) try: content, size_bytes, mtime = await _read_system_log(log_path) except FileNotFoundError: - logger.warning("Лог-файл %s исчез во время чтения", log_path) + logger.warning('Лог-файл %s исчез во время чтения', log_path) return SystemLogPreviewResponse( path=str(log_path), exists=False, updated_at=None, size_bytes=0, size_chars=0, - preview="", + preview='', preview_chars=0, preview_truncated=False, - download_url="/logs/system/download", + download_url='/logs/system/download', ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения - logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) - raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error + logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error - preview_text = content[-preview_limit:] if preview_limit > 0 else "" + preview_text = content[-preview_limit:] if preview_limit > 0 else '' truncated = len(content) > len(preview_text) return SystemLogPreviewResponse( @@ -116,11 +118,11 @@ async def get_system_log_preview( preview=preview_text, preview_chars=len(preview_text), preview_truncated=truncated, - download_url="/logs/system/download", + download_url='/logs/system/download', ) -@router.get("/system/download") +@router.get('/system/download') async def download_system_log( _: Any = Security(require_api_token), ) -> FileResponse: @@ -129,20 +131,20 @@ async def download_system_log( log_path = _resolve_system_log_path() if not log_path.exists() or not log_path.is_file(): - raise HTTPException(status_code=404, detail="Лог-файл не найден") + raise HTTPException(status_code=404, detail='Лог-файл не найден') try: return FileResponse( log_path, - media_type="text/plain", + media_type='text/plain', filename=log_path.name, ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла - logger.error("Ошибка отправки лог-файла %s: %s", log_path, error) - raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error + logger.error('Ошибка отправки лог-файла %s: %s', log_path, error) + raise HTTPException(status_code=500, detail='Не удалось отправить лог-файл') from error -@router.get("/system/full", response_model=SystemLogFullResponse) +@router.get('/system/full', response_model=SystemLogFullResponse) async def get_system_log_full( _: Any = Security(require_api_token), ) -> SystemLogFullResponse: @@ -151,13 +153,13 @@ async def get_system_log_full( log_path = _resolve_system_log_path() if not log_path.exists() or not log_path.is_file(): - raise HTTPException(status_code=404, detail="Лог-файл не найден") + raise HTTPException(status_code=404, detail='Лог-файл не найден') try: content, size_bytes, mtime = await _read_system_log(log_path) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения - logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) - raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error + logger.error('Ошибка чтения лог-файла %s: %s', log_path, error) + raise HTTPException(status_code=500, detail='Не удалось прочитать лог-файл') from error return SystemLogFullResponse( path=str(log_path), @@ -169,16 +171,16 @@ async def get_system_log_full( ) -@router.get("/monitoring", response_model=MonitoringLogListResponse) +@router.get('/monitoring', response_model=MonitoringLogListResponse) async def list_monitoring_logs( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), - offset: int = Query(0, ge=0, description="Смещение от начала списка"), - event_type: Optional[str] = Query( + limit: int = Query(50, ge=1, le=200, description='Количество записей на странице'), + offset: int = Query(0, ge=0, description='Смещение от начала списка'), + event_type: str | None = Query( default=None, max_length=100, - description="Фильтр по типу события", + description='Фильтр по типу события', ), ) -> MonitoringLogListResponse: """Получить список логов мониторинга с пагинацией.""" @@ -202,7 +204,7 @@ async def list_monitoring_logs( ) -@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse) +@router.get('/monitoring/event-types', response_model=MonitoringLogTypeListResponse) async def list_monitoring_event_types( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -213,16 +215,16 @@ async def list_monitoring_event_types( return MonitoringLogTypeListResponse(items=event_types) -@router.get("/support", response_model=SupportAuditLogListResponse) +@router.get('/support', response_model=SupportAuditLogListResponse) async def list_support_audit_logs( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), - offset: int = Query(0, ge=0, description="Смещение от начала списка"), - action: Optional[str] = Query( + limit: int = Query(50, ge=1, le=200, description='Количество записей на странице'), + offset: int = Query(0, ge=0, description='Смещение от начала списка'), + action: str | None = Query( default=None, max_length=50, - description="Фильтр по типу действия модератора", + description='Фильтр по типу действия модератора', ), ) -> SupportAuditLogListResponse: """Получить список аудита действий модераторов поддержки.""" @@ -256,7 +258,7 @@ async def list_support_audit_logs( ) -@router.get("/support/actions", response_model=SupportAuditActionsResponse) +@router.get('/support/actions', response_model=SupportAuditActionsResponse) async def list_support_audit_actions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), diff --git a/app/webapi/routes/main_menu_buttons.py b/app/webapi/routes/main_menu_buttons.py index 64102874..0cc499f4 100644 --- a/app/webapi/routes/main_menu_buttons.py +++ b/app/webapi/routes/main_menu_buttons.py @@ -42,7 +42,7 @@ def _serialize(button: MainMenuButton) -> MainMenuButtonResponse: ) -@router.get("", response_model=MainMenuButtonListResponse) +@router.get('', response_model=MainMenuButtonListResponse) async def list_main_menu_buttons( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -60,7 +60,7 @@ async def list_main_menu_buttons( ) -@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED) async def create_main_menu_button_endpoint( payload: MainMenuButtonCreateRequest, _: Any = Security(require_api_token), @@ -80,7 +80,7 @@ async def create_main_menu_button_endpoint( return _serialize(button) -@router.patch("/{button_id}", response_model=MainMenuButtonResponse) +@router.patch('/{button_id}', response_model=MainMenuButtonResponse) async def update_main_menu_button_endpoint( button_id: int, payload: MainMenuButtonUpdateRequest, @@ -89,7 +89,7 @@ async def update_main_menu_button_endpoint( ) -> MainMenuButtonResponse: button = await get_main_menu_button_by_id(db, button_id) if not button: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Main menu button not found') update_payload = payload.dict(exclude_unset=True) button = await update_main_menu_button(db, button, **update_payload) @@ -98,7 +98,7 @@ async def update_main_menu_button_endpoint( return _serialize(button) -@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/{button_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_main_menu_button_endpoint( button_id: int, _: Any = Security(require_api_token), @@ -106,7 +106,7 @@ async def delete_main_menu_button_endpoint( ) -> Response: button = await get_main_menu_button_by_id(db, button_id) if not button: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Main menu button not found') await delete_main_menu_button(db, button) MainMenuButtonService.invalidate_cache() diff --git a/app/webapi/routes/media.py b/app/webapi/routes/media.py index 2fd49c3e..56f675c8 100644 --- a/app/webapi/routes/media.py +++ b/app/webapi/routes/media.py @@ -29,7 +29,7 @@ from ..schemas.media import MediaUploadResponse router = APIRouter() logger = logging.getLogger(__name__) -ALLOWED_MEDIA_TYPES = {"photo", "video", "document"} +ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'} def _resolve_target_chat_id() -> int: @@ -45,32 +45,32 @@ def _resolve_target_chat_id() -> int: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - "Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)", + 'Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)', ) def _build_media_url(request: Request, file_id: str) -> str: - return str(request.url_for("download_media", file_id=file_id)) + return str(request.url_for('download_media', file_id=file_id)) -@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED) +@router.post('/upload', response_model=MediaUploadResponse, tags=['media'], status_code=status.HTTP_201_CREATED) async def upload_media( request: Request, _: Any = Security(require_api_token), file: UploadFile = File(...), - media_type: str = Form("document", description="Тип файла: photo, video или document"), - caption: str | None = Form(None, description="Необязательная подпись к файлу"), + media_type: str = Form('document', description='Тип файла: photo, video или document'), + caption: str | None = Form(None, description='Необязательная подпись к файлу'), ) -> MediaUploadResponse: - media_type_normalized = (media_type or "").strip().lower() + media_type_normalized = (media_type or '').strip().lower() if media_type_normalized not in ALLOWED_MEDIA_TYPES: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Unsupported media type') file_bytes = await file.read() if not file_bytes: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'File is empty') target_chat_id = _resolve_target_chat_id() - upload = BufferedInputFile(file_bytes, filename=file.filename or "upload") + upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') bot = Bot( token=settings.BOT_TOKEN, @@ -78,14 +78,14 @@ async def upload_media( ) try: - if media_type_normalized == "photo": + if media_type_normalized == 'photo': message = await bot.send_photo( chat_id=target_chat_id, photo=upload, caption=caption, ) media = message.photo[-1] - elif media_type_normalized == "video": + elif media_type_normalized == 'video': message = await bot.send_video( chat_id=target_chat_id, video=upload, @@ -104,19 +104,19 @@ async def upload_media( return MediaUploadResponse( media_type=media_type_normalized, file_id=media.file_id, - file_unique_id=getattr(media, "file_unique_id", None), + file_unique_id=getattr(media, 'file_unique_id', None), media_url=media_url, ) except HTTPException: raise except Exception as error: - logger.error("Failed to upload media: %s", error) - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error + logger.error('Failed to upload media: %s', error) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to upload media') from error finally: await bot.session.close() -@router.get("/media/{file_id}", name="download_media", tags=["media"]) +@router.get('/media/{file_id}', name='download_media', tags=['media']) async def download_media( file_id: str, _: Any = Security(require_api_token), @@ -129,30 +129,29 @@ async def download_media( try: file = await bot.get_file(file_id) if not file.file_path: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media file not found') buffer = await bot.download_file(file.file_path) - if hasattr(buffer, "seek"): + if hasattr(buffer, 'seek'): buffer.seek(0) - content = buffer.read() if hasattr(buffer, "read") else bytes(buffer) - filename = file.file_path.split("/")[-1] + content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer) + filename = file.file_path.split('/')[-1] - media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' return Response( content=content, media_type=media_type, headers={ - "Content-Disposition": f"inline; filename={filename}", + 'Content-Disposition': f'inline; filename={filename}', }, ) except HTTPException: raise except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла - logger.error("Failed to download media %s: %s", file_id, error) - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error + logger.error('Failed to download media %s: %s', file_id, error) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to download media') from error finally: await bot.session.close() - diff --git a/app/webapi/routes/menu_layout.py b/app/webapi/routes/menu_layout.py index 6253d119..a71c935a 100644 --- a/app/webapi/routes/menu_layout.py +++ b/app/webapi/routes/menu_layout.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, List, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Response, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +14,7 @@ from app.services.menu_layout_service import ( MenuLayoutService, ) + logger = logging.getLogger(__name__) from ..dependencies import get_db_session, require_api_token @@ -36,14 +37,12 @@ from ..schemas.menu_layout import ( HourlyStatsResponse, MenuButtonConfig, MenuClickStatsResponse, - MenuLayoutConfig, MenuLayoutExportResponse, MenuLayoutHistoryEntry, MenuLayoutHistoryResponse, MenuLayoutImportRequest, MenuLayoutImportResponse, MenuLayoutResponse, - MenuLayoutRollbackRequest, MenuLayoutUpdateRequest, MenuLayoutValidateRequest, MenuLayoutValidateResponse, @@ -56,17 +55,17 @@ from ..schemas.menu_layout import ( MoveButtonToRowRequest, PeriodComparisonResponse, ReorderButtonsInRowRequest, - TopUserStats, - TopUsersResponse, - UserClickSequence, - UserClickSequencesResponse, - WeekdayStats, - WeekdayStatsResponse, ReorderButtonsResponse, RowsReorderRequest, SwapButtonsRequest, SwapButtonsResponse, + TopUsersResponse, + TopUserStats, + UserClickSequence, + UserClickSequencesResponse, ValidationError, + WeekdayStats, + WeekdayStatsResponse, ) @@ -76,40 +75,36 @@ router = APIRouter() def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutResponse: """Сериализовать конфигурацию в response.""" rows = [] - for row_data in config.get("rows", []): + for row_data in config.get('rows', []): rows.append( MenuRowConfig( - id=row_data["id"], - buttons=row_data.get("buttons", []), - conditions=ButtonConditions(**row_data["conditions"]) - if row_data.get("conditions") - else None, - max_per_row=row_data.get("max_per_row", 2), + id=row_data['id'], + buttons=row_data.get('buttons', []), + conditions=ButtonConditions(**row_data['conditions']) if row_data.get('conditions') else None, + max_per_row=row_data.get('max_per_row', 2), ) ) buttons = {} - for btn_id, btn_data in config.get("buttons", {}).items(): + for btn_id, btn_data in config.get('buttons', {}).items(): buttons[btn_id] = MenuButtonConfig( - type=btn_data["type"], - builtin_id=btn_data.get("builtin_id"), - text=btn_data.get("text", {}), - icon=btn_data.get("icon"), - action=btn_data.get("action", ""), - enabled=btn_data.get("enabled", True), - visibility=btn_data.get("visibility", "all"), - conditions=ButtonConditions(**btn_data["conditions"]) - if btn_data.get("conditions") - else None, - dynamic_text=btn_data.get("dynamic_text", False), - open_mode=btn_data.get("open_mode", "callback"), - webapp_url=btn_data.get("webapp_url"), - description=btn_data.get("description"), - sort_order=btn_data.get("sort_order"), + type=btn_data['type'], + builtin_id=btn_data.get('builtin_id'), + text=btn_data.get('text', {}), + icon=btn_data.get('icon'), + action=btn_data.get('action', ''), + enabled=btn_data.get('enabled', True), + visibility=btn_data.get('visibility', 'all'), + conditions=ButtonConditions(**btn_data['conditions']) if btn_data.get('conditions') else None, + dynamic_text=btn_data.get('dynamic_text', False), + open_mode=btn_data.get('open_mode', 'callback'), + webapp_url=btn_data.get('webapp_url'), + description=btn_data.get('description'), + sort_order=btn_data.get('sort_order'), ) return MenuLayoutResponse( - version=config.get("version", 1), + version=config.get('version', 1), rows=rows, buttons=buttons, is_enabled=is_enabled, @@ -117,7 +112,7 @@ def _serialize_config(config: dict, is_enabled: bool, updated_at) -> MenuLayoutR ) -@router.get("", response_model=MenuLayoutResponse) +@router.get('', response_model=MenuLayoutResponse) async def get_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -128,7 +123,7 @@ async def get_menu_layout( return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) -@router.put("", response_model=MenuLayoutResponse) +@router.put('', response_model=MenuLayoutResponse) async def update_menu_layout( payload: MenuLayoutUpdateRequest, _: Any = Security(require_api_token), @@ -139,25 +134,26 @@ async def update_menu_layout( config = config.copy() if payload.rows is not None: - config["rows"] = [row.model_dump() for row in payload.rows] + config['rows'] = [row.model_dump() for row in payload.rows] if payload.buttons is not None: buttons_config = {} for btn_id, btn in payload.buttons.items(): btn_dict = btn.model_dump() # Автоматически определяем наличие плейсхолдеров, если dynamic_text не установлен - if not btn_dict.get("dynamic_text", False): + if not btn_dict.get('dynamic_text', False): from app.services.menu_layout.service import MenuLayoutService - btn_dict["dynamic_text"] = MenuLayoutService._text_has_placeholders(btn_dict.get("text", {})) + + btn_dict['dynamic_text'] = MenuLayoutService._text_has_placeholders(btn_dict.get('text', {})) buttons_config[btn_id] = btn_dict - config["buttons"] = buttons_config + config['buttons'] = buttons_config await MenuLayoutService.save_config(db, config) updated_at = await MenuLayoutService.get_config_updated_at(db) return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) -@router.post("/reset", response_model=MenuLayoutResponse) +@router.post('/reset', response_model=MenuLayoutResponse) async def reset_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -168,7 +164,7 @@ async def reset_menu_layout( return _serialize_config(config, settings.MENU_LAYOUT_ENABLED, updated_at) -@router.get("/builtin-buttons", response_model=BuiltinButtonsListResponse) +@router.get('/builtin-buttons', response_model=BuiltinButtonsListResponse) async def list_builtin_buttons( _: Any = Security(require_api_token), ) -> BuiltinButtonsListResponse: @@ -177,21 +173,21 @@ async def list_builtin_buttons( for btn_info in MenuLayoutService.get_builtin_buttons_info(): items.append( BuiltinButtonInfo( - id=btn_info["id"], - default_text=btn_info["default_text"], - callback_data=btn_info["callback_data"], - default_conditions=ButtonConditions(**btn_info["default_conditions"]) - if btn_info.get("default_conditions") + id=btn_info['id'], + default_text=btn_info['default_text'], + callback_data=btn_info['callback_data'], + default_conditions=ButtonConditions(**btn_info['default_conditions']) + if btn_info.get('default_conditions') else None, - supports_dynamic_text=btn_info.get("supports_dynamic_text", False), - supports_direct_open=btn_info.get("supports_direct_open", False), + supports_dynamic_text=btn_info.get('supports_dynamic_text', False), + supports_direct_open=btn_info.get('supports_direct_open', False), ) ) return BuiltinButtonsListResponse(items=items, total=len(items)) -@router.patch("/buttons/{button_id}") +@router.patch('/buttons/{button_id}') async def update_button( button_id: str, payload: ButtonUpdateRequest, @@ -202,59 +198,55 @@ async def update_button( try: updates = payload.model_dump(exclude_unset=True) # Конвертируем visibility в строку если есть - if "visibility" in updates and updates["visibility"] is not None: - if hasattr(updates["visibility"], "value"): - updates["visibility"] = updates["visibility"].value + if 'visibility' in updates and updates['visibility'] is not None: + if hasattr(updates['visibility'], 'value'): + updates['visibility'] = updates['visibility'].value # Конвертируем open_mode в строку если есть - if "open_mode" in updates and updates["open_mode"] is not None: - if hasattr(updates["open_mode"], "value"): - updates["open_mode"] = updates["open_mode"].value + if 'open_mode' in updates and updates['open_mode'] is not None: + if hasattr(updates['open_mode'], 'value'): + updates['open_mode'] = updates['open_mode'].value # Конвертируем conditions - убираем None значения если это dict - if "conditions" in updates and updates["conditions"] is not None: - if isinstance(updates["conditions"], dict): - updates["conditions"] = {k: v for k, v in updates["conditions"].items() if v is not None} - elif hasattr(updates["conditions"], "model_dump"): - updates["conditions"] = updates["conditions"].model_dump(exclude_none=True) + if 'conditions' in updates and updates['conditions'] is not None: + if isinstance(updates['conditions'], dict): + updates['conditions'] = {k: v for k, v in updates['conditions'].items() if v is not None} + elif hasattr(updates['conditions'], 'model_dump'): + updates['conditions'] = updates['conditions'].model_dump(exclude_none=True) button = await MenuLayoutService.update_button(db, button_id, updates) return MenuButtonConfig( - type=button["type"], - builtin_id=button.get("builtin_id"), - text=button.get("text", {}), - icon=button.get("icon"), - action=button.get("action", ""), - enabled=button.get("enabled", True), - visibility=button.get("visibility", "all"), - conditions=ButtonConditions(**button["conditions"]) - if button.get("conditions") - else None, - dynamic_text=button.get("dynamic_text", False), - open_mode=button.get("open_mode", "callback"), - webapp_url=button.get("webapp_url"), - description=button.get("description"), + type=button['type'], + builtin_id=button.get('builtin_id'), + text=button.get('text', {}), + icon=button.get('icon'), + action=button.get('action', ''), + enabled=button.get('enabled', True), + visibility=button.get('visibility', 'all'), + conditions=ButtonConditions(**button['conditions']) if button.get('conditions') else None, + dynamic_text=button.get('dynamic_text', False), + open_mode=button.get('open_mode', 'callback'), + webapp_url=button.get('webapp_url'), + description=button.get('description'), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e -@router.post("/rows/reorder", response_model=List[MenuRowConfig]) +@router.post('/rows/reorder', response_model=list[MenuRowConfig]) async def reorder_rows( payload: RowsReorderRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> List[MenuRowConfig]: +) -> list[MenuRowConfig]: """Изменить порядок строк.""" try: rows = await MenuLayoutService.reorder_rows(db, payload.ordered_ids) return [ MenuRowConfig( - id=row["id"], - buttons=row.get("buttons", []), - conditions=ButtonConditions(**row["conditions"]) - if row.get("conditions") - else None, - max_per_row=row.get("max_per_row", 2), + id=row['id'], + buttons=row.get('buttons', []), + conditions=ButtonConditions(**row['conditions']) if row.get('conditions') else None, + max_per_row=row.get('max_per_row', 2), ) for row in rows ] @@ -262,7 +254,7 @@ async def reorder_rows( raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e -@router.post("/rows", response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED) +@router.post('/rows', response_model=MenuRowConfig, status_code=status.HTTP_201_CREATED) async def add_row( payload: AddRowRequest, _: Any = Security(require_api_token), @@ -271,28 +263,24 @@ async def add_row( """Добавить новую строку.""" try: row_config = { - "id": payload.id, - "buttons": payload.buttons, - "conditions": payload.conditions.model_dump(exclude_none=True) - if payload.conditions - else None, - "max_per_row": payload.max_per_row, + 'id': payload.id, + 'buttons': payload.buttons, + 'conditions': payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, + 'max_per_row': payload.max_per_row, } row = await MenuLayoutService.add_row(db, row_config, payload.position) return MenuRowConfig( - id=row["id"], - buttons=row.get("buttons", []), - conditions=ButtonConditions(**row["conditions"]) - if row.get("conditions") - else None, - max_per_row=row.get("max_per_row", 2), + id=row['id'], + buttons=row.get('buttons', []), + conditions=ButtonConditions(**row['conditions']) if row.get('conditions') else None, + max_per_row=row.get('max_per_row', 2), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.delete("/rows/{row_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete('/rows/{row_id}', status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_row( row_id: str, _: Any = Security(require_api_token), @@ -306,9 +294,7 @@ async def delete_row( raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e -@router.post( - "/buttons", response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED -) +@router.post('/buttons', response_model=MenuButtonConfig, status_code=status.HTTP_201_CREATED) async def add_custom_button( payload: AddCustomButtonRequest, _: Any = Security(require_api_token), @@ -320,45 +306,40 @@ async def add_custom_button( dynamic_text = payload.dynamic_text if not dynamic_text: from app.services.menu_layout.service import MenuLayoutService + dynamic_text = MenuLayoutService._text_has_placeholders(payload.text) - + button_config = { - "type": payload.type.value, - "text": payload.text, - "icon": payload.icon, - "action": payload.action, - "visibility": payload.visibility.value, - "conditions": payload.conditions.model_dump(exclude_none=True) - if payload.conditions - else None, - "dynamic_text": dynamic_text, - "description": payload.description, + 'type': payload.type.value, + 'text': payload.text, + 'icon': payload.icon, + 'action': payload.action, + 'visibility': payload.visibility.value, + 'conditions': payload.conditions.model_dump(exclude_none=True) if payload.conditions else None, + 'dynamic_text': dynamic_text, + 'description': payload.description, } - button = await MenuLayoutService.add_custom_button( - db, payload.id, button_config, payload.row_id - ) + button = await MenuLayoutService.add_custom_button(db, payload.id, button_config, payload.row_id) return MenuButtonConfig( - type=button["type"], - builtin_id=button.get("builtin_id"), - text=button.get("text", {}), - icon=button.get("icon"), - action=button.get("action", ""), - enabled=button.get("enabled", True), - visibility=button.get("visibility", "all"), - conditions=ButtonConditions(**button["conditions"]) - if button.get("conditions") - else None, - dynamic_text=button.get("dynamic_text", False), - open_mode=button.get("open_mode", "callback"), - webapp_url=button.get("webapp_url"), - description=button.get("description"), + type=button['type'], + builtin_id=button.get('builtin_id'), + text=button.get('text', {}), + icon=button.get('icon'), + action=button.get('action', ''), + enabled=button.get('enabled', True), + visibility=button.get('visibility', 'all'), + conditions=ButtonConditions(**button['conditions']) if button.get('conditions') else None, + dynamic_text=button.get('dynamic_text', False), + open_mode=button.get('open_mode', 'callback'), + webapp_url=button.get('webapp_url'), + description=button.get('description'), ) except ValueError as e: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.delete("/buttons/{button_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete('/buttons/{button_id}', status_code=status.HTTP_204_NO_CONTENT, response_class=Response) async def delete_custom_button( button_id: str, _: Any = Security(require_api_token), @@ -374,7 +355,7 @@ async def delete_custom_button( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.post("/preview", response_model=MenuPreviewResponse) +@router.post('/preview', response_model=MenuPreviewResponse) async def preview_menu( payload: MenuPreviewRequest, _: Any = Security(require_api_token), @@ -397,11 +378,11 @@ async def preview_menu( for row_data in preview_rows: buttons = [ MenuPreviewButton( - text=btn["text"], - action=btn["action"], - type=btn["type"], + text=btn['text'], + action=btn['action'], + type=btn['type'], ) - for btn in row_data["buttons"] + for btn in row_data['buttons'] ] total_buttons += len(buttons) rows.append(MenuPreviewRow(buttons=buttons)) @@ -412,7 +393,7 @@ async def preview_menu( # --- Эндпоинты для перемещения кнопок --- -@router.post("/buttons/{button_id}/move-up", response_model=MoveButtonResponse) +@router.post('/buttons/{button_id}/move-up', response_model=MoveButtonResponse) async def move_button_up( button_id: str, _: Any = Security(require_api_token), @@ -423,8 +404,8 @@ async def move_button_up( result = await MenuLayoutService.move_button_up(db, button_id) return MoveButtonResponse( button_id=button_id, - new_row_index=result.get("new_row_index"), - position=result.get("new_position"), + new_row_index=result.get('new_row_index'), + position=result.get('new_position'), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -432,7 +413,7 @@ async def move_button_up( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.post("/buttons/{button_id}/move-down", response_model=MoveButtonResponse) +@router.post('/buttons/{button_id}/move-down', response_model=MoveButtonResponse) async def move_button_down( button_id: str, _: Any = Security(require_api_token), @@ -443,8 +424,8 @@ async def move_button_down( result = await MenuLayoutService.move_button_down(db, button_id) return MoveButtonResponse( button_id=button_id, - new_row_index=result.get("new_row_index"), - position=result.get("new_position"), + new_row_index=result.get('new_row_index'), + position=result.get('new_position'), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -452,7 +433,7 @@ async def move_button_down( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.post("/buttons/{button_id}/move-to-row", response_model=MoveButtonResponse) +@router.post('/buttons/{button_id}/move-to-row', response_model=MoveButtonResponse) async def move_button_to_row( button_id: str, payload: MoveButtonToRowRequest, @@ -461,13 +442,11 @@ async def move_button_to_row( ) -> MoveButtonResponse: """Переместить кнопку в указанную строку.""" try: - result = await MenuLayoutService.move_button_to_row( - db, button_id, payload.target_row_id, payload.position - ) + result = await MenuLayoutService.move_button_to_row(db, button_id, payload.target_row_id, payload.position) return MoveButtonResponse( button_id=button_id, target_row_id=payload.target_row_id, - position=result.get("new_position"), + position=result.get('new_position'), ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -475,7 +454,7 @@ async def move_button_to_row( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.post("/rows/{row_id}/reorder-buttons", response_model=ReorderButtonsResponse) +@router.post('/rows/{row_id}/reorder-buttons', response_model=ReorderButtonsResponse) async def reorder_buttons_in_row( row_id: str, payload: ReorderButtonsInRowRequest, @@ -484,12 +463,10 @@ async def reorder_buttons_in_row( ) -> ReorderButtonsResponse: """Изменить порядок кнопок в строке.""" try: - result = await MenuLayoutService.reorder_buttons_in_row( - db, row_id, payload.ordered_button_ids - ) + result = await MenuLayoutService.reorder_buttons_in_row(db, row_id, payload.ordered_button_ids) return ReorderButtonsResponse( row_id=row_id, - buttons=result["buttons"], + buttons=result['buttons'], ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -497,7 +474,7 @@ async def reorder_buttons_in_row( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e -@router.post("/buttons/swap", response_model=SwapButtonsResponse) +@router.post('/buttons/swap', response_model=SwapButtonsResponse) async def swap_buttons( payload: SwapButtonsRequest, _: Any = Security(require_api_token), @@ -505,12 +482,10 @@ async def swap_buttons( ) -> SwapButtonsResponse: """Обменять местами две кнопки (даже из разных строк).""" try: - result = await MenuLayoutService.swap_buttons( - db, payload.button_id_1, payload.button_id_2 - ) + result = await MenuLayoutService.swap_buttons(db, payload.button_id_1, payload.button_id_2) return SwapButtonsResponse( - button_1=result["button_1"], - button_2=result["button_2"], + button_1=result['button_1'], + button_2=result['button_2'], ) except KeyError as e: raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e @@ -521,7 +496,7 @@ async def swap_buttons( # --- Новые эндпоинты --- -@router.get("/available-callbacks", response_model=AvailableCallbacksResponse) +@router.get('/available-callbacks', response_model=AvailableCallbacksResponse) async def list_available_callbacks( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -531,19 +506,19 @@ async def list_available_callbacks( items = [ AvailableCallback( - callback_data=cb["callback_data"], - name=cb["name"], - description=cb.get("description"), - category=cb["category"], - default_text=cb.get("default_text"), - default_icon=cb.get("default_icon"), - requires_subscription=cb.get("requires_subscription", False), - is_in_menu=cb.get("is_in_menu", False), + callback_data=cb['callback_data'], + name=cb['name'], + description=cb.get('description'), + category=cb['category'], + default_text=cb.get('default_text'), + default_icon=cb.get('default_icon'), + requires_subscription=cb.get('requires_subscription', False), + is_in_menu=cb.get('is_in_menu', False), ) for cb in callbacks ] - categories = list(set(cb["category"] for cb in callbacks)) + categories = list({cb['category'] for cb in callbacks}) return AvailableCallbacksResponse( items=items, @@ -552,7 +527,7 @@ async def list_available_callbacks( ) -@router.get("/placeholders", response_model=DynamicPlaceholdersResponse) +@router.get('/placeholders', response_model=DynamicPlaceholdersResponse) async def list_dynamic_placeholders( _: Any = Security(require_api_token), ) -> DynamicPlaceholdersResponse: @@ -561,10 +536,10 @@ async def list_dynamic_placeholders( items = [ DynamicPlaceholder( - placeholder=p["placeholder"], - description=p["description"], - example=p["example"], - category=p["category"], + placeholder=p['placeholder'], + description=p['description'], + example=p['example'], + category=p['category'], ) for p in placeholders ] @@ -572,7 +547,7 @@ async def list_dynamic_placeholders( return DynamicPlaceholdersResponse(items=items, total=len(items)) -@router.get("/export", response_model=MenuLayoutExportResponse) +@router.get('/export', response_model=MenuLayoutExportResponse) async def export_menu_layout( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -583,46 +558,42 @@ async def export_menu_layout( export_data = await MenuLayoutService.export_config(db) rows = [] - for row_data in export_data.get("rows", []): + for row_data in export_data.get('rows', []): rows.append( MenuRowConfig( - id=row_data["id"], - buttons=row_data.get("buttons", []), - conditions=ButtonConditions(**row_data["conditions"]) - if row_data.get("conditions") - else None, - max_per_row=row_data.get("max_per_row", 2), + id=row_data['id'], + buttons=row_data.get('buttons', []), + conditions=ButtonConditions(**row_data['conditions']) if row_data.get('conditions') else None, + max_per_row=row_data.get('max_per_row', 2), ) ) buttons = {} - for btn_id, btn_data in export_data.get("buttons", {}).items(): + for btn_id, btn_data in export_data.get('buttons', {}).items(): buttons[btn_id] = MenuButtonConfig( - type=btn_data["type"], - builtin_id=btn_data.get("builtin_id"), - text=btn_data.get("text", {}), - icon=btn_data.get("icon"), - action=btn_data.get("action", ""), - enabled=btn_data.get("enabled", True), - visibility=btn_data.get("visibility", "all"), - conditions=ButtonConditions(**btn_data["conditions"]) - if btn_data.get("conditions") - else None, - dynamic_text=btn_data.get("dynamic_text", False), - open_mode=btn_data.get("open_mode", "callback"), - webapp_url=btn_data.get("webapp_url"), - description=btn_data.get("description"), + type=btn_data['type'], + builtin_id=btn_data.get('builtin_id'), + text=btn_data.get('text', {}), + icon=btn_data.get('icon'), + action=btn_data.get('action', ''), + enabled=btn_data.get('enabled', True), + visibility=btn_data.get('visibility', 'all'), + conditions=ButtonConditions(**btn_data['conditions']) if btn_data.get('conditions') else None, + dynamic_text=btn_data.get('dynamic_text', False), + open_mode=btn_data.get('open_mode', 'callback'), + webapp_url=btn_data.get('webapp_url'), + description=btn_data.get('description'), ) return MenuLayoutExportResponse( - version=export_data.get("version", 1), + version=export_data.get('version', 1), rows=rows, buttons=buttons, exported_at=datetime.utcnow(), ) -@router.post("/import", response_model=MenuLayoutImportResponse) +@router.post('/import', response_model=MenuLayoutImportResponse) async def import_menu_layout( payload: MenuLayoutImportRequest, _: Any = Security(require_api_token), @@ -630,22 +601,22 @@ async def import_menu_layout( ) -> MenuLayoutImportResponse: """Импортировать конфигурацию меню.""" import_data = { - "version": payload.version, - "rows": [row.model_dump() for row in payload.rows], - "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}, + 'version': payload.version, + 'rows': [row.model_dump() for row in payload.rows], + 'buttons': {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()}, } result = await MenuLayoutService.import_config(db, import_data, payload.merge_mode) return MenuLayoutImportResponse( - success=result["success"], - imported_rows=result["imported_rows"], - imported_buttons=result["imported_buttons"], - warnings=result["warnings"], + success=result['success'], + imported_rows=result['imported_rows'], + imported_buttons=result['imported_buttons'], + warnings=result['warnings'], ) -@router.post("/validate", response_model=MenuLayoutValidateResponse) +@router.post('/validate', response_model=MenuLayoutValidateResponse) async def validate_menu_layout( payload: MenuLayoutValidateRequest, _: Any = Security(require_api_token), @@ -657,31 +628,29 @@ async def validate_menu_layout( config = await MenuLayoutService.get_config(db) else: config = { - "rows": [row.model_dump() for row in payload.rows] if payload.rows else [], - "buttons": {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()} - if payload.buttons - else {}, + 'rows': [row.model_dump() for row in payload.rows] if payload.rows else [], + 'buttons': {btn_id: btn.model_dump() for btn_id, btn in payload.buttons.items()} if payload.buttons else {}, } result = MenuLayoutService.validate_config(config) return MenuLayoutValidateResponse( - is_valid=result["is_valid"], + is_valid=result['is_valid'], errors=[ ValidationError( - field=e["field"], - message=e["message"], - severity=e["severity"], + field=e['field'], + message=e['message'], + severity=e['severity'], ) - for e in result["errors"] + for e in result['errors'] ], warnings=[ ValidationError( - field=w["field"], - message=w["message"], - severity=w["severity"], + field=w['field'], + message=w['message'], + severity=w['severity'], ) - for w in result["warnings"] + for w in result['warnings'] ], ) @@ -689,7 +658,7 @@ async def validate_menu_layout( # --- Эндпоинты истории изменений --- -@router.get("/history", response_model=MenuLayoutHistoryResponse) +@router.get('/history', response_model=MenuLayoutHistoryResponse) async def get_menu_layout_history( limit: int = 50, offset: int = 0, @@ -703,11 +672,11 @@ async def get_menu_layout_history( return MenuLayoutHistoryResponse( items=[ MenuLayoutHistoryEntry( - id=entry["id"], - created_at=entry["created_at"], - action=entry["action"], - changes_summary=entry["changes_summary"] or "", - user_info=entry["user_info"], + id=entry['id'], + created_at=entry['created_at'], + action=entry['action'], + changes_summary=entry['changes_summary'] or '', + user_info=entry['user_info'], ) for entry in entries ], @@ -715,7 +684,7 @@ async def get_menu_layout_history( ) -@router.get("/history/{history_id}") +@router.get('/history/{history_id}') async def get_history_entry( history_id: int, _: Any = Security(require_api_token), @@ -724,19 +693,19 @@ async def get_history_entry( """Получить конкретную запись истории с полной конфигурацией.""" entry = await MenuLayoutService.get_history_entry(db, history_id) if not entry: - raise HTTPException(status.HTTP_404_NOT_FOUND, f"History entry {history_id} not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, f'History entry {history_id} not found') return { - "id": entry["id"], - "action": entry["action"], - "changes_summary": entry["changes_summary"], - "user_info": entry["user_info"], - "created_at": entry["created_at"].isoformat() if entry["created_at"] else None, - "config": entry["config"], + 'id': entry['id'], + 'action': entry['action'], + 'changes_summary': entry['changes_summary'], + 'user_info': entry['user_info'], + 'created_at': entry['created_at'].isoformat() if entry['created_at'] else None, + 'config': entry['config'], } -@router.post("/history/{history_id}/rollback", response_model=MenuLayoutResponse) +@router.post('/history/{history_id}/rollback', response_model=MenuLayoutResponse) async def rollback_to_history( history_id: int, _: Any = Security(require_api_token), @@ -754,7 +723,7 @@ async def rollback_to_history( # --- Эндпоинты статистики кликов --- -@router.get("/stats", response_model=MenuClickStatsResponse) +@router.get('/stats', response_model=MenuClickStatsResponse) async def get_menu_click_stats( days: int = 30, _: Any = Security(require_api_token), @@ -772,13 +741,13 @@ async def get_menu_click_stats( return MenuClickStatsResponse( items=[ ButtonClickStats( - button_id=s["button_id"], - clicks_total=s["clicks_total"], - clicks_today=s.get("clicks_today", 0), - clicks_week=s.get("clicks_week", 0), - clicks_month=s.get("clicks_month", 0), - unique_users=s["unique_users"], - last_click_at=s["last_click_at"], + button_id=s['button_id'], + clicks_total=s['clicks_total'], + clicks_today=s.get('clicks_today', 0), + clicks_week=s.get('clicks_week', 0), + clicks_month=s.get('clicks_month', 0), + unique_users=s['unique_users'], + last_click_at=s['last_click_at'], ) for s in stats ], @@ -788,7 +757,7 @@ async def get_menu_click_stats( ) -@router.get("/stats/buttons/{button_id}", response_model=ButtonClickStatsResponse) +@router.get('/stats/buttons/{button_id}', response_model=ButtonClickStatsResponse) async def get_button_click_stats( button_id: str, days: int = 30, @@ -802,25 +771,25 @@ async def get_button_click_stats( return ButtonClickStatsResponse( button_id=button_id, stats=ButtonClickStats( - button_id=stats["button_id"], - clicks_total=stats["clicks_total"], - clicks_today=stats["clicks_today"], - clicks_week=stats["clicks_week"], - clicks_month=stats["clicks_month"], - unique_users=stats["unique_users"], - last_click_at=stats["last_click_at"], + button_id=stats['button_id'], + clicks_total=stats['clicks_total'], + clicks_today=stats['clicks_today'], + clicks_week=stats['clicks_week'], + clicks_month=stats['clicks_month'], + unique_users=stats['unique_users'], + last_click_at=stats['last_click_at'], ), clicks_by_day=clicks_by_day, ) -@router.post("/stats/log-click") +@router.post('/stats/log-click') async def log_button_click( button_id: str, - user_id: Optional[int] = None, - callback_data: Optional[str] = None, - button_type: Optional[str] = None, - button_text: Optional[str] = None, + user_id: int | None = None, + callback_data: str | None = None, + button_type: str | None = None, + button_text: str | None = None, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> dict: @@ -833,10 +802,10 @@ async def log_button_click( button_type=button_type, button_text=button_text, ) - return {"success": True} + return {'success': True} -@router.get("/stats/by-type", response_model=ButtonTypeStatsResponse) +@router.get('/stats/by-type', response_model=ButtonTypeStatsResponse) async def get_stats_by_button_type( days: int = 30, _: Any = Security(require_api_token), @@ -845,69 +814,59 @@ async def get_stats_by_button_type( """Получить статистику кликов по типам кнопок (builtin, callback, url, mini_app).""" try: stats = await MenuLayoutService.get_stats_by_button_type(db, days) - total_clicks = sum(s["clicks_total"] for s in stats) - + total_clicks = sum(s['clicks_total'] for s in stats) + return ButtonTypeStatsResponse( items=[ ButtonTypeStats( - button_type=s["button_type"], - clicks_total=s["clicks_total"], - unique_users=s["unique_users"], + button_type=s['button_type'], + clicks_total=s['clicks_total'], + unique_users=s['unique_users'], ) for s in stats ], total_clicks=total_clicks, ) except Exception as e: - logger.error(f"Error getting stats by type: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + logger.error(f'Error getting stats by type: {e}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') -@router.get("/stats/by-hour", response_model=HourlyStatsResponse) +@router.get('/stats/by-hour', response_model=HourlyStatsResponse) async def get_clicks_by_hour( - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> HourlyStatsResponse: """Получить статистику кликов по часам дня (0-23).""" stats = await MenuLayoutService.get_clicks_by_hour(db, button_id, days) - + return HourlyStatsResponse( - items=[ - HourlyStats(hour=s["hour"], count=s["count"]) - for s in stats - ], + items=[HourlyStats(hour=s['hour'], count=s['count']) for s in stats], button_id=button_id, ) -@router.get("/stats/by-weekday", response_model=WeekdayStatsResponse) +@router.get('/stats/by-weekday', response_model=WeekdayStatsResponse) async def get_clicks_by_weekday( - button_id: Optional[str] = None, + button_id: str | None = None, days: int = 30, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> WeekdayStatsResponse: """Получить статистику кликов по дням недели.""" stats = await MenuLayoutService.get_clicks_by_weekday(db, button_id, days) - + return WeekdayStatsResponse( - items=[ - WeekdayStats( - weekday=s["weekday"], - weekday_name=s["weekday_name"], - count=s["count"] - ) - for s in stats - ], + items=[WeekdayStats(weekday=s['weekday'], weekday_name=s['weekday_name'], count=s['count']) for s in stats], button_id=button_id, ) -@router.get("/stats/top-users", response_model=TopUsersResponse) +@router.get('/stats/top-users', response_model=TopUsersResponse) async def get_top_users( - button_id: Optional[str] = None, + button_id: str | None = None, limit: int = 10, days: int = 30, _: Any = Security(require_api_token), @@ -920,9 +879,9 @@ async def get_top_users( return TopUsersResponse( items=[ TopUserStats( - user_id=s["user_id"], - clicks_count=s["clicks_count"], - last_click_at=s["last_click_at"], + user_id=s['user_id'], + clicks_count=s['clicks_count'], + last_click_at=s['last_click_at'], ) for s in stats ], @@ -930,13 +889,13 @@ async def get_top_users( limit=limit, ) except Exception as e: - logger.error(f"Error getting top users: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + logger.error(f'Error getting top users: {e}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') -@router.get("/stats/compare", response_model=PeriodComparisonResponse) +@router.get('/stats/compare', response_model=PeriodComparisonResponse) async def get_period_comparison( - button_id: Optional[str] = None, + button_id: str | None = None, current_days: int = 7, previous_days: int = 7, _: Any = Security(require_api_token), @@ -944,24 +903,24 @@ async def get_period_comparison( ) -> PeriodComparisonResponse: """Сравнить статистику текущего и предыдущего периода.""" try: - comparison = await MenuLayoutService.get_period_comparison( - db, button_id, current_days, previous_days + comparison = await MenuLayoutService.get_period_comparison(db, button_id, current_days, previous_days) + + logger.debug( + f'Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get("change", {}).get("trend")}' ) - - logger.debug(f"Period comparison: button_id={button_id}, current_days={current_days}, previous_days={previous_days}, trend={comparison.get('change', {}).get('trend')}") - + return PeriodComparisonResponse( - current_period=comparison["current_period"], - previous_period=comparison["previous_period"], - change=comparison["change"], + current_period=comparison['current_period'], + previous_period=comparison['previous_period'], + change=comparison['change'], button_id=button_id, ) except Exception as e: - logger.error(f"Error getting period comparison: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + logger.error(f'Error getting period comparison: {e}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') -@router.get("/stats/users/{user_id}/sequences", response_model=UserClickSequencesResponse) +@router.get('/stats/users/{user_id}/sequences', response_model=UserClickSequencesResponse) async def get_user_click_sequences( user_id: int, limit: int = 50, @@ -971,21 +930,21 @@ async def get_user_click_sequences( """Получить последовательности кликов пользователя.""" try: sequences = await MenuLayoutService.get_user_click_sequences(db, user_id, limit) - - logger.debug(f"User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences") - + + logger.debug(f'User sequences: user_id={user_id}, limit={limit}, found={len(sequences)} sequences') + return UserClickSequencesResponse( user_id=user_id, items=[ UserClickSequence( - button_id=s["button_id"], - button_text=s["button_text"], - clicked_at=s["clicked_at"], + button_id=s['button_id'], + button_text=s['button_text'], + clicked_at=s['clicked_at'], ) for s in sequences ], total=len(sequences), ) except Exception as e: - logger.error(f"Error getting user sequences: user_id={user_id}, error={e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + logger.error(f'Error getting user sequences: user_id={user_id}, error={e}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Internal server error: {e!s}') diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 0f79cd46..d1fd6724 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -2,13 +2,14 @@ from __future__ import annotations import json import logging -import re import math -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR, ROUND_UP -from datetime import datetime, timedelta, timezone +import re +from collections.abc import Collection +from datetime import UTC, datetime, timedelta +from decimal import ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation from pathlib import Path +from typing import Any from uuid import uuid4 -from typing import Any, Callable, Collection, Dict, List, Optional, Tuple, Union from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status @@ -25,15 +26,14 @@ from app.database.crud.discount_offer import ( mark_offer_claimed, ) from app.database.crud.promo_group import get_auto_assign_promo_groups -from app.database.crud.rules import get_rules_by_language from app.database.crud.promo_offer_template import get_promo_offer_template_by_id +from app.database.crud.rules import get_rules_by_language from app.database.crud.server_squad import ( add_user_to_servers, get_available_server_squads, get_server_squad_by_uuid, remove_user_from_servers, ) -from app.database.crud.tariff import get_all_tariffs, get_tariff_by_id, get_tariffs_for_user from app.database.crud.subscription import ( add_subscription_servers, create_trial_subscription, @@ -41,44 +41,48 @@ from app.database.crud.subscription import ( remove_subscription_servers, update_subscription_autopay, ) +from app.database.crud.tariff import get_tariff_by_id, get_tariffs_for_user from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, ) from app.database.crud.user import get_user_by_telegram_id, subtract_user_balance from app.database.models import ( + PaymentMethod, PromoGroup, PromoOfferTemplate, Subscription, SubscriptionTemporaryAccess, Transaction, TransactionType, - PaymentMethod, User, ) from app.services.faq_service import FaqService +from app.services.maintenance_service import maintenance_service +from app.services.payment_service import PaymentService, get_wata_payment_by_link_id from app.services.privacy_policy_service import PrivacyPolicyService +from app.services.promo_offer_service import promo_offer_service +from app.services.promocode_service import PromoCodeService from app.services.public_offer_service import PublicOfferService -from app.utils.timezone import format_local_datetime from app.services.remnawave_service import ( RemnaWaveConfigurationError, RemnaWaveService, ) -from app.services.payment_service import PaymentService, get_wata_payment_by_link_id -from app.services.promo_offer_service import promo_offer_service -from app.services.promocode_service import PromoCodeService -from app.services.maintenance_service import maintenance_service -from app.services.subscription_service import SubscriptionService +from app.services.subscription_purchase_service import ( + PurchaseBalanceError, + PurchaseValidationError, + purchase_service, +) from app.services.subscription_renewal_service import ( SubscriptionRenewalChargeError, SubscriptionRenewalService, build_payment_descriptor, - build_renewal_period_id, - decode_payment_payload, calculate_missing_amount, + decode_payment_payload, encode_payment_payload, with_admin_notification_service, ) +from app.services.subscription_service import SubscriptionService from app.services.trial_activation_service import ( TrialPaymentChargeFailed, TrialPaymentInsufficientFunds, @@ -87,23 +91,8 @@ from app.services.trial_activation_service import ( revert_trial_activation, rollback_trial_subscription_activation, ) -from app.services.subscription_purchase_service import ( - purchase_service, - PurchaseBalanceError, - PurchaseValidationError, -) from app.services.tribute_service import TributeService from app.utils.currency_converter import currency_converter -from app.utils.subscription_utils import get_happ_cryptolink_redirect_link -from app.utils.telegram_webapp import ( - TelegramWebAppAuthError, - parse_webapp_init_data, -) -from app.utils.user_utils import ( - get_effective_referral_commission_percent, - get_detailed_referral_list, - get_user_referral_summary, -) from app.utils.pricing_utils import ( apply_percentage_discount, calculate_prorated_price, @@ -111,18 +100,31 @@ from app.utils.pricing_utils import ( get_remaining_months, ) from app.utils.promo_offer import get_user_active_promo_discount_percent +from app.utils.subscription_utils import get_happ_cryptolink_redirect_link +from app.utils.telegram_webapp import ( + TelegramWebAppAuthError, + parse_webapp_init_data, +) +from app.utils.timezone import format_local_datetime +from app.utils.user_utils import ( + get_detailed_referral_list, + get_effective_referral_commission_percent, + get_user_referral_summary, +) from ..dependencies import get_db_session from ..schemas.miniapp import ( MiniAppAutoPromoGroupLevel, MiniAppConnectedServer, + MiniAppCurrentTariff, + MiniAppDailySubscriptionToggleRequest, MiniAppDevice, MiniAppDeviceRemovalRequest, MiniAppDeviceRemovalResponse, - MiniAppMaintenanceStatusResponse, MiniAppFaq, MiniAppFaqItem, MiniAppLegalDocuments, + MiniAppMaintenanceStatusResponse, MiniAppPaymentCreateRequest, MiniAppPaymentCreateResponse, MiniAppPaymentIframeConfig, @@ -149,56 +151,51 @@ from ..schemas.miniapp import ( MiniAppReferralStats, MiniAppReferralTerms, MiniAppRichTextDocument, - MiniAppSubscriptionRequest, - MiniAppSubscriptionResponse, - MiniAppSubscriptionUser, - MiniAppTransaction, - MiniAppSubscriptionSettingsRequest, - MiniAppSubscriptionSettingsResponse, - MiniAppSubscriptionSettings, - MiniAppSubscriptionCurrentSettings, - MiniAppSubscriptionServersSettings, - MiniAppSubscriptionServerOption, - MiniAppSubscriptionTrafficSettings, - MiniAppSubscriptionTrafficOption, - MiniAppSubscriptionDevicesSettings, - MiniAppSubscriptionDeviceOption, + MiniAppSubscriptionAutopay, + MiniAppSubscriptionAutopayRequest, + MiniAppSubscriptionAutopayResponse, MiniAppSubscriptionBillingContext, - MiniAppSubscriptionServersUpdateRequest, - MiniAppSubscriptionTrafficUpdateRequest, + MiniAppSubscriptionCurrentSettings, + MiniAppSubscriptionDeviceOption, + MiniAppSubscriptionDevicesSettings, MiniAppSubscriptionDevicesUpdateRequest, - MiniAppSubscriptionUpdateResponse, MiniAppSubscriptionPurchaseOptionsRequest, MiniAppSubscriptionPurchaseOptionsResponse, MiniAppSubscriptionPurchasePreviewRequest, MiniAppSubscriptionPurchasePreviewResponse, MiniAppSubscriptionPurchaseRequest, MiniAppSubscriptionPurchaseResponse, - MiniAppSubscriptionTrialRequest, - MiniAppSubscriptionTrialResponse, - MiniAppSubscriptionAutopay, - MiniAppSubscriptionAutopayRequest, - MiniAppSubscriptionAutopayResponse, MiniAppSubscriptionRenewalOptionsRequest, MiniAppSubscriptionRenewalOptionsResponse, MiniAppSubscriptionRenewalPeriod, MiniAppSubscriptionRenewalRequest, MiniAppSubscriptionRenewalResponse, + MiniAppSubscriptionRequest, + MiniAppSubscriptionResponse, + MiniAppSubscriptionServerOption, + MiniAppSubscriptionServersSettings, + MiniAppSubscriptionServersUpdateRequest, + MiniAppSubscriptionSettings, + MiniAppSubscriptionSettingsRequest, + MiniAppSubscriptionSettingsResponse, + MiniAppSubscriptionTrafficOption, + MiniAppSubscriptionTrafficSettings, + MiniAppSubscriptionTrafficUpdateRequest, + MiniAppSubscriptionTrialRequest, + MiniAppSubscriptionTrialResponse, + MiniAppSubscriptionUpdateResponse, + MiniAppSubscriptionUser, MiniAppTariff, MiniAppTariffPeriod, - MiniAppTariffsRequest, - MiniAppTariffsResponse, MiniAppTariffPurchaseRequest, MiniAppTariffPurchaseResponse, - MiniAppTariffSwitchRequest, + MiniAppTariffsRequest, + MiniAppTariffsResponse, MiniAppTariffSwitchPreviewResponse, + MiniAppTariffSwitchRequest, MiniAppTariffSwitchResponse, - MiniAppCurrentTariff, - MiniAppConnectedServer, MiniAppTrafficTopupRequest, - MiniAppTrafficTopupResponse, - MiniAppDailySubscriptionToggleRequest, - MiniAppDailySubscriptionToggleResponse, + MiniAppTransaction, ) @@ -210,7 +207,7 @@ promo_code_service = PromoCodeService() renewal_service = SubscriptionRenewalService() # Кешированный Bot для проверки подписки на канал (снижает нагрузку) -_channel_check_bot: Optional[Bot] = None +_channel_check_bot: Bot | None = None def _get_channel_check_bot() -> Bot: @@ -243,18 +240,18 @@ def _get_tariff_monthly_price(tariff) -> int: return 0 -@router.get("/app-config.json") -async def get_app_config() -> Dict[str, Any]: +@router.get('/app-config.json') +async def get_app_config() -> dict[str, Any]: data = _load_app_config_data() if data is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="App config not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='App config not found') return data -def _get_app_config_candidate_files() -> List[Path]: +def _get_app_config_candidate_files() -> list[Path]: seen: set[Path] = set() - candidates: List[Path] = [] + candidates: list[Path] = [] def _add_candidate(path: Path) -> None: resolved = path.resolve() @@ -263,29 +260,29 @@ def _get_app_config_candidate_files() -> List[Path]: candidates.append(resolved) cwd = Path.cwd() - _add_candidate(cwd / "miniapp" / "app-config.json") - _add_candidate(cwd / "app-config.json") + _add_candidate(cwd / 'miniapp' / 'app-config.json') + _add_candidate(cwd / 'app-config.json') current = Path(__file__).resolve() for parent in current.parents: - _add_candidate(parent / "miniapp" / "app-config.json") - _add_candidate(parent / "app-config.json") + _add_candidate(parent / 'miniapp' / 'app-config.json') + _add_candidate(parent / 'app-config.json') - _add_candidate(Path("/var/www/remnawave-miniapp/app-config.json")) + _add_candidate(Path('/var/www/remnawave-miniapp/app-config.json')) return candidates -def _load_app_config_data() -> Optional[Dict[str, Any]]: +def _load_app_config_data() -> dict[str, Any] | None: for path in _get_app_config_candidate_files(): if not path.is_file(): continue try: - with path.open("r", encoding="utf-8") as file: + with path.open('r', encoding='utf-8') as file: data = json.load(file) except (OSError, json.JSONDecodeError) as error: - logger.warning("Failed to load app-config from %s: %s", path, error) + logger.warning('Failed to load app-config from %s: %s', path, error) continue if isinstance(data, dict): @@ -293,39 +290,40 @@ def _load_app_config_data() -> Optional[Dict[str, Any]]: return None + _DECIMAL_ONE_HUNDRED = Decimal(100) -_DECIMAL_CENT = Decimal("0.01") +_DECIMAL_CENT = Decimal('0.01') _PAYMENT_SUCCESS_STATUSES = { - "paid", - "success", - "succeeded", - "completed", - "captured", - "done", - "overpaid", + 'paid', + 'success', + 'succeeded', + 'completed', + 'captured', + 'done', + 'overpaid', } _PAYMENT_FAILURE_STATUSES = { - "fail", - "failed", - "canceled", - "cancelled", - "declined", - "expired", - "rejected", - "error", - "refunded", - "chargeback", + 'fail', + 'failed', + 'canceled', + 'cancelled', + 'declined', + 'expired', + 'rejected', + 'error', + 'refunded', + 'chargeback', } -_PERIOD_ID_PATTERN = re.compile(r"(\d+)") +_PERIOD_ID_PATTERN = re.compile(r'(\d+)') _AUTOPAY_DEFAULT_DAY_OPTIONS = (1, 3, 7, 14) -def _normalize_autopay_days(value: Optional[Any]) -> Optional[int]: +def _normalize_autopay_days(value: Any | None) -> int | None: if value is None: return None try: @@ -335,23 +333,19 @@ def _normalize_autopay_days(value: Optional[Any]) -> Optional[int]: return numeric if numeric >= 0 else None -def _get_autopay_day_options(subscription: Optional[Subscription]) -> List[int]: +def _get_autopay_day_options(subscription: Subscription | None) -> list[int]: options: set[int] = set() for candidate in _AUTOPAY_DEFAULT_DAY_OPTIONS: normalized = _normalize_autopay_days(candidate) if normalized is not None: options.add(normalized) - default_setting = _normalize_autopay_days( - getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) - ) + default_setting = _normalize_autopay_days(getattr(settings, 'DEFAULT_AUTOPAY_DAYS_BEFORE', None)) if default_setting is not None: options.add(default_setting) if subscription is not None: - current = _normalize_autopay_days( - getattr(subscription, "autopay_days_before", None) - ) + current = _normalize_autopay_days(getattr(subscription, 'autopay_days_before', None)) if current is not None: options.add(current) @@ -359,42 +353,38 @@ def _get_autopay_day_options(subscription: Optional[Subscription]) -> List[int]: def _build_autopay_payload( - subscription: Optional[Subscription], -) -> Optional[MiniAppSubscriptionAutopay]: + subscription: Subscription | None, +) -> MiniAppSubscriptionAutopay | None: if subscription is None: return None - enabled = bool(getattr(subscription, "autopay_enabled", False)) - days_before = _normalize_autopay_days( - getattr(subscription, "autopay_days_before", None) - ) + enabled = bool(getattr(subscription, 'autopay_enabled', False)) + days_before = _normalize_autopay_days(getattr(subscription, 'autopay_days_before', None)) options = _get_autopay_day_options(subscription) default_days = days_before if default_days is None: - default_days = _normalize_autopay_days( - getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) - ) + default_days = _normalize_autopay_days(getattr(settings, 'DEFAULT_AUTOPAY_DAYS_BEFORE', None)) if default_days is None and options: default_days = options[0] - autopay_kwargs: Dict[str, Any] = { - "enabled": enabled, - "autopay_enabled": enabled, - "days_before": days_before, - "autopay_days_before": days_before, - "default_days_before": default_days, - "autopay_days_options": options, - "days_options": options, - "options": options, - "available_days": options, - "availableDays": options, - "autopayEnabled": enabled, - "autopayDaysBefore": days_before, - "autopayDaysOptions": options, - "daysBefore": days_before, - "daysOptions": options, - "defaultDaysBefore": default_days, + autopay_kwargs: dict[str, Any] = { + 'enabled': enabled, + 'autopay_enabled': enabled, + 'days_before': days_before, + 'autopay_days_before': days_before, + 'default_days_before': default_days, + 'autopay_days_options': options, + 'days_options': options, + 'options': options, + 'available_days': options, + 'availableDays': options, + 'autopayEnabled': enabled, + 'autopayDaysBefore': days_before, + 'autopayDaysOptions': options, + 'daysBefore': days_before, + 'daysOptions': options, + 'defaultDaysBefore': default_days, } return MiniAppSubscriptionAutopay(**autopay_kwargs) @@ -402,21 +392,21 @@ def _build_autopay_payload( def _autopay_response_extras( enabled: bool, - days_before: Optional[int], - options: List[int], - autopay_payload: Optional[MiniAppSubscriptionAutopay], -) -> Dict[str, Any]: - extras: Dict[str, Any] = { - "autopayEnabled": enabled, - "autopayDaysBefore": days_before, - "autopayDaysOptions": options, + days_before: int | None, + options: list[int], + autopay_payload: MiniAppSubscriptionAutopay | None, +) -> dict[str, Any]: + extras: dict[str, Any] = { + 'autopayEnabled': enabled, + 'autopayDaysBefore': days_before, + 'autopayDaysOptions': options, } if days_before is not None: - extras["daysBefore"] = days_before + extras['daysBefore'] = days_before if options: - extras["daysOptions"] = options + extras['daysOptions'] = options if autopay_payload is not None: - extras["autopaySettings"] = autopay_payload + extras['autopaySettings'] = autopay_payload return extras @@ -430,11 +420,10 @@ async def _get_usd_to_rub_rate() -> float: return float(rate) -def _compute_cryptobot_limits(rate: float) -> Tuple[int, int]: +def _compute_cryptobot_limits(rate: float) -> tuple[int, int]: min_kopeks = max(1, int(math.ceil(rate * _CRYPTOBOT_MIN_USD * 100))) max_kopeks = int(math.floor(rate * _CRYPTOBOT_MAX_USD * 100)) - if max_kopeks < min_kopeks: - max_kopeks = min_kopeks + max_kopeks = max(max_kopeks, min_kopeks) return min_kopeks, max_kopeks @@ -442,7 +431,7 @@ def _current_request_timestamp() -> str: return datetime.utcnow().replace(microsecond=0).isoformat() -def _compute_stars_min_amount() -> Optional[int]: +def _compute_stars_min_amount() -> int | None: try: rate = Decimal(str(settings.get_stars_rate())) except (InvalidOperation, TypeError): @@ -454,14 +443,14 @@ def _compute_stars_min_amount() -> Optional[int]: return int((rate * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP)) -def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]: +def _normalize_stars_amount(amount_kopeks: int) -> tuple[int, int]: try: rate = Decimal(str(settings.get_stars_rate())) except (InvalidOperation, TypeError): - raise ValueError("Stars rate is not configured") + raise ValueError('Stars rate is not configured') if rate <= 0: - raise ValueError("Stars rate must be positive") + raise ValueError('Stars rate must be positive') amount_rubles = Decimal(amount_kopeks) / _DECIMAL_ONE_HUNDRED stars_amount = int((amount_rubles / rate).to_integral_value(rounding=ROUND_FLOOR)) @@ -472,27 +461,20 @@ def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]: _DECIMAL_CENT, rounding=ROUND_HALF_UP, ) - normalized_amount_kopeks = int( - (normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value( - rounding=ROUND_HALF_UP - ) - ) + normalized_amount_kopeks = int((normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP)) return stars_amount, normalized_amount_kopeks def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str: suffix = uuid4().hex[:8] - return f"balance_{user_id}_{amount_kopeks}_{suffix}" + return f'balance_{user_id}_{amount_kopeks}_{suffix}' def _merge_purchase_selection_from_request( - payload: Union[ - "MiniAppSubscriptionPurchasePreviewRequest", - "MiniAppSubscriptionPurchaseRequest", - ] -) -> Dict[str, Any]: - base: Dict[str, Any] = {} + payload: MiniAppSubscriptionPurchasePreviewRequest | MiniAppSubscriptionPurchaseRequest, +) -> dict[str, Any]: + base: dict[str, Any] = {} if payload.selection: base.update(payload.selection) @@ -502,30 +484,30 @@ def _merge_purchase_selection_from_request( if key not in base: base[key] = value - _maybe_set("period_id", getattr(payload, "period_id", None)) - _maybe_set("period_days", getattr(payload, "period_days", None)) + _maybe_set('period_id', getattr(payload, 'period_id', None)) + _maybe_set('period_days', getattr(payload, 'period_days', None)) - _maybe_set("traffic_value", getattr(payload, "traffic_value", None)) - _maybe_set("traffic", getattr(payload, "traffic", None)) - _maybe_set("traffic_gb", getattr(payload, "traffic_gb", None)) + _maybe_set('traffic_value', getattr(payload, 'traffic_value', None)) + _maybe_set('traffic', getattr(payload, 'traffic', None)) + _maybe_set('traffic_gb', getattr(payload, 'traffic_gb', None)) - servers = getattr(payload, "servers", None) - if servers is not None and "servers" not in base: - base["servers"] = servers - countries = getattr(payload, "countries", None) - if countries is not None and "countries" not in base: - base["countries"] = countries - server_uuids = getattr(payload, "server_uuids", None) - if server_uuids is not None and "server_uuids" not in base: - base["server_uuids"] = server_uuids + servers = getattr(payload, 'servers', None) + if servers is not None and 'servers' not in base: + base['servers'] = servers + countries = getattr(payload, 'countries', None) + if countries is not None and 'countries' not in base: + base['countries'] = countries + server_uuids = getattr(payload, 'server_uuids', None) + if server_uuids is not None and 'server_uuids' not in base: + base['server_uuids'] = server_uuids - _maybe_set("devices", getattr(payload, "devices", None)) - _maybe_set("device_limit", getattr(payload, "device_limit", None)) + _maybe_set('devices', getattr(payload, 'devices', None)) + _maybe_set('device_limit', getattr(payload, 'device_limit', None)) return base -def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]: +def _parse_client_timestamp(value: str | float | None) -> datetime | None: if value is None: return None if isinstance(value, (int, float)): @@ -536,7 +518,7 @@ def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional if timestamp > 1e12: timestamp /= 1000.0 try: - return datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None) + return datetime.fromtimestamp(timestamp, tz=UTC).replace(tzinfo=None) except (OverflowError, OSError, ValueError): return None if isinstance(value, str): @@ -545,16 +527,16 @@ def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional return None if normalized.isdigit(): return _parse_client_timestamp(int(normalized)) - for suffix in ("Z", "z"): + for suffix in ('Z', 'z'): if normalized.endswith(suffix): - normalized = normalized[:-1] + "+00:00" + normalized = normalized[:-1] + '+00:00' break try: parsed = datetime.fromisoformat(normalized) except ValueError: return None if parsed.tzinfo: - return parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed.astimezone(UTC).replace(tzinfo=None) return parsed return None @@ -564,13 +546,13 @@ async def _find_recent_deposit( *, user_id: int, payment_method: PaymentMethod, - amount_kopeks: Optional[int], - started_at: Optional[datetime], + amount_kopeks: int | None, + started_at: datetime | None, tolerance: timedelta = timedelta(minutes=5), -) -> Optional[Transaction]: +) -> Transaction | None: def _transaction_matches_started_at( transaction: Transaction, - reference: Optional[datetime], + reference: datetime | None, ) -> bool: if not reference: return True @@ -578,7 +560,7 @@ async def _find_recent_deposit( if not timestamp: return False if timestamp.tzinfo: - timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None) + timestamp = timestamp.astimezone(UTC).replace(tzinfo=None) return timestamp >= reference query = ( @@ -609,19 +591,20 @@ async def _find_recent_deposit( return transaction -def _classify_status(status: Optional[str], is_paid: bool) -> str: +def _classify_status(status: str | None, is_paid: bool) -> str: if is_paid: - return "paid" - normalized = (status or "").strip().lower() + return 'paid' + normalized = (status or '').strip().lower() if not normalized: - return "pending" + return 'pending' if normalized in _PAYMENT_SUCCESS_STATUSES: - return "paid" + return 'paid' if normalized in _PAYMENT_FAILURE_STATUSES: - return "failed" - return "pending" + return 'failed' + return 'pending' -def _format_gb(value: Optional[float]) -> float: + +def _format_gb(value: float | None) -> float: if value is None: return 0.0 try: @@ -633,26 +616,26 @@ def _format_gb(value: Optional[float]) -> float: def _format_gb_label(value: float) -> str: absolute = abs(value) if absolute >= 100: - return f"{value:.0f} GB" + return f'{value:.0f} GB' if absolute >= 10: - return f"{value:.1f} GB" - return f"{value:.2f} GB" + return f'{value:.1f} GB' + return f'{value:.2f} GB' -def _format_limit_label(limit: Optional[int]) -> str: +def _format_limit_label(limit: int | None) -> str: if not limit: - return "Unlimited" - return f"{limit} GB" + return 'Unlimited' + return f'{limit} GB' async def _resolve_user_from_init_data( db: AsyncSession, init_data: str, -) -> Tuple[User, Dict[str, Any]]: +) -> tuple[User, dict[str, Any]]: if not init_data: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail="Missing initData", + detail='Missing initData', ) try: @@ -663,35 +646,35 @@ async def _resolve_user_from_init_data( detail=str(error), ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", + detail='Invalid Telegram user payload', ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user identifier", + detail='Invalid Telegram user identifier', ) from None user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail="User not found", + detail='User not found', ) return user, webapp_data def _normalize_amount_kopeks( - amount_rubles: Optional[float], - amount_kopeks: Optional[int], -) -> Optional[int]: + amount_rubles: float | None, + amount_kopeks: int | None, +) -> int | None: if amount_kopeks is not None: try: normalized = int(amount_kopeks) @@ -703,9 +686,7 @@ def _normalize_amount_kopeks( return None try: - decimal_amount = Decimal(str(amount_rubles)).quantize( - Decimal("0.01"), rounding=ROUND_HALF_UP - ) + decimal_amount = Decimal(str(amount_rubles)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) except (InvalidOperation, ValueError): return None @@ -713,7 +694,7 @@ def _normalize_amount_kopeks( return normalized if normalized >= 0 else None -def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]: +def _build_mulenpay_iframe_config() -> MiniAppPaymentIframeConfig | None: expected_origin = settings.get_mulenpay_expected_origin() if not expected_origin: return None @@ -726,7 +707,7 @@ def _build_mulenpay_iframe_config() -> Optional[MiniAppPaymentIframeConfig]: @router.post( - "/maintenance/status", + '/maintenance/status', response_model=MiniAppMaintenanceStatusResponse, ) async def get_maintenance_status( @@ -736,14 +717,14 @@ async def get_maintenance_status( _, _ = await _resolve_user_from_init_data(db, payload.init_data) status_info = maintenance_service.get_status_info() return MiniAppMaintenanceStatusResponse( - is_active=bool(status_info.get("is_active")), + is_active=bool(status_info.get('is_active')), message=maintenance_service.get_maintenance_message(), - reason=status_info.get("reason"), + reason=status_info.get('reason'), ) @router.post( - "/payments/methods", + '/payments/methods', response_model=MiniAppPaymentMethodsResponse, ) async def get_payment_methods( @@ -752,16 +733,16 @@ async def get_payment_methods( ) -> MiniAppPaymentMethodsResponse: _, _ = await _resolve_user_from_init_data(db, payload.init_data) - methods: List[MiniAppPaymentMethod] = [] + methods: list[MiniAppPaymentMethod] = [] if settings.TELEGRAM_STARS_ENABLED: stars_min_amount = _compute_stars_min_amount() methods.append( MiniAppPaymentMethod( - id="stars", - icon="⭐", + id='stars', + icon='⭐', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=stars_min_amount, amount_step_kopeks=stars_min_amount, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -769,13 +750,13 @@ async def get_payment_methods( ) if settings.is_yookassa_enabled(): - if getattr(settings, "YOOKASSA_SBP_ENABLED", False): + if getattr(settings, 'YOOKASSA_SBP_ENABLED', False): methods.append( MiniAppPaymentMethod( - id="yookassa_sbp", - icon="🏦", + id='yookassa_sbp', + icon='🏦', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -784,10 +765,10 @@ async def get_payment_methods( methods.append( MiniAppPaymentMethod( - id="yookassa", - icon="💳", + id='yookassa', + icon='💳', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -797,17 +778,15 @@ async def get_payment_methods( if settings.is_mulenpay_enabled(): mulenpay_iframe_config = _build_mulenpay_iframe_config() mulenpay_integration = ( - MiniAppPaymentIntegrationType.IFRAME - if mulenpay_iframe_config - else MiniAppPaymentIntegrationType.REDIRECT + MiniAppPaymentIntegrationType.IFRAME if mulenpay_iframe_config else MiniAppPaymentIntegrationType.REDIRECT ) methods.append( MiniAppPaymentMethod( - id="mulenpay", + id='mulenpay', name=settings.get_mulenpay_display_name(), - icon="💳", + icon='💳', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS, integration_type=mulenpay_integration, @@ -818,29 +797,29 @@ async def get_payment_methods( if settings.is_pal24_enabled(): methods.append( MiniAppPaymentMethod( - id="pal24", - icon="🏦", + id='pal24', + icon='🏦', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, options=[ MiniAppPaymentOption( - id="sbp", - icon="🏦", - title_key="topup.method.pal24.option.sbp.title", - description_key="topup.method.pal24.option.sbp.description", - title="Faster Payments (SBP)", - description="Instant SBP transfer with no fees.", + id='sbp', + icon='🏦', + title_key='topup.method.pal24.option.sbp.title', + description_key='topup.method.pal24.option.sbp.description', + title='Faster Payments (SBP)', + description='Instant SBP transfer with no fees.', ), MiniAppPaymentOption( - id="card", - icon="💳", - title_key="topup.method.pal24.option.card.title", - description_key="topup.method.pal24.option.card.description", - title="Bank card", - description="Pay with a bank card via PayPalych.", + id='card', + icon='💳', + title_key='topup.method.pal24.option.card.title', + description_key='topup.method.pal24.option.card.description', + title='Bank card', + description='Pay with a bank card via PayPalych.', ), ], ) @@ -849,10 +828,10 @@ async def get_payment_methods( if settings.is_wata_enabled(): methods.append( MiniAppPaymentMethod( - id="wata", - icon="🌊", + id='wata', + icon='🌊', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.WATA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.WATA_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -862,25 +841,25 @@ async def get_payment_methods( if settings.is_platega_enabled() and settings.get_platega_active_methods(): platega_methods = settings.get_platega_active_methods() definitions = settings.get_platega_method_definitions() - options: List[MiniAppPaymentOption] = [] + options: list[MiniAppPaymentOption] = [] for method_code in platega_methods: info = definitions.get(method_code, {}) options.append( MiniAppPaymentOption( id=str(method_code), - icon=info.get("icon") or ("🏦" if method_code == 2 else "💳"), - title_key=f"topup.method.platega.option.{method_code}.title", - description_key=f"topup.method.platega.option.{method_code}.description", - title=info.get("title") or info.get("name") or f"Platega {method_code}", - description=info.get("description") or info.get("name"), + icon=info.get('icon') or ('🏦' if method_code == 2 else '💳'), + title_key=f'topup.method.platega.option.{method_code}.title', + description_key=f'topup.method.platega.option.{method_code}.description', + title=info.get('title') or info.get('name') or f'Platega {method_code}', + description=info.get('description') or info.get('name'), ) ) methods.append( MiniAppPaymentMethod( - id="platega", - icon="💳", + id='platega', + icon='💳', requires_amount=True, currency=settings.PLATEGA_CURRENCY, min_amount_kopeks=settings.PLATEGA_MIN_AMOUNT_KOPEKS, @@ -895,10 +874,10 @@ async def get_payment_methods( min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) methods.append( MiniAppPaymentMethod( - id="cryptobot", - icon="🪙", + id='cryptobot', + icon='🪙', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=min_amount_kopeks, max_amount_kopeks=max_amount_kopeks, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -908,10 +887,10 @@ async def get_payment_methods( if settings.is_heleket_enabled(): methods.append( MiniAppPaymentMethod( - id="heleket", - icon="🪙", + id='heleket', + icon='🪙', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=100 * 100, max_amount_kopeks=100_000 * 100, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -921,10 +900,10 @@ async def get_payment_methods( if settings.is_cloudpayments_enabled(): methods.append( MiniAppPaymentMethod( - id="cloudpayments", - icon="💳", + id='cloudpayments', + icon='💳', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -934,10 +913,10 @@ async def get_payment_methods( if settings.is_freekassa_enabled(): methods.append( MiniAppPaymentMethod( - id="freekassa", - icon="💳", + id='freekassa', + icon='💳', requires_amount=True, - currency="RUB", + currency='RUB', min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS, max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS, integration_type=MiniAppPaymentIntegrationType.REDIRECT, @@ -947,27 +926,27 @@ async def get_payment_methods( if settings.TRIBUTE_ENABLED: methods.append( MiniAppPaymentMethod( - id="tribute", - icon="💎", + id='tribute', + icon='💎', requires_amount=False, - currency="RUB", + currency='RUB', integration_type=MiniAppPaymentIntegrationType.REDIRECT, ) ) order_map = { - "stars": 1, - "yookassa_sbp": 2, - "yookassa": 3, - "cloudpayments": 4, - "freekassa": 5, - "mulenpay": 6, - "pal24": 7, - "platega": 8, - "wata": 9, - "cryptobot": 10, - "heleket": 11, - "tribute": 12, + 'stars': 1, + 'yookassa_sbp': 2, + 'yookassa': 3, + 'cloudpayments': 4, + 'freekassa': 5, + 'mulenpay': 6, + 'pal24': 7, + 'platega': 8, + 'wata': 9, + 'cryptobot': 10, + 'heleket': 11, + 'tribute': 12, } methods.sort(key=lambda item: order_map.get(item.id, 99)) @@ -975,7 +954,7 @@ async def get_payment_methods( @router.post( - "/payments/create", + '/payments/create', response_model=MiniAppPaymentCreateResponse, ) async def create_payment_link( @@ -984,11 +963,11 @@ async def create_payment_link( ) -> MiniAppPaymentCreateResponse: user, _ = await _resolve_user_from_init_data(db, payload.init_data) - method = (payload.method or "").strip().lower() + method = (payload.method or '').strip().lower() if not method: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Payment method is required", + detail='Payment method is required', ) amount_kopeks = _normalize_amount_kopeks( @@ -996,22 +975,22 @@ async def create_payment_link( payload.amount_kopeks, ) - if method == "stars": + if method == 'stars': if not settings.TELEGRAM_STARS_ENABLED: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if not settings.BOT_TOKEN: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured') requested_amount_kopeks = amount_kopeks try: stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks) except ValueError as exc: - logger.error("Failed to normalize Stars amount: %s", exc) + logger.error('Failed to normalize Stars amount: %s', exc) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to prepare Stars payment", + detail='Failed to prepare Stars payment', ) from exc bot = Bot(token=settings.BOT_TOKEN) @@ -1028,29 +1007,29 @@ async def create_payment_link( await bot.session.close() if not invoice_link: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create invoice') return MiniAppPaymentCreateResponse( method=method, payment_url=invoice_link, amount_kopeks=amount_kopeks, extra={ - "invoice_payload": invoice_payload, - "requested_at": _current_request_timestamp(), - "stars_amount": stars_amount, - "requested_amount_kopeks": requested_amount_kopeks, + 'invoice_payload': invoice_payload, + 'requested_at': _current_request_timestamp(), + 'stars_amount': stars_amount, + 'requested_amount_kopeks': requested_amount_kopeks, }, ) - if method == "yookassa_sbp": - if not settings.is_yookassa_enabled() or not getattr(settings, "YOOKASSA_SBP_ENABLED", False): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + if method == 'yookassa_sbp': + if not settings.is_yookassa_enabled() or not getattr(settings, 'YOOKASSA_SBP_ENABLED', False): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') payment_service = PaymentService() result = await payment_service.create_yookassa_sbp_payment( @@ -1059,19 +1038,19 @@ async def create_payment_link( amount_kopeks=amount_kopeks, description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), ) - confirmation_url = result.get("confirmation_url") if result else None + confirmation_url = result.get('confirmation_url') if result else None if not result or not confirmation_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') extra: dict[str, Any] = { - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("yookassa_payment_id"), - "status": result.get("status"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'payment_id': result.get('yookassa_payment_id'), + 'status': result.get('status'), + 'requested_at': _current_request_timestamp(), } - confirmation_token = result.get("confirmation_token") + confirmation_token = result.get('confirmation_token') if confirmation_token: - extra["confirmation_token"] = confirmation_token + extra['confirmation_token'] = confirmation_token return MiniAppPaymentCreateResponse( method=method, @@ -1080,15 +1059,15 @@ async def create_payment_link( extra=extra, ) - if method == "yookassa": + if method == 'yookassa': if not settings.is_yookassa_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') payment_service = PaymentService() result = await payment_service.create_yookassa_payment( @@ -1097,30 +1076,30 @@ async def create_payment_link( amount_kopeks=amount_kopeks, description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), ) - if not result or not result.get("confirmation_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + if not result or not result.get('confirmation_url'): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, - payment_url=result["confirmation_url"], + payment_url=result['confirmation_url'], amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("yookassa_payment_id"), - "status": result.get("status"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'payment_id': result.get('yookassa_payment_id'), + 'status': result.get('status'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "mulenpay": + if method == 'mulenpay': if not settings.is_mulenpay_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') payment_service = PaymentService() result = await payment_service.create_mulenpay_payment( @@ -1130,39 +1109,39 @@ async def create_payment_link( description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language, ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + if not result or not result.get('payment_url'): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, - payment_url=result["payment_url"], + payment_url=result['payment_url'], amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("mulen_payment_id"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'payment_id': result.get('mulen_payment_id'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "platega": + if method == 'platega': if not settings.is_platega_enabled() or not settings.get_platega_active_methods(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') active_methods = settings.get_platega_active_methods() method_option = payload.payment_option or str(active_methods[0]) try: method_code = int(str(method_option).strip()) except (TypeError, ValueError): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid Platega payment option") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Invalid Platega payment option') if method_code not in active_methods: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Selected Platega method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Selected Platega method is unavailable') payment_service = PaymentService() result = await payment_service.create_platega_payment( @@ -1174,33 +1153,33 @@ async def create_payment_link( payment_method_code=method_code, ) - redirect_url = result.get("redirect_url") if result else None + redirect_url = result.get('redirect_url') if result else None if not result or not redirect_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, payment_url=redirect_url, amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_id": result.get("transaction_id"), - "correlation_id": result.get("correlation_id"), - "selected_option": str(method_code), - "payload": result.get("payload"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'payment_id': result.get('transaction_id'), + 'correlation_id': result.get('correlation_id'), + 'selected_option': str(method_code), + 'payload': result.get('payload'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "wata": + if method == 'wata': if not settings.is_wata_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.WATA_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.WATA_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') payment_service = PaymentService() result = await payment_service.create_wata_payment( @@ -1210,38 +1189,38 @@ async def create_payment_link( description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), language=user.language, ) - payment_url = result.get("payment_url") if result else None + payment_url = result.get('payment_url') if result else None if not result or not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, payment_url=payment_url, amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "payment_link_id": result.get("payment_link_id"), - "payment_id": result.get("payment_link_id"), - "status": result.get("status"), - "order_id": result.get("order_id"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'payment_link_id': result.get('payment_link_id'), + 'payment_id': result.get('payment_link_id'), + 'status': result.get('status'), + 'order_id': result.get('order_id'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "pal24": + if method == 'pal24': if not settings.is_pal24_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount is below minimum') if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount exceeds maximum') - option = (payload.payment_option or "").strip().lower() - if option not in {"card", "sbp"}: - option = "sbp" - provider_method = "card" if option == "card" else "sbp" + option = (payload.payment_option or '').strip().lower() + if option not in {'card', 'sbp'}: + option = 'sbp' + provider_method = 'card' if option == 'card' else 'sbp' payment_service = PaymentService() result = await payment_service.create_pal24_payment( @@ -1253,71 +1232,72 @@ async def create_payment_link( payment_method=provider_method, ) if not result: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') - preferred_urls: List[Optional[str]] = [] - if option == "sbp": - preferred_urls.append(result.get("sbp_url") or result.get("transfer_url")) - elif option == "card": - preferred_urls.append(result.get("card_url")) + preferred_urls: list[str | None] = [] + if option == 'sbp': + preferred_urls.append(result.get('sbp_url') or result.get('transfer_url')) + elif option == 'card': + preferred_urls.append(result.get('card_url')) preferred_urls.extend( [ - result.get("link_url"), - result.get("link_page_url"), - result.get("payment_url"), - result.get("transfer_url"), + result.get('link_url'), + result.get('link_page_url'), + result.get('payment_url'), + result.get('transfer_url'), ] ) payment_url = next((url for url in preferred_urls if url), None) if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to obtain payment url') return MiniAppPaymentCreateResponse( method=method, payment_url=payment_url, amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "bill_id": result.get("bill_id"), - "order_id": result.get("order_id"), - "payment_method": result.get("payment_method") or provider_method, - "sbp_url": result.get("sbp_url") or result.get("transfer_url"), - "card_url": result.get("card_url"), - "link_url": result.get("link_url"), - "link_page_url": result.get("link_page_url"), - "transfer_url": result.get("transfer_url"), - "selected_option": option, - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'bill_id': result.get('bill_id'), + 'order_id': result.get('order_id'), + 'payment_method': result.get('payment_method') or provider_method, + 'sbp_url': result.get('sbp_url') or result.get('transfer_url'), + 'card_url': result.get('card_url'), + 'link_url': result.get('link_url'), + 'link_page_url': result.get('link_page_url'), + 'transfer_url': result.get('transfer_url'), + 'selected_option': option, + 'requested_at': _current_request_timestamp(), }, ) - if method == "cryptobot": + if method == 'cryptobot': if not settings.is_cryptobot_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') rate = await _get_usd_to_rub_rate() min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) if amount_kopeks < min_amount_kopeks: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + detail=f'Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)', ) if amount_kopeks > max_amount_kopeks: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + detail=f'Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)', ) try: amount_usd = float( - (Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate))) - .quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + (Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize( + Decimal('0.01'), rounding=ROUND_HALF_UP + ) ) except (InvalidOperation, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Unable to convert amount to USD", + detail='Unable to convert amount to USD', ) payment_service = PaymentService() @@ -1327,50 +1307,48 @@ async def create_payment_link( amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), - payload=f"balance_{user.id}_{amount_kopeks}", + payload=f'balance_{user.id}_{amount_kopeks}', ) if not result: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback payment_url = ( - result.get("web_app_invoice_url") - or result.get("mini_app_invoice_url") - or result.get("bot_invoice_url") + result.get('web_app_invoice_url') or result.get('mini_app_invoice_url') or result.get('bot_invoice_url') ) if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to obtain payment url') return MiniAppPaymentCreateResponse( method=method, payment_url=payment_url, amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "invoice_id": result.get("invoice_id"), - "amount_usd": amount_usd, - "rate": rate, - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'invoice_id': result.get('invoice_id'), + 'amount_usd': amount_usd, + 'rate': rate, + 'requested_at': _current_request_timestamp(), }, ) - if method == "heleket": + if method == 'heleket': if not settings.is_heleket_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') min_amount_kopeks = 100 * 100 max_amount_kopeks = 100_000 * 100 if amount_kopeks < min_amount_kopeks: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + detail=f'Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)', ) if amount_kopeks > max_amount_kopeks: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + detail=f'Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)', ) payment_service = PaymentService() @@ -1382,40 +1360,40 @@ async def create_payment_link( language=user.language or settings.DEFAULT_LANGUAGE, ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + if not result or not result.get('payment_url'): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, - payment_url=result["payment_url"], + payment_url=result['payment_url'], amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "uuid": result.get("uuid"), - "order_id": result.get("order_id"), - "payer_amount": result.get("payer_amount"), - "payer_currency": result.get("payer_currency"), - "discount_percent": result.get("discount_percent"), - "exchange_rate": result.get("exchange_rate"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'uuid': result.get('uuid'), + 'order_id': result.get('order_id'), + 'payer_amount': result.get('payer_amount'), + 'payer_currency': result.get('payer_currency'), + 'discount_percent': result.get('discount_percent'), + 'exchange_rate': result.get('exchange_rate'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "cloudpayments": + if method == 'cloudpayments': if not settings.is_cloudpayments_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount is below minimum ({settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100:.2f} RUB)", + detail=f'Amount is below minimum ({settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100:.2f} RUB)', ) if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount exceeds maximum ({settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100:.2f} RUB)", + detail=f'Amount exceeds maximum ({settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100:.2f} RUB)', ) payment_service = PaymentService() @@ -1428,35 +1406,35 @@ async def create_payment_link( language=user.language or settings.DEFAULT_LANGUAGE, ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + if not result or not result.get('payment_url'): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, - payment_url=result["payment_url"], + payment_url=result['payment_url'], amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("payment_id"), - "invoice_id": result.get("invoice_id"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('payment_id'), + 'invoice_id': result.get('invoice_id'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "freekassa": + if method == 'freekassa': if not settings.is_freekassa_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if amount_kopeks is None or amount_kopeks <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Amount must be positive') if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount is below minimum ({settings.FREEKASSA_MIN_AMOUNT_KOPEKS / 100:.2f} RUB)", + detail=f'Amount is below minimum ({settings.FREEKASSA_MIN_AMOUNT_KOPEKS / 100:.2f} RUB)', ) if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Amount exceeds maximum ({settings.FREEKASSA_MAX_AMOUNT_KOPEKS / 100:.2f} RUB)", + detail=f'Amount exceeds maximum ({settings.FREEKASSA_MAX_AMOUNT_KOPEKS / 100:.2f} RUB)', ) payment_service = PaymentService() @@ -1465,29 +1443,29 @@ async def create_payment_link( user_id=user.id, amount_kopeks=amount_kopeks, description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), - email=getattr(user, "email", None), + email=getattr(user, 'email', None), language=user.language or settings.DEFAULT_LANGUAGE, ) - if not result or not result.get("payment_url"): - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + if not result or not result.get('payment_url'): + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, - payment_url=result["payment_url"], + payment_url=result['payment_url'], amount_kopeks=amount_kopeks, extra={ - "local_payment_id": result.get("local_payment_id"), - "order_id": result.get("order_id"), - "requested_at": _current_request_timestamp(), + 'local_payment_id': result.get('local_payment_id'), + 'order_id': result.get('order_id'), + 'requested_at': _current_request_timestamp(), }, ) - if method == "tribute": + if method == 'tribute': if not settings.TRIBUTE_ENABLED: - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') if not settings.BOT_TOKEN: - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured') bot = Bot(token=settings.BOT_TOKEN) try: @@ -1501,22 +1479,22 @@ async def create_payment_link( await bot.session.close() if not payment_url: - raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment") + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') return MiniAppPaymentCreateResponse( method=method, payment_url=payment_url, amount_kopeks=amount_kopeks, extra={ - "requested_at": _current_request_timestamp(), + 'requested_at': _current_request_timestamp(), }, ) - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Unknown payment method') @router.post( - "/payments/status", + '/payments/status', response_model=MiniAppPaymentStatusResponse, ) async def get_payment_statuses( @@ -1530,7 +1508,7 @@ async def get_payment_statuses( return MiniAppPaymentStatusResponse(results=[]) payment_service = PaymentService() - results: List[MiniAppPaymentStatusResult] = [] + results: list[MiniAppPaymentStatusResult] = [] for entry in entries: result = await _resolve_payment_status_entry( @@ -1552,46 +1530,46 @@ async def _resolve_payment_status_entry( user: User, query: MiniAppPaymentStatusQuery, ) -> MiniAppPaymentStatusResult: - method = (query.method or "").strip().lower() + method = (query.method or '').strip().lower() if not method: return MiniAppPaymentStatusResult( - method="", - status="unknown", - message="Payment method is required", + method='', + status='unknown', + message='Payment method is required', ) - if method in {"yookassa", "yookassa_sbp"}: + if method in {'yookassa', 'yookassa_sbp'}: return await _resolve_yookassa_payment_status( db, user, query, method=method, ) - if method == "mulenpay": + if method == 'mulenpay': return await _resolve_mulenpay_payment_status(payment_service, db, user, query) - if method == "platega": + if method == 'platega': return await _resolve_platega_payment_status(payment_service, db, user, query) - if method == "wata": + if method == 'wata': return await _resolve_wata_payment_status(payment_service, db, user, query) - if method == "pal24": + if method == 'pal24': return await _resolve_pal24_payment_status(payment_service, db, user, query) - if method == "cryptobot": + if method == 'cryptobot': return await _resolve_cryptobot_payment_status(db, user, query) - if method == "heleket": + if method == 'heleket': return await _resolve_heleket_payment_status(db, user, query) - if method == "cloudpayments": + if method == 'cloudpayments': return await _resolve_cloudpayments_payment_status(db, user, query) - if method == "freekassa": + if method == 'freekassa': return await _resolve_freekassa_payment_status(db, user, query) - if method == "stars": + if method == 'stars': return await _resolve_stars_payment_status(db, user, query) - if method == "tribute": + if method == 'tribute': return await _resolve_tribute_payment_status(db, user, query) return MiniAppPaymentStatusResult( method=method, - status="unknown", - message="Unsupported payment method", + status='unknown', + message='Unsupported payment method', ) @@ -1600,7 +1578,7 @@ async def _resolve_yookassa_payment_status( user: User, query: MiniAppPaymentStatusQuery, *, - method: str = "yookassa", + method: str = 'yookassa', ) -> MiniAppPaymentStatusResult: from app.database.crud.yookassa import ( get_yookassa_payment_by_id, @@ -1616,40 +1594,40 @@ async def _resolve_yookassa_payment_status( if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( method=method, - status="pending", + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "payment_id": query.payment_id, - "invoice_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'payment_id': query.payment_id, + 'invoice_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) - succeeded = bool(payment.is_paid and (payment.status or "").lower() == "succeeded") + succeeded = bool(payment.is_paid and (payment.status or '').lower() == 'succeeded') status = _classify_status(payment.status, succeeded) completed_at = payment.captured_at or payment.updated_at or payment.created_at return MiniAppPaymentStatusResult( method=method, status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, transaction_id=payment.transaction_id, external_id=payment.yookassa_payment_id, extra={ - "status": payment.status, - "is_paid": payment.is_paid, - "local_payment_id": payment.id, - "payment_id": payment.yookassa_payment_id, - "invoice_id": payment.yookassa_payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'status': payment.status, + 'is_paid': payment.is_paid, + 'local_payment_id': payment.id, + 'payment_id': payment.yookassa_payment_id, + 'invoice_id': payment.yookassa_payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -1662,53 +1640,53 @@ async def _resolve_mulenpay_payment_status( ) -> MiniAppPaymentStatusResult: if not query.local_payment_id: return MiniAppPaymentStatusResult( - method="mulenpay", - status="pending", + method='mulenpay', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", + message='Missing payment identifier', extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'invoice_id': query.invoice_id, + 'payment_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) status_info = await payment_service.get_mulenpay_payment_status(db, query.local_payment_id) - payment = status_info.get("payment") if status_info else None + payment = status_info.get('payment') if status_info else None if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="mulenpay", - status="pending", + method='mulenpay', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'invoice_id': query.invoice_id, + 'payment_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) - status_raw = status_info.get("status") or payment.status + status_raw = status_info.get('status') or payment.status is_paid = bool(payment.is_paid) status = _classify_status(status_raw, is_paid) completed_at = payment.paid_at or payment.updated_at or payment.created_at message = None - if status == "failed": - remote_status = status_info.get("remote_status_code") or status_raw + if status == 'failed': + remote_status = status_info.get('remote_status_code') or status_raw if remote_status: - message = f"Status: {remote_status}" + message = f'Status: {remote_status}' return MiniAppPaymentStatusResult( - method="mulenpay", + method='mulenpay', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -1716,13 +1694,13 @@ async def _resolve_mulenpay_payment_status( external_id=str(payment.mulen_payment_id or payment.uuid), message=message, extra={ - "status": payment.status, - "remote_status": status_info.get("remote_status_code"), - "local_payment_id": payment.id, - "payment_id": payment.mulen_payment_id, - "uuid": str(payment.uuid), - "payload": query.payload, - "started_at": query.started_at, + 'status': payment.status, + 'remote_status': status_info.get('remote_status_code'), + 'local_payment_id': payment.id, + 'payment_id': payment.mulen_payment_id, + 'uuid': str(payment.uuid), + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -1748,54 +1726,54 @@ async def _resolve_platega_payment_status( payment = await get_platega_payment_by_transaction_id(db, query.payment_id) if not payment and query.payload: - correlation = str(query.payload).replace("platega:", "") + correlation = str(query.payload).replace('platega:', '') payment = await get_platega_payment_by_correlation_id(db, correlation) if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="platega", - status="pending", + method='platega', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'payment_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) status_info = await payment_service.get_platega_payment_status(db, payment.id) - refreshed_payment = (status_info or {}).get("payment") or payment + refreshed_payment = (status_info or {}).get('payment') or payment - status_raw = (status_info or {}).get("status") or getattr(payment, "status", None) - is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False)) + status_raw = (status_info or {}).get('status') or getattr(payment, 'status', None) + is_paid_flag = bool((status_info or {}).get('is_paid') or getattr(payment, 'is_paid', False)) status_value = _classify_status(status_raw, is_paid_flag) completed_at = ( - getattr(refreshed_payment, "paid_at", None) - or getattr(refreshed_payment, "updated_at", None) - or getattr(refreshed_payment, "created_at", None) + getattr(refreshed_payment, 'paid_at', None) + or getattr(refreshed_payment, 'updated_at', None) + or getattr(refreshed_payment, 'created_at', None) ) - extra: Dict[str, Any] = { - "local_payment_id": refreshed_payment.id, - "payment_id": refreshed_payment.platega_transaction_id, - "correlation_id": refreshed_payment.correlation_id, - "status": status_raw, - "is_paid": getattr(refreshed_payment, "is_paid", False), - "payload": query.payload, - "started_at": query.started_at, + extra: dict[str, Any] = { + 'local_payment_id': refreshed_payment.id, + 'payment_id': refreshed_payment.platega_transaction_id, + 'correlation_id': refreshed_payment.correlation_id, + 'status': status_raw, + 'is_paid': getattr(refreshed_payment, 'is_paid', False), + 'payload': query.payload, + 'started_at': query.started_at, } - if status_info and status_info.get("remote"): - extra["remote"] = status_info.get("remote") + if status_info and status_info.get('remote'): + extra['remote'] = status_info.get('remote') return MiniAppPaymentStatusResult( - method="platega", + method='platega', status=status_value, - is_paid=status_value == "paid", + is_paid=status_value == 'paid', amount_kopeks=refreshed_payment.amount_kopeks, currency=refreshed_payment.currency, completed_at=completed_at, @@ -1823,79 +1801,79 @@ async def _resolve_wata_payment_status( if not local_id: return MiniAppPaymentStatusResult( - method="wata", - status="pending", + method='wata', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", + message='Missing payment identifier', extra={ - "local_payment_id": query.local_payment_id, - "payment_link_id": payment_link_id, - "payment_id": query.payment_id, - "invoice_id": query.invoice_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'payment_link_id': payment_link_id, + 'payment_id': query.payment_id, + 'invoice_id': query.invoice_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) status_info = await payment_service.get_wata_payment_status(db, local_id) - payment = (status_info or {}).get("payment") or fallback_payment + payment = (status_info or {}).get('payment') or fallback_payment if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="wata", - status="pending", + method='wata', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": local_id, - "payment_link_id": (payment_link_id or getattr(payment, "payment_link_id", None)), - "payment_id": query.payment_id, - "invoice_id": query.invoice_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': local_id, + 'payment_link_id': (payment_link_id or getattr(payment, 'payment_link_id', None)), + 'payment_id': query.payment_id, + 'invoice_id': query.invoice_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) - remote_link = (status_info or {}).get("remote_link") if status_info else None - transaction_payload = (status_info or {}).get("transaction") if status_info else None - status_raw = (status_info or {}).get("status") or getattr(payment, "status", None) - is_paid_flag = bool((status_info or {}).get("is_paid") or getattr(payment, "is_paid", False)) + remote_link = (status_info or {}).get('remote_link') if status_info else None + transaction_payload = (status_info or {}).get('transaction') if status_info else None + status_raw = (status_info or {}).get('status') or getattr(payment, 'status', None) + is_paid_flag = bool((status_info or {}).get('is_paid') or getattr(payment, 'is_paid', False)) status_value = _classify_status(status_raw, is_paid_flag) completed_at = ( - getattr(payment, "paid_at", None) - or getattr(payment, "updated_at", None) - or getattr(payment, "created_at", None) + getattr(payment, 'paid_at', None) + or getattr(payment, 'updated_at', None) + or getattr(payment, 'created_at', None) ) message = None - if status_value == "failed": + if status_value == 'failed': message = ( - (transaction_payload or {}).get("errorDescription") - or (transaction_payload or {}).get("errorCode") - or (remote_link or {}).get("status") + (transaction_payload or {}).get('errorDescription') + or (transaction_payload or {}).get('errorCode') + or (remote_link or {}).get('status') ) - extra: Dict[str, Any] = { - "local_payment_id": payment.id, - "payment_link_id": payment.payment_link_id, - "payment_id": payment.payment_link_id, - "status": status_raw, - "is_paid": getattr(payment, "is_paid", False), - "order_id": getattr(payment, "order_id", None), - "payload": query.payload, - "started_at": query.started_at, + extra: dict[str, Any] = { + 'local_payment_id': payment.id, + 'payment_link_id': payment.payment_link_id, + 'payment_id': payment.payment_link_id, + 'status': status_raw, + 'is_paid': getattr(payment, 'is_paid', False), + 'order_id': getattr(payment, 'order_id', None), + 'payload': query.payload, + 'started_at': query.started_at, } if remote_link: - extra["remote_link"] = remote_link + extra['remote_link'] = remote_link if transaction_payload: - extra["transaction"] = transaction_payload + extra['transaction'] = transaction_payload return MiniAppPaymentStatusResult( - method="wata", + method='wata', status=status_value, - is_paid=status_value == "paid", + is_paid=status_value == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -1922,55 +1900,55 @@ async def _resolve_pal24_payment_status( if not local_id: return MiniAppPaymentStatusResult( - method="pal24", - status="pending", + method='pal24', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Missing payment identifier", + message='Missing payment identifier', extra={ - "local_payment_id": query.local_payment_id, - "bill_id": query.invoice_id, - "order_id": None, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'bill_id': query.invoice_id, + 'order_id': None, + 'payload': query.payload, + 'started_at': query.started_at, }, ) status_info = await payment_service.get_pal24_payment_status(db, local_id) - payment = status_info.get("payment") if status_info else None + payment = status_info.get('payment') if status_info else None if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="pal24", - status="pending", + method='pal24', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": local_id, - "bill_id": query.invoice_id, - "order_id": None, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': local_id, + 'bill_id': query.invoice_id, + 'order_id': None, + 'payload': query.payload, + 'started_at': query.started_at, }, ) - status_raw = status_info.get("status") or payment.status + status_raw = status_info.get('status') or payment.status is_paid = bool(payment.is_paid) status = _classify_status(status_raw, is_paid) completed_at = payment.paid_at or payment.updated_at or payment.created_at message = None - if status == "failed": - remote_status = status_info.get("remote_status") or status_raw + if status == 'failed': + remote_status = status_info.get('remote_status') or status_raw if remote_status: - message = f"Status: {remote_status}" + message = f'Status: {remote_status}' - links_info = status_info.get("links") if status_info else {} + links_info = status_info.get('links') if status_info else {} return MiniAppPaymentStatusResult( - method="pal24", + method='pal24', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -1978,22 +1956,22 @@ async def _resolve_pal24_payment_status( external_id=payment.bill_id, message=message, extra={ - "status": payment.status, - "remote_status": status_info.get("remote_status"), - "local_payment_id": payment.id, - "bill_id": payment.bill_id, - "order_id": payment.order_id, - "payment_method": getattr(payment, "payment_method", None), - "payload": query.payload, - "started_at": query.started_at, - "links": links_info or None, - "sbp_url": status_info.get("sbp_url") if status_info else None, - "card_url": status_info.get("card_url") if status_info else None, - "link_url": status_info.get("link_url") if status_info else None, - "link_page_url": status_info.get("link_page_url") if status_info else None, - "primary_url": status_info.get("primary_url") if status_info else None, - "secondary_url": status_info.get("secondary_url") if status_info else None, - "selected_method": status_info.get("selected_method") if status_info else None, + 'status': payment.status, + 'remote_status': status_info.get('remote_status'), + 'local_payment_id': payment.id, + 'bill_id': payment.bill_id, + 'order_id': payment.order_id, + 'payment_method': getattr(payment, 'payment_method', None), + 'payload': query.payload, + 'started_at': query.started_at, + 'links': links_info or None, + 'sbp_url': status_info.get('sbp_url') if status_info else None, + 'card_url': status_info.get('card_url') if status_info else None, + 'link_url': status_info.get('link_url') if status_info else None, + 'link_page_url': status_info.get('link_page_url') if status_info else None, + 'primary_url': status_info.get('primary_url') if status_info else None, + 'secondary_url': status_info.get('secondary_url') if status_info else None, + 'selected_method': status_info.get('selected_method') if status_info else None, }, ) @@ -2016,22 +1994,22 @@ async def _resolve_cryptobot_payment_status( if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="cryptobot", - status="pending", + method='cryptobot', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payment_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'invoice_id': query.invoice_id, + 'payment_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) status_raw = payment.status - is_paid = (status_raw or "").lower() == "paid" + is_paid = (status_raw or '').lower() == 'paid' status = _classify_status(status_raw, is_paid) completed_at = payment.paid_at or payment.updated_at or payment.created_at @@ -2041,28 +2019,28 @@ async def _resolve_cryptobot_payment_status( except (InvalidOperation, TypeError): amount_kopeks = None - descriptor = decode_payment_payload(getattr(payment, "payload", "") or "", expected_user_id=user.id) - purpose = "subscription_renewal" if descriptor else "balance_topup" + descriptor = decode_payment_payload(getattr(payment, 'payload', '') or '', expected_user_id=user.id) + purpose = 'subscription_renewal' if descriptor else 'balance_topup' return MiniAppPaymentStatusResult( - method="cryptobot", + method='cryptobot', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=amount_kopeks, currency=payment.asset, completed_at=completed_at, transaction_id=payment.transaction_id, external_id=payment.invoice_id, extra={ - "status": payment.status, - "asset": payment.asset, - "local_payment_id": payment.id, - "invoice_id": payment.invoice_id, - "payload": query.payload, - "started_at": query.started_at, - "purpose": purpose, - "subscription_id": descriptor.subscription_id if descriptor else None, - "period_days": descriptor.period_days if descriptor else None, + 'status': payment.status, + 'asset': payment.asset, + 'local_payment_id': payment.id, + 'invoice_id': payment.invoice_id, + 'payload': query.payload, + 'started_at': query.started_at, + 'purpose': purpose, + 'subscription_id': descriptor.subscription_id if descriptor else None, + 'period_days': descriptor.period_days if descriptor else None, }, ) @@ -2090,17 +2068,17 @@ async def _resolve_heleket_payment_status( if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="heleket", - status="pending", + method='heleket', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "uuid": query.payment_id or query.invoice_id, - "order_id": query.bill_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'uuid': query.payment_id or query.invoice_id, + 'order_id': query.bill_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2110,9 +2088,9 @@ async def _resolve_heleket_payment_status( completed_at = payment.paid_at or payment.updated_at or payment.created_at return MiniAppPaymentStatusResult( - method="heleket", + method='heleket', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -2120,17 +2098,17 @@ async def _resolve_heleket_payment_status( external_id=payment.uuid, message=None, extra={ - "status": payment.status, - "local_payment_id": payment.id, - "uuid": payment.uuid, - "order_id": payment.order_id, - "payer_amount": payment.payer_amount, - "payer_currency": payment.payer_currency, - "discount_percent": payment.discount_percent, - "exchange_rate": payment.exchange_rate, - "payment_url": payment.payment_url, - "payload": query.payload, - "started_at": query.started_at, + 'status': payment.status, + 'local_payment_id': payment.id, + 'uuid': payment.uuid, + 'order_id': payment.order_id, + 'payer_amount': payment.payer_amount, + 'payer_currency': payment.payer_currency, + 'discount_percent': payment.discount_percent, + 'exchange_rate': payment.exchange_rate, + 'payment_url': payment.payment_url, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2155,16 +2133,16 @@ async def _resolve_cloudpayments_payment_status( if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="cloudpayments", - status="pending", + method='cloudpayments', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "invoice_id": query.invoice_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'invoice_id': query.invoice_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2174,9 +2152,9 @@ async def _resolve_cloudpayments_payment_status( completed_at = payment.paid_at or payment.updated_at or payment.created_at return MiniAppPaymentStatusResult( - method="cloudpayments", + method='cloudpayments', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -2184,15 +2162,15 @@ async def _resolve_cloudpayments_payment_status( external_id=payment.invoice_id, message=None, extra={ - "status": payment.status, - "local_payment_id": payment.id, - "invoice_id": payment.invoice_id, - "transaction_id_cp": payment.transaction_id_cp, - "card_type": payment.card_type, - "card_last_four": payment.card_last_four, - "payment_url": payment.payment_url, - "payload": query.payload, - "started_at": query.started_at, + 'status': payment.status, + 'local_payment_id': payment.id, + 'invoice_id': payment.invoice_id, + 'transaction_id_cp': payment.transaction_id_cp, + 'card_type': payment.card_type, + 'card_last_four': payment.card_last_four, + 'payment_url': payment.payment_url, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2215,16 +2193,16 @@ async def _resolve_freekassa_payment_status( if not payment or payment.user_id != user.id: return MiniAppPaymentStatusResult( - method="freekassa", - status="pending", + method='freekassa', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Payment not found", + message='Payment not found', extra={ - "local_payment_id": query.local_payment_id, - "order_id": query.payment_id, - "payload": query.payload, - "started_at": query.started_at, + 'local_payment_id': query.local_payment_id, + 'order_id': query.payment_id, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2234,9 +2212,9 @@ async def _resolve_freekassa_payment_status( completed_at = payment.paid_at or payment.updated_at or payment.created_at return MiniAppPaymentStatusResult( - method="freekassa", + method='freekassa', status=status, - is_paid=status == "paid", + is_paid=status == 'paid', amount_kopeks=payment.amount_kopeks, currency=payment.currency, completed_at=completed_at, @@ -2244,13 +2222,13 @@ async def _resolve_freekassa_payment_status( external_id=payment.freekassa_order_id, message=None, extra={ - "status": payment.status, - "local_payment_id": payment.id, - "order_id": payment.order_id, - "freekassa_order_id": payment.freekassa_order_id, - "payment_url": payment.payment_url, - "payload": query.payload, - "started_at": query.started_at, + 'status': payment.status, + 'local_payment_id': payment.id, + 'order_id': payment.order_id, + 'freekassa_order_id': payment.freekassa_order_id, + 'payment_url': payment.payment_url, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2271,29 +2249,29 @@ async def _resolve_stars_payment_status( if not transaction: return MiniAppPaymentStatusResult( - method="stars", - status="pending", + method='stars', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Waiting for confirmation", + message='Waiting for confirmation', extra={ - "payload": query.payload, - "started_at": query.started_at, + 'payload': query.payload, + 'started_at': query.started_at, }, ) return MiniAppPaymentStatusResult( - method="stars", - status="paid", + method='stars', + status='paid', is_paid=True, amount_kopeks=transaction.amount_kopeks, - currency="RUB", + currency='RUB', completed_at=transaction.completed_at or transaction.created_at, transaction_id=transaction.id, external_id=transaction.external_id, extra={ - "payload": query.payload, - "started_at": query.started_at, + 'payload': query.payload, + 'started_at': query.started_at, }, ) @@ -2314,50 +2292,50 @@ async def _resolve_tribute_payment_status( if not transaction: return MiniAppPaymentStatusResult( - method="tribute", - status="pending", + method='tribute', + status='pending', is_paid=False, amount_kopeks=query.amount_kopeks, - message="Waiting for confirmation", + message='Waiting for confirmation', extra={ - "payload": query.payload, - "started_at": query.started_at, + 'payload': query.payload, + 'started_at': query.started_at, }, ) return MiniAppPaymentStatusResult( - method="tribute", - status="paid", + method='tribute', + status='paid', is_paid=True, amount_kopeks=transaction.amount_kopeks, - currency="RUB", + currency='RUB', completed_at=transaction.completed_at or transaction.created_at, transaction_id=transaction.id, external_id=transaction.external_id, extra={ - "payload": query.payload, - "started_at": query.started_at, + 'payload': query.payload, + 'started_at': query.started_at, }, ) -_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P\d+)$") +_TEMPLATE_ID_PATTERN = re.compile(r'promo_template_(?P\d+)$') _OFFER_TYPE_ICONS = { - "extend_discount": "💎", - "purchase_discount": "🎯", - "test_access": "🧪", + 'extend_discount': '💎', + 'purchase_discount': '🎯', + 'test_access': '🧪', } _EFFECT_TYPE_ICONS = { - "percent_discount": "🎁", - "test_access": "🧪", - "balance_bonus": "💰", + 'percent_discount': '🎁', + 'test_access': '🧪', + 'balance_bonus': '💰', } -_DEFAULT_OFFER_ICON = "🎉" +_DEFAULT_OFFER_ICON = '🎉' -ActiveOfferContext = Tuple[Any, Optional[int], Optional[datetime]] +ActiveOfferContext = tuple[Any, int | None, datetime | None] -def _extract_template_id(notification_type: Optional[str]) -> Optional[int]: +def _extract_template_id(notification_type: str | None) -> int | None: if not notification_type: return None @@ -2366,33 +2344,33 @@ def _extract_template_id(notification_type: Optional[str]) -> Optional[int]: return None try: - return int(match.group("template_id")) + return int(match.group('template_id')) except (TypeError, ValueError): return None -def _extract_offer_extra(offer: Any) -> Dict[str, Any]: - extra = getattr(offer, "extra_data", None) +def _extract_offer_extra(offer: Any) -> dict[str, Any]: + extra = getattr(offer, 'extra_data', None) return extra if isinstance(extra, dict) else {} -def _extract_offer_type(offer: Any, template: Optional[PromoOfferTemplate]) -> Optional[str]: +def _extract_offer_type(offer: Any, template: PromoOfferTemplate | None) -> str | None: extra = _extract_offer_extra(offer) - offer_type = extra.get("offer_type") if isinstance(extra.get("offer_type"), str) else None + offer_type = extra.get('offer_type') if isinstance(extra.get('offer_type'), str) else None if offer_type: return offer_type - template_type = getattr(template, "offer_type", None) + template_type = getattr(template, 'offer_type', None) return template_type if isinstance(template_type, str) else None -def _normalize_effect_type(effect_type: Optional[str]) -> str: - normalized = (effect_type or "percent_discount").strip().lower() - if normalized == "balance_bonus": - return "percent_discount" - return normalized or "percent_discount" +def _normalize_effect_type(effect_type: str | None) -> str: + normalized = (effect_type or 'percent_discount').strip().lower() + if normalized == 'balance_bonus': + return 'percent_discount' + return normalized or 'percent_discount' -def _determine_offer_icon(offer_type: Optional[str], effect_type: str) -> str: +def _determine_offer_icon(offer_type: str | None, effect_type: str) -> str: if offer_type and offer_type in _OFFER_TYPE_ICONS: return _OFFER_TYPE_ICONS[offer_type] if effect_type in _EFFECT_TYPE_ICONS: @@ -2400,14 +2378,14 @@ def _determine_offer_icon(offer_type: Optional[str], effect_type: str) -> str: return _DEFAULT_OFFER_ICON -def _extract_offer_test_squad_uuids(offer: Any) -> List[str]: +def _extract_offer_test_squad_uuids(offer: Any) -> list[str]: extra = _extract_offer_extra(offer) - raw = extra.get("test_squad_uuids") or extra.get("squads") or [] + raw = extra.get('test_squad_uuids') or extra.get('squads') or [] if isinstance(raw, str): raw = [raw] - uuids: List[str] = [] + uuids: list[str] = [] try: for item in raw: if not item: @@ -2420,18 +2398,18 @@ def _extract_offer_test_squad_uuids(offer: Any) -> List[str]: def _format_offer_message( - template: Optional[PromoOfferTemplate], + template: PromoOfferTemplate | None, offer: Any, *, - server_name: Optional[str] = None, -) -> Optional[str]: - message_template: Optional[str] = None + server_name: str | None = None, +) -> str | None: + message_template: str | None = None if template and isinstance(template.message_text, str): message_template = template.message_text else: extra = _extract_offer_extra(offer) - raw_message = extra.get("message_text") or extra.get("text") + raw_message = extra.get('message_text') or extra.get('text') if isinstance(raw_message, str): message_template = raw_message @@ -2439,17 +2417,17 @@ def _format_offer_message( return None extra = _extract_offer_extra(offer) - discount_percent = getattr(offer, "discount_percent", None) + discount_percent = getattr(offer, 'discount_percent', None) try: discount_percent = int(discount_percent) except (TypeError, ValueError): discount_percent = None - replacements: Dict[str, Any] = {} + replacements: dict[str, Any] = {} if discount_percent is not None: - replacements.setdefault("discount_percent", discount_percent) + replacements.setdefault('discount_percent', discount_percent) - for key in ("valid_hours", "active_discount_hours", "test_duration_hours"): + for key in ('valid_hours', 'active_discount_hours', 'test_duration_hours'): value = extra.get(key) if value is None and template is not None: template_value = getattr(template, key, None) @@ -2457,21 +2435,17 @@ def _format_offer_message( template_value = None replacements.setdefault(key, value if value is not None else template_value) - if replacements.get("active_discount_hours") is None and template: - replacements["active_discount_hours"] = getattr(template, "valid_hours", None) + if replacements.get('active_discount_hours') is None and template: + replacements['active_discount_hours'] = getattr(template, 'valid_hours', None) - if replacements.get("test_duration_hours") is None and template: - replacements["test_duration_hours"] = getattr(template, "test_duration_hours", None) + if replacements.get('test_duration_hours') is None and template: + replacements['test_duration_hours'] = getattr(template, 'test_duration_hours', None) if server_name: - replacements.setdefault("server_name", server_name) + replacements.setdefault('server_name', server_name) for key, value in extra.items(): - if ( - isinstance(key, str) - and key not in replacements - and isinstance(value, (str, int, float)) - ): + if isinstance(key, str) and key not in replacements and isinstance(value, (str, int, float)): replacements[key] = value try: @@ -2482,18 +2456,18 @@ def _format_offer_message( def _extract_offer_duration_hours( offer: Any, - template: Optional[PromoOfferTemplate], + template: PromoOfferTemplate | None, effect_type: str, -) -> Optional[int]: +) -> int | None: extra = _extract_offer_extra(offer) - if effect_type == "test_access": - source = extra.get("test_duration_hours") + if effect_type == 'test_access': + source = extra.get('test_duration_hours') if source is None and template is not None: - source = getattr(template, "test_duration_hours", None) + source = getattr(template, 'test_duration_hours', None) else: - source = extra.get("active_discount_hours") + source = extra.get('active_discount_hours') if source is None and template is not None: - source = getattr(template, "active_discount_hours", None) + source = getattr(template, 'active_discount_hours', None) try: if source is None: @@ -2504,20 +2478,20 @@ def _extract_offer_duration_hours( return None -def _format_bonus_label(amount_kopeks: int) -> Optional[str]: +def _format_bonus_label(amount_kopeks: int) -> str | None: if amount_kopeks <= 0: return None try: return settings.format_price(amount_kopeks) except Exception: # pragma: no cover - defensive - return f"{amount_kopeks / 100:.2f}" + return f'{amount_kopeks / 100:.2f}' async def _find_active_test_access_offers( db: AsyncSession, - subscription: Optional[Subscription], -) -> List[ActiveOfferContext]: - if not subscription or not getattr(subscription, "id", None): + subscription: Subscription | None, +) -> list[ActiveOfferContext]: + if not subscription or not getattr(subscription, 'id', None): return [] now = datetime.utcnow() @@ -2526,7 +2500,7 @@ async def _find_active_test_access_offers( .options(selectinload(SubscriptionTemporaryAccess.offer)) .where( SubscriptionTemporaryAccess.subscription_id == subscription.id, - SubscriptionTemporaryAccess.is_active == True, # noqa: E712 + SubscriptionTemporaryAccess.is_active == True, SubscriptionTemporaryAccess.expires_at > now, ) .order_by(SubscriptionTemporaryAccess.expires_at.desc()) @@ -2536,21 +2510,21 @@ async def _find_active_test_access_offers( if not entries: return [] - offer_map: Dict[int, Tuple[Any, Optional[datetime]]] = {} + offer_map: dict[int, tuple[Any, datetime | None]] = {} for entry in entries: - offer = getattr(entry, "offer", None) + offer = getattr(entry, 'offer', None) if not offer: continue - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) - if effect_type != "test_access": + effect_type = _normalize_effect_type(getattr(offer, 'effect_type', None)) + if effect_type != 'test_access': continue - expires_at = getattr(entry, "expires_at", None) + expires_at = getattr(entry, 'expires_at', None) if not expires_at or expires_at <= now: continue - offer_id = getattr(offer, "id", None) + offer_id = getattr(offer, 'id', None) if not isinstance(offer_id, int): continue @@ -2562,7 +2536,7 @@ async def _find_active_test_access_offers( if current_expiry is None or (expires_at and expires_at > current_expiry): offer_map[offer_id] = (offer, expires_at) - contexts: List[ActiveOfferContext] = [] + contexts: list[ActiveOfferContext] = [] for offer_id, (offer, expires_at) in offer_map.items(): contexts.append((offer, None, expires_at)) @@ -2572,16 +2546,16 @@ async def _find_active_test_access_offers( async def _build_promo_offer_models( db: AsyncSession, - available_offers: List[Any], - active_offers: Optional[List[ActiveOfferContext]], + available_offers: list[Any], + active_offers: list[ActiveOfferContext] | None, *, user: User, -) -> List[MiniAppPromoOffer]: - promo_offers: List[MiniAppPromoOffer] = [] - template_cache: Dict[int, Optional[PromoOfferTemplate]] = {} +) -> list[MiniAppPromoOffer]: + promo_offers: list[MiniAppPromoOffer] = [] + template_cache: dict[int, PromoOfferTemplate | None] = {} - candidates: List[Any] = [offer for offer in available_offers if offer] - active_offer_contexts: List[ActiveOfferContext] = [] + candidates: list[Any] = [offer for offer in available_offers if offer] + active_offer_contexts: list[ActiveOfferContext] = [] if active_offers: for offer, discount_override, expires_override in active_offers: if not offer: @@ -2589,9 +2563,9 @@ async def _build_promo_offer_models( active_offer_contexts.append((offer, discount_override, expires_override)) candidates.append(offer) - squad_map: Dict[str, MiniAppConnectedServer] = {} + squad_map: dict[str, MiniAppConnectedServer] = {} if candidates: - all_uuids: List[str] = [] + all_uuids: list[str] = [] for offer in candidates: all_uuids.extend(_extract_offer_test_squad_uuids(offer)) if all_uuids: @@ -2599,49 +2573,47 @@ async def _build_promo_offer_models( resolved = await _resolve_connected_servers(db, unique) squad_map = {server.uuid: server for server in resolved} - async def get_template(template_id: Optional[int]) -> Optional[PromoOfferTemplate]: + async def get_template(template_id: int | None) -> PromoOfferTemplate | None: if not template_id: return None if template_id not in template_cache: template_cache[template_id] = await get_promo_offer_template_by_id(db, template_id) return template_cache[template_id] - def build_test_squads(offer: Any) -> List[MiniAppConnectedServer]: - test_squads: List[MiniAppConnectedServer] = [] + def build_test_squads(offer: Any) -> list[MiniAppConnectedServer]: + test_squads: list[MiniAppConnectedServer] = [] for uuid in _extract_offer_test_squad_uuids(offer): resolved = squad_map.get(uuid) if resolved: - test_squads.append( - MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name) - ) + test_squads.append(MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name)) else: test_squads.append(MiniAppConnectedServer(uuid=uuid, name=uuid)) return test_squads def resolve_title( offer: Any, - template: Optional[PromoOfferTemplate], - offer_type: Optional[str], - ) -> Optional[str]: + template: PromoOfferTemplate | None, + offer_type: str | None, + ) -> str | None: extra = _extract_offer_extra(offer) - if isinstance(extra.get("title"), str) and extra["title"].strip(): - return extra["title"].strip() + if isinstance(extra.get('title'), str) and extra['title'].strip(): + return extra['title'].strip() if template and template.name: return template.name if offer_type: - return offer_type.replace("_", " ").title() + return offer_type.replace('_', ' ').title() return None for offer in available_offers: - template_id = _extract_template_id(getattr(offer, "notification_type", None)) + template_id = _extract_template_id(getattr(offer, 'notification_type', None)) template = await get_template(template_id) - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) + effect_type = _normalize_effect_type(getattr(offer, 'effect_type', None)) offer_type = _extract_offer_type(offer, template) test_squads = build_test_squads(offer) server_name = test_squads[0].name if test_squads else None message_text = _format_offer_message(template, offer, server_name=server_name) - bonus_label = _format_bonus_label(int(getattr(offer, "bonus_amount_kopeks", 0) or 0)) - discount_percent = getattr(offer, "discount_percent", 0) + bonus_label = _format_bonus_label(int(getattr(offer, 'bonus_amount_kopeks', 0) or 0)) + discount_percent = getattr(offer, 'discount_percent', 0) try: discount_percent = int(discount_percent) except (TypeError, ValueError): @@ -2649,26 +2621,26 @@ async def _build_promo_offer_models( extra = _extract_offer_extra(offer) button_text = None - if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): - button_text = extra["button_text"].strip() + if isinstance(extra.get('button_text'), str) and extra['button_text'].strip(): + button_text = extra['button_text'].strip() elif template and isinstance(template.button_text, str): button_text = template.button_text promo_offers.append( MiniAppPromoOffer( - id=int(getattr(offer, "id", 0) or 0), - status="pending", - notification_type=getattr(offer, "notification_type", None), + id=int(getattr(offer, 'id', 0) or 0), + status='pending', + notification_type=getattr(offer, 'notification_type', None), offer_type=offer_type, effect_type=effect_type, discount_percent=max(0, discount_percent), - bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0), + bonus_amount_kopeks=int(getattr(offer, 'bonus_amount_kopeks', 0) or 0), bonus_amount_label=bonus_label, - expires_at=getattr(offer, "expires_at", None), - claimed_at=getattr(offer, "claimed_at", None), - is_active=bool(getattr(offer, "is_active", False)), + expires_at=getattr(offer, 'expires_at', None), + claimed_at=getattr(offer, 'claimed_at', None), + is_active=bool(getattr(offer, 'is_active', False)), template_id=template_id, - template_name=getattr(template, "name", None), + template_name=getattr(template, 'name', None), button_text=button_text, title=resolve_title(offer, template, offer_type), message_text=message_text, @@ -2680,21 +2652,19 @@ async def _build_promo_offer_models( if active_offer_contexts: seen_active_ids: set[int] = set() for active_offer_record, discount_override, expires_override in reversed(active_offer_contexts): - offer_id = int(getattr(active_offer_record, "id", 0) or 0) + offer_id = int(getattr(active_offer_record, 'id', 0) or 0) if offer_id and offer_id in seen_active_ids: continue if offer_id: seen_active_ids.add(offer_id) - template_id = _extract_template_id(getattr(active_offer_record, "notification_type", None)) + template_id = _extract_template_id(getattr(active_offer_record, 'notification_type', None)) template = await get_template(template_id) - effect_type = _normalize_effect_type(getattr(active_offer_record, "effect_type", None)) + effect_type = _normalize_effect_type(getattr(active_offer_record, 'effect_type', None)) offer_type = _extract_offer_type(active_offer_record, template) show_active = False discount_value = discount_override if discount_override is not None else 0 - if discount_value and discount_value > 0: - show_active = True - elif effect_type == "test_access": + if (discount_value and discount_value > 0) or effect_type == 'test_access': show_active = True if not show_active: continue @@ -2706,13 +2676,11 @@ async def _build_promo_offer_models( active_offer_record, server_name=server_name, ) - bonus_label = _format_bonus_label( - int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0) - ) + bonus_label = _format_bonus_label(int(getattr(active_offer_record, 'bonus_amount_kopeks', 0) or 0)) - started_at = getattr(active_offer_record, "claimed_at", None) - expires_at = expires_override or getattr(active_offer_record, "expires_at", None) - duration_seconds: Optional[int] = None + started_at = getattr(active_offer_record, 'claimed_at', None) + expires_at = expires_override or getattr(active_offer_record, 'expires_at', None) + duration_seconds: int | None = None duration_hours = _extract_offer_duration_hours(active_offer_record, template, effect_type) if expires_at is None and duration_hours and started_at: expires_at = started_at + timedelta(hours=duration_hours) @@ -2722,9 +2690,9 @@ async def _build_promo_offer_models( except Exception: # pragma: no cover - defensive duration_seconds = None - if (discount_value is None or discount_value <= 0) and effect_type != "test_access": + if (discount_value is None or discount_value <= 0) and effect_type != 'test_access': try: - discount_value = int(getattr(active_offer_record, "discount_percent", 0) or 0) + discount_value = int(getattr(active_offer_record, 'discount_percent', 0) or 0) except (TypeError, ValueError): discount_value = 0 if discount_value is None: @@ -2732,8 +2700,8 @@ async def _build_promo_offer_models( extra = _extract_offer_extra(active_offer_record) button_text = None - if isinstance(extra.get("button_text"), str) and extra["button_text"].strip(): - button_text = extra["button_text"].strip() + if isinstance(extra.get('button_text'), str) and extra['button_text'].strip(): + button_text = extra['button_text'].strip() elif template and isinstance(template.button_text, str): button_text = template.button_text @@ -2741,18 +2709,18 @@ async def _build_promo_offer_models( 0, MiniAppPromoOffer( id=offer_id, - status="active", - notification_type=getattr(active_offer_record, "notification_type", None), + status='active', + notification_type=getattr(active_offer_record, 'notification_type', None), offer_type=offer_type, effect_type=effect_type, discount_percent=max(0, discount_value or 0), - bonus_amount_kopeks=int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0), + bonus_amount_kopeks=int(getattr(active_offer_record, 'bonus_amount_kopeks', 0) or 0), bonus_amount_label=bonus_label, - expires_at=getattr(active_offer_record, "expires_at", None), + expires_at=getattr(active_offer_record, 'expires_at', None), claimed_at=started_at, is_active=False, template_id=template_id, - template_name=getattr(template, "name", None), + template_name=getattr(template, 'name', None), button_text=button_text, title=resolve_title(active_offer_record, template, offer_type), message_text=message_text, @@ -2767,33 +2735,33 @@ async def _build_promo_offer_models( return promo_offers -def _bytes_to_gb(bytes_value: Optional[int]) -> float: +def _bytes_to_gb(bytes_value: int | None) -> float: if not bytes_value: return 0.0 - return round(bytes_value / (1024 ** 3), 2) + return round(bytes_value / (1024**3), 2) def _status_label(status: str) -> str: mapping = { - "active": "Active", - "trial": "Trial", - "expired": "Expired", - "disabled": "Disabled", + 'active': 'Active', + 'trial': 'Trial', + 'expired': 'Expired', + 'disabled': 'Disabled', } return mapping.get(status, status.title()) -def _parse_datetime_string(value: Optional[str]) -> Optional[str]: +def _parse_datetime_string(value: str | None) -> str | None: if not value: return None try: cleaned = value.strip() - if cleaned.endswith("Z"): - cleaned = f"{cleaned[:-1]}+00:00" + if cleaned.endswith('Z'): + cleaned = f'{cleaned[:-1]}+00:00' # Normalize duplicated timezone suffixes like +00:00+00:00 - if "+00:00+00:00" in cleaned: - cleaned = cleaned.replace("+00:00+00:00", "+00:00") + if '+00:00+00:00' in cleaned: + cleaned = cleaned.replace('+00:00+00:00', '+00:00') datetime.fromisoformat(cleaned) return cleaned @@ -2803,13 +2771,13 @@ def _parse_datetime_string(value: Optional[str]) -> Optional[str]: async def _resolve_connected_servers( db: AsyncSession, - squad_uuids: List[str], -) -> List[MiniAppConnectedServer]: + squad_uuids: list[str], +) -> list[MiniAppConnectedServer]: if not squad_uuids: return [] - resolved: Dict[str, str] = {} - missing: List[str] = [] + resolved: dict[str, str] = {} + missing: list[str] = [] for squad_uuid in squad_uuids: if squad_uuid in resolved: @@ -2826,16 +2794,16 @@ async def _resolve_connected_servers( if service.is_configured: squads = await service.get_all_squads() for squad in squads: - uuid = squad.get("uuid") - name = squad.get("name") + uuid = squad.get('uuid') + name = squad.get('name') if uuid in missing and name: resolved[uuid] = name except RemnaWaveConfigurationError: - logger.debug("RemnaWave is not configured; skipping server name enrichment") + logger.debug('RemnaWave is not configured; skipping server name enrichment') except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to resolve server names from RemnaWave: %s", error) + logger.warning('Failed to resolve server names from RemnaWave: %s', error) - connected_servers: List[MiniAppConnectedServer] = [] + connected_servers: list[MiniAppConnectedServer] = [] for squad_uuid in squad_uuids: name = resolved.get(squad_uuid, squad_uuid) connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) @@ -2843,15 +2811,15 @@ async def _resolve_connected_servers( return connected_servers -async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: - remnawave_uuid = getattr(user, "remnawave_uuid", None) +async def _load_devices_info(user: User) -> tuple[int, list[MiniAppDevice]]: + remnawave_uuid = getattr(user, 'remnawave_uuid', None) if not remnawave_uuid: return 0, [] try: service = RemnaWaveService() except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to initialise RemnaWave service: %s", error) + logger.warning('Failed to initialise RemnaWave service: %s', error) return 0, [] if not service.is_configured: @@ -2861,28 +2829,25 @@ async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: async with service.get_api_client() as api: response = await api.get_user_devices(remnawave_uuid) except RemnaWaveConfigurationError: - logger.debug("RemnaWave configuration missing while loading devices") + logger.debug('RemnaWave configuration missing while loading devices') return 0, [] except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load devices from RemnaWave: %s", error) + logger.warning('Failed to load devices from RemnaWave: %s', error) return 0, [] - total_devices = int(response.get("total") or 0) - devices_payload = response.get("devices") or [] + total_devices = int(response.get('total') or 0) + devices_payload = response.get('devices') or [] - devices: List[MiniAppDevice] = [] + devices: list[MiniAppDevice] = [] for device in devices_payload: - hwid = device.get("hwid") or device.get("deviceId") or device.get("id") - platform = device.get("platform") or device.get("platformType") - model = device.get("deviceModel") or device.get("model") or device.get("name") - app_version = device.get("appVersion") or device.get("version") + hwid = device.get('hwid') or device.get('deviceId') or device.get('id') + platform = device.get('platform') or device.get('platformType') + model = device.get('deviceModel') or device.get('model') or device.get('name') + app_version = device.get('appVersion') or device.get('version') last_seen_raw = ( - device.get("updatedAt") - or device.get("lastSeen") - or device.get("lastActiveAt") - or device.get("createdAt") + device.get('updatedAt') or device.get('lastSeen') or device.get('lastActiveAt') or device.get('createdAt') ) - last_ip = device.get("ip") or device.get("ipAddress") + last_ip = device.get('ip') or device.get('ipAddress') devices.append( MiniAppDevice( @@ -2901,24 +2866,24 @@ async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: return total_devices, devices -def _resolve_display_name(user_data: Dict[str, Any]) -> str: - username = user_data.get("username") +def _resolve_display_name(user_data: dict[str, Any]) -> str: + username = user_data.get('username') if username: return username - first = user_data.get("first_name") - last = user_data.get("last_name") + first = user_data.get('first_name') + last = user_data.get('last_name') parts = [part for part in [first, last] if part] if parts: - return " ".join(parts) + return ' '.join(parts) - telegram_id = user_data.get("telegram_id") - return f"User {telegram_id}" if telegram_id else "User" + telegram_id = user_data.get('telegram_id') + return f'User {telegram_id}' if telegram_id else 'User' def _is_remnawave_configured() -> bool: params = settings.get_remnawave_auth_params() - return bool(params.get("base_url") and params.get("api_key")) + return bool(params.get('base_url') and params.get('api_key')) def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: @@ -2938,7 +2903,7 @@ def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: async def _load_subscription_links( subscription: Subscription, -) -> Dict[str, Any]: +) -> dict[str, Any]: if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): return {} @@ -2946,19 +2911,19 @@ async def _load_subscription_links( service = SubscriptionService() info = await service.get_subscription_info(subscription.remnawave_short_uuid) except Exception as error: # pragma: no cover - defensive logging - logger.warning("Failed to load subscription info from RemnaWave: %s", error) + logger.warning('Failed to load subscription info from RemnaWave: %s', error) return {} if not info: return {} - payload: Dict[str, Any] = { - "links": list(info.links or []), - "ss_conf_links": dict(info.ss_conf_links or {}), - "subscription_url": info.subscription_url, - "happ": info.happ, - "happ_link": getattr(info, "happ_link", None), - "happ_crypto_link": getattr(info, "happ_crypto_link", None), + payload: dict[str, Any] = { + 'links': list(info.links or []), + 'ss_conf_links': dict(info.ss_conf_links or {}), + 'subscription_url': info.subscription_url, + 'happ': info.happ, + 'happ_link': getattr(info, 'happ_link', None), + 'happ_crypto_link': getattr(info, 'happ_crypto_link', None), } return payload @@ -2967,23 +2932,20 @@ async def _load_subscription_links( async def _build_referral_info( db: AsyncSession, user: User, -) -> Optional[MiniAppReferralInfo]: - referral_code = getattr(user, "referral_code", None) +) -> MiniAppReferralInfo | None: + referral_code = getattr(user, 'referral_code', None) referral_settings = settings.get_referral_settings() or {} bot_username = settings.get_bot_username() referral_link = None if referral_code and bot_username: - referral_link = f"https://t.me/{bot_username}?start={referral_code}" + referral_link = f'https://t.me/{bot_username}?start={referral_code}' - minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0) - first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0) - inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0) + minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0) + first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0) + inviter_bonus_kopeks = int(referral_settings.get('inviter_bonus_kopeks') or 0) commission_percent = float( - get_effective_referral_commission_percent(user) - if user - else referral_settings.get("commission_percent") - or 0 + get_effective_referral_commission_percent(user) if user else referral_settings.get('commission_percent') or 0 ) terms = MiniAppReferralTerms( @@ -2997,68 +2959,68 @@ async def _build_referral_info( ) summary = await get_user_referral_summary(db, user.id) - stats: Optional[MiniAppReferralStats] = None - recent_earnings: List[MiniAppReferralRecentEarning] = [] + stats: MiniAppReferralStats | None = None + recent_earnings: list[MiniAppReferralRecentEarning] = [] if summary: - total_earned_kopeks = int(summary.get("total_earned_kopeks") or 0) - month_earned_kopeks = int(summary.get("month_earned_kopeks") or 0) + total_earned_kopeks = int(summary.get('total_earned_kopeks') or 0) + month_earned_kopeks = int(summary.get('month_earned_kopeks') or 0) stats = MiniAppReferralStats( - invited_count=int(summary.get("invited_count") or 0), - paid_referrals_count=int(summary.get("paid_referrals_count") or 0), - active_referrals_count=int(summary.get("active_referrals_count") or 0), + invited_count=int(summary.get('invited_count') or 0), + paid_referrals_count=int(summary.get('paid_referrals_count') or 0), + active_referrals_count=int(summary.get('active_referrals_count') or 0), total_earned_kopeks=total_earned_kopeks, total_earned_label=settings.format_price(total_earned_kopeks), month_earned_kopeks=month_earned_kopeks, month_earned_label=settings.format_price(month_earned_kopeks), - conversion_rate=float(summary.get("conversion_rate") or 0.0), + conversion_rate=float(summary.get('conversion_rate') or 0.0), ) - for earning in summary.get("recent_earnings", []) or []: - amount = int(earning.get("amount_kopeks") or 0) + for earning in summary.get('recent_earnings', []) or []: + amount = int(earning.get('amount_kopeks') or 0) recent_earnings.append( MiniAppReferralRecentEarning( amount_kopeks=amount, amount_label=settings.format_price(amount), - reason=earning.get("reason"), - referral_name=earning.get("referral_name"), - created_at=earning.get("created_at"), + reason=earning.get('reason'), + referral_name=earning.get('referral_name'), + created_at=earning.get('created_at'), ) ) detailed = await get_detailed_referral_list(db, user.id, limit=50, offset=0) - referral_items: List[MiniAppReferralItem] = [] + referral_items: list[MiniAppReferralItem] = [] if detailed: - for item in detailed.get("referrals", []) or []: - total_earned = int(item.get("total_earned_kopeks") or 0) - balance = int(item.get("balance_kopeks") or 0) + for item in detailed.get('referrals', []) or []: + total_earned = int(item.get('total_earned_kopeks') or 0) + balance = int(item.get('balance_kopeks') or 0) referral_items.append( MiniAppReferralItem( - id=int(item.get("id") or 0), - telegram_id=item.get("telegram_id"), - full_name=item.get("full_name"), - username=item.get("username"), - created_at=item.get("created_at"), - last_activity=item.get("last_activity"), - has_made_first_topup=bool(item.get("has_made_first_topup")), + id=int(item.get('id') or 0), + telegram_id=item.get('telegram_id'), + full_name=item.get('full_name'), + username=item.get('username'), + created_at=item.get('created_at'), + last_activity=item.get('last_activity'), + has_made_first_topup=bool(item.get('has_made_first_topup')), balance_kopeks=balance, balance_label=settings.format_price(balance), total_earned_kopeks=total_earned, total_earned_label=settings.format_price(total_earned), - topups_count=int(item.get("topups_count") or 0), - days_since_registration=item.get("days_since_registration"), - days_since_activity=item.get("days_since_activity"), - status=item.get("status"), + topups_count=int(item.get('topups_count') or 0), + days_since_registration=item.get('days_since_registration'), + days_since_activity=item.get('days_since_activity'), + status=item.get('status'), ) ) referral_list = MiniAppReferralList( - total_count=int(detailed.get("total_count") or 0) if detailed else 0, - has_next=bool(detailed.get("has_next")) if detailed else False, - has_prev=bool(detailed.get("has_prev")) if detailed else False, - current_page=int(detailed.get("current_page") or 1) if detailed else 1, - total_pages=int(detailed.get("total_pages") or 1) if detailed else 1, + total_count=int(detailed.get('total_count') or 0) if detailed else 0, + has_next=bool(detailed.get('has_next')) if detailed else False, + has_prev=bool(detailed.get('has_prev')) if detailed else False, + current_page=int(detailed.get('current_page') or 1) if detailed else 1, + total_pages=int(detailed.get('total_pages') or 1) if detailed else 1, items=referral_items, ) @@ -3085,17 +3047,17 @@ def _is_trial_available_for_user(user: User) -> bool: if settings.TRIAL_DURATION_DAYS <= 0: return False - if getattr(user, "has_had_paid_subscription", False): + if getattr(user, 'has_had_paid_subscription', False): return False - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if subscription is not None: return False return True -@router.post("/subscription", response_model=MiniAppSubscriptionResponse) +@router.post('/subscription', response_model=MiniAppSubscriptionResponse) async def get_subscription_details( payload: MiniAppSubscriptionRequest, db: AsyncSession = Depends(get_db_session), @@ -3106,9 +3068,9 @@ async def get_subscription_details( raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={ - "code": "maintenance", - "message": maintenance_service.get_maintenance_message() or "Service is under maintenance", - "reason": status_info.get("reason"), + 'code': 'maintenance', + 'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance', + 'reason': status_info.get('reason'), }, ) @@ -3120,63 +3082,60 @@ async def get_subscription_details( detail=str(error), ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user payload", + detail='Invalid Telegram user payload', ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid Telegram user identifier", + detail='Invalid Telegram user identifier', ) from None # Check required channel subscription if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID: try: bot = _get_channel_check_bot() - chat_member = await bot.get_chat_member( - chat_id=settings.CHANNEL_SUB_ID, - user_id=telegram_id - ) + chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=telegram_id) # Не закрываем сессию - бот переиспользуется - if chat_member.status not in ["member", "administrator", "creator"]: + if chat_member.status not in ['member', 'administrator', 'creator']: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ - "code": "channel_subscription_required", - "message": "Please subscribe to our channel to continue", - "channel_link": settings.CHANNEL_LINK, + 'code': 'channel_subscription_required', + 'message': 'Please subscribe to our channel to continue', + 'channel_link': settings.CHANNEL_LINK, }, ) except HTTPException: raise except Exception as e: - logger.warning(f"Failed to check channel subscription for user {telegram_id}: {e}") + logger.warning(f'Failed to check channel subscription for user {telegram_id}: {e}') # Don't block user if check fails user = await get_user_by_telegram_id(db, telegram_id) - purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() + purchase_url = (settings.MINIAPP_PURCHASE_URL or '').strip() if not user: - detail: Dict[str, Any] = { - "code": "user_not_found", - "message": "User not found. Please register in the bot to continue.", - "title": "Registration required", + detail: dict[str, Any] = { + 'code': 'user_not_found', + 'message': 'User not found. Please register in the bot to continue.', + 'title': 'Registration required', } if purchase_url: - detail["purchase_url"] = purchase_url + detail['purchase_url'] = purchase_url raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=detail, ) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) usage_synced = False if subscription and _is_remnawave_configured(): @@ -3185,17 +3144,17 @@ async def get_subscription_details( usage_synced = await service.sync_subscription_usage(db, subscription) except Exception as error: # pragma: no cover - defensive logging logger.warning( - "Failed to sync subscription usage for user %s: %s", - getattr(user, "id", "unknown"), + 'Failed to sync subscription usage for user %s: %s', + getattr(user, 'id', 'unknown'), error, ) if usage_synced: try: - await db.refresh(subscription, attribute_names=["traffic_used_gb", "updated_at"]) + await db.refresh(subscription, attribute_names=['traffic_used_gb', 'updated_at']) except Exception as refresh_error: # pragma: no cover - defensive logging logger.debug( - "Failed to refresh subscription after usage sync: %s", + 'Failed to refresh subscription after usage sync: %s', refresh_error, ) @@ -3203,32 +3162,29 @@ async def get_subscription_details( await db.refresh(user) except Exception as refresh_error: # pragma: no cover - defensive logging logger.debug( - "Failed to refresh user after usage sync: %s", + 'Failed to refresh user after usage sync: %s', refresh_error, ) user = await get_user_by_telegram_id(db, telegram_id) - subscription = getattr(user, "subscription", subscription) - lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) + subscription = getattr(user, 'subscription', subscription) + lifetime_used = _bytes_to_gb(getattr(user, 'lifetime_used_traffic_bytes', 0)) transactions_query = ( - select(Transaction) - .where(Transaction.user_id == user.id) - .order_by(Transaction.created_at.desc()) - .limit(10) + select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.created_at.desc()).limit(10) ) transactions_result = await db.execute(transactions_query) transactions = list(transactions_result.scalars().all()) - balance_currency = getattr(user, "balance_currency", None) + balance_currency = getattr(user, 'balance_currency', None) if isinstance(balance_currency, str): balance_currency = balance_currency.upper() - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id) auto_assign_groups = await get_auto_assign_promo_groups(db) - auto_promo_levels: List[MiniAppAutoPromoGroupLevel] = [] + auto_promo_levels: list[MiniAppAutoPromoGroupLevel] = [] for group in auto_assign_groups: threshold = group.auto_assign_total_spent_kopeks or 0 if threshold <= 0: @@ -3249,11 +3205,11 @@ async def get_subscription_details( active_discount_percent = 0 try: - active_discount_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + active_discount_percent = int(getattr(user, 'promo_offer_discount_percent', 0) or 0) except (TypeError, ValueError): active_discount_percent = 0 - active_discount_expires_at = getattr(user, "promo_offer_discount_expires_at", None) + active_discount_expires_at = getattr(user, 'promo_offer_discount_expires_at', None) now = datetime.utcnow() if active_discount_expires_at and active_discount_expires_at <= now: active_discount_expires_at = None @@ -3261,8 +3217,8 @@ async def get_subscription_details( available_promo_offers = await list_active_discount_offers_for_user(db, user.id) - promo_offer_source = getattr(user, "promo_offer_discount_source", None) - active_offer_contexts: List[ActiveOfferContext] = [] + promo_offer_source = getattr(user, 'promo_offer_discount_source', None) + active_offer_contexts: list[ActiveOfferContext] = [] if promo_offer_source or active_discount_percent > 0: active_discount_offer = await get_latest_claimed_offer_for_user( db, @@ -3279,9 +3235,7 @@ async def get_subscription_details( ) if subscription: - active_offer_contexts.extend( - await _find_active_test_access_offers(db, subscription) - ) + active_offer_contexts.extend(await _find_active_test_access_offers(db, subscription)) promo_offers = await _build_promo_offer_models( db, @@ -3290,13 +3244,13 @@ async def get_subscription_details( user=user, ) - content_language_preference = user.language or settings.DEFAULT_LANGUAGE or "ru" + content_language_preference = user.language or settings.DEFAULT_LANGUAGE or 'ru' - def _normalize_language_code(language: Optional[str]) -> str: - base_language = language or settings.DEFAULT_LANGUAGE or "ru" - return base_language.split("-")[0].lower() + def _normalize_language_code(language: str | None) -> str: + base_language = language or settings.DEFAULT_LANGUAGE or 'ru' + return base_language.split('-')[0].lower() - faq_payload: Optional[MiniAppFaq] = None + faq_payload: MiniAppFaq | None = None requested_faq_language = FaqService.normalize_language(content_language_preference) faq_pages = await FaqService.get_pages( db, @@ -3321,27 +3275,25 @@ async def get_subscription_details( page.id, ), ) - faq_items: List[MiniAppFaqItem] = [] + faq_items: list[MiniAppFaqItem] = [] for page in ordered_pages: - raw_content = (page.content or "").strip() + raw_content = (page.content or '').strip() if not raw_content: continue - if not re.sub(r"<[^>]+>", "", raw_content).strip(): + if not re.sub(r'<[^>]+>', '', raw_content).strip(): continue faq_items.append( MiniAppFaqItem( id=page.id, title=page.title or None, - content=page.content or "", - display_order=getattr(page, "display_order", None), + content=page.content or '', + display_order=getattr(page, 'display_order', None), ) - ) + ) if faq_items: resolved_language = ( - faq_setting.language - if faq_setting and faq_setting.language - else ordered_pages[0].language + faq_setting.language if faq_setting and faq_setting.language else ordered_pages[0].language ) faq_payload = MiniAppFaq( requested_language=requested_faq_language, @@ -3351,40 +3303,38 @@ async def get_subscription_details( items=faq_items, ) - legal_documents_payload: Optional[MiniAppLegalDocuments] = None + legal_documents_payload: MiniAppLegalDocuments | None = None requested_offer_language = PublicOfferService.normalize_language(content_language_preference) public_offer = await PublicOfferService.get_active_offer( db, requested_offer_language, ) - if public_offer and (public_offer.content or "").strip(): + if public_offer and (public_offer.content or '').strip(): legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments() legal_documents_payload.public_offer = MiniAppRichTextDocument( requested_language=requested_offer_language, language=public_offer.language, title=None, is_enabled=bool(public_offer.is_enabled), - content=public_offer.content or "", + content=public_offer.content or '', created_at=public_offer.created_at, updated_at=public_offer.updated_at, ) - requested_policy_language = PrivacyPolicyService.normalize_language( - content_language_preference - ) + requested_policy_language = PrivacyPolicyService.normalize_language(content_language_preference) privacy_policy = await PrivacyPolicyService.get_active_policy( db, requested_policy_language, ) - if privacy_policy and (privacy_policy.content or "").strip(): + if privacy_policy and (privacy_policy.content or '').strip(): legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments() legal_documents_payload.privacy_policy = MiniAppRichTextDocument( requested_language=requested_policy_language, language=privacy_policy.language, title=None, is_enabled=bool(privacy_policy.is_enabled), - content=privacy_policy.content or "", + content=privacy_policy.content or '', created_at=privacy_policy.created_at, updated_at=privacy_policy.updated_at, ) @@ -3395,33 +3345,33 @@ async def get_subscription_details( if not service_rules and requested_rules_language != default_rules_language: service_rules = await get_rules_by_language(db, default_rules_language) - if service_rules and (service_rules.content or "").strip(): + if service_rules and (service_rules.content or '').strip(): legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments() legal_documents_payload.service_rules = MiniAppRichTextDocument( requested_language=requested_rules_language, language=service_rules.language, - title=getattr(service_rules, "title", None), - is_enabled=bool(getattr(service_rules, "is_active", True)), - content=service_rules.content or "", - created_at=getattr(service_rules, "created_at", None), - updated_at=getattr(service_rules, "updated_at", None), + title=getattr(service_rules, 'title', None), + is_enabled=bool(getattr(service_rules, 'is_active', True)), + content=service_rules.content or '', + created_at=getattr(service_rules, 'created_at', None), + updated_at=getattr(service_rules, 'updated_at', None), ) - links_payload: Dict[str, Any] = {} - connected_squads: List[str] = [] - connected_servers: List[MiniAppConnectedServer] = [] - links: List[str] = [] - ss_conf_links: Dict[str, str] = {} - subscription_url: Optional[str] = None - subscription_crypto_link: Optional[str] = None - happ_redirect_link: Optional[str] = None + links_payload: dict[str, Any] = {} + connected_squads: list[str] = [] + connected_servers: list[MiniAppConnectedServer] = [] + links: list[str] = [] + ss_conf_links: dict[str, str] = {} + subscription_url: str | None = None + subscription_crypto_link: str | None = None + happ_redirect_link: str | None = None hide_subscription_link: bool = False - remnawave_short_uuid: Optional[str] = None - status_actual = "missing" - subscription_status_value = "none" + remnawave_short_uuid: str | None = None + status_actual = 'missing' + subscription_status_value = 'none' traffic_used_value = 0.0 traffic_limit_value = 0 - device_limit_value: Optional[int] = settings.DEFAULT_DEVICE_LIMIT or None + device_limit_value: int | None = settings.DEFAULT_DEVICE_LIMIT or None autopay_enabled = False if subscription: @@ -3432,33 +3382,20 @@ async def get_subscription_details( links_payload = await _load_subscription_links(subscription) # Флаг скрытия ссылки (скрывается только текст, кнопки работают) hide_subscription_link = settings.should_hide_subscription_link() - subscription_url = ( - links_payload.get("subscription_url") or subscription.subscription_url - ) - subscription_crypto_link = ( - links_payload.get("happ_crypto_link") - or subscription.subscription_crypto_link - ) + subscription_url = links_payload.get('subscription_url') or subscription.subscription_url + subscription_crypto_link = links_payload.get('happ_crypto_link') or subscription.subscription_crypto_link happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) connected_squads = list(subscription.connected_squads or []) connected_servers = await _resolve_connected_servers(db, connected_squads) - links = links_payload.get("links") or connected_squads - ss_conf_links = links_payload.get("ss_conf_links") or {} + links = links_payload.get('links') or connected_squads + ss_conf_links = links_payload.get('ss_conf_links') or {} remnawave_short_uuid = subscription.remnawave_short_uuid device_limit_value = subscription.device_limit autopay_enabled = bool(subscription.autopay_enabled) autopay_payload = _build_autopay_payload(subscription) - autopay_days_before = ( - getattr(autopay_payload, "autopay_days_before", None) - if autopay_payload - else None - ) - autopay_days_options = ( - list(getattr(autopay_payload, "autopay_days_options", []) or []) - if autopay_payload - else [] - ) + autopay_days_before = getattr(autopay_payload, 'autopay_days_before', None) if autopay_payload else None + autopay_days_options = list(getattr(autopay_payload, 'autopay_days_options', []) or []) if autopay_payload else [] autopay_extras = _autopay_response_extras( autopay_enabled, autopay_days_before, @@ -3476,14 +3413,14 @@ async def get_subscription_details( daily_price_label = None daily_next_charge_at = None - if subscription and getattr(subscription, "tariff_id", None): + if subscription and getattr(subscription, 'tariff_id', None): tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff and getattr(tariff, 'is_daily', False): is_daily_tariff = True is_daily_paused = getattr(subscription, 'is_daily_paused', False) daily_tariff_name = tariff.name daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) - daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if daily_price_kopeks > 0 else None + daily_price_label = settings.format_price(daily_price_kopeks) + '/день' if daily_price_kopeks > 0 else None # Оставшееся время подписки (показываем даже при паузе) if subscription.end_date: daily_next_charge_at = subscription.end_date @@ -3495,10 +3432,10 @@ async def get_subscription_details( last_name=user.last_name, display_name=_resolve_display_name( { - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "telegram_id": user.telegram_id, + 'username': user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'telegram_id': user.telegram_id, } ), language=user.language, @@ -3506,14 +3443,14 @@ async def get_subscription_details( subscription_status=subscription_status_value, subscription_actual_status=status_actual, status_label=_status_label(status_actual), - expires_at=getattr(subscription, "end_date", None), + expires_at=getattr(subscription, 'end_date', None), device_limit=device_limit_value, traffic_used_gb=round(traffic_used_value, 2), traffic_used_label=_format_gb_label(traffic_used_value), traffic_limit_gb=traffic_limit_value, traffic_limit_label=_format_limit_label(traffic_limit_value), lifetime_used_traffic_gb=lifetime_used, - has_active_subscription=status_actual in {"active", "trial"}, + has_active_subscription=status_actual in {'active', 'trial'}, promo_offer_discount_percent=active_discount_percent, promo_offer_discount_expires_at=active_discount_expires_at, promo_offer_discount_source=promo_offer_source, @@ -3528,30 +3465,25 @@ async def get_subscription_details( referral_info = await _build_referral_info(db, user) trial_available = _is_trial_available_for_user(user) - trial_duration_days = ( - settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None - ) + trial_duration_days = settings.TRIAL_DURATION_DAYS if settings.TRIAL_DURATION_DAYS > 0 else None trial_price_kopeks = settings.get_trial_activation_price() - trial_payment_required = ( - settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0 - ) - trial_price_label = ( - settings.format_price(trial_price_kopeks) if trial_payment_required else None - ) + trial_payment_required = settings.is_trial_paid_activation_enabled() and trial_price_kopeks > 0 + trial_price_label = settings.format_price(trial_price_kopeks) if trial_payment_required else None subscription_missing_reason = None if subscription is None: if not trial_available and settings.TRIAL_DURATION_DAYS > 0: - subscription_missing_reason = "trial_expired" + subscription_missing_reason = 'trial_expired' else: - subscription_missing_reason = "not_found" + subscription_missing_reason = 'not_found' # Получаем докупки трафика traffic_purchases_data = [] if subscription: - from app.database.models import TrafficPurchase from sqlalchemy import select as sql_select + from app.database.models import TrafficPurchase + now = datetime.utcnow() purchases_query = ( sql_select(TrafficPurchase) @@ -3567,20 +3499,24 @@ async def get_subscription_details( days_remaining = max(0, int(time_remaining.total_seconds() / 86400)) total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds() elapsed_seconds = (now - purchase.created_at).total_seconds() - progress_percent = min(100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)) + progress_percent = min( + 100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0) + ) - traffic_purchases_data.append({ - "id": purchase.id, - "traffic_gb": purchase.traffic_gb, - "expires_at": purchase.expires_at, - "created_at": purchase.created_at, - "days_remaining": days_remaining, - "progress_percent": round(progress_percent, 1) - }) + traffic_purchases_data.append( + { + 'id': purchase.id, + 'traffic_gb': purchase.traffic_gb, + 'expires_at': purchase.expires_at, + 'created_at': purchase.created_at, + 'days_remaining': days_remaining, + 'progress_percent': round(progress_percent, 1), + } + ) return MiniAppSubscriptionResponse( traffic_purchases=traffic_purchases_data, - subscription_id=getattr(subscription, "id", None), + subscription_id=getattr(subscription, 'id', None), remnawave_short_uuid=remnawave_short_uuid, user=response_user, subscription_url=subscription_url, @@ -3593,8 +3529,8 @@ async def get_subscription_details( connected_servers=connected_servers, connected_devices_count=devices_count, connected_devices=devices, - happ=links_payload.get("happ") if subscription else None, - happ_link=links_payload.get("happ_link") if subscription else None, + happ=links_payload.get('happ') if subscription else None, + happ_link=links_payload.get('happ_link') if subscription else None, happ_crypto_link=subscription_crypto_link, # Используем уже вычисленное значение с fallback happ_cryptolink_redirect_link=happ_redirect_link, happ_cryptolink_redirect_template=settings.get_happ_cryptolink_redirect_template(), @@ -3616,11 +3552,7 @@ async def get_subscription_details( total_spent_kopeks=total_spent_kopeks, total_spent_rubles=round(total_spent_kopeks / 100, 2), total_spent_label=settings.format_price(total_spent_kopeks), - subscription_type=( - "trial" - if subscription and subscription.is_trial - else ("paid" if subscription else "none") - ), + subscription_type=('trial' if subscription and subscription.is_trial else ('paid' if subscription else 'none')), autopay_enabled=autopay_enabled, autopay_days_before=autopay_days_before, autopay_days_options=autopay_days_options, @@ -3634,7 +3566,7 @@ async def get_subscription_details( subscription_missing_reason=subscription_missing_reason, trial_available=trial_available, trial_duration_days=trial_duration_days, - trial_status="available" if trial_available else "unavailable", + trial_status='available' if trial_available else 'unavailable', trial_payment_required=trial_payment_required, trial_price_kopeks=trial_price_kopeks if trial_payment_required else None, trial_price_label=trial_price_label, @@ -3644,11 +3576,11 @@ async def get_subscription_details( ) -async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -> Optional[MiniAppCurrentTariff]: +async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) -> MiniAppCurrentTariff | None: """Возвращает модель текущего тарифа пользователя.""" from app.webapi.schemas.miniapp import MiniAppTrafficTopupPackage - if not subscription or not getattr(subscription, "tariff_id", None): + if not subscription or not getattr(subscription, 'tariff_id', None): return None tariff = await get_tariff_by_id(db, subscription.tariff_id) @@ -3659,7 +3591,15 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - # Получаем скидку на трафик из промогруппы traffic_discount_percent = 0 - promo_group = (user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None)) if user else None + promo_group = ( + ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) + if user + else None + ) if promo_group: apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) if apply_to_addons: @@ -3689,20 +3629,24 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - # Применяем скидку if traffic_discount_percent > 0: discounted_price = int(base_price * (100 - traffic_discount_percent) / 100) - traffic_topup_packages.append(MiniAppTrafficTopupPackage( - gb=gb, - price_kopeks=discounted_price, - price_label=settings.format_price(discounted_price), - original_price_kopeks=base_price, - original_price_label=settings.format_price(base_price), - discount_percent=traffic_discount_percent, - )) + traffic_topup_packages.append( + MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=discounted_price, + price_label=settings.format_price(discounted_price), + original_price_kopeks=base_price, + original_price_label=settings.format_price(base_price), + discount_percent=traffic_discount_percent, + ) + ) else: - traffic_topup_packages.append(MiniAppTrafficTopupPackage( - gb=gb, - price_kopeks=base_price, - price_label=settings.format_price(base_price), - )) + traffic_topup_packages.append( + MiniAppTrafficTopupPackage( + gb=gb, + price_kopeks=base_price, + price_label=settings.format_price(base_price), + ) + ) # Если нет доступных пакетов из-за лимита - отключаем докупку if traffic_topup_enabled and not traffic_topup_packages and available_topup_gb == 0: @@ -3728,7 +3672,9 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - description=tariff.description, tier_level=tariff.tier_level, traffic_limit_gb=tariff.traffic_limit_gb, - traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb) if settings.is_tariffs_mode() else f"{tariff.traffic_limit_gb} ГБ", + traffic_limit_label=_format_traffic_limit_label(tariff.traffic_limit_gb) + if settings.is_tariffs_mode() + else f'{tariff.traffic_limit_gb} ГБ', is_unlimited_traffic=tariff.traffic_limit_gb == 0, device_limit=tariff.device_limit, servers_count=servers_count, @@ -3741,7 +3687,7 @@ async def _get_current_tariff_model(db: AsyncSession, subscription, user=None) - @router.post( - "/subscription/autopay", + '/subscription/autopay', response_model=MiniAppSubscriptionAutopayResponse, ) async def update_subscription_autopay_endpoint( @@ -3752,24 +3698,16 @@ async def update_subscription_autopay_endpoint( subscription = _ensure_paid_subscription(user) _validate_subscription_id(payload.subscription_id, subscription) - target_enabled = ( - bool(payload.enabled) - if payload.enabled is not None - else bool(subscription.autopay_enabled) - ) + target_enabled = bool(payload.enabled) if payload.enabled is not None else bool(subscription.autopay_enabled) requested_days = payload.days_before normalized_days = _normalize_autopay_days(requested_days) - current_days = _normalize_autopay_days( - getattr(subscription, "autopay_days_before", None) - ) + current_days = _normalize_autopay_days(getattr(subscription, 'autopay_days_before', None)) if normalized_days is None: normalized_days = current_days options = _get_autopay_day_options(subscription) - default_day = _normalize_autopay_days( - getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None) - ) + default_day = _normalize_autopay_days(getattr(settings, 'DEFAULT_AUTOPAY_DAYS_BEFORE', None)) if default_day is None and options: default_day = options[0] @@ -3778,8 +3716,8 @@ async def update_subscription_autopay_endpoint( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "autopay_no_days", - "message": "Auto-pay day selection is temporarily unavailable", + 'code': 'autopay_no_days', + 'message': 'Auto-pay day selection is temporarily unavailable', }, ) normalized_days = default_day @@ -3787,20 +3725,11 @@ async def update_subscription_autopay_endpoint( if normalized_days is None: normalized_days = default_day or (options[0] if options else 1) - if ( - bool(subscription.autopay_enabled) == target_enabled - and current_days == normalized_days - ): + if bool(subscription.autopay_enabled) == target_enabled and current_days == normalized_days: autopay_payload = _build_autopay_payload(subscription) - autopay_days_before = ( - getattr(autopay_payload, "autopay_days_before", None) - if autopay_payload - else None - ) + autopay_days_before = getattr(autopay_payload, 'autopay_days_before', None) if autopay_payload else None autopay_days_options = ( - list(getattr(autopay_payload, "autopay_days_options", []) or []) - if autopay_payload - else options + list(getattr(autopay_payload, 'autopay_days_options', []) or []) if autopay_payload else options ) extras = _autopay_response_extras( target_enabled, @@ -3826,13 +3755,9 @@ async def update_subscription_autopay_endpoint( ) autopay_payload = _build_autopay_payload(updated_subscription) - autopay_days_before = ( - getattr(autopay_payload, "autopay_days_before", None) - if autopay_payload - else None - ) + autopay_days_before = getattr(autopay_payload, 'autopay_days_before', None) if autopay_payload else None autopay_days_options = ( - list(getattr(autopay_payload, "autopay_days_options", []) or []) + list(getattr(autopay_payload, 'autopay_days_options', []) or []) if autopay_payload else _get_autopay_day_options(updated_subscription) ) @@ -3855,7 +3780,7 @@ async def update_subscription_autopay_endpoint( @router.post( - "/subscription/trial", + '/subscription/trial', response_model=MiniAppSubscriptionTrialResponse, ) async def activate_subscription_trial_endpoint( @@ -3864,27 +3789,27 @@ async def activate_subscription_trial_endpoint( ) -> MiniAppSubscriptionTrialResponse: user = await _authorize_miniapp_user(payload.init_data, db) - existing_subscription = getattr(user, "subscription", None) + existing_subscription = getattr(user, 'subscription', None) if existing_subscription is not None: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "subscription_exists", - "message": "Subscription is already active", + 'code': 'subscription_exists', + 'message': 'Subscription is already active', }, ) if not _is_trial_available_for_user(user): - error_code = "trial_unavailable" - if getattr(user, "has_had_paid_subscription", False): - error_code = "trial_expired" + error_code = 'trial_unavailable' + if getattr(user, 'has_had_paid_subscription', False): + error_code = 'trial_expired' elif settings.TRIAL_DURATION_DAYS <= 0: - error_code = "trial_disabled" + error_code = 'trial_disabled' raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": error_code, - "message": "Trial is not available for this user", + 'code': error_code, + 'message': 'Trial is not available for this user', }, ) @@ -3895,11 +3820,11 @@ async def activate_subscription_trial_endpoint( raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": "Not enough funds to activate the trial", - "missing_amount_kopeks": missing, - "required_amount_kopeks": error.required_amount, - "balance_kopeks": error.balance_amount, + 'code': 'insufficient_funds', + 'message': 'Not enough funds to activate the trial', + 'missing_amount_kopeks': missing, + 'required_amount_kopeks': error.required_amount, + 'balance_kopeks': error.balance_amount, }, ) from error forced_devices = None @@ -3934,9 +3859,9 @@ async def activate_subscription_trial_endpoint( tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None) if tariff_trial_days: trial_duration = tariff_trial_days - logger.info(f"Miniapp: используем триальный тариф {trial_tariff.name}") + logger.info(f'Miniapp: используем триальный тариф {trial_tariff.name}') except Exception as e: - logger.error(f"Ошибка получения триального тарифа: {e}") + logger.error(f'Ошибка получения триального тарифа: {e}') try: subscription = await create_trial_subscription( @@ -3950,15 +3875,15 @@ async def activate_subscription_trial_endpoint( ) except Exception as error: # pragma: no cover - defensive logging logger.error( - "Failed to activate trial subscription for user %s: %s", + 'Failed to activate trial subscription for user %s: %s', user.id, error, ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_activation_failed", - "message": "Failed to activate trial subscription", + 'code': 'trial_activation_failed', + 'message': 'Failed to activate trial subscription', }, ) from error @@ -3972,24 +3897,24 @@ async def activate_subscription_trial_endpoint( raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after charge error", + 'code': 'trial_rollback_failed', + 'message': 'Failed to revert trial activation after charge error', }, ) from error logger.error( - "Balance check failed after trial creation for user %s: %s", + 'Balance check failed after trial creation for user %s: %s', user.id, error, ) raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": "Not enough funds to activate the trial", - "missing_amount_kopeks": error.missing_amount, - "required_amount_kopeks": error.required_amount, - "balance_kopeks": error.balance_amount, + 'code': 'insufficient_funds', + 'message': 'Not enough funds to activate the trial', + 'missing_amount_kopeks': error.missing_amount, + 'required_amount_kopeks': error.required_amount, + 'balance_kopeks': error.balance_amount, }, ) from error except TrialPaymentChargeFailed as error: @@ -3999,21 +3924,21 @@ async def activate_subscription_trial_endpoint( raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after charge error", + 'code': 'trial_rollback_failed', + 'message': 'Failed to revert trial activation after charge error', }, ) from error logger.error( - "Failed to charge balance for trial activation after subscription %s creation: %s", + 'Failed to charge balance for trial activation after subscription %s creation: %s', subscription.id, error, ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "charge_failed", - "message": "Failed to charge balance for trial activation", + 'code': 'charge_failed', + 'message': 'Failed to charge balance for trial activation', }, ) from error @@ -4024,41 +3949,41 @@ async def activate_subscription_trial_endpoint( try: await subscription_service.create_remnawave_user(db, subscription) except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues - logger.error("RemnaWave update skipped due to configuration error: %s", error) + logger.error('RemnaWave update skipped due to configuration error: %s', error) revert_result = await revert_trial_activation( db, user, subscription, charged_amount, - refund_description="Возврат оплаты за активацию триала в мини-приложении", + refund_description='Возврат оплаты за активацию триала в мини-приложении', ) if not revert_result.subscription_rolled_back: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after RemnaWave error", + 'code': 'trial_rollback_failed', + 'message': 'Failed to revert trial activation after RemnaWave error', }, ) from error if charged_amount > 0 and not revert_result.refunded: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_refund_failed", - "message": "Failed to refund trial activation charge after RemnaWave error", + 'code': 'trial_refund_failed', + 'message': 'Failed to refund trial activation charge after RemnaWave error', }, ) from error raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={ - "code": "remnawave_configuration_error", - "message": "Trial activation failed due to RemnaWave configuration. Charge refunded.", + 'code': 'remnawave_configuration_error', + 'message': 'Trial activation failed due to RemnaWave configuration. Charge refunded.', }, ) from error except Exception as error: # pragma: no cover - defensive logging logger.error( - "Failed to create RemnaWave user for trial subscription %s: %s", + 'Failed to create RemnaWave user for trial subscription %s: %s', subscription.id, error, ) @@ -4067,36 +3992,36 @@ async def activate_subscription_trial_endpoint( user, subscription, charged_amount, - refund_description="Возврат оплаты за активацию триала в мини-приложении", + refund_description='Возврат оплаты за активацию триала в мини-приложении', ) if not revert_result.subscription_rolled_back: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_rollback_failed", - "message": "Failed to revert trial activation after RemnaWave error", + 'code': 'trial_rollback_failed', + 'message': 'Failed to revert trial activation after RemnaWave error', }, ) from error if charged_amount > 0 and not revert_result.refunded: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "trial_refund_failed", - "message": "Failed to refund trial activation charge after RemnaWave error", + 'code': 'trial_refund_failed', + 'message': 'Failed to refund trial activation charge after RemnaWave error', }, ) from error raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={ - "code": "remnawave_provisioning_failed", - "message": "Trial activation failed due to RemnaWave provisioning. Charge refunded.", + 'code': 'remnawave_provisioning_failed', + 'message': 'Trial activation failed due to RemnaWave provisioning. Charge refunded.', }, ) from error await db.refresh(subscription) - duration_days: Optional[int] = None + duration_days: int | None = None if subscription.start_date and subscription.end_date: try: duration_days = max( @@ -4110,25 +4035,22 @@ async def activate_subscription_trial_endpoint( duration_days = settings.TRIAL_DURATION_DAYS language_code = _normalize_language_code(user) - charged_amount_label = ( - settings.format_price(charged_amount) if charged_amount > 0 else None - ) - if language_code == "ru": + charged_amount_label = settings.format_price(charged_amount) if charged_amount > 0 else None + if language_code == 'ru': if duration_days: - message = f"Триал активирован на {duration_days} дн. Приятного пользования!" + message = f'Триал активирован на {duration_days} дн. Приятного пользования!' else: - message = "Триал активирован. Приятного пользования!" + message = 'Триал активирован. Приятного пользования!' + elif duration_days: + message = f'Trial activated for {duration_days} days. Enjoy!' else: - if duration_days: - message = f"Trial activated for {duration_days} days. Enjoy!" - else: - message = "Trial activated successfully. Enjoy!" + message = 'Trial activated successfully. Enjoy!' if charged_amount_label: - if language_code == "ru": - message = f"{message}\n\n💳 С вашего баланса списано {charged_amount_label}." + if language_code == 'ru': + message = f'{message}\n\n💳 С вашего баланса списано {charged_amount_label}.' else: - message = f"{message}\n\n💳 {charged_amount_label} has been deducted from your balance." + message = f'{message}\n\n💳 {charged_amount_label} has been deducted from your balance.' await with_admin_notification_service( lambda service: service.send_trial_activation_notification( @@ -4141,8 +4063,8 @@ async def activate_subscription_trial_endpoint( return MiniAppSubscriptionTrialResponse( message=message, - subscription_id=getattr(subscription, "id", None), - trial_status="activated", + subscription_id=getattr(subscription, 'id', None), + trial_status='activated', trial_duration_days=duration_days, charged_amount_kopeks=charged_amount if charged_amount > 0 else None, charged_amount_label=charged_amount_label, @@ -4152,7 +4074,7 @@ async def activate_subscription_trial_endpoint( @router.post( - "/promo-codes/activate", + '/promo-codes/activate', response_model=MiniAppPromoCodeActivationResponse, ) async def activate_promo_code( @@ -4164,98 +4086,98 @@ async def activate_promo_code( except TelegramWebAppAuthError as error: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, + detail={'code': 'unauthorized', 'message': str(error)}, ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user payload'}, ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user identifier'}, ) from None user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, + detail={'code': 'user_not_found', 'message': 'User not found'}, ) - code = (payload.code or "").strip().upper() + code = (payload.code or '').strip().upper() if not code: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid", "message": "Promo code must not be empty"}, + detail={'code': 'invalid', 'message': 'Promo code must not be empty'}, ) result = await promo_code_service.activate_promocode(db, user.id, code) - if result.get("success"): - promocode_data = result.get("promocode") or {} + if result.get('success'): + promocode_data = result.get('promocode') or {} try: - balance_bonus = int(promocode_data.get("balance_bonus_kopeks") or 0) + balance_bonus = int(promocode_data.get('balance_bonus_kopeks') or 0) except (TypeError, ValueError): balance_bonus = 0 try: - subscription_days = int(promocode_data.get("subscription_days") or 0) + subscription_days = int(promocode_data.get('subscription_days') or 0) except (TypeError, ValueError): subscription_days = 0 promo_payload = MiniAppPromoCode( - code=str(promocode_data.get("code") or code), - type=promocode_data.get("type"), + code=str(promocode_data.get('code') or code), + type=promocode_data.get('type'), balance_bonus_kopeks=balance_bonus, subscription_days=subscription_days, - max_uses=promocode_data.get("max_uses"), - current_uses=promocode_data.get("current_uses"), - valid_until=promocode_data.get("valid_until"), + max_uses=promocode_data.get('max_uses'), + current_uses=promocode_data.get('current_uses'), + valid_until=promocode_data.get('valid_until'), ) return MiniAppPromoCodeActivationResponse( success=True, - description=result.get("description"), + description=result.get('description'), promocode=promo_payload, ) - error_code = str(result.get("error") or "generic") + error_code = str(result.get('error') or 'generic') status_map = { - "user_not_found": status.HTTP_404_NOT_FOUND, - "not_found": status.HTTP_404_NOT_FOUND, - "expired": status.HTTP_410_GONE, - "used": status.HTTP_409_CONFLICT, - "already_used_by_user": status.HTTP_409_CONFLICT, - "server_error": status.HTTP_500_INTERNAL_SERVER_ERROR, + 'user_not_found': status.HTTP_404_NOT_FOUND, + 'not_found': status.HTTP_404_NOT_FOUND, + 'expired': status.HTTP_410_GONE, + 'used': status.HTTP_409_CONFLICT, + 'already_used_by_user': status.HTTP_409_CONFLICT, + 'server_error': status.HTTP_500_INTERNAL_SERVER_ERROR, } message_map = { - "invalid": "Promo code must not be empty", - "not_found": "Promo code not found", - "expired": "Promo code expired", - "used": "Promo code already used", - "already_used_by_user": "Promo code already used by this user", - "user_not_found": "User not found", - "server_error": "Failed to activate promo code", + 'invalid': 'Promo code must not be empty', + 'not_found': 'Promo code not found', + 'expired': 'Promo code expired', + 'used': 'Promo code already used', + 'already_used_by_user': 'Promo code already used by this user', + 'user_not_found': 'User not found', + 'server_error': 'Failed to activate promo code', } http_status = status_map.get(error_code, status.HTTP_400_BAD_REQUEST) - message = message_map.get(error_code, "Unable to activate promo code") + message = message_map.get(error_code, 'Unable to activate promo code') raise HTTPException( http_status, - detail={"code": error_code, "message": message}, + detail={'code': error_code, 'message': message}, ) @router.post( - "/promo-offers/{offer_id}/claim", + '/promo-offers/{offer_id}/claim', response_model=MiniAppPromoOfferClaimResponse, ) async def claim_promo_offer( @@ -4268,43 +4190,43 @@ async def claim_promo_offer( except TelegramWebAppAuthError as error: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, + detail={'code': 'unauthorized', 'message': str(error)}, ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user payload'}, ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user identifier'}, ) from None user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, + detail={'code': 'user_not_found', 'message': 'User not found'}, ) offer = await get_offer_by_id(db, offer_id) if not offer or offer.user_id != user.id: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "offer_not_found", "message": "Offer not found"}, + detail={'code': 'offer_not_found', 'message': 'Offer not found'}, ) now = datetime.utcnow() if offer.claimed_at is not None: raise HTTPException( status.HTTP_409_CONFLICT, - detail={"code": "already_claimed", "message": "Offer already claimed"}, + detail={'code': 'already_claimed', 'message': 'Offer already claimed'}, ) if not offer.is_active or offer.expires_at <= now: @@ -4312,12 +4234,12 @@ async def claim_promo_offer( await db.commit() raise HTTPException( status.HTTP_410_GONE, - detail={"code": "offer_expired", "message": "Offer expired"}, + detail={'code': 'offer_expired', 'message': 'Offer expired'}, ) - effect_type = _normalize_effect_type(getattr(offer, "effect_type", None)) + effect_type = _normalize_effect_type(getattr(offer, 'effect_type', None)) - if effect_type == "test_access": + if effect_type == 'test_access': success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access( db, user, @@ -4325,35 +4247,35 @@ async def claim_promo_offer( ) if not success: - code = error_code or "claim_failed" + code = error_code or 'claim_failed' message_map = { - "subscription_missing": "Active subscription required", - "squads_missing": "No squads configured for test access", - "already_connected": "Servers already connected", - "remnawave_sync_failed": "Failed to apply servers", + 'subscription_missing': 'Active subscription required', + 'squads_missing': 'No squads configured for test access', + 'already_connected': 'Servers already connected', + 'remnawave_sync_failed': 'Failed to apply servers', } raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": code, "message": message_map.get(code, "Unable to activate offer")}, + detail={'code': code, 'message': message_map.get(code, 'Unable to activate offer')}, ) await mark_offer_claimed( db, offer, details={ - "context": "test_access_claim", - "new_squads": newly_added, - "expires_at": expires_at.isoformat() if expires_at else None, + 'context': 'test_access_claim', + 'new_squads': newly_added, + 'expires_at': expires_at.isoformat() if expires_at else None, }, ) - return MiniAppPromoOfferClaimResponse(success=True, code="test_access_claimed") + return MiniAppPromoOfferClaimResponse(success=True, code='test_access_claimed') - discount_percent = int(getattr(offer, "discount_percent", 0) or 0) + discount_percent = int(getattr(offer, 'discount_percent', 0) or 0) if discount_percent <= 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_discount", "message": "Offer does not contain discount"}, + detail={'code': 'invalid_discount', 'message': 'Offer does not contain discount'}, ) user.promo_offer_discount_percent = discount_percent @@ -4361,10 +4283,10 @@ async def claim_promo_offer( user.updated_at = now extra_data = _extract_offer_extra(offer) - raw_duration = extra_data.get("active_discount_hours") - template_id = extra_data.get("template_id") + raw_duration = extra_data.get('active_discount_hours') + template_id = extra_data.get('template_id') - if raw_duration in (None, "") and template_id: + if raw_duration in (None, '') and template_id: try: template = await get_promo_offer_template_by_id(db, int(template_id)) except (TypeError, ValueError): @@ -4390,18 +4312,18 @@ async def claim_promo_offer( db, offer, details={ - "context": "discount_claim", - "discount_percent": discount_percent, - "discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None, + 'context': 'discount_claim', + 'discount_percent': discount_percent, + 'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None, }, ) await db.refresh(user) - return MiniAppPromoOfferClaimResponse(success=True, code="discount_claimed") + return MiniAppPromoOfferClaimResponse(success=True, code='discount_claimed') @router.post( - "/devices/remove", + '/devices/remove', response_model=MiniAppDeviceRemovalResponse, ) async def remove_connected_device( @@ -4413,50 +4335,50 @@ async def remove_connected_device( except TelegramWebAppAuthError as error: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, + detail={'code': 'unauthorized', 'message': str(error)}, ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user payload'}, ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user identifier'}, ) from None user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, + detail={'code': 'user_not_found', 'message': 'User not found'}, ) - remnawave_uuid = getattr(user, "remnawave_uuid", None) + remnawave_uuid = getattr(user, 'remnawave_uuid', None) if not remnawave_uuid: raise HTTPException( status.HTTP_409_CONFLICT, - detail={"code": "remnawave_unavailable", "message": "RemnaWave user is not linked"}, + detail={'code': 'remnawave_unavailable', 'message': 'RemnaWave user is not linked'}, ) - hwid = (payload.hwid or "").strip() + hwid = (payload.hwid or '').strip() if not hwid: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_hwid", "message": "Device identifier is required"}, + detail={'code': 'invalid_hwid', 'message': 'Device identifier is required'}, ) service = RemnaWaveService() if not service.is_configured: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "service_unavailable", "message": "Device management is temporarily unavailable"}, + detail={'code': 'service_unavailable', 'message': 'Device management is temporarily unavailable'}, ) try: @@ -4465,24 +4387,24 @@ async def remove_connected_device( except RemnaWaveConfigurationError as error: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"code": "service_unavailable", "message": str(error)}, + detail={'code': 'service_unavailable', 'message': str(error)}, ) from error except Exception as error: # pragma: no cover - defensive logger.warning( - "Failed to remove device %s for user %s: %s", + 'Failed to remove device %s for user %s: %s', hwid, telegram_id, error, ) raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail={"code": "remnawave_error", "message": "Failed to remove device"}, + detail={'code': 'remnawave_error', 'message': 'Failed to remove device'}, ) from error if not success: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail={"code": "remnawave_error", "message": "Failed to remove device"}, + detail={'code': 'remnawave_error', 'message': 'Failed to remove device'}, ) return MiniAppDeviceRemovalResponse(success=True) @@ -4495,13 +4417,11 @@ def _safe_int(value: Any) -> int: return 0 -def _normalize_period_discounts( - raw: Optional[Dict[Any, Any]] -) -> Dict[str, int]: +def _normalize_period_discounts(raw: dict[Any, Any] | None) -> dict[str, int]: if not isinstance(raw, dict): return {} - normalized: Dict[str, int] = {} + normalized: dict[str, int] = {} for key, value in raw.items(): try: period = int(key) @@ -4512,73 +4432,71 @@ def _normalize_period_discounts( return normalized -def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]: +def _extract_promo_discounts(group: PromoGroup | None) -> dict[str, Any]: if not group: return { - "server_discount_percent": 0, - "traffic_discount_percent": 0, - "device_discount_percent": 0, - "period_discounts": {}, - "apply_discounts_to_addons": True, + 'server_discount_percent': 0, + 'traffic_discount_percent': 0, + 'device_discount_percent': 0, + 'period_discounts': {}, + 'apply_discounts_to_addons': True, } return { - "server_discount_percent": max(0, _safe_int(getattr(group, "server_discount_percent", 0))), - "traffic_discount_percent": max(0, _safe_int(getattr(group, "traffic_discount_percent", 0))), - "device_discount_percent": max(0, _safe_int(getattr(group, "device_discount_percent", 0))), - "period_discounts": _normalize_period_discounts(getattr(group, "period_discounts", None)), - "apply_discounts_to_addons": bool( - getattr(group, "apply_discounts_to_addons", True) - ), + 'server_discount_percent': max(0, _safe_int(getattr(group, 'server_discount_percent', 0))), + 'traffic_discount_percent': max(0, _safe_int(getattr(group, 'traffic_discount_percent', 0))), + 'device_discount_percent': max(0, _safe_int(getattr(group, 'device_discount_percent', 0))), + 'period_discounts': _normalize_period_discounts(getattr(group, 'period_discounts', None)), + 'apply_discounts_to_addons': bool(getattr(group, 'apply_discounts_to_addons', True)), } -def _normalize_language_code(user: Optional[User]) -> str: - language = getattr(user, "language", None) or settings.DEFAULT_LANGUAGE or "ru" - return language.split("-")[0].lower() +def _normalize_language_code(user: User | None) -> str: + language = getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE or 'ru' + return language.split('-')[0].lower() -def _build_renewal_status_message(user: Optional[User]) -> str: +def _build_renewal_status_message(user: User | None) -> str: language_code = _normalize_language_code(user) - if language_code == "ru": - return "Стоимость указана с учётом ваших текущих серверов, трафика и устройств." - return "Prices already include your current servers, traffic, and devices." + if language_code == 'ru': + return 'Стоимость указана с учётом ваших текущих серверов, трафика и устройств.' + return 'Prices already include your current servers, traffic, and devices.' -def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]: +def _build_promo_offer_payload(user: User | None) -> dict[str, Any] | None: percent = get_user_active_promo_discount_percent(user) if percent <= 0: return None - payload: Dict[str, Any] = {"percent": percent} + payload: dict[str, Any] = {'percent': percent} - expires_at = getattr(user, "promo_offer_discount_expires_at", None) + expires_at = getattr(user, 'promo_offer_discount_expires_at', None) if expires_at: - payload["expires_at"] = expires_at + payload['expires_at'] = expires_at language_code = _normalize_language_code(user) - if language_code == "ru": - payload["message"] = "Дополнительная скидка применяется автоматически." + if language_code == 'ru': + payload['message'] = 'Дополнительная скидка применяется автоматически.' else: - payload["message"] = "Extra discount is applied automatically." + payload['message'] = 'Extra discount is applied automatically.' return payload def _format_payment_method_title(method: str) -> str: mapping = { - "cryptobot": "CryptoBot", - "yookassa": "YooKassa", - "yookassa_sbp": "YooKassa СБП", - "mulenpay": "MulenPay", - "pal24": "Pal24", - "wata": "WataPay", - "heleket": "Heleket", - "tribute": "Tribute", - "stars": "Telegram Stars", + 'cryptobot': 'CryptoBot', + 'yookassa': 'YooKassa', + 'yookassa_sbp': 'YooKassa СБП', + 'mulenpay': 'MulenPay', + 'pal24': 'Pal24', + 'wata': 'WataPay', + 'heleket': 'Heleket', + 'tribute': 'Tribute', + 'stars': 'Telegram Stars', } - key = (method or "").lower() - return mapping.get(key, method.title() if method else "") + key = (method or '').lower() + return mapping.get(key, method.title() if method else '') def _build_renewal_success_message( @@ -4589,41 +4507,28 @@ def _build_renewal_success_message( ) -> str: language_code = _normalize_language_code(user) amount_label = settings.format_price(max(0, charged_amount)) - date_label = ( - format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M") - if subscription.end_date - else "" - ) + date_label = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M') if subscription.end_date else '' - if language_code == "ru": + if language_code == 'ru': if charged_amount > 0: message = ( - f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. " - ) + f"Списано {amount_label}." + f'Подписка продлена до {date_label}. ' if date_label else 'Подписка продлена. ' + ) + f'Списано {amount_label}.' else: - message = ( - f"Подписка продлена до {date_label}." - if date_label - else "Подписка успешно продлена." - ) + message = f'Подписка продлена до {date_label}.' if date_label else 'Подписка успешно продлена.' + elif charged_amount > 0: + message = ( + f'Subscription renewed until {date_label}. ' if date_label else 'Subscription renewed. ' + ) + f'Charged {amount_label}.' else: - if charged_amount > 0: - message = ( - f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. " - ) + f"Charged {amount_label}." - else: - message = ( - f"Subscription renewed until {date_label}." - if date_label - else "Subscription renewed successfully." - ) + message = f'Subscription renewed until {date_label}.' if date_label else 'Subscription renewed successfully.' if promo_discount_value > 0: discount_label = settings.format_price(promo_discount_value) - if language_code == "ru": - message += f" Применена дополнительная скидка {discount_label}." + if language_code == 'ru': + message += f' Применена дополнительная скидка {discount_label}.' else: - message += f" Promo discount applied: {discount_label}." + message += f' Promo discount applied: {discount_label}.' return message @@ -4637,22 +4542,20 @@ def _build_renewal_pending_message( amount_label = settings.format_price(max(0, missing_amount)) method_title = _format_payment_method_title(method) - if language_code == "ru": + if language_code == 'ru': if method_title: return ( - f"Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, " - "чтобы завершить продление." + f'Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, ' + 'чтобы завершить продление.' ) - return ( - f"Недостаточно средств на балансе. Доплатите {amount_label}, чтобы завершить продление." - ) + return f'Недостаточно средств на балансе. Доплатите {amount_label}, чтобы завершить продление.' if method_title: - return ( - f"Not enough balance. Pay the remaining {amount_label} via {method_title} to finish the renewal." - ) - return f"Not enough balance. Pay the remaining {amount_label} to finish the renewal." -def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]: + return f'Not enough balance. Pay the remaining {amount_label} via {method_title} to finish the renewal.' + return f'Not enough balance. Pay the remaining {amount_label} to finish the renewal.' + + +def _parse_period_identifier(identifier: str | None) -> int | None: if not identifier: return None @@ -4684,19 +4587,24 @@ async def _prepare_subscription_renewal_options( db: AsyncSession, user: User, subscription: Subscription, -) -> Tuple[List[MiniAppSubscriptionRenewalPeriod], Dict[Union[str, int], Dict[str, Any]], Optional[str]]: - option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] +) -> tuple[list[MiniAppSubscriptionRenewalPeriod], dict[str | int, dict[str, Any]], str | None]: + option_payloads: list[tuple[MiniAppSubscriptionRenewalPeriod, dict[str, Any]]] = [] # Проверяем, есть ли у подписки тариф (режим тарифов) tariff_id = getattr(subscription, 'tariff_id', None) tariff = None if tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if tariff and tariff.period_prices: # Режим тарифов: используем периоды и цены из тарифа - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) # Получаем скидки промогруппы по периодам period_discounts = {} @@ -4723,7 +4631,7 @@ async def _prepare_subscription_renewal_options( label = format_period_description( period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), + getattr(user, 'language', settings.DEFAULT_LANGUAGE), ) price_label = settings.format_price(price_kopeks) @@ -4731,7 +4639,7 @@ async def _prepare_subscription_renewal_options( per_month_label = settings.format_price(per_month) option_model = MiniAppSubscriptionRenewalPeriod( - id=f"tariff_{tariff.id}_{period_days}", + id=f'tariff_{tariff.id}_{period_days}', days=period_days, months=months, price_kopeks=price_kopeks, @@ -4745,22 +4653,20 @@ async def _prepare_subscription_renewal_options( ) pricing = { - "period_id": option_model.id, - "period_days": period_days, - "months": months, - "final_total": price_kopeks, - "base_original_total": original_price_kopeks if discount_percent > 0 else price_kopeks, - "overall_discount_percent": discount_percent, - "per_month": per_month, - "tariff_id": tariff.id, + 'period_id': option_model.id, + 'period_days': period_days, + 'months': months, + 'final_total': price_kopeks, + 'base_original_total': original_price_kopeks if discount_percent > 0 else price_kopeks, + 'overall_discount_percent': discount_percent, + 'per_month': per_month, + 'tariff_id': tariff.id, } option_payloads.append((option_model, pricing)) else: # Классический режим: используем периоды из настроек - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] + available_periods = [period for period in settings.get_available_renewal_periods() if period > 0] for period_days in available_periods: try: @@ -4773,7 +4679,7 @@ async def _prepare_subscription_renewal_options( pricing = pricing_model.to_payload() except Exception as error: # pragma: no cover - defensive logging logger.warning( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", + 'Failed to calculate renewal pricing for subscription %s (period %s): %s', subscription.id, period_days, error, @@ -4782,26 +4688,26 @@ async def _prepare_subscription_renewal_options( label = format_period_description( period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), + getattr(user, 'language', settings.DEFAULT_LANGUAGE), ) - price_label = settings.format_price(pricing["final_total"]) + price_label = settings.format_price(pricing['final_total']) original_label = None - if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: - original_label = settings.format_price(pricing["base_original_total"]) + if pricing['base_original_total'] and pricing['base_original_total'] != pricing['final_total']: + original_label = settings.format_price(pricing['base_original_total']) - per_month_label = settings.format_price(pricing["per_month"]) + per_month_label = settings.format_price(pricing['per_month']) option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing["period_id"], + id=pricing['period_id'], days=period_days, - months=pricing["months"], - price_kopeks=pricing["final_total"], + months=pricing['months'], + price_kopeks=pricing['final_total'], price_label=price_label, - original_price_kopeks=pricing["base_original_total"], + original_price_kopeks=pricing['base_original_total'], original_price_label=original_label, - discount_percent=pricing["overall_discount_percent"], - price_per_month_kopeks=pricing["per_month"], + discount_percent=pricing['overall_discount_percent'], + price_per_month_kopeks=pricing['per_month'], price_per_month_label=per_month_label, title=label, ) @@ -4816,18 +4722,18 @@ async def _prepare_subscription_renewal_options( recommended_option = max( option_payloads, key=lambda item: ( - item[1]["overall_discount_percent"], + item[1]['overall_discount_percent'], item[0].months or 0, - -(item[1]["final_total"] or 0), + -(item[1]['final_total'] or 0), ), ) recommended_option[0].is_recommended = True - pricing_map: Dict[Union[str, int], Dict[str, Any]] = {} + pricing_map: dict[str | int, dict[str, Any]] = {} for option_model, pricing in option_payloads: pricing_map[option_model.id] = pricing - pricing_map[pricing["period_days"]] = pricing - pricing_map[str(pricing["period_days"])] = pricing + pricing_map[pricing['period_days']] = pricing + pricing_map[str(pricing['period_days'])] = pricing periods = [item[0] for item in option_payloads] @@ -4835,18 +4741,18 @@ async def _prepare_subscription_renewal_options( def _get_addon_discount_percent_for_user( - user: Optional[User], + user: User | None, category: str, - period_days_hint: Optional[int] = None, + period_days_hint: int | None = None, ) -> int: if user is None: return 0 - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) if promo_group is None: return 0 - if not getattr(promo_group, "apply_discounts_to_addons", True): + if not getattr(promo_group, 'apply_discounts_to_addons', True): return 0 try: @@ -4861,8 +4767,8 @@ def _get_addon_discount_percent_for_user( def _get_period_hint_from_subscription( - subscription: Optional[Subscription], -) -> Optional[int]: + subscription: Subscription | None, +) -> int | None: if not subscription: return None @@ -4874,7 +4780,7 @@ def _get_period_hint_from_subscription( def _validate_subscription_id( - requested_id: Optional[int], + requested_id: int | None, subscription: Subscription, ) -> None: if requested_id is None: @@ -4886,8 +4792,8 @@ def _validate_subscription_id( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "invalid_subscription_id", - "message": "Invalid subscription identifier", + 'code': 'invalid_subscription_id', + 'message': 'Invalid subscription identifier', }, ) from None @@ -4895,8 +4801,8 @@ def _validate_subscription_id( raise HTTPException( status.HTTP_403_FORBIDDEN, detail={ - "code": "subscription_mismatch", - "message": "Subscription does not belong to the authorized user", + 'code': 'subscription_mismatch', + 'message': 'Subscription does not belong to the authorized user', }, ) @@ -4908,7 +4814,7 @@ async def _authorize_miniapp_user( if not init_data: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": "Authorization data is missing"}, + detail={'code': 'unauthorized', 'message': 'Authorization data is missing'}, ) try: @@ -4916,29 +4822,29 @@ async def _authorize_miniapp_user( except TelegramWebAppAuthError as error: raise HTTPException( status.HTTP_401_UNAUTHORIZED, - detail={"code": "unauthorized", "message": str(error)}, + detail={'code': 'unauthorized', 'message': str(error)}, ) from error - telegram_user = webapp_data.get("user") - if not isinstance(telegram_user, dict) or "id" not in telegram_user: + telegram_user = webapp_data.get('user') + if not isinstance(telegram_user, dict) or 'id' not in telegram_user: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user payload"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user payload'}, ) try: - telegram_id = int(telegram_user["id"]) + telegram_id = int(telegram_user['id']) except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"}, + detail={'code': 'invalid_user', 'message': 'Invalid Telegram user identifier'}, ) from None user = await get_user_by_telegram_id(db, telegram_id) if not user: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "user_not_found", "message": "User not found"}, + detail={'code': 'user_not_found', 'message': 'User not found'}, ) return user @@ -4947,53 +4853,53 @@ async def _authorize_miniapp_user( def _ensure_paid_subscription( user: User, *, - allowed_statuses: Optional[Collection[str]] = None, + allowed_statuses: Collection[str] | None = None, ) -> Subscription: - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if not subscription: raise HTTPException( status.HTTP_404_NOT_FOUND, - detail={"code": "subscription_not_found", "message": "Subscription not found"}, + detail={'code': 'subscription_not_found', 'message': 'Subscription not found'}, ) - normalized_allowed_statuses = set(allowed_statuses or {"active"}) + normalized_allowed_statuses = set(allowed_statuses or {'active'}) - if getattr(subscription, "is_trial", False) and "trial" not in normalized_allowed_statuses: + if getattr(subscription, 'is_trial', False) and 'trial' not in normalized_allowed_statuses: raise HTTPException( status.HTTP_403_FORBIDDEN, detail={ - "code": "paid_subscription_required", - "message": "This action is available only for paid subscriptions", + 'code': 'paid_subscription_required', + 'message': 'This action is available only for paid subscriptions', }, ) - actual_status = getattr(subscription, "actual_status", None) or "" + actual_status = getattr(subscription, 'actual_status', None) or '' if actual_status not in normalized_allowed_statuses: - if actual_status == "trial": + if actual_status == 'trial': detail = { - "code": "paid_subscription_required", - "message": "This action is available only for paid subscriptions", + 'code': 'paid_subscription_required', + 'message': 'This action is available only for paid subscriptions', } - elif actual_status == "disabled": + elif actual_status == 'disabled': detail = { - "code": "subscription_disabled", - "message": "Subscription is disabled", + 'code': 'subscription_disabled', + 'message': 'Subscription is disabled', } else: detail = { - "code": "subscription_inactive", - "message": "Subscription must be active to manage settings", + 'code': 'subscription_inactive', + 'message': 'Subscription must be active to manage settings', } raise HTTPException(status.HTTP_403_FORBIDDEN, detail=detail) - if not getattr(subscription, "is_active", False) and "expired" not in normalized_allowed_statuses: + if not getattr(subscription, 'is_active', False) and 'expired' not in normalized_allowed_statuses: raise HTTPException( status.HTTP_403_FORBIDDEN, detail={ - "code": "subscription_inactive", - "message": "Subscription must be active to manage settings", + 'code': 'subscription_inactive', + 'message': 'Subscription must be active to manage settings', }, ) @@ -5005,73 +4911,73 @@ async def _prepare_server_catalog( user: User, subscription: Subscription, discount_percent: int, -) -> Tuple[ - List[MiniAppConnectedServer], - List[MiniAppSubscriptionServerOption], - Dict[str, Dict[str, Any]], +) -> tuple[ + list[MiniAppConnectedServer], + list[MiniAppSubscriptionServerOption], + dict[str, dict[str, Any]], ]: available_servers = await get_available_server_squads( db, - promo_group_id=getattr(user, "promo_group_id", None), + promo_group_id=getattr(user, 'promo_group_id', None), ) available_by_uuid = {server.squad_uuid: server for server in available_servers} current_squads = list(subscription.connected_squads or []) - catalog: Dict[str, Dict[str, Any]] = {} - ordered_uuids: List[str] = [] + catalog: dict[str, dict[str, Any]] = {} + ordered_uuids: list[str] = [] - def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None: + def _register_server(server: Any | None, *, is_connected: bool = False) -> None: if server is None: return uuid = server.squad_uuid discounted_per_month, discount_per_month = apply_percentage_discount( - int(getattr(server, "price_kopeks", 0) or 0), + int(getattr(server, 'price_kopeks', 0) or 0), discount_percent, ) - available_for_new = bool(getattr(server, "is_available", True) and not server.is_full) + available_for_new = bool(getattr(server, 'is_available', True) and not server.is_full) entry = catalog.get(uuid) if entry: entry.update( { - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, + 'name': getattr(server, 'display_name', uuid), + 'server_id': getattr(server, 'id', None), + 'price_per_month': int(getattr(server, 'price_kopeks', 0) or 0), + 'discounted_per_month': discounted_per_month, + 'discount_per_month': discount_per_month, + 'available_for_new': available_for_new, } ) - entry["is_connected"] = entry["is_connected"] or is_connected + entry['is_connected'] = entry['is_connected'] or is_connected return catalog[uuid] = { - "uuid": uuid, - "name": getattr(server, "display_name", uuid), - "server_id": getattr(server, "id", None), - "price_per_month": int(getattr(server, "price_kopeks", 0) or 0), - "discounted_per_month": discounted_per_month, - "discount_per_month": discount_per_month, - "available_for_new": available_for_new, - "is_connected": is_connected, + 'uuid': uuid, + 'name': getattr(server, 'display_name', uuid), + 'server_id': getattr(server, 'id', None), + 'price_per_month': int(getattr(server, 'price_kopeks', 0) or 0), + 'discounted_per_month': discounted_per_month, + 'discount_per_month': discount_per_month, + 'available_for_new': available_for_new, + 'is_connected': is_connected, } ordered_uuids.append(uuid) def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None: if uuid in catalog: - catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected + catalog[uuid]['is_connected'] = catalog[uuid]['is_connected'] or is_connected return catalog[uuid] = { - "uuid": uuid, - "name": uuid, - "server_id": None, - "price_per_month": 0, - "discounted_per_month": 0, - "discount_per_month": 0, - "available_for_new": False, - "is_connected": is_connected, + 'uuid': uuid, + 'name': uuid, + 'server_id': None, + 'price_per_month': 0, + 'discounted_per_month': 0, + 'discount_per_month': 0, + 'available_for_new': False, + 'is_connected': is_connected, } ordered_uuids.append(uuid) @@ -5095,29 +5001,29 @@ async def _prepare_server_catalog( current_servers = [ MiniAppConnectedServer( uuid=uuid, - name=catalog.get(uuid, {}).get("name", uuid), + name=catalog.get(uuid, {}).get('name', uuid), ) for uuid in current_squads ] - server_options: List[MiniAppSubscriptionServerOption] = [] + server_options: list[MiniAppSubscriptionServerOption] = [] discount_value = discount_percent if discount_percent > 0 else None for uuid in ordered_uuids: entry = catalog[uuid] - available_for_new = bool(entry.get("available_for_new", False)) - is_connected = bool(entry.get("is_connected", False)) + available_for_new = bool(entry.get('available_for_new', False)) + is_connected = bool(entry.get('is_connected', False)) option_available = available_for_new or is_connected server_options.append( MiniAppSubscriptionServerOption( uuid=uuid, - name=entry.get("name", uuid), - price_kopeks=int(entry.get("discounted_per_month", 0)), + name=entry.get('name', uuid), + price_kopeks=int(entry.get('discounted_per_month', 0)), price_label=None, discount_percent=discount_value, is_connected=is_connected, is_available=option_available, - disabled_reason=None if option_available else "Server is not available", + disabled_reason=None if option_available else 'Server is not available', ) ) @@ -5133,17 +5039,17 @@ async def _build_subscription_settings( months_remaining = get_remaining_months(subscription.end_date) servers_discount = _get_addon_discount_percent_for_user( user, - "servers", + 'servers', period_hint_days, ) traffic_discount = _get_addon_discount_percent_for_user( user, - "traffic", + 'traffic', period_hint_days, ) devices_discount = _get_addon_discount_percent_for_user( user, - "devices", + 'devices', period_hint_days, ) @@ -5154,21 +5060,21 @@ async def _build_subscription_settings( servers_discount, ) - traffic_options: List[MiniAppSubscriptionTrafficOption] = [] + traffic_options: list[MiniAppSubscriptionTrafficOption] = [] # В режиме fixed_with_topup показываем опции трафика (для докупки) if not settings.is_traffic_topup_blocked(): for package in settings.get_traffic_packages(): - is_enabled = bool(package.get("enabled", True)) - if package.get("is_active") is False: + is_enabled = bool(package.get('enabled', True)) + if package.get('is_active') is False: is_enabled = False if not is_enabled: continue try: - gb_value = int(package.get("gb")) + gb_value = int(package.get('gb')) except (TypeError, ValueError): continue - price = int(package.get("price") or 0) + price = int(package.get('price') or 0) discounted_price, _ = apply_percentage_discount(price, traffic_discount) traffic_options.append( MiniAppSubscriptionTrafficOption( @@ -5196,7 +5102,7 @@ async def _build_subscription_settings( devices_discount, ) - devices_options: List[MiniAppSubscriptionDeviceOption] = [] + devices_options: list[MiniAppSubscriptionDeviceOption] = [] for value in range(1, max_devices + 1): chargeable = max(0, value - default_device_limit) discounted_per_month, _ = apply_percentage_discount( @@ -5214,7 +5120,7 @@ async def _build_subscription_settings( settings_payload = MiniAppSubscriptionSettings( subscription_id=subscription.id, - currency=(getattr(user, "balance_currency", None) or "RUB").upper(), + currency=(getattr(user, 'balance_currency', None) or 'RUB').upper(), current=MiniAppSubscriptionCurrentSettings( servers=current_servers, traffic_limit_gb=subscription.traffic_limit_gb, @@ -5254,7 +5160,7 @@ async def _build_subscription_settings( @router.post( - "/subscription/renewal/options", + '/subscription/renewal/options', response_model=MiniAppSubscriptionRenewalOptionsResponse, ) async def get_subscription_renewal_options_endpoint( @@ -5264,7 +5170,7 @@ async def get_subscription_renewal_options_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial", "expired"}, + allowed_statuses={'active', 'trial', 'expired'}, ) _validate_subscription_id(payload.subscription_id, subscription) @@ -5274,10 +5180,10 @@ async def get_subscription_renewal_options_endpoint( subscription, ) - balance_kopeks = getattr(user, "balance_kopeks", 0) - currency = (getattr(user, "balance_currency", None) or "RUB").upper() + balance_kopeks = getattr(user, 'balance_kopeks', 0) + currency = (getattr(user, 'balance_currency', None) or 'RUB').upper() - promo_group = getattr(user, "promo_group", None) + promo_group = getattr(user, 'promo_group', None) promo_group_model = ( MiniAppPromoGroup( id=promo_group.id, @@ -5293,20 +5199,16 @@ async def get_subscription_renewal_options_endpoint( missing_amount = None if default_period_id and default_period_id in pricing_map: selected_pricing = pricing_map[default_period_id] - final_total = selected_pricing.get("final_total") + final_total = selected_pricing.get('final_total') if isinstance(final_total, int) and balance_kopeks < final_total: missing_amount = final_total - balance_kopeks renewal_autopay_payload = _build_autopay_payload(subscription) renewal_autopay_days_before = ( - getattr(renewal_autopay_payload, "autopay_days_before", None) - if renewal_autopay_payload - else None + getattr(renewal_autopay_payload, 'autopay_days_before', None) if renewal_autopay_payload else None ) renewal_autopay_days_options = ( - list(getattr(renewal_autopay_payload, "autopay_days_options", []) or []) - if renewal_autopay_payload - else [] + list(getattr(renewal_autopay_payload, 'autopay_days_options', []) or []) if renewal_autopay_payload else [] ) renewal_autopay_extras = _autopay_response_extras( bool(subscription.autopay_enabled), @@ -5331,14 +5233,14 @@ async def get_subscription_renewal_options_endpoint( autopay_days_options=renewal_autopay_days_options, autopay=renewal_autopay_payload, autopay_settings=renewal_autopay_payload, - is_trial=bool(getattr(subscription, "is_trial", False)), + is_trial=bool(getattr(subscription, 'is_trial', False)), sales_mode=settings.get_sales_mode(), **renewal_autopay_extras, ) @router.post( - "/subscription/renewal", + '/subscription/renewal', response_model=MiniAppSubscriptionRenewalResponse, ) async def submit_subscription_renewal_endpoint( @@ -5348,18 +5250,18 @@ async def submit_subscription_renewal_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial", "expired"}, + allowed_statuses={'active', 'trial', 'expired'}, ) _validate_subscription_id(payload.subscription_id, subscription) - period_days: Optional[int] = None + period_days: int | None = None if payload.period_days is not None: try: period_days = int(payload.period_days) except (TypeError, ValueError) as error: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": "Invalid renewal period"}, + detail={'code': 'invalid_period', 'message': 'Invalid renewal period'}, ) from error if period_days is None: @@ -5368,7 +5270,7 @@ async def submit_subscription_renewal_endpoint( if period_days is None or period_days <= 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": "Invalid renewal period"}, + detail={'code': 'invalid_period', 'message': 'Invalid renewal period'}, ) # Проверяем, есть ли у подписки тариф (режим тарифов) @@ -5378,6 +5280,7 @@ async def submit_subscription_renewal_endpoint( if tariff_id: from app.database.crud.tariff import get_tariff_by_id + tariff = await get_tariff_by_id(db, tariff_id) if tariff and tariff.period_prices: @@ -5386,14 +5289,21 @@ async def submit_subscription_renewal_endpoint( if period_days not in available_periods: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected renewal period is not available for this tariff"}, + detail={ + 'code': 'period_unavailable', + 'message': 'Selected renewal period is not available for this tariff', + }, ) # Рассчитываем цену из тарифа original_price_kopeks = tariff.period_prices.get(str(period_days), tariff.period_prices.get(period_days, 0)) # Применяем скидку промогруппы - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) discount_percent = 0 if promo_group: raw_discounts = getattr(promo_group, 'period_discounts', None) or {} @@ -5411,28 +5321,26 @@ async def submit_subscription_renewal_endpoint( final_total = original_price_kopeks tariff_pricing = { - "period_days": period_days, - "original_price_kopeks": original_price_kopeks, - "discount_percent": discount_percent, - "final_total": final_total, - "tariff_id": tariff.id, + 'period_days': period_days, + 'original_price_kopeks': original_price_kopeks, + 'discount_percent': discount_percent, + 'final_total': final_total, + 'tariff_id': tariff.id, } else: # Классический режим - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] + available_periods = [period for period in settings.get_available_renewal_periods() if period > 0] if period_days not in available_periods: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, + detail={'code': 'period_unavailable', 'message': 'Selected renewal period is not available'}, ) - method = (payload.method or "").strip().lower() + method = (payload.method or '').strip().lower() # Для тарифного режима используем упрощённый расчёт if tariff_pricing: - final_total = tariff_pricing["final_total"] + final_total = tariff_pricing['final_total'] pricing = tariff_pricing else: try: @@ -5446,28 +5354,28 @@ async def submit_subscription_renewal_endpoint( raise except Exception as error: logger.error( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", + 'Failed to calculate renewal pricing for subscription %s (period %s): %s', subscription.id, period_days, error, ) raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, + detail={'code': 'pricing_failed', 'message': 'Failed to calculate renewal pricing'}, ) from error pricing = pricing_model.to_payload() final_total = int(pricing_model.final_total) - balance_kopeks = getattr(user, "balance_kopeks", 0) + balance_kopeks = getattr(user, 'balance_kopeks', 0) missing_amount = calculate_missing_amount(balance_kopeks, final_total) - description = f"Продление подписки на {period_days} дней" + description = f'Продление подписки на {period_days} дней' if missing_amount <= 0: if tariff_pricing: # Тарифный режим: простое продление - from app.database.crud.user import subtract_user_balance from app.database.crud.subscription import extend_subscription from app.database.crud.transaction import create_transaction + from app.database.crud.user import subtract_user_balance try: # Списываем баланс (subtract_user_balance делает commit и обновляет user.balance_kopeks) @@ -5475,7 +5383,7 @@ async def submit_subscription_renewal_endpoint( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "balance_error", "message": "Failed to subtract balance"}, + detail={'code': 'balance_error', 'message': 'Failed to subtract balance'}, ) # Продлеваем подписку @@ -5484,6 +5392,7 @@ async def submit_subscription_renewal_endpoint( # Записываем транзакцию from app.database.models import TransactionType + await create_transaction( db, user_id=user.id, @@ -5495,21 +5404,22 @@ async def submit_subscription_renewal_endpoint( # Синхронизируем с RemnaWave (сброс трафика по настройке) try: from app.services.subscription_service import SubscriptionService + service = SubscriptionService() await service.update_remnawave_user( db, subscription, reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="subscription renewal (miniapp)", + reset_reason='subscription renewal (miniapp)', ) except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave при продлении (miniapp): {e}") + logger.error(f'Ошибка синхронизации с RemnaWave при продлении (miniapp): {e}') - lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) - if lang == "ru": - message = f"Подписка продлена до {new_end_date.strftime('%d.%m.%Y')}" + lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) + if lang == 'ru': + message = f'Подписка продлена до {new_end_date.strftime("%d.%m.%Y")}' else: - message = f"Subscription extended until {new_end_date.strftime('%Y-%m-%d')}" + message = f'Subscription extended until {new_end_date.strftime("%Y-%m-%d")}' return MiniAppSubscriptionRenewalResponse( message=message, @@ -5521,13 +5431,13 @@ async def submit_subscription_renewal_endpoint( except Exception as error: await db.rollback() logger.error( - "Failed to renew tariff subscription %s: %s", + 'Failed to renew tariff subscription %s: %s', subscription.id, error, ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "renewal_failed", "message": "Failed to renew subscription"}, + detail={'code': 'renewal_failed', 'message': 'Failed to renew subscription'}, ) from error else: # Классический режим @@ -5541,13 +5451,13 @@ async def submit_subscription_renewal_endpoint( ) except SubscriptionRenewalChargeError as error: logger.error( - "Failed to charge balance for subscription renewal %s: %s", + 'Failed to charge balance for subscription renewal %s: %s', subscription.id, error, ) raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "charge_failed", "message": "Failed to charge balance"}, + detail={'code': 'charge_failed', 'message': 'Failed to charge balance'}, ) from error updated_subscription = result.subscription @@ -5572,30 +5482,30 @@ async def submit_subscription_renewal_endpoint( raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": "Not enough funds to renew the subscription", - "missing_amount_kopeks": missing, + 'code': 'insufficient_funds', + 'message': 'Not enough funds to renew the subscription', + 'missing_amount_kopeks': missing, }, ) raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "payment_method_required", - "message": "Payment method is required when balance is insufficient", + 'code': 'payment_method_required', + 'message': 'Payment method is required when balance is insufficient', }, ) - supported_methods = {"cryptobot"} + supported_methods = {'cryptobot'} if method not in supported_methods: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"}, + detail={'code': 'unsupported_method', 'message': 'Payment method is not supported for renewal'}, ) - if method == "cryptobot": + if method == 'cryptobot': if not settings.is_cryptobot_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable") + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail='Payment method is unavailable') rate = await _get_usd_to_rub_rate() min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate) @@ -5603,34 +5513,30 @@ async def submit_subscription_renewal_endpoint( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "amount_below_minimum", - "message": f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)", + 'code': 'amount_below_minimum', + 'message': f'Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)', }, ) if missing_amount > max_amount_kopeks: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "amount_above_maximum", - "message": f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)", + 'code': 'amount_above_maximum', + 'message': f'Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)', }, ) try: - decimal_amount = (Decimal(missing_amount) / Decimal(100) / Decimal(str(rate))) - amount_usd = float( - decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_UP) - ) + decimal_amount = Decimal(missing_amount) / Decimal(100) / Decimal(str(rate)) + amount_usd = float(decimal_amount.quantize(Decimal('0.01'), rounding=ROUND_UP)) except (InvalidOperation, ValueError) as error: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "conversion_failed", "message": "Unable to convert amount to USD"}, + detail={'code': 'conversion_failed', 'message': 'Unable to convert amount to USD'}, ) from error if amount_usd <= 0: - amount_usd = float( - decimal_amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - ) + amount_usd = float(decimal_amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) descriptor = build_payment_descriptor( user.id, @@ -5654,25 +5560,23 @@ async def submit_subscription_renewal_endpoint( if not result: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail={"code": "payment_creation_failed", "message": "Failed to create payment"}, + detail={'code': 'payment_creation_failed', 'message': 'Failed to create payment'}, ) # Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback payment_url = ( - result.get("web_app_invoice_url") - or result.get("mini_app_invoice_url") - or result.get("bot_invoice_url") + result.get('web_app_invoice_url') or result.get('mini_app_invoice_url') or result.get('bot_invoice_url') ) if not payment_url: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail={"code": "payment_url_missing", "message": "Failed to obtain payment url"}, + detail={'code': 'payment_url_missing', 'message': 'Failed to obtain payment url'}, ) extra_payload = { - "bot_invoice_url": result.get("bot_invoice_url"), - "mini_app_invoice_url": result.get("mini_app_invoice_url"), - "web_app_invoice_url": result.get("web_app_invoice_url"), + 'bot_invoice_url': result.get('bot_invoice_url'), + 'mini_app_invoice_url': result.get('mini_app_invoice_url'), + 'web_app_invoice_url': result.get('web_app_invoice_url'), } message = _build_renewal_pending_message(user, missing_amount, method) @@ -5687,20 +5591,20 @@ async def submit_subscription_renewal_endpoint( payment_method=method, payment_url=payment_url, payment_amount_kopeks=missing_amount, - payment_id=result.get("local_payment_id"), - invoice_id=result.get("invoice_id"), + payment_id=result.get('local_payment_id'), + invoice_id=result.get('invoice_id'), payment_payload=payload_value, payment_extra={key: value for key, value in extra_payload.items() if value}, ) raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "unsupported_method", "message": "Payment method is not supported for renewal"}, + detail={'code': 'unsupported_method', 'message': 'Payment method is not supported for renewal'}, ) @router.post( - "/subscription/purchase/options", + '/subscription/purchase/options', response_model=MiniAppSubscriptionPurchaseOptionsResponse, ) async def get_subscription_purchase_options_endpoint( @@ -5711,23 +5615,23 @@ async def get_subscription_purchase_options_endpoint( context = await purchase_service.build_options(db, user) data_payload = dict(context.payload) - data_payload.setdefault("currency", context.currency) - data_payload.setdefault("balance_kopeks", context.balance_kopeks) - data_payload.setdefault("balanceKopeks", context.balance_kopeks) - data_payload.setdefault("balance_label", settings.format_price(context.balance_kopeks)) - data_payload.setdefault("balanceLabel", settings.format_price(context.balance_kopeks)) + data_payload.setdefault('currency', context.currency) + data_payload.setdefault('balance_kopeks', context.balance_kopeks) + data_payload.setdefault('balanceKopeks', context.balance_kopeks) + data_payload.setdefault('balance_label', settings.format_price(context.balance_kopeks)) + data_payload.setdefault('balanceLabel', settings.format_price(context.balance_kopeks)) return MiniAppSubscriptionPurchaseOptionsResponse( currency=context.currency, balance_kopeks=context.balance_kopeks, balance_label=settings.format_price(context.balance_kopeks), - subscription_id=data_payload.get("subscription_id") or data_payload.get("subscriptionId"), + subscription_id=data_payload.get('subscription_id') or data_payload.get('subscriptionId'), data=data_payload, ) @router.post( - "/subscription/purchase/preview", + '/subscription/purchase/preview', response_model=MiniAppSubscriptionPurchasePreviewResponse, ) async def subscription_purchase_preview_endpoint( @@ -5743,13 +5647,13 @@ async def subscription_purchase_preview_endpoint( except PurchaseValidationError as error: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, + detail={'code': error.code, 'message': str(error)}, ) from error pricing = await purchase_service.calculate_pricing(db, context, selection) preview_payload = purchase_service.build_preview_payload(context, pricing) - balance_label = settings.format_price(getattr(user, "balance_kopeks", 0)) + balance_label = settings.format_price(getattr(user, 'balance_kopeks', 0)) return MiniAppSubscriptionPurchasePreviewResponse( preview=preview_payload, @@ -5759,7 +5663,7 @@ async def subscription_purchase_preview_endpoint( @router.post( - "/subscription/purchase", + '/subscription/purchase', response_model=MiniAppSubscriptionPurchaseResponse, ) async def subscription_purchase_endpoint( @@ -5775,7 +5679,7 @@ async def subscription_purchase_endpoint( except PurchaseValidationError as error: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, + detail={'code': error.code, 'message': str(error)}, ) from error pricing = await purchase_service.calculate_pricing(db, context, selection) @@ -5785,21 +5689,21 @@ async def subscription_purchase_endpoint( except PurchaseBalanceError as error: raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, - detail={"code": "insufficient_funds", "message": str(error)}, + detail={'code': 'insufficient_funds', 'message': str(error)}, ) from error except PurchaseValidationError as error: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, + detail={'code': error.code, 'message': str(error)}, ) from error await db.refresh(user) - subscription = result.get("subscription") - transaction = result.get("transaction") - was_trial_conversion = bool(result.get("was_trial_conversion")) - period_days = getattr(getattr(pricing, "selection", None), "period", None) - period_days = getattr(period_days, "days", None) if period_days else None + subscription = result.get('subscription') + transaction = result.get('transaction') + was_trial_conversion = bool(result.get('was_trial_conversion')) + period_days = getattr(getattr(pricing, 'selection', None), 'period', None) + period_days = getattr(period_days, 'days', None) if period_days else None if subscription is not None: try: @@ -5819,18 +5723,18 @@ async def subscription_purchase_endpoint( ) ) - balance_label = settings.format_price(getattr(user, "balance_kopeks", 0)) + balance_label = settings.format_price(getattr(user, 'balance_kopeks', 0)) return MiniAppSubscriptionPurchaseResponse( - message=result.get("message"), + message=result.get('message'), balance_kopeks=user.balance_kopeks, balance_label=balance_label, - subscription_id=getattr(subscription, "id", None), + subscription_id=getattr(subscription, 'id', None), ) @router.post( - "/subscription/settings", + '/subscription/settings', response_model=MiniAppSubscriptionSettingsResponse, ) async def get_subscription_settings_endpoint( @@ -5840,7 +5744,7 @@ async def get_subscription_settings_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial"}, + allowed_statuses={'active', 'trial'}, ) _validate_subscription_id(payload.subscription_id, subscription) @@ -5850,7 +5754,7 @@ async def get_subscription_settings_endpoint( @router.post( - "/subscription/servers", + '/subscription/servers', response_model=MiniAppSubscriptionUpdateResponse, ) async def update_subscription_servers_endpoint( @@ -5860,12 +5764,12 @@ async def update_subscription_servers_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial"}, + allowed_statuses={'active', 'trial'}, ) _validate_subscription_id(payload.subscription_id, subscription) - old_servers = list(getattr(subscription, "connected_squads", []) or []) + old_servers = list(getattr(subscription, 'connected_squads', []) or []) - raw_selection: List[str] = [] + raw_selection: list[str] = [] for collection in ( payload.servers, payload.squads, @@ -5875,7 +5779,7 @@ async def update_subscription_servers_endpoint( if collection: raw_selection.extend(collection) - selected_order: List[str] = [] + selected_order: list[str] = [] seen: set[str] = set() for item in raw_selection: if not item: @@ -5890,8 +5794,8 @@ async def update_subscription_servers_endpoint( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "validation_error", - "message": "At least one server must be selected", + 'code': 'validation_error', + 'message': 'At least one server must be selected', }, ) @@ -5905,13 +5809,13 @@ async def update_subscription_servers_endpoint( if not added and not removed: return MiniAppSubscriptionUpdateResponse( success=True, - message="No changes", + message='No changes', ) period_hint_days = _get_period_hint_from_subscription(subscription) servers_discount = _get_addon_discount_percent_for_user( user, - "servers", + 'servers', period_hint_days, ) @@ -5927,23 +5831,23 @@ async def update_subscription_servers_endpoint( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "invalid_servers", - "message": "Some of the selected servers are not available", + 'code': 'invalid_servers', + 'message': 'Some of the selected servers are not available', }, ) for uuid in added: entry = catalog.get(uuid) - if not entry or not entry.get("available_for_new", False): + if not entry or not entry.get('available_for_new', False): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "server_unavailable", - "message": "Selected server is not available", + 'code': 'server_unavailable', + 'message': 'Selected server is not available', }, ) - cost_per_month = sum(int(catalog[uuid].get("discounted_per_month", 0)) for uuid in added) + cost_per_month = sum(int(catalog[uuid].get('discounted_per_month', 0)) for uuid in added) total_cost = 0 charged_months = 0 if cost_per_month > 0: @@ -5954,36 +5858,29 @@ async def update_subscription_servers_endpoint( else: charged_months = get_remaining_months(subscription.end_date) - added_server_ids = [ - catalog[uuid].get("server_id") - for uuid in added - if catalog[uuid].get("server_id") is not None - ] + added_server_ids = [catalog[uuid].get('server_id') for uuid in added if catalog[uuid].get('server_id') is not None] added_server_prices = [ - int(catalog[uuid].get("discounted_per_month", 0)) * charged_months + int(catalog[uuid].get('discounted_per_month', 0)) * charged_months for uuid in added - if catalog[uuid].get("server_id") is not None + if catalog[uuid].get('server_id') is not None ] - if total_cost > 0 and getattr(user, "balance_kopeks", 0) < total_cost: - missing = total_cost - getattr(user, "balance_kopeks", 0) + if total_cost > 0 and getattr(user, 'balance_kopeks', 0) < total_cost: + missing = total_cost - getattr(user, 'balance_kopeks', 0) raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), + 'code': 'insufficient_funds', + 'message': (f'Недостаточно средств на балансе. Не хватает {settings.format_price(missing)}'), }, ) if total_cost > 0: - added_names = [catalog[uuid].get("name", uuid) for uuid in added] + added_names = [catalog[uuid].get('name', uuid) for uuid in added] description = ( - f"Добавление серверов: {', '.join(added_names)} на {charged_months} мес" + f'Добавление серверов: {", ".join(added_names)} на {charged_months} мес' if added_names - else "Изменение списка серверов" + else 'Изменение списка серверов' ) success = await subtract_user_balance( @@ -5996,8 +5893,8 @@ async def update_subscription_servers_endpoint( raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", + 'code': 'balance_charge_failed', + 'message': 'Failed to charge user balance', }, ) @@ -6014,9 +5911,7 @@ async def update_subscription_servers_endpoint( await add_user_to_servers(db, added_server_ids) removed_server_ids = [ - catalog[uuid].get("server_id") - for uuid in removed - if catalog[uuid].get("server_id") is not None + catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None ] if removed_server_ids: @@ -6048,7 +5943,7 @@ async def update_subscription_servers_endpoint( db, user, subscription, - "servers", + 'servers', old_servers, subscription.connected_squads or [], price_paid=max(total_cost, 0), @@ -6059,7 +5954,7 @@ async def update_subscription_servers_endpoint( @router.post( - "/subscription/traffic", + '/subscription/traffic', response_model=MiniAppSubscriptionUpdateResponse, ) async def update_subscription_traffic_endpoint( @@ -6069,20 +5964,16 @@ async def update_subscription_traffic_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial"}, + allowed_statuses={'active', 'trial'}, ) _validate_subscription_id(payload.subscription_id, subscription) old_traffic = subscription.traffic_limit_gb - raw_value = ( - payload.traffic - if payload.traffic is not None - else payload.traffic_gb - ) + raw_value = payload.traffic if payload.traffic is not None else payload.traffic_gb if raw_value is None: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Traffic amount is required"}, + detail={'code': 'validation_error', 'message': 'Traffic amount is required'}, ) try: @@ -6090,17 +5981,17 @@ async def update_subscription_traffic_endpoint( except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Invalid traffic amount"}, + detail={'code': 'validation_error', 'message': 'Invalid traffic amount'}, ) from None if new_traffic < 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Traffic amount must be non-negative"}, + detail={'code': 'validation_error', 'message': 'Traffic amount must be non-negative'}, ) if new_traffic == subscription.traffic_limit_gb: - return MiniAppSubscriptionUpdateResponse(success=True, message="No changes") + return MiniAppSubscriptionUpdateResponse(success=True, message='No changes') # В режиме fixed полностью блокируем изменение трафика # В режиме fixed_with_topup разрешаем докупку (is_traffic_topup_blocked = False) @@ -6108,19 +5999,19 @@ async def update_subscription_traffic_endpoint( raise HTTPException( status.HTTP_403_FORBIDDEN, detail={ - "code": "traffic_fixed", - "message": "Traffic cannot be changed for this subscription", + 'code': 'traffic_fixed', + 'message': 'Traffic cannot be changed for this subscription', }, ) - available_packages: List[int] = [] + available_packages: list[int] = [] for package in settings.get_traffic_packages(): try: - gb_value = int(package.get("gb")) + gb_value = int(package.get('gb')) except (TypeError, ValueError): continue - is_enabled = bool(package.get("enabled", True)) - if package.get("is_active") is False: + is_enabled = bool(package.get('enabled', True)) + if package.get('is_active') is False: is_enabled = False if is_enabled: available_packages.append(gb_value) @@ -6129,8 +6020,8 @@ async def update_subscription_traffic_endpoint( raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "traffic_unavailable", - "message": "Selected traffic package is not available", + 'code': 'traffic_unavailable', + 'message': 'Selected traffic package is not available', }, ) @@ -6138,7 +6029,7 @@ async def update_subscription_traffic_endpoint( period_hint_days = months_remaining * 30 if months_remaining > 0 else None traffic_discount = _get_addon_discount_percent_for_user( user, - "traffic", + 'traffic', period_hint_days, ) @@ -6159,23 +6050,17 @@ async def update_subscription_traffic_endpoint( if price_difference_per_month > 0: total_price_difference = price_difference_per_month * months_remaining - if getattr(user, "balance_kopeks", 0) < total_price_difference: - missing = total_price_difference - getattr(user, "balance_kopeks", 0) + if getattr(user, 'balance_kopeks', 0) < total_price_difference: + missing = total_price_difference - getattr(user, 'balance_kopeks', 0) raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), + 'code': 'insufficient_funds', + 'message': (f'Недостаточно средств на балансе. Не хватает {settings.format_price(missing)}'), }, ) - description = ( - "Переключение трафика с " - f"{subscription.traffic_limit_gb}GB на {new_traffic}GB" - ) + description = f'Переключение трафика с {subscription.traffic_limit_gb}GB на {new_traffic}GB' success = await subtract_user_balance( db, @@ -6187,8 +6072,8 @@ async def update_subscription_traffic_endpoint( raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", + 'code': 'balance_charge_failed', + 'message': 'Failed to charge user balance', }, ) @@ -6197,7 +6082,7 @@ async def update_subscription_traffic_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=total_price_difference, - description=f"{description} на {months_remaining} мес", + description=f'{description} на {months_remaining} мес', ) subscription.traffic_limit_gb = new_traffic @@ -6217,7 +6102,7 @@ async def update_subscription_traffic_endpoint( db, user, subscription, - "traffic", + 'traffic', old_traffic, subscription.traffic_limit_gb, price_paid=max(total_price_difference, 0), @@ -6228,7 +6113,7 @@ async def update_subscription_traffic_endpoint( @router.post( - "/subscription/devices", + '/subscription/devices', response_model=MiniAppSubscriptionUpdateResponse, ) async def update_subscription_devices_endpoint( @@ -6238,7 +6123,7 @@ async def update_subscription_devices_endpoint( user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription( user, - allowed_statuses={"active", "trial"}, + allowed_statuses={'active', 'trial'}, ) _validate_subscription_id(payload.subscription_id, subscription) @@ -6246,7 +6131,7 @@ async def update_subscription_devices_endpoint( if raw_value is None: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Device limit is required"}, + detail={'code': 'validation_error', 'message': 'Device limit is required'}, ) try: @@ -6254,24 +6139,21 @@ async def update_subscription_devices_endpoint( except (TypeError, ValueError): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Invalid device limit"}, + detail={'code': 'validation_error', 'message': 'Invalid device limit'}, ) from None if new_devices <= 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail={"code": "validation_error", "message": "Device limit must be positive"}, + detail={'code': 'validation_error', 'message': 'Device limit must be positive'}, ) if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT: raise HTTPException( status.HTTP_400_BAD_REQUEST, detail={ - "code": "devices_limit_exceeded", - "message": ( - "Превышен максимальный лимит устройств " - f"({settings.MAX_DEVICES_LIMIT})" - ), + 'code': 'devices_limit_exceeded', + 'message': (f'Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})'), }, ) @@ -6284,7 +6166,7 @@ async def update_subscription_devices_endpoint( old_devices = current_devices if new_devices == current_devices: - return MiniAppSubscriptionUpdateResponse(success=True, message="No changes") + return MiniAppSubscriptionUpdateResponse(success=True, message='No changes') devices_difference = new_devices - current_devices price_to_charge = 0 @@ -6300,7 +6182,7 @@ async def update_subscription_devices_endpoint( period_hint_days = months_remaining * 30 if months_remaining > 0 else None devices_discount = _get_addon_discount_percent_for_user( user, - "devices", + 'devices', period_hint_days, ) @@ -6313,24 +6195,18 @@ async def update_subscription_devices_endpoint( subscription.end_date, ) - if price_to_charge > 0 and getattr(user, "balance_kopeks", 0) < price_to_charge: - missing = price_to_charge - getattr(user, "balance_kopeks", 0) + if price_to_charge > 0 and getattr(user, 'balance_kopeks', 0) < price_to_charge: + missing = price_to_charge - getattr(user, 'balance_kopeks', 0) raise HTTPException( status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": ( - "Недостаточно средств на балансе. " - f"Не хватает {settings.format_price(missing)}" - ), + 'code': 'insufficient_funds', + 'message': (f'Недостаточно средств на балансе. Не хватает {settings.format_price(missing)}'), }, ) if price_to_charge > 0: - description = ( - "Изменение количества устройств с " - f"{current_devices} до {new_devices}" - ) + description = f'Изменение количества устройств с {current_devices} до {new_devices}' success = await subtract_user_balance( db, user, @@ -6341,8 +6217,8 @@ async def update_subscription_devices_endpoint( raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail={ - "code": "balance_charge_failed", - "message": "Failed to charge user balance", + 'code': 'balance_charge_failed', + 'message': 'Failed to charge user balance', }, ) @@ -6351,7 +6227,7 @@ async def update_subscription_devices_endpoint( user_id=user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, amount_kopeks=price_to_charge, - description=f"{description} на {charged_months or get_remaining_months(subscription.end_date)} мес", + description=f'{description} на {charged_months or get_remaining_months(subscription.end_date)} мес', ) subscription.device_limit = new_devices @@ -6371,7 +6247,7 @@ async def update_subscription_devices_endpoint( db, user, subscription, - "devices", + 'devices', old_devices, subscription.device_limit, price_paid=max(price_to_charge, 0), @@ -6385,24 +6261,25 @@ async def update_subscription_devices_endpoint( # Тарифы для режима продаж "Тарифы" # ============================================================================= + def _format_traffic_limit_label(traffic_gb: int) -> str: """Форматирует лимит трафика для отображения.""" if traffic_gb == 0: - return "♾️ Безлимит" - return f"{traffic_gb} ГБ" + return '♾️ Безлимит' + return f'{traffic_gb} ГБ' async def _build_tariff_model( db: AsyncSession, tariff, - current_tariff_id: Optional[int] = None, + current_tariff_id: int | None = None, promo_group=None, current_tariff=None, remaining_days: int = 0, user=None, ) -> MiniAppTariff: """Преобразует объект тарифа в модель для API.""" - servers: List[MiniAppConnectedServer] = [] + servers: list[MiniAppConnectedServer] = [] servers_count = 0 if tariff.allowed_squads: @@ -6410,10 +6287,12 @@ async def _build_tariff_model( for squad_uuid in tariff.allowed_squads[:5]: # Ограничиваем для превью server = await get_server_squad_by_uuid(db, squad_uuid) if server: - servers.append(MiniAppConnectedServer( - uuid=squad_uuid, - name=server.display_name or squad_uuid[:8], - )) + servers.append( + MiniAppConnectedServer( + uuid=squad_uuid, + name=server.display_name or squad_uuid[:8], + ) + ) # Получаем скидки промогруппы по периодам period_discounts = {} @@ -6425,7 +6304,7 @@ async def _build_tariff_model( except (TypeError, ValueError): pass - periods: List[MiniAppTariffPeriod] = [] + periods: list[MiniAppTariffPeriod] = [] if tariff.period_prices: for period_str, original_price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])): period_days = int(period_str) @@ -6440,18 +6319,20 @@ async def _build_tariff_model( months = max(1, period_days // 30) per_month = price_kopeks // months if months > 0 else price_kopeks - periods.append(MiniAppTariffPeriod( - days=period_days, - months=months, - label=format_period_description(period_days), - price_kopeks=price_kopeks, - price_label=settings.format_price(price_kopeks), - price_per_month_kopeks=per_month, - price_per_month_label=settings.format_price(per_month), - original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, - original_price_label=settings.format_price(original_price_kopeks) if discount_percent > 0 else None, - discount_percent=discount_percent, - )) + periods.append( + MiniAppTariffPeriod( + days=period_days, + months=months, + label=format_period_description(period_days), + price_kopeks=price_kopeks, + price_label=settings.format_price(price_kopeks), + price_per_month_kopeks=per_month, + price_per_month_label=settings.format_price(per_month), + original_price_kopeks=original_price_kopeks if discount_percent > 0 else None, + original_price_label=settings.format_price(original_price_kopeks) if discount_percent > 0 else None, + discount_percent=discount_percent, + ) + ) # Расчёт стоимости переключения тарифа (если есть текущий тариф и это не он же) switch_cost_kopeks = None @@ -6476,9 +6357,7 @@ async def _build_tariff_model( is_switch_free = False elif remaining_days > 0: # Обычный расчёт для периодных тарифов - cost, upgrade = _calculate_tariff_switch_cost( - current_tariff, tariff, remaining_days, promo_group, user - ) + cost, upgrade = _calculate_tariff_switch_cost(current_tariff, tariff, remaining_days, promo_group, user) switch_cost_kopeks = cost switch_cost_label = settings.format_price(cost) if cost > 0 else None is_upgrade = upgrade @@ -6487,7 +6366,9 @@ async def _build_tariff_model( # Суточный тариф is_daily = getattr(tariff, 'is_daily', False) daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 - daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + daily_price_label = ( + settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None + ) return MiniAppTariff( id=tariff.id, @@ -6533,7 +6414,9 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None # Суточный тариф is_daily = getattr(tariff, 'is_daily', False) daily_price_kopeks = getattr(tariff, 'daily_price_kopeks', 0) if is_daily else 0 - daily_price_label = settings.format_price(daily_price_kopeks) + "/день" if is_daily and daily_price_kopeks > 0 else None + daily_price_label = ( + settings.format_price(daily_price_kopeks) + '/день' if is_daily and daily_price_kopeks > 0 else None + ) return MiniAppCurrentTariff( id=tariff.id, @@ -6552,7 +6435,7 @@ async def _build_current_tariff_model(db: AsyncSession, tariff, promo_group=None ) -@router.post("/subscription/tariffs", response_model=MiniAppTariffsResponse) +@router.post('/subscription/tariffs', response_model=MiniAppTariffsResponse) async def get_tariffs_endpoint( payload: MiniAppTariffsRequest, db: AsyncSession = Depends(get_db_session), @@ -6565,22 +6448,26 @@ async def get_tariffs_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "tariffs_mode_disabled", - "message": "Tariffs mode is not enabled", + 'code': 'tariffs_mode_disabled', + 'message': 'Tariffs mode is not enabled', }, ) # Получаем промогруппу пользователя (с приоритетом) - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) promo_group_id = promo_group.id if promo_group else None # Получаем тарифы, доступные пользователю tariffs = await get_tariffs_for_user(db, promo_group_id) # Текущий тариф пользователя - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) current_tariff_id = subscription.tariff_id if subscription else None - current_tariff_model: Optional[MiniAppCurrentTariff] = None + current_tariff_model: MiniAppCurrentTariff | None = None current_tariff = None # Вычисляем оставшиеся дни подписки @@ -6595,10 +6482,13 @@ async def get_tariffs_endpoint( current_tariff_model = await _build_current_tariff_model(db, current_tariff, promo_group) # Формируем список тарифов - tariff_models: List[MiniAppTariff] = [] + tariff_models: list[MiniAppTariff] = [] for tariff in tariffs: model = await _build_tariff_model( - db, tariff, current_tariff_id, promo_group, + db, + tariff, + current_tariff_id, + promo_group, current_tariff=current_tariff, remaining_days=remaining_days, user=user, @@ -6616,7 +6506,7 @@ async def get_tariffs_endpoint( return MiniAppTariffsResponse( success=True, - sales_mode="tariffs", + sales_mode='tariffs', tariffs=tariff_models, current_tariff=current_tariff_model, balance_kopeks=user.balance_kopeks, @@ -6625,7 +6515,7 @@ async def get_tariffs_endpoint( ) -@router.post("/subscription/tariff/purchase", response_model=MiniAppTariffPurchaseResponse) +@router.post('/subscription/tariff/purchase', response_model=MiniAppTariffPurchaseResponse) async def purchase_tariff_endpoint( payload: MiniAppTariffPurchaseRequest, db: AsyncSession = Depends(get_db_session), @@ -6637,8 +6527,8 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "tariffs_mode_disabled", - "message": "Tariffs mode is not enabled", + 'code': 'tariffs_mode_disabled', + 'message': 'Tariffs mode is not enabled', }, ) @@ -6647,20 +6537,24 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ - "code": "tariff_not_found", - "message": "Tariff not found or inactive", + 'code': 'tariff_not_found', + 'message': 'Tariff not found or inactive', }, ) # Проверяем доступность тарифа для пользователя - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) promo_group_id = promo_group.id if promo_group else None if not tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ - "code": "tariff_not_available", - "message": "This tariff is not available for your promo group", + 'code': 'tariff_not_available', + 'message': 'This tariff is not available for your promo group', }, ) @@ -6675,8 +6569,8 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "invalid_daily_price", - "message": "Daily tariff has no price configured", + 'code': 'invalid_daily_price', + 'message': 'Daily tariff has no price configured', }, ) else: @@ -6686,8 +6580,8 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "invalid_period", - "message": "Invalid period for this tariff", + 'code': 'invalid_period', + 'message': 'Invalid period for this tariff', }, ) @@ -6712,13 +6606,13 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", - "missing_amount": missing, + 'code': 'insufficient_funds', + 'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}', + 'missing_amount': missing, }, ) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) # Списываем баланс if is_daily_tariff: @@ -6732,8 +6626,8 @@ async def purchase_tariff_endpoint( raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail={ - "code": "balance_charge_failed", - "message": "Failed to charge balance", + 'code': 'balance_charge_failed', + 'message': 'Failed to charge balance', }, ) @@ -6752,6 +6646,7 @@ async def purchase_tariff_endpoint( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -6769,6 +6664,7 @@ async def purchase_tariff_endpoint( else: # Создание новой подписки from app.database.crud.subscription import create_paid_subscription + subscription = await create_paid_subscription( db=db, user_id=user.id, @@ -6796,25 +6692,26 @@ async def purchase_tariff_endpoint( db, subscription, reset_traffic=True, - reset_reason="покупка тарифа (miniapp)", + reset_reason='покупка тарифа (miniapp)', ) # Сохраняем корзину для автопродления try: from app.services.user_cart_service import user_cart_service + cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": payload.period_days, - "total_price": price_kopeks, - "tariff_id": tariff.id, - "description": f"Продление тарифа {tariff.name} на {payload.period_days} дней", + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': payload.period_days, + 'total_price': price_kopeks, + 'tariff_id': tariff.id, + 'description': f'Продление тарифа {tariff.name} на {payload.period_days} дней', } await user_cart_service.save_user_cart(user.id, cart_data) - user_id_display = user.telegram_id or user.email or f"#{user.id}" - logger.info(f"Корзина тарифа сохранена для автопродления (miniapp) пользователя {user_id_display}") + user_id_display = user.telegram_id or user.email or f'#{user.id}' + logger.info(f'Корзина тарифа сохранена для автопродления (miniapp) пользователя {user_id_display}') except Exception as e: - logger.error(f"Ошибка сохранения корзины тарифа (miniapp): {e}") + logger.error(f'Ошибка сохранения корзины тарифа (miniapp): {e}') await db.refresh(user) @@ -6835,7 +6732,7 @@ def _get_user_period_discount(user, period_days: int) -> int: promo_group = getattr(user, 'promo_group', None) if user else None if promo_group: - discount = promo_group.get_discount_percent("period", period_days) + discount = promo_group.get_discount_percent('period', period_days) if discount > 0: return discount @@ -6897,7 +6794,7 @@ def _calculate_tariff_switch_cost( return upgrade_cost, True -@router.post("/subscription/tariff/switch/preview") +@router.post('/subscription/tariff/switch/preview') async def preview_tariff_switch_endpoint( payload: MiniAppTariffSwitchRequest, db: AsyncSession = Depends(get_db_session), @@ -6909,20 +6806,20 @@ async def preview_tariff_switch_endpoint( if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + detail={'code': 'tariffs_mode_disabled', 'message': 'Tariffs mode is not enabled'}, ) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if not subscription or not subscription.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + detail={'code': 'no_subscription', 'message': 'No active subscription with tariff'}, ) - if subscription.status not in ("active", "trial"): + if subscription.status not in ('active', 'trial'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + detail={'code': 'subscription_inactive', 'message': 'Subscription is not active'}, ) current_tariff = await get_tariff_by_id(db, subscription.tariff_id) @@ -6931,22 +6828,26 @@ async def preview_tariff_switch_endpoint( if not new_tariff or not new_tariff.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + detail={'code': 'tariff_not_found', 'message': 'Tariff not found or inactive'}, ) if subscription.tariff_id == payload.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "same_tariff", "message": "Already on this tariff"}, + detail={'code': 'same_tariff', 'message': 'Already on this tariff'}, ) # Проверяем доступность тарифа для пользователя - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail={"code": "tariff_not_available", "message": "Tariff not available for your promo group"}, + detail={'code': 'tariff_not_available', 'message': 'Tariff not available for your promo group'}, ) # Рассчитываем оставшиеся дни @@ -6984,18 +6885,18 @@ async def preview_tariff_switch_endpoint( new_tariff_name=new_tariff.name, remaining_days=remaining_days, upgrade_cost_kopeks=upgrade_cost, - upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else "Бесплатно", + upgrade_cost_label=settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно', balance_kopeks=balance, balance_label=settings.format_price(balance), has_enough_balance=has_enough, missing_amount_kopeks=missing, - missing_amount_label=settings.format_price(missing) if missing > 0 else "", + missing_amount_label=settings.format_price(missing) if missing > 0 else '', is_upgrade=is_upgrade, message=None, ) -@router.post("/subscription/tariff/switch") +@router.post('/subscription/tariff/switch') async def switch_tariff_endpoint( payload: MiniAppTariffSwitchRequest, db: AsyncSession = Depends(get_db_session), @@ -7006,20 +6907,20 @@ async def switch_tariff_endpoint( if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "tariffs_mode_disabled", "message": "Tariffs mode is not enabled"}, + detail={'code': 'tariffs_mode_disabled', 'message': 'Tariffs mode is not enabled'}, ) - subscription = getattr(user, "subscription", None) + subscription = getattr(user, 'subscription', None) if not subscription or not subscription.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "no_subscription", "message": "No active subscription with tariff"}, + detail={'code': 'no_subscription', 'message': 'No active subscription with tariff'}, ) - if subscription.status not in ("active", "trial"): + if subscription.status not in ('active', 'trial'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "subscription_inactive", "message": "Subscription is not active"}, + detail={'code': 'subscription_inactive', 'message': 'Subscription is not active'}, ) current_tariff = await get_tariff_by_id(db, subscription.tariff_id) @@ -7028,22 +6929,26 @@ async def switch_tariff_endpoint( if not new_tariff or not new_tariff.is_active: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail={"code": "tariff_not_found", "message": "Tariff not found or inactive"}, + detail={'code': 'tariff_not_found', 'message': 'Tariff not found or inactive'}, ) if subscription.tariff_id == payload.tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "same_tariff", "message": "Already on this tariff"}, + detail={'code': 'same_tariff', 'message': 'Already on this tariff'}, ) # Проверяем доступность тарифа - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) promo_group_id = promo_group.id if promo_group else None if not new_tariff.is_available_for_promo_group(promo_group_id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail={"code": "tariff_not_available", "message": "Tariff not available"}, + detail={'code': 'tariff_not_available', 'message': 'Tariff not available'}, ) # Рассчитываем оставшиеся дни @@ -7082,9 +6987,9 @@ async def switch_tariff_endpoint( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_funds", - "message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}", - "missing_amount": missing, + 'code': 'insufficient_funds', + 'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}', + 'missing_amount': missing, }, ) @@ -7096,7 +7001,7 @@ async def switch_tariff_endpoint( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "balance_error", "message": "Failed to charge balance"}, + detail={'code': 'balance_error', 'message': 'Failed to charge balance'}, ) # Записываем транзакцию @@ -7114,6 +7019,7 @@ async def switch_tariff_endpoint( # Если allowed_squads пустой - значит "все серверы", получаем их if not squads: from app.database.crud.server_squad import get_all_server_squads + all_servers, _ = await get_all_server_squads(db, available_only=True) squads = [s.squad_uuid for s in all_servers if s.squad_uuid] @@ -7136,7 +7042,7 @@ async def switch_tariff_endpoint( subscription.last_daily_charge_at = datetime.utcnow() # Для суточного тарифа end_date = сейчас + 1 день subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info(f"🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}") + logger.info(f'🔄 Смена на суточный тариф: установлены daily поля, end_date={subscription.end_date}') elif old_is_daily and not new_is_daily: # Переход с суточного на обычный тариф - очищаем daily поля subscription.is_daily_paused = False @@ -7144,9 +7050,11 @@ async def switch_tariff_endpoint( # Устанавливаем дату окончания для периодного тарифа if new_period_days > 0: subscription.end_date = datetime.utcnow() + timedelta(days=new_period_days) - logger.info(f"🔄 Смена с суточного на периодный тариф: end_date={subscription.end_date} ({new_period_days} дней)") + logger.info( + f'🔄 Смена с суточного на периодный тариф: end_date={subscription.end_date} ({new_period_days} дней)' + ) else: - logger.info(f"🔄 Смена с суточного на обычный тариф: очищены daily поля") + logger.info('🔄 Смена с суточного на обычный тариф: очищены daily поля') await db.commit() await db.refresh(subscription) @@ -7157,19 +7065,18 @@ async def switch_tariff_endpoint( service = SubscriptionService() await service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave при смене тарифа: {e}") + logger.error(f'Ошибка синхронизации с RemnaWave при смене тарифа: {e}') - lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if upgrade_cost > 0: - if lang == "ru": + if lang == 'ru': message = f"Тариф изменён на '{new_tariff.name}'. Списано {settings.format_price(upgrade_cost)}" else: message = f"Switched to '{new_tariff.name}'. Charged {settings.format_price(upgrade_cost)}" + elif lang == 'ru': + message = f"Тариф изменён на '{new_tariff.name}'" else: - if lang == "ru": - message = f"Тариф изменён на '{new_tariff.name}'" - else: - message = f"Switched to '{new_tariff.name}'" + message = f"Switched to '{new_tariff.name}'" return MiniAppTariffSwitchResponse( success=True, @@ -7182,18 +7089,18 @@ async def switch_tariff_endpoint( ) -@router.post("/subscription/traffic-topup") +@router.post('/subscription/traffic-topup') async def purchase_traffic_topup_endpoint( payload: MiniAppTrafficTopupRequest, db: AsyncSession = Depends(get_db_session), ): """Докупка трафика для подписки.""" - from app.webapi.schemas.miniapp import MiniAppTrafficTopupRequest, MiniAppTrafficTopupResponse from app.database.crud.subscription import add_subscription_traffic - from app.database.crud.user import subtract_user_balance from app.database.crud.transaction import create_transaction + from app.database.crud.user import subtract_user_balance from app.database.models import TransactionType from app.utils.pricing_utils import calculate_prorated_price + from app.webapi.schemas.miniapp import MiniAppTrafficTopupResponse user = await _authorize_miniapp_user(payload.init_data, db) subscription = _ensure_paid_subscription(user) @@ -7204,8 +7111,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "tariffs_mode_disabled", - "message": "Traffic top-up is only available in tariffs mode", + 'code': 'tariffs_mode_disabled', + 'message': 'Traffic top-up is only available in tariffs mode', }, ) @@ -7215,8 +7122,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "no_tariff", - "message": "Subscription has no tariff", + 'code': 'no_tariff', + 'message': 'Subscription has no tariff', }, ) @@ -7225,8 +7132,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={ - "code": "tariff_not_found", - "message": "Tariff not found", + 'code': 'tariff_not_found', + 'message': 'Tariff not found', }, ) @@ -7235,8 +7142,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ - "code": "traffic_topup_disabled", - "message": "Traffic top-up is disabled for this tariff", + 'code': 'traffic_topup_disabled', + 'message': 'Traffic top-up is disabled for this tariff', }, ) @@ -7245,8 +7152,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "unlimited_traffic", - "message": "Cannot add traffic to unlimited subscription", + 'code': 'unlimited_traffic', + 'message': 'Cannot add traffic to unlimited subscription', }, ) @@ -7260,11 +7167,11 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "topup_limit_exceeded", - "message": f"Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB", - "max_limit_gb": max_topup_limit, - "current_gb": current_traffic, - "available_gb": available_gb, + 'code': 'topup_limit_exceeded', + 'message': f'Traffic top-up limit exceeded. Maximum allowed: {max_topup_limit} GB, current: {current_traffic} GB, available: {available_gb} GB', + 'max_limit_gb': max_topup_limit, + 'current_gb': current_traffic, + 'available_gb': available_gb, }, ) @@ -7274,8 +7181,8 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "code": "invalid_package", - "message": f"Traffic package {payload.gb}GB is not available", + 'code': 'invalid_package', + 'message': f'Traffic package {payload.gb}GB is not available', }, ) @@ -7283,7 +7190,11 @@ async def purchase_traffic_topup_endpoint( # Применяем скидку промогруппы на трафик traffic_discount_percent = 0 - promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else getattr(user, "promo_group", None) + promo_group = ( + user.get_primary_promo_group() + if hasattr(user, 'get_primary_promo_group') + else getattr(user, 'promo_group', None) + ) if promo_group: apply_to_addons = getattr(promo_group, 'apply_discounts_to_addons', True) if apply_to_addons: @@ -7303,28 +7214,25 @@ async def purchase_traffic_topup_endpoint( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_balance", - "message": "Insufficient balance", - "required": final_price, - "balance": user.balance_kopeks, + 'code': 'insufficient_balance', + 'message': 'Insufficient balance', + 'required': final_price, + 'balance': user.balance_kopeks, }, ) # Списываем баланс if traffic_discount_percent > 0: - traffic_description = f"Докупка {payload.gb} ГБ трафика (скидка {traffic_discount_percent}%)" + traffic_description = f'Докупка {payload.gb} ГБ трафика (скидка {traffic_discount_percent}%)' else: - traffic_description = f"Докупка {payload.gb} ГБ трафика" - success = await subtract_user_balance( - db, user, final_price, - traffic_description - ) + traffic_description = f'Докупка {payload.gb} ГБ трафика' + success = await subtract_user_balance(db, user, final_price, traffic_description) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ - "code": "balance_error", - "message": "Failed to subtract balance", + 'code': 'balance_error', + 'message': 'Failed to subtract balance', }, ) @@ -7336,7 +7244,7 @@ async def purchase_traffic_topup_endpoint( service = SubscriptionService() await service.update_remnawave_user(db, subscription) except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave при докупке трафика: {e}") + logger.error(f'Ошибка синхронизации с RemnaWave при докупке трафика: {e}') # Создаем транзакцию await create_transaction( @@ -7352,21 +7260,21 @@ async def purchase_traffic_topup_endpoint( return MiniAppTrafficTopupResponse( success=True, - message=f"Добавлено {payload.gb} ГБ трафика", + message=f'Добавлено {payload.gb} ГБ трафика', new_traffic_limit_gb=subscription.traffic_limit_gb, new_balance_kopeks=user.balance_kopeks, charged_kopeks=final_price, ) -@router.post("/subscription/daily/toggle-pause") +@router.post('/subscription/daily/toggle-pause') async def toggle_daily_subscription_pause_endpoint( payload: MiniAppDailySubscriptionToggleRequest, db: AsyncSession = Depends(get_db_session), ): """Переключает паузу/активацию суточной подписки.""" - from app.webapi.schemas.miniapp import MiniAppDailySubscriptionToggleResponse from app.services.subscription_service import SubscriptionService + from app.webapi.schemas.miniapp import MiniAppDailySubscriptionToggleResponse user = await _authorize_miniapp_user(payload.init_data, db) subscription = user.subscription @@ -7374,7 +7282,7 @@ async def toggle_daily_subscription_pause_endpoint( if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail={"code": "no_subscription", "message": "No subscription found"}, + detail={'code': 'no_subscription', 'message': 'No subscription found'}, ) # Проверяем наличие тарифа @@ -7382,14 +7290,14 @@ async def toggle_daily_subscription_pause_endpoint( if not tariff_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "no_tariff", "message": "Subscription has no tariff"}, + detail={'code': 'no_tariff', 'message': 'Subscription has no tariff'}, ) tariff = await get_tariff_by_id(db, tariff_id) if not tariff or not getattr(tariff, 'is_daily', False): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail={"code": "not_daily_tariff", "message": "Subscription is not on a daily tariff"}, + detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'}, ) # Переключаем состояние паузы @@ -7404,23 +7312,22 @@ async def toggle_daily_subscription_pause_endpoint( raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ - "code": "insufficient_balance", - "message": "Insufficient balance to resume daily subscription", - "required": daily_price, - "balance": user.balance_kopeks, + 'code': 'insufficient_balance', + 'message': 'Insufficient balance to resume daily subscription', + 'required': daily_price, + 'balance': user.balance_kopeks, }, ) # Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств) from app.database.models import SubscriptionStatus + if subscription.status == SubscriptionStatus.DISABLED.value: subscription.status = SubscriptionStatus.ACTIVE.value # Обновляем время последнего списания для корректного расчёта следующего subscription.last_daily_charge_at = datetime.utcnow() subscription.end_date = datetime.utcnow() + timedelta(days=1) - logger.info( - f"✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE" - ) + logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE') await db.commit() await db.refresh(subscription) @@ -7436,13 +7343,13 @@ async def toggle_daily_subscription_pause_endpoint( if user.remnawave_uuid: await service.enable_remnawave_user(user.remnawave_uuid) except Exception as e: - logger.error(f"Ошибка синхронизации с RemnaWave при возобновлении: {e}") + logger.error(f'Ошибка синхронизации с RemnaWave при возобновлении: {e}') - lang = getattr(user, "language", settings.DEFAULT_LANGUAGE) + lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if new_paused_state: - message = "Суточная подписка приостановлена" if lang == "ru" else "Daily subscription paused" + message = 'Суточная подписка приостановлена' if lang == 'ru' else 'Daily subscription paused' else: - message = "Суточная подписка возобновлена" if lang == "ru" else "Daily subscription resumed" + message = 'Суточная подписка возобновлена' if lang == 'ru' else 'Daily subscription resumed' return MiniAppDailySubscriptionToggleResponse( success=True, diff --git a/app/webapi/routes/pages.py b/app/webapi/routes/pages.py index 81d76291..b30581d0 100644 --- a/app/webapi/routes/pages.py +++ b/app/webapi/routes/pages.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, List, Optional +from typing import Any from fastapi import ( APIRouter, @@ -53,17 +53,17 @@ def _serialize_rich_page( requested_language: str, content: str, language: str, - is_enabled: Optional[bool], + is_enabled: bool | None, created_at, updated_at, splitter, ) -> RichTextPageResponse: - pages = splitter(content or "") + pages = splitter(content or '') return RichTextPageResponse( requested_language=requested_language, language=language, is_enabled=is_enabled, - content=content or "", + content=content or '', content_pages=pages, created_at=created_at, updated_at=updated_at, @@ -96,38 +96,38 @@ def _serialize_rules(rule: ServiceRule) -> ServiceRulesResponse: ) -@router.get("/public-offer", response_model=RichTextPageResponse) +@router.get('/public-offer', response_model=RichTextPageResponse) async def get_public_offer( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), - fallback: bool = Query(True, description="Использовать запасной язык, если контента нет"), + language: str = Query('ru', min_length=2, max_length=10), + fallback: bool = Query(True, description='Использовать запасной язык, если контента нет'), include_disabled: bool = Query( True, - description="Возвращать контент даже если страница выключена", + description='Возвращать контент даже если страница выключена', ), ) -> RichTextPageResponse: requested_lang = PublicOfferService.normalize_language(language) offer = await PublicOfferService.get_offer(db, requested_lang, fallback=fallback) if not offer: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Public offer not found') if not include_disabled and not offer.is_enabled: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Public offer disabled") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Public offer disabled') return _serialize_rich_page( requested_language=requested_lang, language=offer.language, is_enabled=offer.is_enabled, - content=offer.content or "", + content=offer.content or '', created_at=offer.created_at, updated_at=offer.updated_at, splitter=PublicOfferService.split_content_into_pages, ) -@router.put("/public-offer", response_model=RichTextPageResponse) +@router.put('/public-offer', response_model=RichTextPageResponse) async def update_public_offer( payload: RichTextPageUpdateRequest, _: object = Security(require_api_token), @@ -146,18 +146,18 @@ async def update_public_offer( requested_language=lang, language=offer.language, is_enabled=offer.is_enabled, - content=offer.content or "", + content=offer.content or '', created_at=offer.created_at, updated_at=offer.updated_at, splitter=PublicOfferService.split_content_into_pages, ) -@router.get("/privacy-policy", response_model=RichTextPageResponse) +@router.get('/privacy-policy', response_model=RichTextPageResponse) async def get_privacy_policy( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), fallback: bool = Query(True), include_disabled: bool = Query(True), ) -> RichTextPageResponse: @@ -165,23 +165,23 @@ async def get_privacy_policy( policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=fallback) if not policy: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Privacy policy not found') if not include_disabled and not policy.is_enabled: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Privacy policy disabled") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Privacy policy disabled') return _serialize_rich_page( requested_language=requested_lang, language=policy.language, is_enabled=policy.is_enabled, - content=policy.content or "", + content=policy.content or '', created_at=policy.created_at, updated_at=policy.updated_at, splitter=PrivacyPolicyService.split_content_into_pages, ) -@router.put("/privacy-policy", response_model=RichTextPageResponse) +@router.put('/privacy-policy', response_model=RichTextPageResponse) async def update_privacy_policy( payload: RichTextPageUpdateRequest, _: object = Security(require_api_token), @@ -200,18 +200,18 @@ async def update_privacy_policy( requested_language=lang, language=policy.language, is_enabled=policy.is_enabled, - content=policy.content or "", + content=policy.content or '', created_at=policy.created_at, updated_at=policy.updated_at, splitter=PrivacyPolicyService.split_content_into_pages, ) -@router.get("/faq", response_model=FaqPageListResponse) +@router.get('/faq', response_model=FaqPageListResponse) async def list_faq_pages( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), include_inactive: bool = Query(True), fallback: bool = Query(True), ) -> FaqPageListResponse: @@ -242,11 +242,11 @@ async def list_faq_pages( ) -@router.get("/faq/status", response_model=FaqStatusResponse) +@router.get('/faq/status', response_model=FaqStatusResponse) async def get_faq_status( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), fallback: bool = Query(True), ) -> FaqStatusResponse: requested_lang = FaqService.normalize_language(language) @@ -266,23 +266,19 @@ async def get_faq_status( ) -@router.put("/faq/status", response_model=FaqStatusResponse) +@router.put('/faq/status', response_model=FaqStatusResponse) async def update_faq_status( - payload: Optional[FaqStatusUpdateRequest] = Body(None), - language: str = Query("ru", min_length=2, max_length=10), - is_enabled: Optional[bool] = Query(None), + payload: FaqStatusUpdateRequest | None = Body(None), + language: str = Query('ru', min_length=2, max_length=10), + is_enabled: bool | None = Query(None), _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> FaqStatusResponse: - resolved_language = FaqService.normalize_language( - payload.language if payload and payload.language else language - ) + resolved_language = FaqService.normalize_language(payload.language if payload and payload.language else language) enabled_status = payload.is_enabled if payload else is_enabled if enabled_status is None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, "Parameter 'is_enabled' is required" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Parameter 'is_enabled' is required") setting = await FaqService.set_enabled(db, resolved_language, enabled_status) @@ -293,7 +289,7 @@ async def update_faq_status( ) -@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED) +@router.post('/faq', response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED) async def create_faq_page( payload: FaqPageCreateRequest, _: object = Security(require_api_token), @@ -314,12 +310,12 @@ async def create_faq_page( return _serialize_faq_page(page) -@router.get("/faq/{page_id}", response_model=FaqPageResponse) +@router.get('/faq/{page_id}', response_model=FaqPageResponse) async def get_faq_page( page_id: int, _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), include_inactive: bool = Query(True), ) -> FaqPageResponse: requested_lang = FaqService.normalize_language(language) @@ -332,12 +328,12 @@ async def get_faq_page( ) if not page: - raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'FAQ page not found') return _serialize_faq_page(page) -@router.put("/faq/{page_id}", response_model=FaqPageResponse) +@router.put('/faq/{page_id}', response_model=FaqPageResponse) async def update_faq_page( page_id: int, payload: FaqPageUpdateRequest, @@ -347,7 +343,7 @@ async def update_faq_page( page = await get_faq_page_by_id(db, page_id) if not page: - raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'FAQ page not found') updated = await FaqService.update_page( db, @@ -361,7 +357,7 @@ async def update_faq_page( return _serialize_faq_page(updated) -@router.delete("/faq/{page_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/faq/{page_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_faq_page( page_id: int, _: object = Security(require_api_token), @@ -369,13 +365,13 @@ async def delete_faq_page( ) -> Response: page = await get_faq_page_by_id(db, page_id) if not page: - raise HTTPException(status.HTTP_404_NOT_FOUND, "FAQ page not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'FAQ page not found') await FaqService.delete_page(db, page_id) return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post("/faq/reorder", response_model=FaqPageListResponse) +@router.post('/faq/reorder', response_model=FaqPageListResponse) async def reorder_faq_pages( payload: FaqReorderRequest, _: object = Security(require_api_token), @@ -393,13 +389,13 @@ async def reorder_faq_pages( ) pages_by_id = {page.id: page for page in existing_pages} - pages: List[Any] = [] + pages: list[Any] = [] for item in ordered_payload: page = pages_by_id.get(item.id) if not page: raise HTTPException( status.HTTP_404_NOT_FOUND, - f"FAQ page {item.id} not found for language {lang}", + f'FAQ page {item.id} not found for language {lang}', ) pages.append(page) @@ -426,35 +422,35 @@ async def reorder_faq_pages( ) -@router.get("/service-rules", response_model=ServiceRulesResponse) +@router.get('/service-rules', response_model=ServiceRulesResponse) async def get_service_rules( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), fallback: bool = Query(True), ) -> ServiceRulesResponse: - requested_lang = language.split("-")[0].lower() + requested_lang = language.split('-')[0].lower() rules = await get_rules_by_language(db, requested_lang) if not rules and fallback: - default_lang = (settings.DEFAULT_LANGUAGE or "ru").split("-")[0].lower() + default_lang = (settings.DEFAULT_LANGUAGE or 'ru').split('-')[0].lower() if default_lang != requested_lang: rules = await get_rules_by_language(db, default_lang) if not rules: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Service rules not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Service rules not found') return _serialize_rules(rules) -@router.put("/service-rules", response_model=ServiceRulesResponse) +@router.put('/service-rules', response_model=ServiceRulesResponse) async def update_service_rules( payload: ServiceRulesUpdateRequest, _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ServiceRulesResponse: - lang = payload.language.split("-")[0].lower() - title = payload.title or "Правила сервиса" + lang = payload.language.split('-')[0].lower() + title = payload.title or 'Правила сервиса' rules = await create_or_update_rules( db, content=payload.content, @@ -465,25 +461,25 @@ async def update_service_rules( return _serialize_rules(rules) -@router.delete("/service-rules", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/service-rules', status_code=status.HTTP_204_NO_CONTENT) async def clear_service_rules( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), ) -> Response: - lang = language.split("-")[0].lower() + lang = language.split('-')[0].lower() await clear_all_rules(db, lang) return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.get("/service-rules/history", response_model=ServiceRulesHistoryResponse) +@router.get('/service-rules/history', response_model=ServiceRulesHistoryResponse) async def get_service_rules_history( _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), limit: int = Query(10, ge=1, le=100), ) -> ServiceRulesHistoryResponse: - lang = language.split("-")[0].lower() + lang = language.split('-')[0].lower() history = await get_all_rules_versions(db, lang, limit=limit) items = [_serialize_rules(item) for item in history] return ServiceRulesHistoryResponse( @@ -494,7 +490,7 @@ async def get_service_rules_history( @router.post( - "/service-rules/history/{rule_id}/restore", + '/service-rules/history/{rule_id}/restore', response_model=ServiceRulesResponse, status_code=status.HTTP_201_CREATED, ) @@ -502,11 +498,10 @@ async def restore_service_rules_version( rule_id: int, _: object = Security(require_api_token), db: AsyncSession = Depends(get_db_session), - language: str = Query("ru", min_length=2, max_length=10), + language: str = Query('ru', min_length=2, max_length=10), ) -> ServiceRulesResponse: - lang = language.split("-")[0].lower() + lang = language.split('-')[0].lower() restored = await restore_rules_version(db, rule_id, language=lang) if not restored: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Rules version not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Rules version not found') return _serialize_rules(restored) - diff --git a/app/webapi/routes/partners.py b/app/webapi/routes/partners.py index b2ed8652..6e95cd8d 100644 --- a/app/webapi/routes/partners.py +++ b/app/webapi/routes/partners.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select @@ -49,13 +49,14 @@ from ..schemas.partners import ( TopReferrersResponse, ) + logger = logging.getLogger(__name__) router = APIRouter() def _apply_search_filter(query, search: str): - search_lower = f"%{search.lower()}%" + search_lower = f'%{search.lower()}%' conditions = [ func.lower(User.username).like(search_lower), func.lower(User.first_name).like(search_lower), @@ -71,8 +72,8 @@ def _apply_search_filter(query, search: str): def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem: - total_earned_kopeks = int(stats.get("total_earned_kopeks") or 0) - month_earned_kopeks = int(stats.get("month_earned_kopeks") or 0) + total_earned_kopeks = int(stats.get('total_earned_kopeks') or 0) + month_earned_kopeks = int(stats.get('month_earned_kopeks') or 0) return PartnerReferrerItem( id=user.id, @@ -81,10 +82,10 @@ def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem: first_name=user.first_name, last_name=user.last_name, referral_code=user.referral_code, - referral_commission_percent=getattr(user, "referral_commission_percent", None), + referral_commission_percent=getattr(user, 'referral_commission_percent', None), effective_referral_commission_percent=get_effective_referral_commission_percent(user), - invited_count=int(stats.get("invited_count") or 0), - active_referrals=int(stats.get("active_referrals") or 0), + invited_count=int(stats.get('invited_count') or 0), + active_referrals=int(stats.get('active_referrals') or 0), total_earned_kopeks=total_earned_kopeks, total_earned_rubles=round(total_earned_kopeks / 100, 2), month_earned_kopeks=month_earned_kopeks, @@ -95,49 +96,45 @@ def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem: def _serialize_referral_item(referral: dict) -> PartnerReferralItem: - balance_kopeks = int(referral.get("balance_kopeks") or 0) - total_earned_kopeks = int(referral.get("total_earned_kopeks") or 0) + balance_kopeks = int(referral.get('balance_kopeks') or 0) + total_earned_kopeks = int(referral.get('total_earned_kopeks') or 0) # Handle email-only users (telegram_id=None) - raw_telegram_id = referral.get("telegram_id") + raw_telegram_id = referral.get('telegram_id') telegram_id = int(raw_telegram_id) if raw_telegram_id is not None else None return PartnerReferralItem( - id=int(referral.get("id")), + id=int(referral.get('id')), telegram_id=telegram_id, - full_name=str(referral.get("full_name")), - username=referral.get("username"), - created_at=referral.get("created_at"), - last_activity=referral.get("last_activity"), - has_made_first_topup=bool(referral.get("has_made_first_topup", False)), + full_name=str(referral.get('full_name')), + username=referral.get('username'), + created_at=referral.get('created_at'), + last_activity=referral.get('last_activity'), + has_made_first_topup=bool(referral.get('has_made_first_topup', False)), balance_kopeks=balance_kopeks, balance_rubles=round(balance_kopeks / 100, 2), total_earned_kopeks=total_earned_kopeks, total_earned_rubles=round(total_earned_kopeks / 100, 2), - topups_count=int(referral.get("topups_count") or 0), - days_since_registration=int(referral.get("days_since_registration") or 0), - days_since_activity=referral.get("days_since_activity"), - status=str(referral.get("status") or "inactive"), + topups_count=int(referral.get('topups_count') or 0), + days_since_registration=int(referral.get('days_since_registration') or 0), + days_since_activity=referral.get('days_since_activity'), + status=str(referral.get('status') or 'inactive'), ) -@router.get("/referrers", response_model=PartnerReferrerListResponse) +@router.get('/referrers', response_model=PartnerReferrerListResponse) async def list_referrers( _: 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), - search: Optional[str] = Query(default=None), + search: str | None = Query(default=None), ) -> PartnerReferrerListResponse: referral_alias = aliased(User) - has_referrals = ( - select(referral_alias.id) - .where(referral_alias.referred_by_id == User.id) - .exists() - ) + has_referrals = select(referral_alias.id).where(referral_alias.referred_by_id == User.id).exists() - base_query = select(User).options(selectinload(User.referrer)).where( - or_(User.referral_code.isnot(None), has_referrals) + base_query = ( + select(User).options(selectinload(User.referrer)).where(or_(User.referral_code.isnot(None), has_referrals)) ) if search: @@ -146,9 +143,7 @@ async def list_referrers( 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) - ) + result = await db.execute(base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)) referrers = result.scalars().unique().all() items: list[PartnerReferrerItem] = [] @@ -164,7 +159,7 @@ async def list_referrers( ) -@router.get("/referrers/{user_id}", response_model=PartnerReferrerDetail) +@router.get('/referrers/{user_id}', response_model=PartnerReferrerDetail) async def get_referrer_detail( user_id: int, _: Any = Security(require_api_token), @@ -177,31 +172,29 @@ async def get_referrer_detail( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') stats = await get_user_referral_stats(db, user.id) referrer_item = _serialize_referrer(user, stats) referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset) - referral_items = [ - _serialize_referral_item(referral) for referral in referrals_data.get("referrals", []) - ] + referral_items = [_serialize_referral_item(referral) for referral in referrals_data.get('referrals', [])] referrals_list = PartnerReferralList( items=referral_items, - total=int(referrals_data.get("total_count") or 0), + total=int(referrals_data.get('total_count') or 0), limit=limit, offset=offset, - has_next=bool(referrals_data.get("has_next")), - has_prev=bool(referrals_data.get("has_prev")), - current_page=int(referrals_data.get("current_page") or 1), - total_pages=int(referrals_data.get("total_pages") or 1), + has_next=bool(referrals_data.get('has_next')), + has_prev=bool(referrals_data.get('has_prev')), + current_page=int(referrals_data.get('current_page') or 1), + total_pages=int(referrals_data.get('total_pages') or 1), ) return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list) -@router.patch("/referrers/{user_id}/commission", response_model=PartnerReferrerItem) +@router.patch('/referrers/{user_id}/commission', response_model=PartnerReferrerItem) async def update_referrer_commission( user_id: int, payload: PartnerReferralCommissionUpdate, @@ -213,7 +206,7 @@ async def update_referrer_commission( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') await update_user( db, @@ -230,7 +223,7 @@ async def update_referrer_commission( # ============================================================================ -@router.get("/stats", response_model=GlobalPartnerStats) +@router.get('/stats', response_model=GlobalPartnerStats) async def get_global_partner_stats( days: int = Query(30, ge=1, le=365), _: Any = Security(require_api_token), @@ -240,13 +233,13 @@ async def get_global_partner_stats( data = await PartnerStatsService.get_global_partner_stats(db, days) return GlobalPartnerStats( - summary=GlobalPartnerSummary(**data["summary"]), - payouts=PayoutsByPeriod(**data["payouts"]), - new_referrals=NewReferralsByPeriod(**data["new_referrals"]), + summary=GlobalPartnerSummary(**data['summary']), + payouts=PayoutsByPeriod(**data['payouts']), + new_referrals=NewReferralsByPeriod(**data['new_referrals']), ) -@router.get("/stats/daily", response_model=DailyStatsResponse) +@router.get('/stats/daily', response_model=DailyStatsResponse) async def get_global_daily_stats( days: int = Query(30, ge=1, le=365), _: Any = Security(require_api_token), @@ -262,10 +255,10 @@ async def get_global_daily_stats( ) -@router.get("/stats/top-referrers", response_model=TopReferrersResponse) +@router.get('/stats/top-referrers', response_model=TopReferrersResponse) async def get_top_referrers( limit: int = Query(10, ge=1, le=100), - days: Optional[int] = Query(None, ge=1, le=365), + days: int | None = Query(None, ge=1, le=365), _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> TopReferrersResponse: @@ -278,7 +271,7 @@ async def get_top_referrers( ) -@router.get("/referrers/{user_id}/stats", response_model=ReferrerDetailedStats) +@router.get('/referrers/{user_id}/stats', response_model=ReferrerDetailedStats) async def get_referrer_detailed_stats( user_id: int, _: Any = Security(require_api_token), @@ -290,19 +283,19 @@ async def get_referrer_detailed_stats( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id) return ReferrerDetailedStats( - user_id=data["user_id"], - summary=ReferrerSummary(**data["summary"]), - earnings=EarningsByPeriod(**data["earnings"]), - referrals_count=ReferralsCountByPeriod(**data["referrals_count"]), + user_id=data['user_id'], + summary=ReferrerSummary(**data['summary']), + earnings=EarningsByPeriod(**data['earnings']), + referrals_count=ReferralsCountByPeriod(**data['referrals_count']), ) -@router.get("/referrers/{user_id}/stats/daily", response_model=DailyStatsResponse) +@router.get('/referrers/{user_id}/stats/daily', response_model=DailyStatsResponse) async def get_referrer_daily_stats( user_id: int, days: int = Query(30, ge=1, le=365), @@ -315,7 +308,7 @@ async def get_referrer_daily_stats( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days) @@ -326,7 +319,7 @@ async def get_referrer_daily_stats( ) -@router.get("/referrers/{user_id}/stats/top-referrals", response_model=TopReferralsResponse) +@router.get('/referrers/{user_id}/stats/top-referrals', response_model=TopReferralsResponse) async def get_referrer_top_referrals( user_id: int, limit: int = Query(10, ge=1, le=100), @@ -339,7 +332,7 @@ async def get_referrer_top_referrals( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit) @@ -349,7 +342,7 @@ async def get_referrer_top_referrals( ) -@router.get("/referrers/{user_id}/stats/compare", response_model=PeriodComparisonResponse) +@router.get('/referrers/{user_id}/stats/compare', response_model=PeriodComparisonResponse) async def get_referrer_period_comparison( user_id: int, current_days: int = Query(7, ge=1, le=365), @@ -363,18 +356,16 @@ async def get_referrer_period_comparison( user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') - data = await PartnerStatsService.get_referrer_period_comparison( - db, user.id, current_days, previous_days - ) + data = await PartnerStatsService.get_referrer_period_comparison(db, user.id, current_days, previous_days) return PeriodComparisonResponse( - current_period=PeriodData(**data["current_period"]), - previous_period=PeriodData(**data["previous_period"]), + current_period=PeriodData(**data['current_period']), + previous_period=PeriodData(**data['previous_period']), change=PeriodChange( - referrals=ChangeData(**data["change"]["referrals"]), - earnings=ChangeData(**data["change"]["earnings"]), + referrals=ChangeData(**data['change']['referrals']), + earnings=ChangeData(**data['change']['earnings']), ), user_id=user.id, ) diff --git a/app/webapi/routes/pinned_messages.py b/app/webapi/routes/pinned_messages.py index d4b53a05..8eb2c0c1 100644 --- a/app/webapi/routes/pinned_messages.py +++ b/app/webapi/routes/pinned_messages.py @@ -31,6 +31,7 @@ from ..schemas.pinned_messages import ( PinnedMessageUpdateRequest, ) + router = APIRouter() @@ -57,7 +58,7 @@ def _get_bot() -> Bot: ) -@router.get("", response_model=PinnedMessageListResponse) +@router.get('', response_model=PinnedMessageListResponse) async def list_pinned_messages( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), @@ -85,11 +86,11 @@ async def list_pinned_messages( ) -@router.get("/active", response_model=Optional[PinnedMessageResponse]) +@router.get('/active', response_model=Optional[PinnedMessageResponse]) async def get_active_message( token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> Optional[PinnedMessageResponse]: +) -> PinnedMessageResponse | None: """Получить текущее активное закреплённое сообщение.""" msg = await get_active_pinned_message(db) if not msg: @@ -97,26 +98,26 @@ async def get_active_message( return _serialize_pinned_message(msg) -@router.get("/{message_id}", response_model=PinnedMessageResponse) +@router.get('/{message_id}', response_model=PinnedMessageResponse) async def get_pinned_message( message_id: int, token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PinnedMessageResponse: """Получить закреплённое сообщение по ID.""" - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') return _serialize_pinned_message(msg) -@router.post("", response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED) async def create_pinned_message( payload: PinnedMessageCreateRequest, - broadcast: bool = Query(False, description="Разослать сообщение всем пользователям (по умолчанию False — только при /start)"), + broadcast: bool = Query( + False, description='Разослать сообщение всем пользователям (по умолчанию False — только при /start)' + ), token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PinnedMessageBroadcastResponse: @@ -129,10 +130,7 @@ async def create_pinned_message( """ content = payload.content.strip() if not content and not payload.media: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Either content or media must be provided" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided') media_type = payload.media.type if payload.media else None media_file_id = payload.media.file_id if payload.media else None @@ -163,7 +161,7 @@ async def create_pinned_message( ) -@router.patch("/{message_id}", response_model=PinnedMessageResponse) +@router.patch('/{message_id}', response_model=PinnedMessageResponse) async def update_pinned_message( message_id: int, payload: PinnedMessageUpdateRequest, @@ -176,15 +174,14 @@ async def update_pinned_message( Можно обновить контент, медиа и настройки показа. Не делает рассылку — для рассылки используйте POST /pinned-messages/{id}/broadcast. """ - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') if payload.content is not None: from app.utils.validators import sanitize_html, validate_html_tags + sanitized = sanitize_html(payload.content) is_valid, error = validate_html_tags(sanitized) if not is_valid: @@ -192,11 +189,8 @@ async def update_pinned_message( msg.content = sanitized if payload.media is not None: - if payload.media.type not in ("photo", "video"): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Only photo or video media types are supported" - ) + if payload.media.type not in ('photo', 'video'): + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Only photo or video media types are supported') msg.media_type = payload.media.type msg.media_file_id = payload.media.file_id @@ -213,7 +207,7 @@ async def update_pinned_message( return _serialize_pinned_message(msg) -@router.patch("/{message_id}/settings", response_model=PinnedMessageResponse) +@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse) async def update_pinned_message_settings( message_id: int, payload: PinnedMessageSettingsRequest, @@ -226,12 +220,10 @@ async def update_pinned_message_settings( - send_before_menu: показывать до или после меню - send_on_every_start: показывать при каждом /start или только один раз """ - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') if payload.send_before_menu is not None: msg.send_before_menu = payload.send_before_menu @@ -246,10 +238,12 @@ async def update_pinned_message_settings( return _serialize_pinned_message(msg) -@router.post("/{message_id}/activate", response_model=PinnedMessageBroadcastResponse) +@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse) async def activate_pinned_message( message_id: int, - broadcast: bool = Query(False, description="Разослать сообщение всем пользователям (по умолчанию False — только при /start)"), + broadcast: bool = Query( + False, description='Разослать сообщение всем пользователям (по умолчанию False — только при /start)' + ), token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PinnedMessageBroadcastResponse: @@ -260,12 +254,10 @@ async def activate_pinned_message( - broadcast=False (по умолчанию): пользователи увидят при следующем /start - broadcast=True: рассылает сообщение всем активным пользователям сразу """ - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') # Деактивируем все активные await db.execute( @@ -293,7 +285,7 @@ async def activate_pinned_message( ) -@router.post("/{message_id}/broadcast", response_model=PinnedMessageBroadcastResponse) +@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse) async def broadcast_message( message_id: int, token: Any = Depends(require_api_token), @@ -304,12 +296,10 @@ async def broadcast_message( Работает для любого сообщения, независимо от его статуса активности. """ - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg) @@ -320,11 +310,11 @@ async def broadcast_message( ) -@router.post("/active/deactivate", response_model=Optional[PinnedMessageResponse]) +@router.post('/active/deactivate', response_model=Optional[PinnedMessageResponse]) async def deactivate_active_message( token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), -) -> Optional[PinnedMessageResponse]: +) -> PinnedMessageResponse | None: """ Деактивировать текущее активное закреплённое сообщение. @@ -336,7 +326,7 @@ async def deactivate_active_message( return _serialize_pinned_message(msg) -@router.post("/active/unpin", response_model=PinnedMessageUnpinResponse) +@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse) async def unpin_active_message( token: Any = Depends(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -354,7 +344,7 @@ async def unpin_active_message( ) -@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None) async def delete_pinned_message( message_id: int, token: Any = Depends(require_api_token), @@ -366,12 +356,10 @@ async def delete_pinned_message( Если сообщение активно, сначала будет деактивировано. Не открепляет сообщение у пользователей — для этого используйте /active/unpin. """ - result = await db.execute( - select(PinnedMessage).where(PinnedMessage.id == message_id) - ) + result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id)) msg = result.scalar_one_or_none() if not msg: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Pinned message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found') await db.delete(msg) await db.commit() diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py index 68fc278e..016a3dac 100644 --- a/app/webapi/routes/polls.py +++ b/app/webapi/routes/polls.py @@ -40,13 +40,14 @@ from ..schemas.polls import ( PollQuestionResponse, PollQuestionStats, PollResponsesListResponse, + PollSendRequest, + PollSendResponse, PollStatisticsResponse, PollSummaryResponse, PollUserResponse, - PollSendRequest, - PollSendResponse, ) + router = APIRouter() @@ -63,10 +64,7 @@ def _serialize_option(option: PollOption) -> PollQuestionOptionResponse: def _serialize_question(question: PollQuestion) -> PollQuestionResponse: - options = [ - _serialize_option(option) - for option in sorted(question.options, key=lambda item: item.order) - ] + options = [_serialize_option(option) for option in sorted(question.options, key=lambda item: item.order)] return PollQuestionResponse( id=question.id, text=question.text, @@ -79,9 +77,9 @@ def _serialize_poll_summary( poll: Poll, responses_count: int | None = None, ) -> PollSummaryResponse: - questions = getattr(poll, "questions", []) + questions = getattr(poll, 'questions', []) if responses_count is None: - responses = getattr(poll, "responses", []) + responses = getattr(poll, 'responses', []) responses_count = len(responses) return PollSummaryResponse( id=poll.id, @@ -98,10 +96,7 @@ def _serialize_poll_summary( def _serialize_poll_detail(poll: Poll) -> PollDetailResponse: - questions = [ - _serialize_question(question) - for question in sorted(poll.questions, key=lambda item: item.order) - ] + questions = [_serialize_question(question) for question in sorted(poll.questions, key=lambda item: item.order)] return PollDetailResponse( id=poll.id, title=poll.title, @@ -116,8 +111,8 @@ def _serialize_poll_detail(poll: Poll) -> PollDetailResponse: def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse: - question = getattr(answer, "question", None) - option = getattr(answer, "option", None) + question = getattr(answer, 'question', None) + option = getattr(answer, 'option', None) return PollAnswerResponse( question_id=question.id if question else answer.question_id, question_text=question.text if question else None, @@ -128,16 +123,13 @@ def _serialize_answer(answer: PollAnswer) -> PollAnswerResponse: def _serialize_user_response(response: PollResponse) -> PollUserResponse: - user = getattr(response, "user", None) - answers = [ - _serialize_answer(answer) - for answer in sorted(response.answers, key=lambda item: item.created_at) - ] + user = getattr(response, 'user', None) + answers = [_serialize_answer(answer) for answer in sorted(response.answers, key=lambda item: item.created_at)] return PollUserResponse( id=response.id, - user_id=getattr(user, "id", None), - user_telegram_id=getattr(user, "telegram_id", None), - user_username=getattr(user, "username", None), + user_id=getattr(user, 'id', None), + user_telegram_id=getattr(user, 'telegram_id', None), + user_username=getattr(user, 'username', None), sent_at=response.sent_at, started_at=response.started_at, completed_at=response.completed_at, @@ -148,7 +140,7 @@ def _serialize_user_response(response: PollResponse) -> PollUserResponse: ) -@router.get("", response_model=PollListResponse) +@router.get('', response_model=PollListResponse) async def list_polls( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -162,11 +154,7 @@ async def list_polls( return PollListResponse(items=[], total=0, limit=limit, offset=offset) result = await db.execute( - select(Poll) - .options(selectinload(Poll.questions)) - .order_by(Poll.created_at.desc()) - .offset(offset) - .limit(limit) + select(Poll).options(selectinload(Poll.questions)).order_by(Poll.created_at.desc()).offset(offset).limit(limit) ) polls = result.scalars().unique().all() @@ -182,19 +170,14 @@ async def list_polls( responses_counts = {poll_id: count for poll_id, count in counts_result.all()} return PollListResponse( - items=[ - _serialize_poll_summary( - poll, responses_counts.get(poll.id, 0) - ) - for poll in polls - ], + items=[_serialize_poll_summary(poll, responses_counts.get(poll.id, 0)) for poll in polls], total=total, limit=limit, offset=offset, ) -@router.get("/{poll_id}", response_model=PollDetailResponse) +@router.get('/{poll_id}', response_model=PollDetailResponse) async def get_poll( poll_id: int, _: Any = Security(require_api_token), @@ -202,12 +185,12 @@ async def get_poll( ) -> PollDetailResponse: poll = await get_poll_by_id(db, poll_id) if not poll: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Poll not found') return _serialize_poll_detail(poll) -@router.post("", response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=PollDetailResponse, status_code=status.HTTP_201_CREATED) async def create_poll_endpoint( payload: PollCreateRequest, _: Any = Security(require_api_token), @@ -222,8 +205,8 @@ async def create_poll_endpoint( created_by=None, questions=[ { - "text": question.text, - "options": [option.text for option in question.options], + 'text': question.text, + 'options': [option.text for option in question.options], } for question in payload.questions ], @@ -233,7 +216,7 @@ async def create_poll_endpoint( return _serialize_poll_detail(poll) -@router.delete("/{poll_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/{poll_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_poll( poll_id: int, _: Any = Security(require_api_token), @@ -241,12 +224,12 @@ async def delete_poll( ) -> Response: success = await delete_poll_record(db, poll_id) if not success: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Poll not found') return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.get("/{poll_id}/stats", response_model=PollStatisticsResponse) +@router.get('/{poll_id}/stats', response_model=PollStatisticsResponse) async def get_poll_stats( poll_id: int, _: Any = Security(require_api_token), @@ -254,39 +237,39 @@ async def get_poll_stats( ) -> PollStatisticsResponse: poll = await db.get(Poll, poll_id) if not poll: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Poll not found') stats = await get_poll_statistics(db, poll_id) formatted_questions = [ PollQuestionStats( - id=question_data["id"], - text=question_data["text"], - order=question_data["order"], + id=question_data['id'], + text=question_data['text'], + order=question_data['order'], options=[ PollOptionStats( - id=option_data["id"], - text=option_data["text"], - count=option_data["count"], + id=option_data['id'], + text=option_data['text'], + count=option_data['count'], ) - for option_data in question_data.get("options", []) + for option_data in question_data.get('options', []) ], ) - for question_data in stats.get("questions", []) + for question_data in stats.get('questions', []) ] return PollStatisticsResponse( poll_id=poll.id, poll_title=poll.title, - total_responses=stats.get("total_responses", 0), - completed_responses=stats.get("completed_responses", 0), - reward_sum_kopeks=stats.get("reward_sum_kopeks", 0), - reward_sum_rubles=_format_price(stats.get("reward_sum_kopeks", 0)), + total_responses=stats.get('total_responses', 0), + completed_responses=stats.get('completed_responses', 0), + reward_sum_kopeks=stats.get('reward_sum_kopeks', 0), + reward_sum_rubles=_format_price(stats.get('reward_sum_kopeks', 0)), questions=formatted_questions, ) -@router.get("/{poll_id}/responses", response_model=PollResponsesListResponse) +@router.get('/{poll_id}/responses', response_model=PollResponsesListResponse) async def get_poll_responses( poll_id: int, _: Any = Security(require_api_token), @@ -296,7 +279,7 @@ async def get_poll_responses( ) -> PollResponsesListResponse: poll_exists = await db.get(Poll, poll_id) if not poll_exists: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Poll not found') responses, total = await get_poll_responses_with_answers( db, @@ -315,7 +298,7 @@ async def get_poll_responses( ) -@router.post("/{poll_id}/send", response_model=PollSendResponse) +@router.post('/{poll_id}/send', response_model=PollSendResponse) async def send_poll( poll_id: int, payload: PollSendRequest, @@ -324,14 +307,14 @@ async def send_poll( ) -> PollSendResponse: poll = await get_poll_by_id(db, poll_id) if not poll: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Poll not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Poll not found') target = payload.target.strip() if not target: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Target must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Target must not be empty') - if target.startswith("custom_"): - users = await get_custom_users(db, target.replace("custom_", "")) + if target.startswith('custom_'): + users = await get_custom_users(db, target.replace('custom_', '')) else: users = await get_target_users(db, target) @@ -360,8 +343,8 @@ async def send_poll( return PollSendResponse( poll_id=poll_id, target=target, - sent=result.get("sent", 0), - failed=result.get("failed", 0), - skipped=result.get("skipped", 0), - total=result.get("total", 0), + sent=result.get('sent', 0), + failed=result.get('failed', 0), + skipped=result.get('skipped', 0), + total=result.get('total', 0), ) diff --git a/app/webapi/routes/promo_groups.py b/app/webapi/routes/promo_groups.py index 07ffc9e8..d13ed552 100644 --- a/app/webapi/routes/promo_groups.py +++ b/app/webapi/routes/promo_groups.py @@ -25,6 +25,7 @@ from ..schemas.promo_groups import ( PromoGroupUpdateRequest, ) + router = APIRouter() @@ -52,12 +53,12 @@ def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse: apply_discounts_to_addons=group.apply_discounts_to_addons, is_default=group.is_default, members_count=members_count, - created_at=getattr(group, "created_at", None), - updated_at=getattr(group, "updated_at", None), + created_at=getattr(group, 'created_at', None), + updated_at=getattr(group, 'updated_at', None), ) -@router.get("", response_model=PromoGroupListResponse, response_model_exclude_none=True) +@router.get('', response_model=PromoGroupListResponse, response_model_exclude_none=True) async def list_promo_groups( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -79,7 +80,7 @@ async def list_promo_groups( ) -@router.get("/{group_id}", response_model=PromoGroupResponse, response_model_exclude_none=True) +@router.get('/{group_id}', response_model=PromoGroupResponse, response_model_exclude_none=True) async def get_promo_group( group_id: int, _: Any = Security(require_api_token), @@ -87,14 +88,14 @@ async def get_promo_group( ) -> 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") + 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, response_model_exclude_none=True, status_code=status.HTTP_201_CREATED, @@ -110,23 +111,23 @@ async def create_promo_group_endpoint( 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, - ) + 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", + 'Promo group with this name already exists', ) from exc return _serialize(group, members_count=0) @router.patch( - "/{group_id}", + '/{group_id}', response_model=PromoGroupResponse, response_model_exclude_none=True, ) @@ -138,7 +139,7 @@ async def update_promo_group_endpoint( ) -> 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") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found') try: group = await update_promo_group( @@ -147,23 +148,23 @@ async def update_promo_group_endpoint( 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, - ) + 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", + '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) +@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), @@ -171,10 +172,10 @@ async def delete_promo_group_endpoint( ) -> 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") + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group') return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/promo_offers.py b/app/webapi/routes/promo_offers.py index d7f40cf7..a883dfab 100644 --- a/app/webapi/routes/promo_offers.py +++ b/app/webapi/routes/promo_offers.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -11,7 +11,6 @@ from app.database.crud.discount_offer import ( list_discount_offers, upsert_discount_offer, ) -from app.handlers.admin.messages import get_custom_users, get_target_users from app.database.crud.promo_offer_log import list_promo_offer_logs from app.database.crud.promo_offer_template import ( get_promo_offer_template_by_id, @@ -20,6 +19,7 @@ from app.database.crud.promo_offer_template import ( ) from app.database.crud.user import get_user_by_telegram_id from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, Subscription, User +from app.handlers.admin.messages import get_custom_users, get_target_users from ..dependencies import get_db_session, require_api_token from ..schemas.promo_offers import ( @@ -38,10 +38,11 @@ from ..schemas.promo_offers import ( PromoOfferUserInfo, ) + router = APIRouter() -def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: +def _serialize_user(user: User | None) -> PromoOfferUserInfo | None: if not user: return None @@ -51,11 +52,11 @@ def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]: username=user.username, first_name=user.first_name, last_name=user.last_name, - full_name=getattr(user, "full_name", None), + full_name=getattr(user, 'full_name', None), ) -def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[PromoOfferSubscriptionInfo]: +def _serialize_subscription(subscription: Subscription | None) -> PromoOfferSubscriptionInfo | None: if not subscription: return None @@ -84,8 +85,8 @@ def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse: extra_data=offer.extra_data or {}, created_at=offer.created_at, updated_at=offer.updated_at, - user=_serialize_user(getattr(offer, "user", None)), - subscription=_serialize_subscription(getattr(offer, "subscription", None)), + user=_serialize_user(getattr(offer, 'user', None)), + subscription=_serialize_subscription(getattr(offer, 'subscription', None)), ) @@ -110,10 +111,10 @@ def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateRespo def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse: - user_info = _serialize_user(getattr(entry, "user", None)) + user_info = _serialize_user(getattr(entry, 'user', None)) - offer = getattr(entry, "offer", None) - offer_info: Optional[PromoOfferLogOfferInfo] = None + offer = getattr(entry, 'offer', None) + offer_info: PromoOfferLogOfferInfo | None = None if offer: offer_info = PromoOfferLogOfferInfo( id=offer.id, @@ -143,33 +144,33 @@ def _build_log_response(entry: PromoOfferLog) -> PromoOfferLogResponse: async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]: normalized = target.strip().lower() - if normalized.startswith("custom_"): - criteria = normalized[len("custom_"):] + if normalized.startswith('custom_'): + criteria = normalized[len('custom_') :] return await get_custom_users(db, criteria) return await get_target_users(db, normalized) -@router.get("", response_model=PromoOfferListResponse) +@router.get('', response_model=PromoOfferListResponse) async def list_promo_offers( _: 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(None, ge=1), - telegram_id: Optional[int] = Query(None, ge=1), - notification_type: Optional[str] = Query(None, min_length=1), - is_active: Optional[bool] = Query(None), + user_id: int | None = Query(None, ge=1), + telegram_id: int | None = Query(None, ge=1), + notification_type: str | None = Query(None, min_length=1), + is_active: bool | None = Query(None), ) -> PromoOfferListResponse: resolved_user_id = user_id if telegram_id is not None: user = await get_user_by_telegram_id(db, telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, detail='User not found') if resolved_user_id and resolved_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="telegram_id does not match the provided user_id", + detail='telegram_id does not match the provided user_id', ) resolved_user_id = user.id @@ -197,52 +198,52 @@ async def list_promo_offers( ) -@router.post("", response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=PromoOfferResponse, status_code=status.HTTP_201_CREATED) async def create_promo_offer( payload: PromoOfferCreateRequest, _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> PromoOfferResponse: if payload.discount_percent < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'discount_percent must be non-negative') if payload.bonus_amount_kopeks < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'bonus_amount_kopeks must be non-negative') if payload.valid_hours <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_hours must be positive') if not payload.notification_type.strip(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'notification_type must not be empty') if not payload.effect_type.strip(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'effect_type must not be empty') target_user_id = payload.user_id - user: Optional[User] = None + user: User | None = None if payload.telegram_id is not None: user = await get_user_by_telegram_id(db, payload.telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') if target_user_id and target_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Provided user_id does not match telegram_id", + 'Provided user_id does not match telegram_id', ) target_user_id = user.id if target_user_id is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "user_id or telegram_id is required") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'user_id or telegram_id is required') if user is None: user = await db.get(User, target_user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') if payload.subscription_id is not None: subscription = await db.get(Subscription, payload.subscription_id) if not subscription: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Subscription not found') if subscription.user_id != target_user_id: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription does not belong to the user") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Subscription does not belong to the user') offer = await upsert_discount_offer( db, @@ -256,13 +257,13 @@ async def create_promo_offer( extra_data=payload.extra_data, ) - await db.refresh(offer, attribute_names=["user", "subscription"]) + await db.refresh(offer, attribute_names=['user', 'subscription']) return _serialize_offer(offer) @router.post( - "/broadcast", + '/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED, ) @@ -272,15 +273,15 @@ async def broadcast_promo_offers( db: AsyncSession = Depends(get_db_session), ) -> PromoOfferBroadcastResponse: if payload.discount_percent < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'discount_percent must be non-negative') if payload.bonus_amount_kopeks < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'bonus_amount_kopeks must be non-negative') if payload.valid_hours <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_hours must be positive') if not payload.notification_type.strip(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "notification_type must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'notification_type must not be empty') if not payload.effect_type.strip(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "effect_type must not be empty") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'effect_type must not be empty') recipients: dict[int, User] = {} @@ -290,16 +291,16 @@ async def broadcast_promo_offers( recipients.update({user.id: user for user in users if user and user.id}) target_user_id = payload.user_id - user: Optional[User] = None + user: User | None = None if payload.telegram_id is not None: user = await get_user_by_telegram_id(db, payload.telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') if target_user_id and target_user_id != user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Provided user_id does not match telegram_id", + 'Provided user_id does not match telegram_id', ) target_user_id = user.id @@ -308,29 +309,29 @@ async def broadcast_promo_offers( if user is None: user = await db.get(User, target_user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') recipients[target_user_id] = user if not recipients: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Пустая аудитория: укажите target или конкретного пользователя", + 'Пустая аудитория: укажите target или конкретного пользователя', ) if payload.subscription_id is not None: if len(recipients) > 1: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "subscription_id можно использовать только при отправке одному пользователю", + 'subscription_id можно использовать только при отправке одному пользователю', ) sole_user = next(iter(recipients.values())) subscription = await db.get(Subscription, payload.subscription_id) if not subscription: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Subscription not found') if subscription.user_id != sole_user.id: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Subscription does not belong to the user", + 'Subscription does not belong to the user', ) created_offers = 0 @@ -356,16 +357,16 @@ async def broadcast_promo_offers( ) -@router.get("/logs", response_model=PromoOfferLogListResponse) +@router.get('/logs', response_model=PromoOfferLogListResponse) async def get_promo_offer_logs( _: 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(None, ge=1), - offer_id: Optional[int] = Query(None, ge=1), - action: Optional[str] = Query(None, min_length=1), - source: Optional[str] = Query(None, min_length=1), + user_id: int | None = Query(None, ge=1), + offer_id: int | None = Query(None, ge=1), + action: str | None = Query(None, min_length=1), + source: str | None = Query(None, min_length=1), ) -> PromoOfferLogListResponse: logs, total = await list_promo_offer_logs( db, @@ -385,7 +386,7 @@ async def get_promo_offer_logs( ) -@router.get("/templates", response_model=PromoOfferTemplateListResponse) +@router.get('/templates', response_model=PromoOfferTemplateListResponse) async def list_promo_offer_templates_endpoint( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -394,7 +395,7 @@ async def list_promo_offer_templates_endpoint( return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates]) -@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse) async def get_promo_offer_template_endpoint( template_id: int, _: Any = Security(require_api_token), @@ -402,11 +403,11 @@ async def get_promo_offer_template_endpoint( ) -> PromoOfferTemplateResponse: template = await get_promo_offer_template_by_id(db, template_id) if not template: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo offer template not found') return _serialize_template(template) -@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse) +@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse) async def update_promo_offer_template_endpoint( template_id: int, payload: PromoOfferTemplateUpdateRequest, @@ -415,18 +416,18 @@ async def update_promo_offer_template_endpoint( ) -> PromoOfferTemplateResponse: template = await get_promo_offer_template_by_id(db, template_id) if not template: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer template not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo offer template not found') if payload.valid_hours is not None and payload.valid_hours <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_hours must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_hours must be positive') if payload.active_discount_hours is not None and payload.active_discount_hours <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "active_discount_hours must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'active_discount_hours must be positive') if payload.test_duration_hours is not None and payload.test_duration_hours <= 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "test_duration_hours must be positive") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'test_duration_hours must be positive') if payload.discount_percent is not None and payload.discount_percent < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "discount_percent must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'discount_percent must be non-negative') if payload.bonus_amount_kopeks is not None and payload.bonus_amount_kopeks < 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "bonus_amount_kopeks must be non-negative") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'bonus_amount_kopeks must be non-negative') if payload.test_squad_uuids is not None: normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()] @@ -451,7 +452,7 @@ async def update_promo_offer_template_endpoint( return _serialize_template(updated_template) -@router.get("/{offer_id}", response_model=PromoOfferResponse) +@router.get('/{offer_id}', response_model=PromoOfferResponse) async def get_promo_offer_endpoint( offer_id: int, _: Any = Security(require_api_token), @@ -459,6 +460,6 @@ async def get_promo_offer_endpoint( ) -> PromoOfferResponse: offer = await get_offer_by_id(db, offer_id) if not offer: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo offer not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo offer not found') return _serialize_offer(offer) diff --git a/app/webapi/routes/promocodes.py b/app/webapi/routes/promocodes.py index 11e4e312..2acca27c 100644 --- a/app/webapi/routes/promocodes.py +++ b/app/webapi/routes/promocodes.py @@ -1,7 +1,7 @@ from __future__ import annotations -from datetime import datetime, timezone -from typing import Any, Optional +from datetime import UTC, datetime +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.ext.asyncio import AsyncSession @@ -28,15 +28,16 @@ from ..schemas.promocodes import ( PromoCodeUpdateRequest, ) + router = APIRouter() -def _normalize_datetime(value: Optional[datetime]) -> Optional[datetime]: +def _normalize_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is not None and value.utcoffset() is not None: - return value.astimezone(timezone.utc).replace(tzinfo=None) + return value.astimezone(UTC).replace(tzinfo=None) if value.tzinfo is not None: return value.replace(tzinfo=None) @@ -70,9 +71,9 @@ 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), + 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, ) @@ -80,24 +81,27 @@ def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse: 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") + 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") + 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 ( + 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") + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty') if payload.type is not None: new_type = payload.type @@ -105,47 +109,35 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo new_type = PromoCodeType(promocode.type) balance_bonus = ( - payload.balance_bonus_kopeks - if payload.balance_bonus_kopeks is not None - else promocode.balance_bonus_kopeks + 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 + 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") + 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") + 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 - ) + 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") + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses') -@router.get("", response_model=PromoCodeListResponse) +@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), + is_active: bool | None = 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) @@ -158,7 +150,7 @@ async def list_promocodes( ) -@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse) +@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse) async def get_promocode( promocode_id: int, _: Any = Depends(require_api_token), @@ -166,24 +158,21 @@ async def get_promocode( ) -> PromoCodeDetailResponse: promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + 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", []) - ] + 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), + 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) +@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED) async def create_promocode_endpoint( payload: PromoCodeCreateRequest, _: Any = Depends(require_api_token), @@ -197,13 +186,9 @@ async def create_promocode_endpoint( 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") + 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 - ) + creator_id = payload.created_by if payload.created_by is not None and payload.created_by > 0 else None promocode = await create_promocode( db, @@ -218,11 +203,11 @@ async def create_promocode_endpoint( update_fields = {} if normalized_valid_from is not None: - update_fields["valid_from"] = normalized_valid_from + 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 + update_fields['is_active'] = payload.is_active if normalized_valid_until is not None: - update_fields["valid_until"] = normalized_valid_until + update_fields['valid_until'] = normalized_valid_until if update_fields: promocode = await update_promocode(db, promocode, **update_fields) @@ -230,7 +215,7 @@ async def create_promocode_endpoint( return _serialize_promocode(promocode) -@router.patch("/{promocode_id}", response_model=PromoCodeResponse) +@router.patch('/{promocode_id}', response_model=PromoCodeResponse) async def update_promocode_endpoint( promocode_id: int, payload: PromoCodeUpdateRequest, @@ -239,7 +224,7 @@ async def update_promocode_endpoint( ) -> PromoCodeResponse: promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found') _validate_update_payload(payload, promocode) @@ -250,29 +235,29 @@ async def update_promocode_endpoint( 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 + 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 + updates['type'] = payload.type.value if payload.balance_bonus_kopeks is not None: - updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks + updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks if payload.subscription_days is not None: - updates["subscription_days"] = payload.subscription_days + updates['subscription_days'] = payload.subscription_days if payload.max_uses is not None: - updates["max_uses"] = payload.max_uses + updates['max_uses'] = payload.max_uses if payload.valid_from is not None: - updates["valid_from"] = _normalize_datetime(payload.valid_from) + updates['valid_from'] = _normalize_datetime(payload.valid_from) if payload.valid_until is not None: - updates["valid_until"] = _normalize_datetime(payload.valid_until) + updates['valid_until'] = _normalize_datetime(payload.valid_until) if payload.is_active is not None: - updates["is_active"] = payload.is_active + updates['is_active'] = payload.is_active if not updates: return _serialize_promocode(promocode) @@ -282,7 +267,7 @@ async def update_promocode_endpoint( @router.delete( - "/{promocode_id}", + '/{promocode_id}', status_code=status.HTTP_204_NO_CONTENT, response_class=Response, ) @@ -293,10 +278,10 @@ async def delete_promocode_endpoint( ) -> Response: promocode = await get_promocode_by_id(db, promocode_id) if not promocode: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found") + 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Failed to delete promo code') return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/remnawave.py b/app/webapi/routes/remnawave.py index b85193d6..a09d65ae 100644 --- a/app/webapi/routes/remnawave.py +++ b/app/webapi/routes/remnawave.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession @@ -33,11 +33,12 @@ from ..schemas.remnawave import ( RemnaWaveSquadMigrationStats, RemnaWaveSquadUpdateRequest, RemnaWaveStatusResponse, - RemnaWaveSystemStatsResponse, RemnaWaveSyncFromPanelRequest, + RemnaWaveSystemStatsResponse, RemnaWaveUserTrafficResponse, ) + try: # pragma: no cover - импорт может не работать без optional-зависимостей from app.services.remnawave_service import ( # type: ignore RemnaWaveConfigurationError, @@ -56,60 +57,60 @@ else: router = APIRouter() -def _get_service() -> "RemnaWaveServiceType": +def _get_service() -> RemnaWaveServiceType: if RemnaWaveService is None: # pragma: no cover - зависимость не доступна raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="RemnaWave сервис недоступен", + detail='RemnaWave сервис недоступен', ) return RemnaWaveService() -def _ensure_service_configured(service: "RemnaWaveServiceType") -> None: +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 сервис недоступен", + detail='RemnaWave сервис недоступен', ) if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=service.configuration_error or "RemnaWave API не настроен", + detail=service.configuration_error or 'RemnaWave API не настроен', ) -def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode: +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"), - last_status_change=_parse_last_updated(node_data.get("last_status_change")), - last_status_message=node_data.get("last_status_message"), - xray_uptime=node_data.get("xray_uptime"), - is_traffic_tracking_active=bool(node_data.get("is_traffic_tracking_active", False)), - traffic_reset_day=node_data.get("traffic_reset_day"), - notify_percent=node_data.get("notify_percent"), - consumption_multiplier=float(node_data.get("consumption_multiplier", 1.0)), - cpu_count=node_data.get("cpu_count"), - cpu_model=node_data.get("cpu_model"), - total_ram=node_data.get("total_ram"), - created_at=_parse_last_updated(node_data.get("created_at")), - updated_at=_parse_last_updated(node_data.get("updated_at")), - provider_uuid=node_data.get("provider_uuid"), + 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'), + last_status_change=_parse_last_updated(node_data.get('last_status_change')), + last_status_message=node_data.get('last_status_message'), + xray_uptime=node_data.get('xray_uptime'), + is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)), + traffic_reset_day=node_data.get('traffic_reset_day'), + notify_percent=node_data.get('notify_percent'), + consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)), + cpu_count=node_data.get('cpu_count'), + cpu_model=node_data.get('cpu_model'), + total_ram=node_data.get('total_ram'), + created_at=_parse_last_updated(node_data.get('created_at')), + updated_at=_parse_last_updated(node_data.get('updated_at')), + provider_uuid=node_data.get('provider_uuid'), ) -def _parse_last_updated(value: Any) -> Optional[datetime]: +def _parse_last_updated(value: Any) -> datetime | None: if isinstance(value, datetime): return value if isinstance(value, str): @@ -120,13 +121,13 @@ def _parse_last_updated(value: Any) -> Optional[datetime]: return None -@router.get("/status", response_model=RemnaWaveStatusResponse) +@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_info: RemnaWaveConnectionStatus | None = None connection_result = await service.test_api_connection() if connection_result: @@ -139,7 +140,7 @@ async def get_remnawave_status( ) -@router.get("/system", response_model=RemnaWaveSystemStatsResponse) +@router.get('/system', response_model=RemnaWaveSystemStatsResponse) async def get_system_statistics( _: Any = Security(require_api_token), ) -> RemnaWaveSystemStatsResponse: @@ -147,14 +148,14 @@ async def get_system_statistics( _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") + 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")) + stats['last_updated'] = _parse_last_updated(stats.get('last_updated')) return RemnaWaveSystemStatsResponse(**stats) -@router.get("/nodes", response_model=RemnaWaveNodeListResponse) +@router.get('/nodes', response_model=RemnaWaveNodeListResponse) async def list_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeListResponse: @@ -166,16 +167,16 @@ async def list_nodes( return RemnaWaveNodeListResponse(items=serialized, total=len(serialized)) -@router.get("/nodes/realtime", response_model=List[Dict[str, Any]]) +@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]]: +) -> 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) +@router.get('/nodes/{node_uuid}', response_model=RemnaWaveNode) async def get_node_details( node_uuid: str, _: Any = Security(require_api_token), @@ -185,11 +186,11 @@ async def get_node_details( node = await service.get_node_details(node_uuid) if not node: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Нода не найдена") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Нода не найдена') return _serialize_node(node) -@router.get("/nodes/{node_uuid}/statistics", response_model=RemnaWaveNodeStatisticsResponse) +@router.get('/nodes/{node_uuid}/statistics', response_model=RemnaWaveNodeStatisticsResponse) async def get_node_statistics( node_uuid: str, _: Any = Security(require_api_token), @@ -198,13 +199,13 @@ async def get_node_statistics( _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, "Не удалось получить информацию по ноде") + 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")) + 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, @@ -214,11 +215,11 @@ async def get_node_statistics( ) -@router.get("/nodes/{node_uuid}/usage", response_model=RemnaWaveNodeUsageResponse) +@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), + start: datetime | None = Query(default=None), + end: datetime | None = Query(default=None), _: Any = Security(require_api_token), ) -> RemnaWaveNodeUsageResponse: service = _get_service() @@ -228,13 +229,13 @@ async def get_node_usage_range( start_dt = start or (end_dt - timedelta(days=7)) if start_dt >= end_dt: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Некорректный диапазон дат") + 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) +@router.post('/nodes/{node_uuid}/actions', response_model=RemnaWaveNodeActionResponse) async def manage_node( node_uuid: str, payload: RemnaWaveNodeActionRequest, @@ -246,19 +247,19 @@ async def manage_node( 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 = "Команда перезапуска отправлена" + if payload.action == 'enable': + detail = 'Нода включена' + elif payload.action == 'disable': + detail = 'Нода отключена' + elif payload.action == 'restart': + detail = 'Команда перезапуска отправлена' else: - detail = "Не удалось выполнить действие" + detail = 'Не удалось выполнить действие' return RemnaWaveNodeActionResponse(success=success, detail=detail) -@router.post("/nodes/restart", response_model=RemnaWaveNodeActionResponse) +@router.post('/nodes/restart', response_model=RemnaWaveNodeActionResponse) async def restart_all_nodes( _: Any = Security(require_api_token), ) -> RemnaWaveNodeActionResponse: @@ -266,11 +267,11 @@ async def restart_all_nodes( _ensure_service_configured(service) success = await service.restart_all_nodes() - detail = "Команда перезапуска отправлена" if success else "Не удалось перезапустить ноды" + detail = 'Команда перезапуска отправлена' if success else 'Не удалось перезапустить ноды' return RemnaWaveNodeActionResponse(success=success, detail=detail) -@router.get("/squads", response_model=RemnaWaveSquadListResponse) +@router.get('/squads', response_model=RemnaWaveSquadListResponse) async def list_squads( _: Any = Security(require_api_token), ) -> RemnaWaveSquadListResponse: @@ -282,7 +283,7 @@ async def list_squads( return RemnaWaveSquadListResponse(items=serialized, total=len(serialized)) -@router.get("/squads/{squad_uuid}", response_model=RemnaWaveSquad) +@router.get('/squads/{squad_uuid}', response_model=RemnaWaveSquad) async def get_squad_details( squad_uuid: str, _: Any = Security(require_api_token), @@ -292,11 +293,11 @@ async def get_squad_details( squad = await service.get_squad_details(squad_uuid) if not squad: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Сквад не найден') return RemnaWaveSquad(**squad) -@router.post("/squads", response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED) +@router.post('/squads', response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED) async def create_squad( payload: RemnaWaveSquadCreateRequest, _: Any = Security(require_api_token), @@ -307,13 +308,13 @@ async def create_squad( squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids) success = squad_uuid is not None - detail = "Сквад успешно создан" if success else "Не удалось создать сквад" - data = {"uuid": squad_uuid} if success else None + detail = 'Сквад успешно создан' if success else 'Не удалось создать сквад' + data = {'uuid': squad_uuid} if success else None return RemnaWaveOperationResponse(success=success, detail=detail, data=data) -@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse) +@router.patch('/squads/{squad_uuid}', response_model=RemnaWaveOperationResponse) async def update_squad( squad_uuid: str, payload: RemnaWaveSquadUpdateRequest, @@ -323,7 +324,7 @@ async def update_squad( _ensure_service_configured(service) success = False - detail = "Необходимо указать новые данные" + detail = 'Необходимо указать новые данные' if payload.name is not None or payload.inbound_uuids is not None: success = await service.update_squad( @@ -331,12 +332,12 @@ async def update_squad( name=payload.name, inbounds=payload.inbound_uuids, ) - detail = "Сквад обновлен" if success else "Не удалось обновить сквад" + detail = 'Сквад обновлен' if success else 'Не удалось обновить сквад' return RemnaWaveOperationResponse(success=success, detail=detail) -@router.post("/squads/{squad_uuid}/actions", response_model=RemnaWaveOperationResponse) +@router.post('/squads/{squad_uuid}/actions', response_model=RemnaWaveOperationResponse) async def squad_actions( squad_uuid: str, payload: RemnaWaveSquadActionRequest, @@ -347,32 +348,32 @@ async def squad_actions( action = payload.action success = False - detail = "Неизвестное действие" + detail = 'Неизвестное действие' - if action == "add_all_users": + if action == 'add_all_users': success = await service.add_all_users_to_squad(squad_uuid) - detail = "Пользователи добавлены" if success else "Не удалось добавить пользователей" - elif action == "remove_all_users": + 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": + detail = 'Пользователи удалены' if success else 'Не удалось удалить пользователей' + elif action == 'delete': success = await service.delete_squad(squad_uuid) - detail = "Сквад удален" if success else "Не удалось удалить сквад" - elif action == "rename": + detail = 'Сквад удален' if success else 'Не удалось удалить сквад' + elif action == 'rename': if not payload.name: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать новое имя") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Необходимо указать новое имя') success = await service.rename_squad(squad_uuid, payload.name) - detail = "Сквад переименован" if success else "Не удалось переименовать сквад" - elif action == "update_inbounds": + detail = 'Сквад переименован' if success else 'Не удалось переименовать сквад' + elif action == 'update_inbounds': if not payload.inbound_uuids: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать 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 "Не удалось обновить инбаунды" + detail = 'Инбаунды обновлены' if success else 'Не удалось обновить инбаунды' return RemnaWaveOperationResponse(success=success, detail=detail) -@router.get("/inbounds", response_model=RemnaWaveInboundsResponse) +@router.get('/inbounds', response_model=RemnaWaveInboundsResponse) async def list_inbounds( _: Any = Security(require_api_token), ) -> RemnaWaveInboundsResponse: @@ -383,7 +384,7 @@ async def list_inbounds( return RemnaWaveInboundsResponse(items=inbounds or []) -@router.get("/users/{telegram_id}/traffic", response_model=RemnaWaveUserTrafficResponse) +@router.get('/users/{telegram_id}/traffic', response_model=RemnaWaveUserTrafficResponse) async def get_user_traffic( telegram_id: int, _: Any = Security(require_api_token), @@ -393,12 +394,12 @@ async def get_user_traffic( stats = await service.get_user_traffic_stats(telegram_id) if not stats: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден в RemnaWave") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Пользователь не найден в RemnaWave') return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats) -@router.get("/squads/{squad_uuid}/migration-preview", response_model=RemnaWaveSquadMigrationPreviewResponse) +@router.get('/squads/{squad_uuid}/migration-preview', response_model=RemnaWaveSquadMigrationPreviewResponse) async def preview_squad_migration( squad_uuid: str, _: Any = Security(require_api_token), @@ -409,7 +410,7 @@ async def preview_squad_migration( squad = await get_server_squad_by_uuid(db, squad_uuid) if not squad: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Сквад не найден') users_to_migrate = await count_active_users_for_squad(db, squad_uuid) @@ -422,7 +423,7 @@ async def preview_squad_migration( ) -@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse) +@router.post('/sync/from-panel', response_model=RemnaWaveGenericSyncResponse) async def sync_from_panel( payload: RemnaWaveSyncFromPanelRequest, _: Any = Security(require_api_token), @@ -433,7 +434,7 @@ async def sync_from_panel( try: stats = await service.sync_users_from_panel(db, payload.mode) - detail = "Синхронизация из панели выполнена" + detail = 'Синхронизация из панели выполнена' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) except Exception as exc: # pragma: no cover - точный тип зависит от импорта if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError): @@ -441,7 +442,7 @@ async def sync_from_panel( raise -@router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse) +@router.post('/sync/to-panel', response_model=RemnaWaveGenericSyncResponse) async def sync_to_panel( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -450,11 +451,11 @@ async def sync_to_panel( _ensure_service_configured(service) stats = await service.sync_users_to_panel(db) - detail = "Синхронизация в панель выполнена" + detail = 'Синхронизация в панель выполнена' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) -@router.post("/sync/subscriptions/validate", response_model=RemnaWaveGenericSyncResponse) +@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), @@ -463,11 +464,11 @@ async def validate_and_fix_subscriptions( _ensure_service_configured(service) stats = await service.validate_and_fix_subscriptions(db) - detail = "Подписки проверены" + detail = 'Подписки проверены' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) -@router.post("/sync/subscriptions/cleanup", response_model=RemnaWaveGenericSyncResponse) +@router.post('/sync/subscriptions/cleanup', response_model=RemnaWaveGenericSyncResponse) async def cleanup_orphaned_subscriptions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -476,11 +477,11 @@ async def cleanup_orphaned_subscriptions( _ensure_service_configured(service) stats = await service.cleanup_orphaned_subscriptions(db) - detail = "Очистка завершена" + detail = 'Очистка завершена' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) -@router.post("/sync/subscriptions/statuses", response_model=RemnaWaveGenericSyncResponse) +@router.post('/sync/subscriptions/statuses', response_model=RemnaWaveGenericSyncResponse) async def sync_subscription_statuses( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -489,11 +490,11 @@ async def sync_subscription_statuses( _ensure_service_configured(service) stats = await service.sync_subscription_statuses(db) - detail = "Статусы подписок синхронизированы" + detail = 'Статусы подписок синхронизированы' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats) -@router.get("/sync/recommendations", response_model=RemnaWaveGenericSyncResponse) +@router.get('/sync/recommendations', response_model=RemnaWaveGenericSyncResponse) async def get_sync_recommendations( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -502,11 +503,11 @@ async def get_sync_recommendations( _ensure_service_configured(service) data = await service.get_sync_recommendations(db) - detail = "Рекомендации получены" + detail = 'Рекомендации получены' return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data) -@router.post("/squads/migrate", response_model=RemnaWaveSquadMigrationResponse) +@router.post('/squads/migrate', response_model=RemnaWaveSquadMigrationResponse) async def migrate_squad( payload: RemnaWaveSquadMigrationRequest, _: Any = Security(require_api_token), @@ -519,15 +520,15 @@ async def migrate_squad( target_uuid = payload.target_uuid.strip() if source_uuid == target_uuid: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Источник и назначение совпадают") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Источник и назначение совпадают') source = await get_server_squad_by_uuid(db, source_uuid) if not source: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-источник не найден") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Сквад-источник не найден') target = await get_server_squad_by_uuid(db, target_uuid) if not target: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад-назначение не найден") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Сквад-назначение не найден') try: result = await service.migrate_squad_users( @@ -538,24 +539,24 @@ async def migrate_squad( except RemnaWaveConfigurationError as exc: # pragma: no cover - зависит от окружения raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc - if not result.get("success"): - detail = result.get("message") or "Не удалось выполнить переезд" + if not result.get('success'): + detail = result.get('message') or 'Не удалось выполнить переезд' return RemnaWaveSquadMigrationResponse( success=False, detail=detail, - error=result.get("error"), + error=result.get('error'), ) stats = RemnaWaveSquadMigrationStats( source_uuid=source.squad_uuid, target_uuid=target.squad_uuid, - total=result.get("total", 0), - updated=result.get("updated", 0), - panel_updated=result.get("panel_updated", 0), - panel_failed=result.get("panel_failed", 0), - source_removed=result.get("source_removed", 0), - target_added=result.get("target_added", 0), + total=result.get('total', 0), + updated=result.get('updated', 0), + panel_updated=result.get('panel_updated', 0), + panel_failed=result.get('panel_failed', 0), + source_removed=result.get('source_removed', 0), + target_added=result.get('target_added', 0), ) - detail = result.get("message") or "Переезд выполнен" + detail = result.get('message') or 'Переезд выполнен' return RemnaWaveSquadMigrationResponse(success=True, detail=detail, data=stats) diff --git a/app/webapi/routes/servers.py b/app/webapi/routes/servers.py index ad4129ee..5a4f510c 100644 --- a/app/webapi/routes/servers.py +++ b/app/webapi/routes/servers.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, List, Optional +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select @@ -39,6 +40,7 @@ from ..schemas.servers import ( ) from ..schemas.users import PromoGroupSummary + try: # pragma: no cover - импорт может провалиться без optional-зависимостей from app.services.remnawave_service import RemnaWaveService # type: ignore except Exception: # pragma: no cover - скрываем функционал, если сервис недоступен @@ -63,7 +65,7 @@ def _serialize_promo_group(group: PromoGroup) -> PromoGroupSummary: 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), + apply_discounts_to_addons=getattr(group, 'apply_discounts_to_addons', True), ) @@ -71,8 +73,8 @@ def _serialize_server(server: ServerSquad) -> ServerResponse: promo_groups = [ _serialize_promo_group(group) for group in sorted( - getattr(server, "allowed_promo_groups", []) or [], - key=lambda pg: pg.name.lower() if getattr(pg, "name", None) else "", + getattr(server, 'allowed_promo_groups', []) or [], + key=lambda pg: pg.name.lower() if getattr(pg, 'name', None) else '', ) ] @@ -90,16 +92,16 @@ def _serialize_server(server: ServerSquad) -> ServerResponse: sort_order=int(server.sort_order or 0), max_users=server.max_users, current_users=int(server.current_users or 0), - created_at=getattr(server, "created_at", None), - updated_at=getattr(server, "updated_at", None), + created_at=getattr(server, 'created_at', None), + updated_at=getattr(server, 'updated_at', None), promo_groups=promo_groups, ) def _serialize_connected_user(user: User) -> ServerConnectedUser: - subscription = getattr(user, "subscription", None) - subscription_status = getattr(subscription, "status", None) - if hasattr(subscription_status, "value"): + subscription = getattr(user, 'subscription', None) + subscription_status = getattr(subscription, 'status', None) + if hasattr(subscription_status, 'value'): subscription_status = subscription_status.value return ServerConnectedUser( @@ -108,12 +110,12 @@ def _serialize_connected_user(user: User) -> ServerConnectedUser: username=user.username, first_name=user.first_name, last_name=user.last_name, - status=getattr(getattr(user, "status", None), "value", user.status), + status=getattr(getattr(user, 'status', None), 'value', user.status), balance_kopeks=int(user.balance_kopeks or 0), balance_rubles=round((user.balance_kopeks or 0) / 100, 2), - subscription_id=getattr(subscription, "id", None), + subscription_id=getattr(subscription, 'id', None), subscription_status=subscription_status, - subscription_end_date=getattr(subscription, "end_date", None), + subscription_end_date=getattr(subscription, 'end_date', None), ) @@ -126,63 +128,59 @@ def _apply_filters( return query -def _get_remnawave_service() -> "RemnaWaveServiceType": +def _get_remnawave_service() -> RemnaWaveServiceType: if RemnaWaveService is None: # pragma: no cover - зависимость не доступна raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="RemnaWave сервис недоступен", + detail='RemnaWave сервис недоступен', ) return RemnaWaveService() -def _ensure_service_configured(service: "RemnaWaveServiceType") -> None: +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 сервис недоступен", + detail='RemnaWave сервис недоступен', ) if not service.is_configured: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=service.configuration_error or "RemnaWave API не настроен", + detail=service.configuration_error or 'RemnaWave API не настроен', ) -async def _validate_promo_group_ids( - db: AsyncSession, promo_group_ids: Iterable[int] -) -> List[int]: +async def _validate_promo_group_ids(db: AsyncSession, promo_group_ids: Iterable[int]) -> list[int]: unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)] if not unique_ids: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Нужно выбрать хотя бы одну промогруппу", + 'Нужно выбрать хотя бы одну промогруппу', ) - result = await db.execute( - select(PromoGroup.id).where(PromoGroup.id.in_(unique_ids)) - ) + result = await db.execute(select(PromoGroup.id).where(PromoGroup.id.in_(unique_ids))) found_ids = result.scalars().all() if not found_ids: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Не найдены промогруппы для обновления сервера", + 'Не найдены промогруппы для обновления сервера', ) return unique_ids -@router.get("", response_model=ServerListResponse) +@router.get('', response_model=ServerListResponse) async def list_servers( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), page: int = Query(1, ge=1), limit: int = Query(50, ge=1, le=200), - available_only: bool = Query(False, alias="available"), - search: Optional[str] = Query(default=None), + available_only: bool = Query(False, alias='available'), + search: str | None = Query(default=None), ) -> ServerListResponse: filters = [] @@ -190,7 +188,7 @@ async def list_servers( filters.append(ServerSquad.is_available.is_(True)) if search: - pattern = f"%{search.lower()}%" + pattern = f'%{search.lower()}%' filters.append( or_( func.lower(ServerSquad.display_name).like(pattern), @@ -214,9 +212,7 @@ async def list_servers( total = await db.scalar(count_query) or 0 - result = await db.execute( - base_query.offset((page - 1) * limit).limit(limit) - ) + result = await db.execute(base_query.offset((page - 1) * limit).limit(limit)) servers = result.scalars().unique().all() return ServerListResponse( @@ -227,7 +223,7 @@ async def list_servers( ) -@router.get("/stats", response_model=ServerStatisticsResponse) +@router.get('/stats', response_model=ServerStatisticsResponse) async def get_servers_statistics( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -235,16 +231,16 @@ async def get_servers_statistics( stats = await get_server_statistics(db) return ServerStatisticsResponse( - total_servers=int(stats.get("total_servers", 0) or 0), - available_servers=int(stats.get("available_servers", 0) or 0), - unavailable_servers=int(stats.get("unavailable_servers", 0) or 0), - servers_with_connections=int(stats.get("servers_with_connections", 0) or 0), - total_revenue_kopeks=int(stats.get("total_revenue_kopeks", 0) or 0), - total_revenue_rubles=float(stats.get("total_revenue_rubles", 0) or 0), + total_servers=int(stats.get('total_servers', 0) or 0), + available_servers=int(stats.get('available_servers', 0) or 0), + unavailable_servers=int(stats.get('unavailable_servers', 0) or 0), + servers_with_connections=int(stats.get('servers_with_connections', 0) or 0), + total_revenue_kopeks=int(stats.get('total_revenue_kopeks', 0) or 0), + total_revenue_rubles=float(stats.get('total_revenue_rubles', 0) or 0), ) -@router.post("", response_model=ServerResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=ServerResponse, status_code=status.HTTP_201_CREATED) async def create_server_endpoint( payload: ServerCreateRequest, _: Any = Security(require_api_token), @@ -254,7 +250,7 @@ async def create_server_endpoint( if existing: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Server with this UUID already exists", + 'Server with this UUID already exists', ) try: @@ -275,14 +271,14 @@ async def create_server_endpoint( except ValueError as error: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') server = await get_server_squad_by_id(db, server.id) assert server is not None return _serialize_server(server) -@router.get("/{server_id}", response_model=ServerResponse) +@router.get('/{server_id}', response_model=ServerResponse) async def get_server_endpoint( server_id: int, _: Any = Security(require_api_token), @@ -290,12 +286,12 @@ async def get_server_endpoint( ) -> ServerResponse: server = await get_server_squad_by_id(db, server_id) if not server: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Server not found') return _serialize_server(server) -@router.patch("/{server_id}", response_model=ServerResponse) +@router.patch('/{server_id}', response_model=ServerResponse) async def update_server_endpoint( server_id: int, payload: ServerUpdateRequest, @@ -304,16 +300,14 @@ async def update_server_endpoint( ) -> ServerResponse: server = await get_server_squad_by_id(db, server_id) if not server: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Server not found') updates = payload.model_dump(exclude_unset=True, by_alias=False) - promo_group_ids = updates.pop("promo_group_ids", None) + promo_group_ids = updates.pop('promo_group_ids', None) - validated_promo_group_ids: Optional[List[int]] = None + validated_promo_group_ids: list[int] | None = None if promo_group_ids is not None: - validated_promo_group_ids = await _validate_promo_group_ids( - db, promo_group_ids - ) + validated_promo_group_ids = await _validate_promo_group_ids(db, promo_group_ids) if updates: server = await update_server_squad(db, server_id, **updates) or server @@ -321,20 +315,18 @@ async def update_server_endpoint( if promo_group_ids is not None: try: assert validated_promo_group_ids is not None - server = await update_server_squad_promo_groups( - db, server_id, validated_promo_group_ids - ) or server + server = await update_server_squad_promo_groups(db, server_id, validated_promo_group_ids) or server except ValueError as error: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') server = await get_server_squad_by_id(db, server_id) assert server is not None return _serialize_server(server) -@router.delete("/{server_id}", response_model=ServerDeleteResponse) +@router.delete('/{server_id}', response_model=ServerDeleteResponse) async def delete_server_endpoint( server_id: int, _: Any = Security(require_api_token), @@ -342,22 +334,22 @@ async def delete_server_endpoint( ) -> ServerDeleteResponse: server = await get_server_squad_by_id(db, server_id) if not server: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Server not found') deleted = await delete_server_squad(db, server_id) if not deleted: raise HTTPException( status.HTTP_400_BAD_REQUEST, - "Server cannot be deleted because it has active connections", + 'Server cannot be deleted because it has active connections', ) - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') - return ServerDeleteResponse(success=True, message="Server deleted") + return ServerDeleteResponse(success=True, message='Server deleted') @router.get( - "/{server_id}/users", + '/{server_id}/users', response_model=ServerConnectedUsersResponse, ) async def get_server_connected_users_endpoint( @@ -369,7 +361,7 @@ async def get_server_connected_users_endpoint( ) -> ServerConnectedUsersResponse: server = await get_server_squad_by_id(db, server_id) if not server: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Server not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Server not found') users = await get_server_connected_users(db, server_id) total = len(users) @@ -383,7 +375,7 @@ async def get_server_connected_users_endpoint( ) -@router.post("/sync", response_model=ServerSyncResponse) +@router.post('/sync', response_model=ServerSyncResponse) async def sync_servers_with_remnawave( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -398,7 +390,7 @@ async def sync_servers_with_remnawave( if squads: created, updated, removed = await sync_with_remnawave(db, squads) - await cache.delete_pattern("available_countries*") + await cache.delete_pattern('available_countries*') return ServerSyncResponse( created=created, @@ -408,11 +400,10 @@ async def sync_servers_with_remnawave( ) -@router.post("/sync-counts", response_model=ServerCountsSyncResponse) +@router.post('/sync-counts', response_model=ServerCountsSyncResponse) async def sync_server_counts( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> ServerCountsSyncResponse: updated = await sync_server_user_counts(db) return ServerCountsSyncResponse(updated=updated) - diff --git a/app/webapi/routes/stats.py b/app/webapi/routes/stats.py index 032c14ce..f553319f 100644 --- a/app/webapi/routes/stats.py +++ b/app/webapi/routes/stats.py @@ -2,15 +2,14 @@ from __future__ import annotations from datetime import datetime -from app.database.crud.referral import get_referral_statistics -from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics -from app.database.crud.transaction import get_transactions_statistics -from app.database.crud.user import get_users_statistics - from fastapi import APIRouter, Depends, Security from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.referral import get_referral_statistics +from app.database.crud.subscription import get_subscriptions_statistics, get_trial_statistics +from app.database.crud.transaction import get_transactions_statistics +from app.database.crud.user import get_users_statistics from app.database.models import ( Subscription, SubscriptionStatus, @@ -24,100 +23,115 @@ from app.database.models import ( from ..dependencies import get_db_session, require_api_token + router = APIRouter() -def _kopeks_to_rubles(value: int | float | None) -> float: +def _kopeks_to_rubles(value: float | None) -> float: return round((value or 0) / 100, 2) async def _get_overview(db: AsyncSession) -> 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 + 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 + 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, + active_subscriptions = ( + await db.scalar( + select(func.count()) + .select_from(Subscription) + .where( + Subscription.status == SubscriptionStatus.ACTIVE.value, + ) ) - ) or 0 + or 0 + ) - expired_subscriptions = await db.scalar( - select(func.count()).select_from(Subscription).where( - Subscription.status == SubscriptionStatus.EXPIRED.value, + expired_subscriptions = ( + await db.scalar( + select(func.count()) + .select_from(Subscription) + .where( + Subscription.status == SubscriptionStatus.EXPIRED.value, + ) ) - ) or 0 + or 0 + ) - pending_tickets = await db.scalar( - select(func.count()).select_from(Ticket).where( - Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]) + pending_tickets = ( + await db.scalar( + select(func.count()) + .select_from(Ticket) + .where(Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])) ) - ) or 0 + 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, + 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 + or 0 + ) return { - "users": { - "total": total_users, - "active": active_users, - "blocked": blocked_users, - "balance_kopeks": int(total_balance_kopeks), - "balance_rubles": _kopeks_to_rubles(total_balance_kopeks), + 'users': { + 'total': total_users, + 'active': active_users, + 'blocked': blocked_users, + 'balance_kopeks': int(total_balance_kopeks), + 'balance_rubles': _kopeks_to_rubles(total_balance_kopeks), }, - "subscriptions": { - "active": active_subscriptions, - "expired": expired_subscriptions, + 'subscriptions': { + 'active': active_subscriptions, + 'expired': expired_subscriptions, }, - "support": { - "open_tickets": pending_tickets, + 'support': { + 'open_tickets': pending_tickets, }, - "payments": { - "today_kopeks": int(today_transactions), - "today_rubles": _kopeks_to_rubles(today_transactions), + 'payments': { + 'today_kopeks': int(today_transactions), + 'today_rubles': _kopeks_to_rubles(today_transactions), }, } @router.get( - "/overview", - summary="Общая статистика", - response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей", + '/overview', + summary='Общая статистика', + response_description='Агрегированные показатели пользователей, подписок, саппорта и платежей', responses={ 200: { - "content": { - "application/json": { - "example": { - "users": { - "total": 12345, - "active": 9876, - "blocked": 321, - "balance_kopeks": 1234567, - "balance_rubles": 12345.67, + 'content': { + 'application/json': { + 'example': { + 'users': { + 'total': 12345, + 'active': 9876, + 'blocked': 321, + 'balance_kopeks': 1234567, + 'balance_rubles': 12345.67, }, - "subscriptions": { - "active": 4321, - "expired": 210, + 'subscriptions': { + 'active': 4321, + 'expired': 210, }, - "support": { - "open_tickets": 42, + 'support': { + 'open_tickets': 42, }, - "payments": { - "today_kopeks": 654321, - "today_rubles": 6543.21, + 'payments': { + 'today_kopeks': 654321, + 'today_rubles': 6543.21, }, } } @@ -133,105 +147,103 @@ async def stats_overview( @router.get( - "/full", - summary="Полная статистика", - response_description="Расширенные показатели пользователей, подписок, платежей и рефералов", + '/full', + summary='Полная статистика', + response_description='Расширенные показатели пользователей, подписок, платежей и рефералов', responses={ 200: { - "content": { - "application/json": { - "example": { - "overview": { - "users": { - "total": 12345, - "active": 9876, - "blocked": 321, - "balance_kopeks": 1234567, - "balance_rubles": 12345.67, + 'content': { + 'application/json': { + 'example': { + 'overview': { + 'users': { + 'total': 12345, + 'active': 9876, + 'blocked': 321, + 'balance_kopeks': 1234567, + 'balance_rubles': 12345.67, }, - "subscriptions": { - "active": 4321, - "expired": 210, + 'subscriptions': { + 'active': 4321, + 'expired': 210, }, - "support": { - "open_tickets": 42, + 'support': { + 'open_tickets': 42, }, - "payments": { - "today_kopeks": 654321, - "today_rubles": 6543.21, + 'payments': { + 'today_kopeks': 654321, + 'today_rubles': 6543.21, }, }, - "users": { - "total_users": 12345, - "active_users": 9876, - "blocked_users": 321, - "new_today": 12, - "new_week": 345, - "new_month": 1234, + 'users': { + 'total_users': 12345, + 'active_users': 9876, + 'blocked_users': 321, + 'new_today': 12, + 'new_week': 345, + 'new_month': 1234, }, - "subscriptions": { - "total_subscriptions": 9876, - "active_subscriptions": 8765, - "trial_subscriptions": 321, - "paid_subscriptions": 8444, - "purchased_today": 12, - "purchased_week": 210, - "purchased_month": 765, - "trial_to_paid_conversion": 42.5, - "renewals_count": 123, - "trial_statistics": { - "used_trials": 555, - "active_trials": 210, - "resettable_trials": 42, + 'subscriptions': { + 'total_subscriptions': 9876, + 'active_subscriptions': 8765, + 'trial_subscriptions': 321, + 'paid_subscriptions': 8444, + 'purchased_today': 12, + 'purchased_week': 210, + 'purchased_month': 765, + 'trial_to_paid_conversion': 42.5, + 'renewals_count': 123, + 'trial_statistics': { + 'used_trials': 555, + 'active_trials': 210, + 'resettable_trials': 42, }, }, - "transactions": { - "period": { - "start_date": "2024-06-01T00:00:00Z", - "end_date": "2024-06-30T23:59:59Z", + 'transactions': { + 'period': { + 'start_date': '2024-06-01T00:00:00Z', + 'end_date': '2024-06-30T23:59:59Z', }, - "totals": { - "income_kopeks": 1234567, - "income_rubles": 12345.67, - "expenses_kopeks": 21000, - "expenses_rubles": 210, - "profit_kopeks": 1213567, - "profit_rubles": 12135.67, - "subscription_income_kopeks": 987654, - "subscription_income_rubles": 9876.54, + 'totals': { + 'income_kopeks': 1234567, + 'income_rubles': 12345.67, + 'expenses_kopeks': 21000, + 'expenses_rubles': 210, + 'profit_kopeks': 1213567, + 'profit_rubles': 12135.67, + 'subscription_income_kopeks': 987654, + 'subscription_income_rubles': 9876.54, }, - "today": { - "transactions_count": 42, - "income_kopeks": 654321, - "income_rubles": 6543.21, + 'today': { + 'transactions_count': 42, + 'income_kopeks': 654321, + 'income_rubles': 6543.21, }, - "by_type": { - "deposit": {"count": 123, "amount": 1234567}, - "withdrawal": {"count": 10, "amount": 21000}, - }, - "by_payment_method": { - "card": {"count": 100, "amount": 1000000} + 'by_type': { + 'deposit': {'count': 123, 'amount': 1234567}, + 'withdrawal': {'count': 10, 'amount': 21000}, }, + 'by_payment_method': {'card': {'count': 100, 'amount': 1000000}}, }, - "referrals": { - "users_with_referrals": 4321, - "active_referrers": 123, - "total_paid_kopeks": 765432, - "total_paid_rubles": 7654.32, - "today_earnings_kopeks": 12345, - "today_earnings_rubles": 123.45, - "week_earnings_kopeks": 23456, - "week_earnings_rubles": 234.56, - "month_earnings_kopeks": 34567, - "month_earnings_rubles": 345.67, - "top_referrers": [ + 'referrals': { + 'users_with_referrals': 4321, + 'active_referrers': 123, + 'total_paid_kopeks': 765432, + 'total_paid_rubles': 7654.32, + 'today_earnings_kopeks': 12345, + 'today_earnings_rubles': 123.45, + 'week_earnings_kopeks': 23456, + 'week_earnings_rubles': 234.56, + 'month_earnings_kopeks': 34567, + 'month_earnings_rubles': 345.67, + 'top_referrers': [ { - "user_id": 123456789, - "display_name": "@testuser", - "username": "testuser", - "telegram_id": 123456789, - "total_earned_kopeks": 54321, - "referrals_count": 42, + 'user_id': 123456789, + 'display_name': '@testuser', + 'username': 'testuser', + 'telegram_id': 123456789, + 'total_earned_kopeks': 54321, + 'referrals_count': 42, } ], }, @@ -253,42 +265,38 @@ async def stats_full( transactions_stats = await get_transactions_statistics(db) referral_stats = await get_referral_statistics(db) - transactions_totals = transactions_stats.get("totals", {}) - transactions_today = transactions_stats.get("today", {}) + transactions_totals = transactions_stats.get('totals', {}) + transactions_today = transactions_stats.get('today', {}) transactions_totals = { **transactions_totals, - "income_rubles": _kopeks_to_rubles(transactions_totals.get("income_kopeks")), - "expenses_rubles": _kopeks_to_rubles(transactions_totals.get("expenses_kopeks")), - "profit_rubles": _kopeks_to_rubles(transactions_totals.get("profit_kopeks")), - "subscription_income_rubles": _kopeks_to_rubles( - transactions_totals.get("subscription_income_kopeks") - ), + 'income_rubles': _kopeks_to_rubles(transactions_totals.get('income_kopeks')), + 'expenses_rubles': _kopeks_to_rubles(transactions_totals.get('expenses_kopeks')), + 'profit_rubles': _kopeks_to_rubles(transactions_totals.get('profit_kopeks')), + 'subscription_income_rubles': _kopeks_to_rubles(transactions_totals.get('subscription_income_kopeks')), } transactions_today = { **transactions_today, - "income_rubles": _kopeks_to_rubles(transactions_today.get("income_kopeks")), + 'income_rubles': _kopeks_to_rubles(transactions_today.get('income_kopeks')), } referral_stats = { **referral_stats, - "total_paid_rubles": _kopeks_to_rubles(referral_stats.get("total_paid_kopeks")), - "today_earnings_rubles": _kopeks_to_rubles( - referral_stats.get("today_earnings_kopeks") - ), - "week_earnings_rubles": _kopeks_to_rubles(referral_stats.get("week_earnings_kopeks")), - "month_earnings_rubles": _kopeks_to_rubles(referral_stats.get("month_earnings_kopeks")), + 'total_paid_rubles': _kopeks_to_rubles(referral_stats.get('total_paid_kopeks')), + 'today_earnings_rubles': _kopeks_to_rubles(referral_stats.get('today_earnings_kopeks')), + 'week_earnings_rubles': _kopeks_to_rubles(referral_stats.get('week_earnings_kopeks')), + 'month_earnings_rubles': _kopeks_to_rubles(referral_stats.get('month_earnings_kopeks')), } return { - "overview": overview, - "users": users_stats, - "subscriptions": {**subscriptions_stats, "trial_statistics": trial_stats}, - "transactions": { + 'overview': overview, + 'users': users_stats, + 'subscriptions': {**subscriptions_stats, 'trial_statistics': trial_stats}, + 'transactions': { **transactions_stats, - "totals": transactions_totals, - "today": transactions_today, + 'totals': transactions_totals, + 'today': transactions_today, }, - "referrals": referral_stats, + 'referrals': referral_stats, } diff --git a/app/webapi/routes/subscription_events.py b/app/webapi/routes/subscription_events.py index 073021fd..caabc055 100644 --- a/app/webapi/routes/subscription_events.py +++ b/app/webapi/routes/subscription_events.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import Any, Iterable, Optional +from collections.abc import Iterable +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database.models import Subscription, SubscriptionEvent, Transaction, User from app.database.crud.subscription_event import ( create_subscription_event, list_subscription_events, ) +from app.database.models import Subscription, SubscriptionEvent, Transaction, User + from ..dependencies import get_db_session, require_api_token from ..schemas.subscription_events import ( SubscriptionEventCreate, @@ -18,19 +20,18 @@ from ..schemas.subscription_events import ( SubscriptionEventResponse, ) + router = APIRouter() async def _get_user_or_error(db: AsyncSession, user_id: int) -> User: user = await db.get(User, user_id) if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found') return user -async def _ensure_subscription_exists( - db: AsyncSession, subscription_id: Optional[int] -) -> None: +async def _ensure_subscription_exists(db: AsyncSession, subscription_id: int | None) -> None: if not subscription_id: return @@ -38,21 +39,19 @@ async def _ensure_subscription_exists( if not subscription: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Subscription not found", + detail='Subscription not found', ) -async def _ensure_transaction_exists(db: AsyncSession, transaction_id: Optional[int]) -> None: +async def _ensure_transaction_exists(db: AsyncSession, transaction_id: int | None) -> None: if not transaction_id: return - transaction_exists = await db.scalar( - select(Transaction.id).where(Transaction.id == transaction_id) - ) + transaction_exists = await db.scalar(select(Transaction.id).where(Transaction.id == transaction_id)) if not transaction_exists: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Transaction not found", + detail='Transaction not found', ) @@ -61,16 +60,16 @@ def _serialize_event(event: SubscriptionEvent) -> SubscriptionEventResponse: extra = event.extra or {} - if event.event_type == "promocode_activation": + if event.event_type == 'promocode_activation': extra = {**extra} - extra.setdefault("balance_before_kopeks", None) - extra.setdefault("balance_after_kopeks", None) + extra.setdefault('balance_before_kopeks', None) + extra.setdefault('balance_after_kopeks', None) return SubscriptionEventResponse( id=event.id, event_type=event.event_type, user_id=event.user_id, - user_full_name=user.full_name if user else "", + user_full_name=user.full_name if user else '', user_username=user.username if user else None, user_telegram_id=user.telegram_id if user else 0, subscription_id=event.subscription_id, @@ -84,7 +83,7 @@ def _serialize_event(event: SubscriptionEvent) -> SubscriptionEventResponse: ) -@router.post("", response_model=SubscriptionEventResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=SubscriptionEventResponse, status_code=status.HTTP_201_CREATED) async def receive_subscription_event( payload: SubscriptionEventCreate, _: Any = Security(require_api_token), @@ -107,21 +106,21 @@ async def receive_subscription_event( extra=payload.extra or None, ) - await db.refresh(event, attribute_names=["user"]) + await db.refresh(event, attribute_names=['user']) event.user = user return _serialize_event(event) -@router.get("", response_model=SubscriptionEventListResponse) +@router.get('', response_model=SubscriptionEventListResponse) async def list_subscription_event_logs( _: 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), - event_types: Optional[Iterable[str]] = Query(default=None, alias="event_type"), - user_id: Optional[int] = Query(default=None), + event_types: Iterable[str] | None = Query(default=None, alias='event_type'), + user_id: int | None = Query(default=None), ) -> SubscriptionEventListResponse: events, total = await list_subscription_events( db, diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py index ac399db7..979a34a2 100644 --- a/app/webapi/routes/subscriptions.py +++ b/app/webapi/routes/subscriptions.py @@ -1,15 +1,13 @@ from __future__ import annotations import logging -from typing import Any, Optional +from typing import Any 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.services.subscription_service import SubscriptionService - from app.config import settings from app.database.crud.server_squad import get_random_trial_squad_uuid from app.database.crud.subscription import ( @@ -21,11 +19,11 @@ from app.database.crud.subscription import ( deactivate_subscription, extend_subscription, get_subscription_by_user_id, - replace_subscription, remove_subscription_squad, + replace_subscription, ) -from app.services.subscription_service import SubscriptionService from app.database.models import Subscription, SubscriptionStatus +from app.services.subscription_service import SubscriptionService from ..dependencies import get_db_session, require_api_token from ..schemas.subscriptions import ( @@ -38,6 +36,7 @@ from ..schemas.subscriptions import ( SubscriptionTrafficRequest, ) + logger = logging.getLogger(__name__) router = APIRouter() @@ -67,7 +66,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse: async def _choose_trial_squads( - db: AsyncSession, requested_squad_uuid: Optional[str], fallback_squads: list[str] + db: AsyncSession, requested_squad_uuid: str | None, fallback_squads: list[str] ) -> list[str]: if requested_squad_uuid: return [requested_squad_uuid] @@ -78,37 +77,35 @@ async def _choose_trial_squads( try: squad_uuid = await get_random_trial_squad_uuid(db) except Exception as error: - logger.error("Failed to select trial squad: %s", error) + logger.error('Failed to select trial squad: %s', error) squad_uuid = None if not squad_uuid: return [] - logger.debug("Selected trial squad %s for subscription replacement", squad_uuid) + logger.debug('Selected trial squad %s for subscription replacement', squad_uuid) return [squad_uuid] 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) + 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") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Subscription not found') return subscription -@router.get("", response_model=list[SubscriptionResponse]) +@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), + status_filter: SubscriptionStatus | None = Query(default=None, alias='status'), + user_id: int | None = Query(default=None), + is_trial: bool | None = Query(default=None), ) -> list[SubscriptionResponse]: query = select(Subscription).options(selectinload(Subscription.user)) @@ -125,7 +122,7 @@ async def list_subscriptions( return [_serialize_subscription(sub) for sub in subscriptions] -@router.get("/{subscription_id}", response_model=SubscriptionResponse) +@router.get('/{subscription_id}', response_model=SubscriptionResponse) async def get_subscription( subscription_id: int, _: Any = Security(require_api_token), @@ -135,7 +132,7 @@ async def get_subscription( return _serialize_subscription(subscription) -@router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED) async def create_subscription( payload: SubscriptionCreateRequest, _: Any = Security(require_api_token), @@ -143,7 +140,7 @@ async def create_subscription( ) -> SubscriptionResponse: existing = await get_subscription_by_user_id(db, payload.user_id) if existing and not payload.replace_existing: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User already has a subscription') forced_devices = None if not settings.is_devices_selection_enabled(): @@ -168,9 +165,7 @@ async def create_subscription( duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, device_limit=( - trial_device_limit - if trial_device_limit is not None - else settings.TRIAL_DEVICE_LIMIT + trial_device_limit if trial_device_limit is not None else settings.TRIAL_DEVICE_LIMIT ), connected_squads=connected_squads, is_trial=True, @@ -187,7 +182,7 @@ async def create_subscription( ) else: if payload.duration_days is None: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'duration_days is required for paid subscriptions') device_limit = payload.device_limit if device_limit is None: if forced_devices is not None: @@ -217,13 +212,9 @@ async def create_subscription( ) subscription_service = SubscriptionService() - rem_user = await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=False - ) + rem_user = await subscription_service.create_remnawave_user(db, subscription, reset_traffic=False) if not rem_user: - raise ValueError("Failed to create/update user in Remnawave") + raise ValueError('Failed to create/update user in Remnawave') await db.refresh(subscription) @@ -233,14 +224,16 @@ async def create_subscription( try: await db.rollback() except Exception: - logger.exception("Rollback failed after error: %s", e) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to sync with Remnawave: {str(e)}") + logger.exception('Rollback failed after error: %s', e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Failed to sync with Remnawave: {e!s}' + ) subscription = await _get_subscription(db, subscription.id) return _serialize_subscription(subscription) -@router.post("/{subscription_id}/extend", response_model=SubscriptionResponse) +@router.post('/{subscription_id}/extend', response_model=SubscriptionResponse) async def extend_subscription_endpoint( subscription_id: int, payload: SubscriptionExtendRequest, @@ -253,7 +246,7 @@ async def extend_subscription_endpoint( return _serialize_subscription(subscription) -@router.post("/{subscription_id}/traffic", response_model=SubscriptionResponse) +@router.post('/{subscription_id}/traffic', response_model=SubscriptionResponse) async def add_subscription_traffic_endpoint( subscription_id: int, payload: SubscriptionTrafficRequest, @@ -266,7 +259,7 @@ async def add_subscription_traffic_endpoint( return _serialize_subscription(subscription) -@router.post("/{subscription_id}/devices", response_model=SubscriptionResponse) +@router.post('/{subscription_id}/devices', response_model=SubscriptionResponse) async def add_subscription_devices_endpoint( subscription_id: int, payload: SubscriptionDevicesRequest, @@ -279,7 +272,7 @@ async def add_subscription_devices_endpoint( return _serialize_subscription(subscription) -@router.post("/{subscription_id}/squads", response_model=SubscriptionResponse) +@router.post('/{subscription_id}/squads', response_model=SubscriptionResponse) async def add_subscription_squad_endpoint( subscription_id: int, payload: SubscriptionSquadRequest, @@ -287,7 +280,7 @@ async def add_subscription_squad_endpoint( db: AsyncSession = Depends(get_db_session), ) -> SubscriptionResponse: if not payload.squad_uuid: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "squad_uuid is required") + 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) @@ -295,7 +288,7 @@ async def add_subscription_squad_endpoint( return _serialize_subscription(subscription) -@router.delete("/{subscription_id}/squads/{squad_uuid}", response_model=SubscriptionResponse) +@router.delete('/{subscription_id}/squads/{squad_uuid}', response_model=SubscriptionResponse) async def remove_subscription_squad_endpoint( subscription_id: int, squad_uuid: str, @@ -308,7 +301,7 @@ async def remove_subscription_squad_endpoint( return _serialize_subscription(subscription) -@router.delete("/{subscription_id}", response_model=SubscriptionResponse) +@router.delete('/{subscription_id}', response_model=SubscriptionResponse) async def delete_subscription( subscription_id: int, _: Any = Security(require_api_token), @@ -332,7 +325,7 @@ async def delete_subscription( return _serialize_subscription(subscription) -@router.post("/{subscription_id}/modem", response_model=SubscriptionResponse) +@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse) async def set_subscription_modem( subscription_id: int, payload: SubscriptionModemRequest, @@ -343,10 +336,10 @@ async def set_subscription_modem( subscription = await _get_subscription(db, subscription_id) if subscription.is_trial: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem is not available for trial subscriptions") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions') if not settings.is_modem_enabled(): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem feature is disabled") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled') current_modem = getattr(subscription, 'modem_enabled', False) or False diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index e7be044a..7ef0c1ff 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -1,16 +1,14 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Optional - import logging - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status -from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime +from typing import Any from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD @@ -18,16 +16,17 @@ from app.database.models import Ticket, TicketMessage, TicketStatus from ..dependencies import get_db_session, require_api_token from ..schemas.tickets import ( + TicketMediaResponse, TicketMessageResponse, TicketPriorityUpdateRequest, TicketReplyBlockRequest, TicketReplyRequest, TicketReplyResponse, TicketResponse, - TicketMediaResponse, TicketStatusUpdateRequest, ) + router = APIRouter() logger = logging.getLogger(__name__) @@ -66,15 +65,15 @@ def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketR ) -@router.get("", response_model=list[TicketResponse]) +@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), + status_filter: TicketStatus | None = Query(default=None, alias='status'), + priority: str | None = Query(default=None), + user_id: int | None = Query(default=None), ) -> list[TicketResponse]: status_value = status_filter.value if status_filter else None @@ -98,7 +97,7 @@ async def list_tickets( return [_serialize_ticket(ticket) for ticket in tickets] -@router.get("/{ticket_id}", response_model=TicketResponse) +@router.get('/{ticket_id}', response_model=TicketResponse) async def get_ticket( ticket_id: int, _: Any = Security(require_api_token), @@ -106,11 +105,11 @@ async def get_ticket( ) -> 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") + 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) +@router.post('/{ticket_id}/status', response_model=TicketResponse) async def update_ticket_status( ticket_id: int, payload: TicketStatusUpdateRequest, @@ -120,31 +119,31 @@ async def update_ticket_status( try: status_value = TicketStatus(payload.status).value except ValueError as error: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid ticket status") from 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") + 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) +@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"} + allowed_priorities = {'low', 'normal', 'high', 'urgent'} if payload.priority not in allowed_priorities: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid priority") + 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") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found') ticket.priority = payload.priority ticket.updated_at = datetime.utcnow() @@ -154,7 +153,7 @@ async def update_ticket_priority( return _serialize_ticket(ticket, include_messages=True) -@router.post("/{ticket_id}/reply-block", response_model=TicketResponse) +@router.post('/{ticket_id}/reply-block', response_model=TicketResponse) async def update_reply_block( ticket_id: int, payload: TicketReplyBlockRequest, @@ -163,7 +162,7 @@ async def update_reply_block( ) -> 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Block expiration must be in the future') success = await TicketCRUD.set_user_reply_block( db, @@ -172,13 +171,13 @@ async def update_reply_block( until=until, ) if not success: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found") + 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) +@router.delete('/{ticket_id}/reply-block', response_model=TicketResponse) async def clear_reply_block( ticket_id: int, _: Any = Security(require_api_token), @@ -191,13 +190,13 @@ async def clear_reply_block( until=None, ) if not success: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found") + 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}/reply", response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED) +@router.post('/{ticket_id}/reply', response_model=TicketReplyResponse, status_code=status.HTTP_201_CREATED) async def reply_to_ticket( ticket_id: int, payload: TicketReplyRequest, @@ -206,13 +205,13 @@ async def reply_to_ticket( ) -> TicketReplyResponse: ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True) if not ticket: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found') - message_text = (payload.message_text or "").strip() + message_text = (payload.message_text or '').strip() if not message_text and not payload.media_file_id: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text or media is required") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Message text or media is required') - final_message_text = message_text or (payload.media_caption or "").strip() or "[media]" + final_message_text = message_text or (payload.media_caption or '').strip() or '[media]' message = await TicketMessageCRUD.add_message( db, @@ -241,21 +240,22 @@ async def reply_to_ticket( # Отправляем событие о новом сообщении через API try: from app.services.event_emitter import event_emitter + await event_emitter.emit( - "ticket.message_added", + 'ticket.message_added', { - "ticket_id": ticket_id, - "message_id": message.id, - "user_id": ticket.user_id, - "is_from_admin": True, - "message_text": final_message_text[:200], - "has_media": bool(payload.media_file_id), - "status": ticket_with_messages.status, + 'ticket_id': ticket_id, + 'message_id': message.id, + 'user_id': ticket.user_id, + 'is_from_admin': True, + 'message_text': final_message_text[:200], + 'has_media': bool(payload.media_file_id), + 'status': ticket_with_messages.status, }, db=db, ) except Exception as error: - logger.warning("Failed to emit ticket.message_added event: %s", error) + logger.warning('Failed to emit ticket.message_added event: %s', error) return TicketReplyResponse( ticket=_serialize_ticket(ticket_with_messages, include_messages=True), @@ -264,7 +264,7 @@ async def reply_to_ticket( @router.get( - "/{ticket_id}/messages/{message_id}/media", + '/{ticket_id}/messages/{message_id}/media', response_model=TicketMediaResponse, ) async def get_ticket_message_media( @@ -276,16 +276,16 @@ async def get_ticket_message_media( ) -> TicketMediaResponse: 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") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Ticket not found') message = next((m for m in ticket.messages if m.id == message_id), None) if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Message not found') if not message.has_media or not message.media_file_id or not message.media_type: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media not found for this message') - media_url: Optional[str] = None + media_url: str | None = None bot = Bot( token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), @@ -293,9 +293,9 @@ async def get_ticket_message_media( try: file = await bot.get_file(message.media_file_id) if file.file_path: - media_url = str(request.url_for("download_media", file_id=message.media_file_id)) + media_url = str(request.url_for('download_media', file_id=message.media_file_id)) except Exception as error: - logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error) + logger.warning('Failed to resolve media URL for ticket %s message %s: %s', ticket_id, message_id, error) finally: await bot.session.close() diff --git a/app/webapi/routes/tokens.py b/app/webapi/routes/tokens.py index cef1fd91..98bf9b69 100644 --- a/app/webapi/routes/tokens.py +++ b/app/webapi/routes/tokens.py @@ -14,6 +14,7 @@ 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() @@ -33,7 +34,7 @@ def _serialize(token: WebApiToken) -> TokenResponse: ) -@router.get("", response_model=list[TokenResponse]) +@router.get('', response_model=list[TokenResponse]) async def get_tokens( _: WebApiToken = Security(require_api_token), db: AsyncSession = Depends(get_db_session), @@ -42,7 +43,7 @@ async def get_tokens( return [_serialize(token) for token in tokens] -@router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED) async def create_token( payload: TokenCreateRequest, actor: WebApiToken = Security(require_api_token), @@ -58,11 +59,11 @@ async def create_token( await db.commit() base = _serialize(token).model_dump() - base["token"] = token_value + base['token'] = token_value return TokenCreateResponse(**base) -@router.post("/{token_id}/revoke", response_model=TokenResponse) +@router.post('/{token_id}/revoke', response_model=TokenResponse) async def revoke_token( token_id: int, _: WebApiToken = Security(require_api_token), @@ -70,14 +71,14 @@ async def revoke_token( ) -> TokenResponse: token = await get_token_by_id(db, token_id) if not token: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found") + 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) +@router.post('/{token_id}/activate', response_model=TokenResponse) async def activate_token( token_id: int, _: WebApiToken = Security(require_api_token), @@ -85,14 +86,14 @@ async def activate_token( ) -> TokenResponse: token = await get_token_by_id(db, token_id) if not token: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found") + 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) +@router.delete('/{token_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_token_endpoint( token_id: int, _: WebApiToken = Security(require_api_token), @@ -100,7 +101,7 @@ async def delete_token_endpoint( ) -> Response: token = await get_token_by_id(db, token_id) if not token: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Token not found') await delete_token(db, token) await db.commit() diff --git a/app/webapi/routes/transactions.py b/app/webapi/routes/transactions.py index c00ebca1..74bdbc02 100644 --- a/app/webapi/routes/transactions.py +++ b/app/webapi/routes/transactions.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, Query, Security from sqlalchemy import and_, func, select @@ -12,6 +12,7 @@ from app.database.models import Transaction from ..dependencies import get_db_session, require_api_token from ..schemas.transactions import TransactionListResponse, TransactionResponse + router = APIRouter() @@ -31,18 +32,18 @@ def _serialize(transaction: Transaction) -> TransactionResponse: ) -@router.get("", response_model=TransactionListResponse) +@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), + user_id: int | None = Query(default=None), + type_filter: str | None = Query(default=None, alias='type'), + payment_method: str | None = Query(default=None), + is_completed: bool | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), ) -> TransactionListResponse: base_query = select(Transaction) conditions = [] @@ -66,9 +67,7 @@ async def list_transactions( 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) - ) + result = await db.execute(base_query.order_by(Transaction.created_at.desc()).offset(offset).limit(limit)) transactions = result.scalars().all() return TransactionListResponse( diff --git a/app/webapi/routes/user_messages.py b/app/webapi/routes/user_messages.py index 847cd1c0..1bafd4b6 100644 --- a/app/webapi/routes/user_messages.py +++ b/app/webapi/routes/user_messages.py @@ -23,6 +23,7 @@ from ..schemas.user_messages import ( UserMessageUpdateRequest, ) + router = APIRouter() @@ -38,13 +39,13 @@ def _serialize(message) -> UserMessageResponse: ) -@router.get("", response_model=UserMessageListResponse) +@router.get('', response_model=UserMessageListResponse) async def list_user_messages( _: 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="Включать неактивные сообщения"), + include_inactive: bool = Query(True, description='Включать неактивные сообщения'), ) -> UserMessageListResponse: total = await get_user_messages_count(db, include_inactive=include_inactive) messages = await get_all_user_messages( @@ -62,13 +63,13 @@ async def list_user_messages( ) -@router.post("", response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=UserMessageResponse, status_code=status.HTTP_201_CREATED) async def create_user_message_endpoint( payload: UserMessageCreateRequest, token: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> UserMessageResponse: - created_by = getattr(token, "id", None) + created_by = getattr(token, 'id', None) try: message = await create_user_message( db, @@ -83,7 +84,7 @@ async def create_user_message_endpoint( return _serialize(message) -@router.patch("/{message_id}", response_model=UserMessageResponse) +@router.patch('/{message_id}', response_model=UserMessageResponse) async def update_user_message_endpoint( message_id: int, payload: UserMessageUpdateRequest, @@ -97,12 +98,12 @@ async def update_user_message_endpoint( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User message not found') return _serialize(message) -@router.post("/{message_id}/toggle", response_model=UserMessageResponse) +@router.post('/{message_id}/toggle', response_model=UserMessageResponse) async def toggle_user_message_endpoint( message_id: int, _: Any = Security(require_api_token), @@ -111,12 +112,12 @@ async def toggle_user_message_endpoint( message = await toggle_user_message_status(db, message_id) if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User message not found') return _serialize(message) -@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_user_message_endpoint( message_id: int, _: Any = Security(require_api_token), @@ -124,7 +125,7 @@ async def delete_user_message_endpoint( ) -> Response: message = await get_user_message_by_id(db, message_id) if not message: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User message not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User message not found') await delete_user_message(db, message_id) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 201320b1..5dda9800 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy import func, or_, select @@ -39,10 +39,11 @@ from ..schemas.users import ( UserUpdateRequest, ) + router = APIRouter() -def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]: +def _serialize_promo_group(group: PromoGroup | None) -> PromoGroupSummary | None: if not group: return None return PromoGroupSummary( @@ -51,11 +52,11 @@ def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSu 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), + apply_discounts_to_addons=getattr(group, 'apply_discounts_to_addons', True), ) -def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]: +def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSummary | None: if not subscription: return None @@ -79,8 +80,8 @@ def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[Su def _serialize_user(user: User) -> UserResponse: - subscription = getattr(user, "subscription", None) - promo_group = getattr(user, "promo_group", None) + subscription = getattr(user, 'subscription', None) + promo_group = getattr(user, 'promo_group', None) return UserResponse( id=user.id, @@ -105,7 +106,7 @@ def _serialize_user(user: User) -> UserResponse: def _apply_search_filter(query, search: str): - search_lower = f"%{search.lower()}%" + search_lower = f'%{search.lower()}%' conditions = [ func.lower(User.username).like(search_lower), func.lower(User.first_name).like(search_lower), @@ -120,22 +121,19 @@ def _apply_search_filter(query, search: str): return query.where(or_(*conditions)) -@router.get("", response_model=UserListResponse) +@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), + status_filter: UserStatus | None = Query(default=None, alias='status'), + promo_group_id: int | None = Query(default=None), + search: str | None = Query(default=None), ) -> UserListResponse: - base_query = ( - select(User) - .options( - selectinload(User.subscription), - selectinload(User.promo_group), - ) + base_query = select(User).options( + selectinload(User.subscription), + selectinload(User.promo_group), ) if status_filter: @@ -150,9 +148,7 @@ async def list_users( 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) - ) + result = await db.execute(base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)) users = result.scalars().unique().all() return UserListResponse( @@ -163,7 +159,7 @@ async def list_users( ) -@router.get("/{user_id}", response_model=UserResponse) +@router.get('/{user_id}', response_model=UserResponse) async def get_user( user_id: int, _: Any = Security(require_api_token), @@ -173,16 +169,16 @@ async def get_user( user = await get_user_by_telegram_id(db, user_id) if user: return _serialize_user(user) - + # If not found as telegram_id, check as internal user ID user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') return _serialize_user(user) -@router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse) +@router.get('/by-telegram-id/{telegram_id}', response_model=UserResponse) async def get_user_by_telegram_id_endpoint( telegram_id: int, _: Any = Security(require_api_token), @@ -193,12 +189,12 @@ async def get_user_by_telegram_id_endpoint( """ user = await get_user_by_telegram_id(db, telegram_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") - + 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) +@router.post('', response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user_endpoint( payload: UserCreateRequest, _: Any = Security(require_api_token), @@ -208,7 +204,7 @@ async def create_user_endpoint( if payload.telegram_id is not None: 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") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User with this telegram_id already exists') user = await create_user( db, @@ -223,14 +219,14 @@ async def create_user_endpoint( 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") + 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) +@router.patch('/{user_id}', response_model=UserResponse) async def update_user_endpoint( user_id: int, payload: UserUpdateRequest, @@ -244,43 +240,43 @@ async def update_user_endpoint( else: # If not found as telegram_id, check as internal user ID found_user = await get_user_by_id(db, user_id) - + if not found_user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') updates: dict[str, Any] = {} if payload.username is not None: - updates["username"] = payload.username + updates['username'] = payload.username if payload.first_name is not None: - updates["first_name"] = payload.first_name + updates['first_name'] = payload.first_name if payload.last_name is not None: - updates["last_name"] = payload.last_name + updates['last_name'] = payload.last_name if payload.language is not None: - updates["language"] = payload.language + updates['language'] = payload.language if payload.has_had_paid_subscription is not None: - updates["has_had_paid_subscription"] = payload.has_had_paid_subscription + 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 + 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 + 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 + 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 != found_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 != found_user.id: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use") - updates["referral_code"] = payload.referral_code + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Referral code already in use') + updates['referral_code'] = payload.referral_code if not updates: return _serialize_user(found_user) @@ -291,11 +287,11 @@ async def update_user_endpoint( found_user = await get_user_by_telegram_id(db, user_id) else: found_user = await get_user_by_id(db, found_user.id) - + return _serialize_user(found_user) -@router.post("/{user_id}/balance", response_model=UserResponse) +@router.post('/{user_id}/balance', response_model=UserResponse) async def update_balance( user_id: int, payload: BalanceUpdateRequest, @@ -303,7 +299,7 @@ async def update_balance( db: AsyncSession = Depends(get_db_session), ) -> UserResponse: if payload.amount_kopeks == 0: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero") + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Amount must be non-zero') # First check if the provided ID is a telegram_id user = await get_user_by_telegram_id(db, user_id) @@ -312,27 +308,27 @@ async def update_balance( else: # If not found as telegram_id, check as internal user ID found_user = await get_user_by_id(db, user_id) - + if not found_user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') success = await add_user_balance( db, found_user, amount_kopeks=payload.amount_kopeks, - description=payload.description or "Корректировка через веб-API", + 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") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to update balance') # Reload the user to ensure we have the latest data if found_user.telegram_id == user_id: found_user = await get_user_by_telegram_id(db, user_id) else: found_user = await get_user_by_id(db, found_user.id) - + return _serialize_user(found_user) @@ -341,14 +337,14 @@ async def _get_user_by_id_or_telegram_id(db: AsyncSession, user_id: int) -> User user = await get_user_by_telegram_id(db, user_id) if user: return user - + user = await get_user_by_id(db, user_id) if not user: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') return user -@router.post("/{user_id}/subscription", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@router.post('/{user_id}/subscription', response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def create_user_subscription( user_id: int, payload: UserSubscriptionCreateRequest, @@ -360,12 +356,11 @@ async def create_user_subscription( Поддерживает создание как триальных, так и платных подписок. """ user = await _get_user_by_id_or_telegram_id(db, user_id) - + existing = await get_subscription_by_user_id(db, user.id) if existing and not payload.replace_existing: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "User already has a subscription. Use replace_existing=true to replace it" + status.HTTP_400_BAD_REQUEST, 'User already has a subscription. Use replace_existing=true to replace it' ) forced_devices = None @@ -386,17 +381,13 @@ async def create_user_subscription( connected_squads = [payload.squad_uuid] elif payload.connected_squads: connected_squads = payload.connected_squads - + subscription = await replace_subscription( db, existing, duration_days=duration_days, traffic_limit_gb=traffic_limit_gb, - device_limit=( - trial_device_limit - if trial_device_limit is not None - else settings.TRIAL_DEVICE_LIMIT - ), + device_limit=(trial_device_limit if trial_device_limit is not None else settings.TRIAL_DEVICE_LIMIT), connected_squads=connected_squads, is_trial=True, update_server_counters=True, @@ -412,17 +403,14 @@ async def create_user_subscription( ) else: if payload.duration_days is None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "duration_days is required for paid subscriptions" - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'duration_days is required for paid subscriptions') device_limit = payload.device_limit if device_limit is None: if forced_devices is not None: device_limit = forced_devices else: device_limit = settings.DEFAULT_DEVICE_LIMIT - + if existing: subscription = await replace_subscription( db, @@ -444,7 +432,7 @@ async def create_user_subscription( connected_squads=payload.connected_squads or [], update_server_counters=True, ) - + # Создаем пользователя в RemnaWave для платных подписок subscription_service = SubscriptionService() await subscription_service.create_remnawave_user(db, subscription) @@ -454,7 +442,7 @@ async def create_user_subscription( return _serialize_user(user) -@router.delete("/{user_id}/subscription", response_model=UserResponse) +@router.delete('/{user_id}/subscription', response_model=UserResponse) async def delete_user_subscription( user_id: int, _: Any = Security(require_api_token), @@ -465,10 +453,10 @@ async def delete_user_subscription( Подписка не удаляется физически, а помечается как DISABLED. """ user = await _get_user_by_id_or_telegram_id(db, user_id) - + subscription = await get_subscription_by_user_id(db, user.id) if not subscription: - raise HTTPException(status.HTTP_404_NOT_FOUND, "User has no subscription") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription') await deactivate_subscription(db, subscription) diff --git a/app/webapi/routes/webhooks.py b/app/webapi/routes/webhooks.py index 54e5a460..d81dad3c 100644 --- a/app/webapi/routes/webhooks.py +++ b/app/webapi/routes/webhooks.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status from sqlalchemy import func, select @@ -11,7 +11,6 @@ from app.database.crud.webhook import ( delete_webhook, get_webhook_by_id, list_webhooks, - record_webhook_delivery, update_webhook, ) from app.database.models import Webhook, WebhookDelivery @@ -27,6 +26,7 @@ from ..schemas.webhooks import ( WebhookUpdateRequest, ) + router = APIRouter() @@ -63,14 +63,14 @@ def _serialize_delivery(delivery: WebhookDelivery) -> WebhookDeliveryResponse: ) -@router.get("", response_model=WebhookListResponse) +@router.get('', response_model=WebhookListResponse) async def list_webhooks_endpoint( _: 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), - event_type: Optional[str] = Query(default=None), - is_active: Optional[bool] = Query(default=None), + event_type: str | None = Query(default=None), + is_active: bool | None = Query(default=None), ) -> WebhookListResponse: """Список webhooks.""" webhooks, total = await list_webhooks( @@ -89,28 +89,24 @@ async def list_webhooks_endpoint( ) -@router.get("/stats", response_model=WebhookStatsResponse) +@router.get('/stats', response_model=WebhookStatsResponse) async def get_webhook_stats( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> WebhookStatsResponse: """Статистика по webhooks.""" total_webhooks = await db.scalar(select(func.count(Webhook.id))) or 0 - active_webhooks = await db.scalar( - select(func.count(Webhook.id)).where(Webhook.is_active == True) - ) or 0 + active_webhooks = await db.scalar(select(func.count(Webhook.id)).where(Webhook.is_active == True)) or 0 total_deliveries = await db.scalar(select(func.count(WebhookDelivery.id))) or 0 - successful_deliveries = await db.scalar( - select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == "success") - ) or 0 - failed_deliveries = await db.scalar( - select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == "failed") - ) or 0 - - success_rate = ( - (successful_deliveries / total_deliveries * 100) if total_deliveries > 0 else 0.0 + successful_deliveries = ( + await db.scalar(select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == 'success')) or 0 ) + failed_deliveries = ( + await db.scalar(select(func.count(WebhookDelivery.id)).where(WebhookDelivery.status == 'failed')) or 0 + ) + + success_rate = (successful_deliveries / total_deliveries * 100) if total_deliveries > 0 else 0.0 return WebhookStatsResponse( total_webhooks=int(total_webhooks), @@ -122,7 +118,7 @@ async def get_webhook_stats( ) -@router.get("/{webhook_id}", response_model=WebhookResponse) +@router.get('/{webhook_id}', response_model=WebhookResponse) async def get_webhook( webhook_id: int, _: Any = Security(require_api_token), @@ -131,11 +127,11 @@ async def get_webhook( """Получить webhook по ID.""" webhook = await get_webhook_by_id(db, webhook_id) if not webhook: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Webhook not found') return _serialize_webhook(webhook) -@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) async def create_webhook_endpoint( payload: WebhookCreateRequest, _: Any = Security(require_api_token), @@ -153,7 +149,7 @@ async def create_webhook_endpoint( return _serialize_webhook(webhook) -@router.patch("/{webhook_id}", response_model=WebhookResponse) +@router.patch('/{webhook_id}', response_model=WebhookResponse) async def update_webhook_endpoint( webhook_id: int, payload: WebhookUpdateRequest, @@ -163,7 +159,7 @@ async def update_webhook_endpoint( """Обновить webhook.""" webhook = await get_webhook_by_id(db, webhook_id) if not webhook: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Webhook not found') webhook = await update_webhook( db, @@ -177,7 +173,7 @@ async def update_webhook_endpoint( return _serialize_webhook(webhook) -@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/{webhook_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_webhook_endpoint( webhook_id: int, _: Any = Security(require_api_token), @@ -186,25 +182,25 @@ async def delete_webhook_endpoint( """Удалить webhook.""" webhook = await get_webhook_by_id(db, webhook_id) if not webhook: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Webhook not found') await delete_webhook(db, webhook) return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.get("/{webhook_id}/deliveries", response_model=WebhookDeliveryListResponse) +@router.get('/{webhook_id}/deliveries', response_model=WebhookDeliveryListResponse) async def list_webhook_deliveries( webhook_id: int, _: 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[str] = Query(default=None, alias="status"), + status_filter: str | None = Query(default=None, alias='status'), ) -> WebhookDeliveryListResponse: """Список доставок webhook.""" webhook = await get_webhook_by_id(db, webhook_id) if not webhook: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Webhook not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Webhook not found') query = select(WebhookDelivery).where(WebhookDelivery.webhook_id == webhook_id) @@ -226,4 +222,3 @@ async def list_webhook_deliveries( limit=limit, offset=offset, ) - diff --git a/app/webapi/routes/websocket.py b/app/webapi/routes/websocket.py index 5f237e4d..d94f4247 100644 --- a/app/webapi/routes/websocket.py +++ b/app/webapi/routes/websocket.py @@ -2,20 +2,20 @@ from __future__ import annotations import json import logging -from typing import Any -from fastapi import APIRouter, Security, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi.security import APIKeyHeader +from app.database.database import AsyncSessionLocal from app.services.event_emitter import event_emitter from app.services.web_api_token_service import web_api_token_service -from app.database.database import AsyncSessionLocal + logger = logging.getLogger(__name__) router = APIRouter() -api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False) +api_key_header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False) async def verify_websocket_token( @@ -25,7 +25,7 @@ async def verify_websocket_token( """Проверить токен для WebSocket подключения.""" if not token: # Пытаемся получить токен из query параметров - token = websocket.query_params.get("token") or websocket.query_params.get("api_key") + token = websocket.query_params.get('token') or websocket.query_params.get('api_key') if not token: return False @@ -38,44 +38,44 @@ async def verify_websocket_token( remote_ip=websocket.client.host if websocket.client else None, ) if webhook_token: - logger.debug("WebSocket token authenticated successfully") + logger.debug('WebSocket token authenticated successfully') else: - logger.warning("WebSocket token authentication failed: token not found or invalid") + logger.warning('WebSocket token authentication failed: token not found or invalid') return webhook_token is not None except Exception as error: - logger.warning("WebSocket authentication error: %s", error, exc_info=True) + logger.warning('WebSocket authentication error: %s', error, exc_info=True) return False -@router.websocket("/ws") +@router.websocket('/ws') async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint для real-time обновлений.""" - client_host = websocket.client.host if websocket.client else "unknown" - logger.debug("WebSocket connection attempt from %s", client_host) - + client_host = websocket.client.host if websocket.client else 'unknown' + logger.debug('WebSocket connection attempt from %s', client_host) + # Сначала проверяем авторизацию ДО принятия соединения - token = websocket.query_params.get("token") or websocket.query_params.get("api_key") - + token = websocket.query_params.get('token') or websocket.query_params.get('api_key') + if not token: - logger.debug("WebSocket: No token provided from %s", client_host) + logger.debug('WebSocket: No token provided from %s', client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() - await websocket.close(code=1008, reason="Unauthorized: No token provided") + await websocket.close(code=1008, reason='Unauthorized: No token provided') return if not await verify_websocket_token(websocket, token): - logger.debug("WebSocket: Invalid token from %s", client_host) + logger.debug('WebSocket: Invalid token from %s', client_host) # Принимаем и сразу закрываем с кодом ошибки await websocket.accept() - await websocket.close(code=1008, reason="Unauthorized: Invalid token") + await websocket.close(code=1008, reason='Unauthorized: Invalid token') return - + # Только после успешной проверки принимаем соединение try: await websocket.accept() - logger.debug("WebSocket connection accepted from %s", client_host) + logger.debug('WebSocket connection accepted from %s', client_host) except Exception as e: - logger.error("WebSocket: Failed to accept connection from %s: %s", client_host, e) + logger.error('WebSocket: Failed to accept connection from %s: %s', client_host, e) return # Регистрируем подключение @@ -83,11 +83,13 @@ async def websocket_endpoint(websocket: WebSocket): try: # Отправляем приветственное сообщение - await websocket.send_json({ - "type": "connection", - "status": "connected", - "message": "WebSocket connection established", - }) + await websocket.send_json( + { + 'type': 'connection', + 'status': 'connected', + 'message': 'WebSocket connection established', + } + ) # Обрабатываем входящие сообщения (ping/pong для keepalive) while True: @@ -96,22 +98,21 @@ async def websocket_endpoint(websocket: WebSocket): message = json.loads(data) # Обработка ping - if message.get("type") == "ping": - await websocket.send_json({"type": "pong"}) + if message.get('type') == 'ping': + await websocket.send_json({'type': 'pong'}) # Можно добавить другие типы сообщений (подписки на конкретные события и т.д.) except json.JSONDecodeError: - logger.warning("Invalid JSON received from WebSocket client") + logger.warning('Invalid JSON received from WebSocket client') except WebSocketDisconnect: break except Exception as error: - logger.exception("Error processing WebSocket message: %s", error) + logger.exception('Error processing WebSocket message: %s', error) except WebSocketDisconnect: - logger.debug("WebSocket client disconnected") + logger.debug('WebSocket client disconnected') except Exception as error: - logger.exception("WebSocket error: %s", error) + logger.exception('WebSocket error: %s', error) finally: # Отменяем регистрацию при отключении event_emitter.unregister_websocket(websocket) - diff --git a/app/webapi/routes/welcome_texts.py b/app/webapi/routes/welcome_texts.py index b7eac08a..05e17ae1 100644 --- a/app/webapi/routes/welcome_texts.py +++ b/app/webapi/routes/welcome_texts.py @@ -22,6 +22,7 @@ from ..schemas.welcome_texts import ( WelcomeTextUpdateRequest, ) + router = APIRouter() @@ -37,13 +38,13 @@ def _serialize(text) -> WelcomeTextResponse: ) -@router.get("", response_model=WelcomeTextListResponse) +@router.get('', response_model=WelcomeTextListResponse) async def list_welcome_texts_endpoint( _: 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="Включать неактивные тексты"), + include_inactive: bool = Query(True, description='Включать неактивные тексты'), ) -> WelcomeTextListResponse: total = await count_welcome_texts(db, include_inactive=include_inactive) records = await list_welcome_texts( @@ -61,13 +62,13 @@ async def list_welcome_texts_endpoint( ) -@router.post("", response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) +@router.post('', response_model=WelcomeTextResponse, status_code=status.HTTP_201_CREATED) async def create_welcome_text_endpoint( payload: WelcomeTextCreateRequest, token: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> WelcomeTextResponse: - created_by = getattr(token, "id", None) + created_by = getattr(token, 'id', None) record = await create_welcome_text( db, text_content=payload.text, @@ -79,7 +80,7 @@ async def create_welcome_text_endpoint( return _serialize(record) -@router.get("/{welcome_text_id}", response_model=WelcomeTextResponse) +@router.get('/{welcome_text_id}', response_model=WelcomeTextResponse) async def get_welcome_text_endpoint( welcome_text_id: int, _: Any = Security(require_api_token), @@ -87,12 +88,12 @@ async def get_welcome_text_endpoint( ) -> WelcomeTextResponse: record = await get_welcome_text_by_id(db, welcome_text_id) if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Welcome text not found') return _serialize(record) -@router.patch("/{welcome_text_id}", response_model=WelcomeTextResponse) +@router.patch('/{welcome_text_id}', response_model=WelcomeTextResponse) async def update_welcome_text_endpoint( welcome_text_id: int, payload: WelcomeTextUpdateRequest, @@ -101,16 +102,16 @@ async def update_welcome_text_endpoint( ) -> WelcomeTextResponse: record = await get_welcome_text_by_id(db, welcome_text_id) if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Welcome text not found') update_payload = payload.dict(exclude_unset=True) - if "text" in update_payload: - update_payload["text_content"] = update_payload.pop("text") + if 'text' in update_payload: + update_payload['text_content'] = update_payload.pop('text') updated = await update_welcome_text(db, record, **update_payload) return _serialize(updated) -@router.delete("/{welcome_text_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/{welcome_text_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_welcome_text_endpoint( welcome_text_id: int, _: Any = Security(require_api_token), @@ -118,7 +119,7 @@ async def delete_welcome_text_endpoint( ) -> Response: record = await get_welcome_text_by_id(db, welcome_text_id) if not record: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Welcome text not found") + raise HTTPException(status.HTTP_404_NOT_FOUND, 'Welcome text not found') await delete_welcome_text(db, record) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/webapi/schemas/backups.py b/app/webapi/schemas/backups.py index 5a535c85..57903503 100644 --- a/app/webapi/schemas/backups.py +++ b/app/webapi/schemas/backups.py @@ -1,29 +1,28 @@ 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="Текущий статус задачи") + 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 + timestamp: datetime | None = None + tables_count: int | None = None + total_records: int | None = 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 + created_by: int | None = None + database_type: str | None = None + version: str | None = None + error: str | None = None class BackupListResponse(BaseModel): @@ -36,12 +35,12 @@ class BackupListResponse(BaseModel): class BackupStatusResponse(BaseModel): task_id: str status: str - message: Optional[str] = None - file_path: Optional[str] = Field( + message: str | None = None + file_path: str | None = Field( default=None, - description="Полный путь до созданного бекапа, если задача завершена", + description='Полный путь до созданного бекапа, если задача завершена', ) - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime updated_at: datetime @@ -56,17 +55,14 @@ class BackupTaskListResponse(BaseModel): class BackupRestoreRequest(BaseModel): - clear_existing: bool = Field( - default=False, - description="Очистить существующие данные перед восстановлением" - ) + clear_existing: bool = Field(default=False, description='Очистить существующие данные перед восстановлением') class BackupRestoreResponse(BaseModel): success: bool message: str - tables_restored: Optional[int] = None - records_restored: Optional[int] = None + tables_restored: int | None = None + records_restored: int | None = None class BackupDeleteResponse(BaseModel): diff --git a/app/webapi/schemas/ban_notifications.py b/app/webapi/schemas/ban_notifications.py index 3e7b5031..1c3dc922 100644 --- a/app/webapi/schemas/ban_notifications.py +++ b/app/webapi/schemas/ban_notifications.py @@ -1,44 +1,41 @@ from __future__ import annotations -from typing import Literal, Optional +from typing import Literal + from pydantic import BaseModel, Field class BanNotificationRequest(BaseModel): """Запрос на отправку уведомления о бане пользователю""" - notification_type: Literal["punishment", "enabled", "warning", "network_wifi", "network_mobile"] = Field( - description="Тип уведомления: punishment (бан за устройства), enabled (разбан), warning (предупреждение), network_wifi (бан за WiFi), network_mobile (бан за мобильную сеть)" - ) - user_identifier: str = Field( - description="Email или user_id пользователя из Remnawave Panel" - ) - username: str = Field( - description="Имя пользователя для отображения" + notification_type: Literal['punishment', 'enabled', 'warning', 'network_wifi', 'network_mobile'] = Field( + description='Тип уведомления: punishment (бан за устройства), enabled (разбан), warning (предупреждение), network_wifi (бан за WiFi), network_mobile (бан за мобильную сеть)' ) + user_identifier: str = Field(description='Email или user_id пользователя из Remnawave Panel') + username: str = Field(description='Имя пользователя для отображения') # Данные для punishment - ip_count: Optional[int] = Field(None, description="Количество устройств") - limit: Optional[int] = Field(None, description="Лимит устройств") - ban_minutes: Optional[int] = Field(None, description="Длительность бана в минутах") + ip_count: int | None = Field(None, description='Количество устройств') + limit: int | None = Field(None, description='Лимит устройств') + ban_minutes: int | None = Field(None, description='Длительность бана в минутах') # Данные для warning - warning_message: Optional[str] = Field(None, description="Текст предупреждения") + warning_message: str | None = Field(None, description='Текст предупреждения') # Данные для network_wifi/network_mobile и punishment - network_type: Optional[str] = Field(None, description="Тип сети (WiFi/Mobile)") - node_name: Optional[str] = Field(None, description="Название ноды/сервера с которой пришел бан") + network_type: str | None = Field(None, description='Тип сети (WiFi/Mobile)') + node_name: str | None = Field(None, description='Название ноды/сервера с которой пришел бан') class Config: json_schema_extra = { - "example": { - "notification_type": "punishment", - "user_identifier": "user@example.com", - "username": "john_doe", - "ip_count": 5, - "limit": 3, - "ban_minutes": 30, - "node_name": "DE-Server-1" + 'example': { + 'notification_type': 'punishment', + 'user_identifier': 'user@example.com', + 'username': 'john_doe', + 'ip_count': 5, + 'limit': 3, + 'ban_minutes': 30, + 'node_name': 'DE-Server-1', } } @@ -46,17 +43,12 @@ class BanNotificationRequest(BaseModel): class BanNotificationResponse(BaseModel): """Ответ на запрос отправки уведомления""" - success: bool = Field(description="Успешно ли отправлено уведомление") - message: str = Field(description="Сообщение о результате") - telegram_id: Optional[int] = Field(None, description="Telegram ID получателя") - sent: bool = Field(False, description="Было ли фактически отправлено сообщение") + success: bool = Field(description='Успешно ли отправлено уведомление') + message: str = Field(description='Сообщение о результате') + telegram_id: int | None = Field(None, description='Telegram ID получателя') + sent: bool = Field(False, description='Было ли фактически отправлено сообщение') class Config: json_schema_extra = { - "example": { - "success": True, - "message": "Уведомление отправлено", - "telegram_id": 123456789, - "sent": True - } + 'example': {'success': True, 'message': 'Уведомление отправлено', 'telegram_id': 123456789, 'sent': True} } diff --git a/app/webapi/schemas/broadcasts.py b/app/webapi/schemas/broadcasts.py index bc616f5b..a4ea21f4 100644 --- a/app/webapi/schemas/broadcasts.py +++ b/app/webapi/schemas/broadcasts.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import ClassVar, Optional +from typing import ClassVar from pydantic import BaseModel, Field, validator @@ -9,45 +9,43 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS class BroadcastMedia(BaseModel): - type: str = Field(pattern=r"^(photo|video|document)$") + type: str = Field(pattern=r'^(photo|video|document)$') file_id: str - caption: Optional[str] = None + caption: str | None = 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 + selected_buttons: list[str] = Field(default_factory=lambda: list(DEFAULT_BROADCAST_BUTTONS)) + media: BroadcastMedia | None = None _ALLOWED_TARGETS: ClassVar[set[str]] = { - "all", - "active", - "trial", - "no", - "expiring", - "expired", - "active_zero", - "trial_zero", - "zero", + '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", + 'today', + 'week', + 'month', + 'active_today', + 'inactive_week', + 'inactive_month', + 'referrals', + 'direct', } _TARGET_ALIASES: ClassVar[dict[str, str]] = { - "no_sub": "no", + 'no_sub': 'no', } - @validator("target") + @validator('target') def validate_target(cls, value: str) -> str: normalized = value.strip().lower() normalized = cls._TARGET_ALIASES.get(normalized, normalized) @@ -55,19 +53,19 @@ class BroadcastCreateRequest(BaseModel): if normalized in cls._ALLOWED_TARGETS: return normalized - if normalized.startswith("custom_"): - criteria = normalized[len("custom_"):] + if normalized.startswith('custom_'): + criteria = normalized[len('custom_') :] if criteria in cls._CUSTOM_TARGETS: return normalized - raise ValueError("Unsupported target value") + raise ValueError('Unsupported target value') - @validator("selected_buttons", pre=True) + @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") + raise TypeError('selected_buttons must be an array') seen = set() ordered: list[str] = [] @@ -89,17 +87,17 @@ class BroadcastResponse(BaseModel): 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 + media_type: str | None = None + media_file_id: str | None = None + media_caption: str | None = None total_count: int sent_count: int failed_count: int status: str - admin_id: Optional[int] = None - admin_name: Optional[str] = None + admin_id: int | None = None + admin_name: str | None = None created_at: datetime - completed_at: Optional[datetime] = None + completed_at: datetime | None = None class BroadcastListResponse(BaseModel): @@ -107,4 +105,3 @@ class BroadcastListResponse(BaseModel): total: int limit: int offset: int - diff --git a/app/webapi/schemas/campaigns.py b/app/webapi/schemas/campaigns.py index 88b45eb1..9fc5393f 100644 --- a/app/webapi/schemas/campaigns.py +++ b/app/webapi/schemas/campaigns.py @@ -1,62 +1,65 @@ from __future__ import annotations from datetime import datetime -from typing import Annotated, Literal, Optional +from typing import Annotated, Literal from pydantic import BaseModel, Field, validator + CampaignBonusType = Annotated[ - Literal["balance", "subscription", "none", "tariff"], - Field(description="Тип бонуса кампании: balance (баланс), subscription (пробная подписка), none (без награды), tariff (тариф)") + Literal['balance', 'subscription', 'none', 'tariff'], + Field( + description='Тип бонуса кампании: balance (баланс), subscription (пробная подписка), none (без награды), tariff (тариф)' + ), ] class CampaignBase(BaseModel): name: str = Field(..., max_length=255) - start_parameter: str = Field(..., max_length=64, description="Start parameter для deep-link (уникальный)") + 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_duration_days: int | None = Field(None, ge=0) + subscription_traffic_gb: int | None = Field(None, ge=0) + subscription_device_limit: int | None = Field(None, ge=0) subscription_squads: list[str] = Field(default_factory=list) # Поля для типа "tariff" - tariff_id: Optional[int] = Field(None, ge=1, description="ID тарифа для выдачи") - tariff_duration_days: Optional[int] = Field(None, ge=1, description="Длительность тарифа в днях") + tariff_id: int | None = Field(None, ge=1, description='ID тарифа для выдачи') + tariff_duration_days: int | None = Field(None, ge=1, description='Длительность тарифа в днях') - @validator("name", "start_parameter") - def strip_strings(cls, value: str) -> str: # noqa: D401,B902 + @validator('name', 'start_parameter') + def strip_strings(cls, value: str) -> str: 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") + @validator('balance_bonus_kopeks') + def validate_balance_bonus(cls, value: int, values: dict) -> int: + 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": + @validator('subscription_duration_days') + def validate_subscription_bonus(cls, value: int | None, values: dict): + if values.get('bonus_type') == 'subscription': if value is None or value <= 0: - raise ValueError("subscription_duration_days must be positive for subscription bonus") + raise ValueError('subscription_duration_days must be positive for subscription bonus') return value - @validator("tariff_id") - def validate_tariff_id(cls, value: Optional[int], values: dict): # noqa: D401,B902 - if values.get("bonus_type") == "tariff": + @validator('tariff_id') + def validate_tariff_id(cls, value: int | None, values: dict): + if values.get('bonus_type') == 'tariff': if value is None or value <= 0: - raise ValueError("tariff_id must be specified for tariff bonus") + raise ValueError('tariff_id must be specified for tariff bonus') return value - @validator("tariff_duration_days") - def validate_tariff_duration(cls, value: Optional[int], values: dict): # noqa: D401,B902 - if values.get("bonus_type") == "tariff": + @validator('tariff_duration_days') + def validate_tariff_duration(cls, value: int | None, values: dict): + if values.get('bonus_type') == 'tariff': if value is None or value <= 0: - raise ValueError("tariff_duration_days must be positive for tariff bonus") + raise ValueError('tariff_duration_days must be positive for tariff bonus') return value @@ -67,16 +70,16 @@ class CampaignResponse(BaseModel): 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_duration_days: int | None = None + subscription_traffic_gb: int | None = None + subscription_device_limit: int | None = None subscription_squads: list[str] = Field(default_factory=list) # Поля для типа "tariff" - tariff_id: Optional[int] = None - tariff_duration_days: Optional[int] = None - tariff_name: Optional[str] = None # Для отображения названия тарифа + tariff_id: int | None = None + tariff_duration_days: int | None = None + tariff_name: str | None = None # Для отображения названия тарифа is_active: bool - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime updated_at: datetime registrations_count: int = 0 @@ -90,49 +93,49 @@ class CampaignListResponse(BaseModel): 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 + name: str | None = Field(None, max_length=255) + start_parameter: str | None = Field(None, max_length=64) + bonus_type: CampaignBonusType | None = None + balance_bonus_kopeks: int | None = Field(None, ge=0) + subscription_duration_days: int | None = Field(None, ge=0) + subscription_traffic_gb: int | None = Field(None, ge=0) + subscription_device_limit: int | None = Field(None, ge=0) + subscription_squads: list[str] | None = None # Поля для типа "tariff" - tariff_id: Optional[int] = Field(None, ge=1) - tariff_duration_days: Optional[int] = Field(None, ge=1) - is_active: Optional[bool] = None + tariff_id: int | None = Field(None, ge=1) + tariff_duration_days: int | None = Field(None, ge=1) + is_active: bool | None = None - @validator("name", "start_parameter", pre=True) - def strip_optional_strings(cls, value: Optional[str]): # noqa: D401,B902 + @validator('name', 'start_parameter', pre=True) + def strip_optional_strings(cls, value: str | None): 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") + @validator('balance_bonus_kopeks') + def validate_balance_bonus(cls, value: int | None, values: dict): + 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") + @validator('subscription_duration_days') + def validate_subscription_bonus(cls, value: int | None, values: dict): + 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 - @validator("tariff_id") - def validate_tariff_id(cls, value: Optional[int], values: dict): # noqa: D401,B902 - bonus_type = values.get("bonus_type") - if bonus_type == "tariff" and value is not None and value <= 0: - raise ValueError("tariff_id must be positive for tariff bonus") + @validator('tariff_id') + def validate_tariff_id(cls, value: int | None, values: dict): + bonus_type = values.get('bonus_type') + if bonus_type == 'tariff' and value is not None and value <= 0: + raise ValueError('tariff_id must be positive for tariff bonus') return value - @validator("tariff_duration_days") - def validate_tariff_duration(cls, value: Optional[int], values: dict): # noqa: D401,B902 - bonus_type = values.get("bonus_type") - if bonus_type == "tariff" and value is not None and value <= 0: - raise ValueError("tariff_duration_days must be positive for tariff bonus") + @validator('tariff_duration_days') + def validate_tariff_duration(cls, value: int | None, values: dict): + bonus_type = values.get('bonus_type') + if bonus_type == 'tariff' and value is not None and value <= 0: + raise ValueError('tariff_duration_days must be positive for tariff bonus') return value diff --git a/app/webapi/schemas/config.py b/app/webapi/schemas/config.py index 7497444d..5fc74740 100644 --- a/app/webapi/schemas/config.py +++ b/app/webapi/schemas/config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -12,7 +12,7 @@ class SettingCategorySummary(BaseModel): label: str items: int - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class SettingCategoryRef(BaseModel): @@ -21,7 +21,7 @@ class SettingCategoryRef(BaseModel): key: str label: str - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class SettingChoice(BaseModel): @@ -29,9 +29,9 @@ class SettingChoice(BaseModel): value: Any label: str - description: Optional[str] = None + description: str | None = None - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class SettingDefinition(BaseModel): @@ -48,7 +48,7 @@ class SettingDefinition(BaseModel): read_only: bool = Field(default=False) choices: list[SettingChoice] = Field(default_factory=list) - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class SettingUpdateRequest(BaseModel): @@ -56,4 +56,4 @@ class SettingUpdateRequest(BaseModel): value: Any - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') diff --git a/app/webapi/schemas/contests.py b/app/webapi/schemas/contests.py index cbc6a2d1..a1197ea3 100644 --- a/app/webapi/schemas/contests.py +++ b/app/webapi/schemas/contests.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import date, datetime, time -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, Field @@ -10,43 +10,43 @@ class ContestTemplateResponse(BaseModel): id: int name: str slug: str - description: Optional[str] = None + description: str | None = None prize_type: str prize_value: str max_winners: int attempts_per_user: int times_per_day: int - schedule_times: Optional[str] = None + schedule_times: str | None = None cooldown_hours: int - payload: Dict[str, Any] = Field(default_factory=dict) + payload: dict[str, Any] = Field(default_factory=dict) is_enabled: bool created_at: datetime updated_at: datetime class ContestTemplateListResponse(BaseModel): - items: List[ContestTemplateResponse] + items: list[ContestTemplateResponse] class ContestTemplateUpdateRequest(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - prize_type: Optional[str] = None - prize_value: Optional[str] = None - max_winners: Optional[int] = Field(None, ge=1) - attempts_per_user: Optional[int] = Field(None, ge=1) - times_per_day: Optional[int] = Field(None, ge=1) - schedule_times: Optional[str] = None - cooldown_hours: Optional[int] = Field(None, ge=1) - payload: Optional[Dict[str, Any]] = None - is_enabled: Optional[bool] = None + name: str | None = None + description: str | None = None + prize_type: str | None = None + prize_value: str | None = None + max_winners: int | None = Field(None, ge=1) + attempts_per_user: int | None = Field(None, ge=1) + times_per_day: int | None = Field(None, ge=1) + schedule_times: str | None = None + cooldown_hours: int | None = Field(None, ge=1) + payload: dict[str, Any] | None = None + is_enabled: bool | None = None class StartRoundRequest(BaseModel): - starts_at: Optional[datetime] = None - ends_at: Optional[datetime] = None - cooldown_hours: Optional[int] = Field(None, ge=1) - payload: Optional[Dict[str, Any]] = None + starts_at: datetime | None = None + ends_at: datetime | None = None + cooldown_hours: int | None = Field(None, ge=1) + payload: dict[str, Any] | None = None force: bool = False @@ -54,11 +54,11 @@ class ContestRoundResponse(BaseModel): id: int template_id: int template_slug: str - template_name: Optional[str] = None + template_name: str | None = None starts_at: datetime ends_at: datetime status: str - payload: Dict[str, Any] = Field(default_factory=dict) + payload: dict[str, Any] = Field(default_factory=dict) winners_count: int max_winners: int attempts_per_user: int @@ -67,7 +67,7 @@ class ContestRoundResponse(BaseModel): class ContestRoundListResponse(BaseModel): - items: List[ContestRoundResponse] + items: list[ContestRoundResponse] total: int limit: int offset: int @@ -75,22 +75,22 @@ class ContestRoundListResponse(BaseModel): class ContestAttemptUser(BaseModel): id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - full_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + full_name: str | None = None class ContestAttemptResponse(BaseModel): id: int round_id: int user: ContestAttemptUser - answer: Optional[str] = None + answer: str | None = None is_winner: bool created_at: datetime class ContestAttemptListResponse(BaseModel): - items: List[ContestAttemptResponse] + items: list[ContestAttemptResponse] total: int limit: int offset: int @@ -99,25 +99,25 @@ class ContestAttemptListResponse(BaseModel): class ReferralContestResponse(BaseModel): id: int title: str - description: Optional[str] = None - prize_text: Optional[str] = None + description: str | None = None + prize_text: str | None = None contest_type: str start_at: datetime end_at: datetime daily_summary_time: time - daily_summary_times: Optional[str] = None + daily_summary_times: str | None = None timezone: str is_active: bool - last_daily_summary_date: Optional[date] = None - last_daily_summary_at: Optional[datetime] = None + last_daily_summary_date: date | None = None + last_daily_summary_at: datetime | None = None final_summary_sent: bool - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime updated_at: datetime class ReferralContestListResponse(BaseModel): - items: List[ReferralContestResponse] + items: list[ReferralContestResponse] total: int limit: int offset: int @@ -125,57 +125,55 @@ class ReferralContestListResponse(BaseModel): class ReferralContestCreateRequest(BaseModel): title: str - description: Optional[str] = None - prize_text: Optional[str] = None - contest_type: str = Field("referral_paid", min_length=1) + description: str | None = None + prize_text: str | None = None + contest_type: str = Field('referral_paid', min_length=1) start_at: datetime end_at: datetime daily_summary_time: time = Field(default=time(hour=12)) - daily_summary_times: Optional[str] = Field( - default=None, description="Список времён ЧЧ:ММ через запятую (например, 12:00,18:00)" + daily_summary_times: str | None = Field( + default=None, description='Список времён ЧЧ:ММ через запятую (например, 12:00,18:00)' ) - timezone: str = Field(default="UTC") + timezone: str = Field(default='UTC') is_active: bool = True - created_by: Optional[int] = None + created_by: int | None = None class ReferralContestUpdateRequest(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - prize_text: Optional[str] = None - contest_type: Optional[str] = Field(None, min_length=1) - start_at: Optional[datetime] = None - end_at: Optional[datetime] = None - daily_summary_time: Optional[time] = None - daily_summary_times: Optional[str] = Field( - default=None, description="Список времён ЧЧ:ММ через запятую" - ) - timezone: Optional[str] = None - is_active: Optional[bool] = None - final_summary_sent: Optional[bool] = None - created_by: Optional[int] = None + title: str | None = None + description: str | None = None + prize_text: str | None = None + contest_type: str | None = Field(None, min_length=1) + start_at: datetime | None = None + end_at: datetime | None = None + daily_summary_time: time | None = None + daily_summary_times: str | None = Field(default=None, description='Список времён ЧЧ:ММ через запятую') + timezone: str | None = None + is_active: bool | None = None + final_summary_sent: bool | None = None + created_by: int | None = None class ReferralContestLeaderboardItem(BaseModel): user_id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - full_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + full_name: str | None = None referrals_count: int total_amount_kopeks: int total_amount_rubles: float class ReferralContestDetailResponse(ReferralContestResponse): - total_events: Optional[int] = None - leaderboard: Optional[List[ReferralContestLeaderboardItem]] = None + total_events: int | None = None + leaderboard: list[ReferralContestLeaderboardItem] | None = None class ReferralContestEventUser(BaseModel): id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - full_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + full_name: str | None = None class ReferralContestEventResponse(BaseModel): @@ -190,7 +188,7 @@ class ReferralContestEventResponse(BaseModel): class ReferralContestEventListResponse(BaseModel): - items: List[ReferralContestEventResponse] + items: list[ReferralContestEventResponse] total: int limit: int offset: int @@ -210,4 +208,4 @@ class ReferralContestDetailedStatsResponse(BaseModel): total_invited: int total_paid_amount: int total_unpaid: int - participants: List[ReferralContestParticipant] + participants: list[ReferralContestParticipant] diff --git a/app/webapi/schemas/health.py b/app/webapi/schemas/health.py index e3f0c4ab..04888c56 100644 --- a/app/webapi/schemas/health.py +++ b/app/webapi/schemas/health.py @@ -11,7 +11,7 @@ class HealthFeatureFlags(BaseModel): reporting: bool webhooks: bool - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class HealthCheckResponse(BaseModel): @@ -22,4 +22,4 @@ class HealthCheckResponse(BaseModel): bot_version: str | None features: HealthFeatureFlags - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py index a7cdcab2..627b5640 100644 --- a/app/webapi/schemas/logs.py +++ b/app/webapi/schemas/logs.py @@ -1,8 +1,9 @@ """Pydantic-схемы для работы с логами административного API.""" + from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, Field @@ -11,14 +12,14 @@ class MonitoringLogEntry(BaseModel): """Запись лога мониторинга.""" id: int - event_type: str = Field(..., description="Тип события мониторинга") - message: str = Field(..., description="Краткое описание события") - data: Optional[Dict[str, Any]] = Field( + event_type: str = Field(..., description='Тип события мониторинга') + message: str = Field(..., description='Краткое описание события') + data: dict[str, Any] | None = Field( default=None, - description="Дополнительные данные события", + description='Дополнительные данные события', ) - is_success: bool = Field(..., description="Флаг успешности выполнения операции") - created_at: datetime = Field(..., description="Дата и время создания записи") + is_success: bool = Field(..., description='Флаг успешности выполнения операции') + created_at: datetime = Field(..., description='Дата и время создания записи') class MonitoringLogListResponse(BaseModel): @@ -27,26 +28,26 @@ class MonitoringLogListResponse(BaseModel): total: int = Field(..., ge=0) limit: int = Field(..., ge=1) offset: int = Field(..., ge=0) - items: List[MonitoringLogEntry] + items: list[MonitoringLogEntry] class MonitoringLogTypeListResponse(BaseModel): """Ответ со списком доступных типов событий мониторинга.""" - items: List[str] = Field(default_factory=list) + items: list[str] = Field(default_factory=list) class SupportAuditLogEntry(BaseModel): """Запись аудита модераторов поддержки.""" id: int - actor_user_id: Optional[int] - actor_telegram_id: Optional[int] = None + actor_user_id: int | None + actor_telegram_id: int | None = None is_moderator: bool action: str - ticket_id: Optional[int] - target_user_id: Optional[int] - details: Optional[Dict[str, Any]] = None + ticket_id: int | None + target_user_id: int | None + details: dict[str, Any] | None = None created_at: datetime @@ -56,37 +57,35 @@ class SupportAuditLogListResponse(BaseModel): total: int = Field(..., ge=0) limit: int = Field(..., ge=1) offset: int = Field(..., ge=0) - items: List[SupportAuditLogEntry] + items: list[SupportAuditLogEntry] class SupportAuditActionsResponse(BaseModel): """Ответ со списком доступных действий аудита поддержки.""" - items: List[str] = Field(default_factory=list) + items: list[str] = Field(default_factory=list) class SystemLogPreviewResponse(BaseModel): """Ответ с превью системного лог-файла бота.""" - path: str = Field(..., description="Абсолютный путь до лог-файла") - exists: bool = Field(..., description="Флаг наличия лог-файла") - updated_at: Optional[datetime] = Field( + path: str = Field(..., description='Абсолютный путь до лог-файла') + exists: bool = Field(..., description='Флаг наличия лог-файла') + updated_at: datetime | None = Field( default=None, - description="Дата и время последнего изменения лог-файла", + description='Дата и время последнего изменения лог-файла', ) - size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах") - size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле") + size_bytes: int = Field(..., ge=0, description='Размер лог-файла в байтах') + size_chars: int = Field(..., ge=0, description='Количество символов в лог-файле') preview: str = Field( - default="", - description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра", + default='', + description='Фрагмент содержимого лог-файла, возвращаемый для предпросмотра', ) - preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах") - preview_truncated: bool = Field( - ..., description="Флаг усечения предпросмотра относительно полного файла" - ) - download_url: Optional[str] = Field( + preview_chars: int = Field(..., ge=0, description='Размер предпросмотра в символах') + preview_truncated: bool = Field(..., description='Флаг усечения предпросмотра относительно полного файла') + download_url: str | None = Field( default=None, - description="Относительный путь до endpoint для скачивания лог-файла", + description='Относительный путь до endpoint для скачивания лог-файла', ) @@ -95,7 +94,7 @@ class SystemLogFullResponse(BaseModel): path: str exists: bool - updated_at: Optional[datetime] = None + updated_at: datetime | None = None size_bytes: int size_chars: int content: str diff --git a/app/webapi/schemas/main_menu_buttons.py b/app/webapi/schemas/main_menu_buttons.py index e7b89a70..6ed81ccb 100644 --- a/app/webapi/schemas/main_menu_buttons.py +++ b/app/webapi/schemas/main_menu_buttons.py @@ -1,10 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional - -from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field, validator @@ -12,18 +8,18 @@ from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibili def _clean_text(value: str) -> str: - cleaned = (value or "").strip() + cleaned = (value or '').strip() if not cleaned: - raise ValueError("Text cannot be empty") + raise ValueError('Text cannot be empty') return cleaned def _validate_action_value(value: str) -> str: - cleaned = (value or "").strip() + cleaned = (value or '').strip() if not cleaned: - raise ValueError("Action value cannot be empty") - if not cleaned.lower().startswith(("http://", "https://")): - raise ValueError("Action value must start with http:// or https://") + raise ValueError('Action value cannot be empty') + if not cleaned.lower().startswith(('http://', 'https://')): + raise ValueError('Action value must start with http:// or https://') return cleaned @@ -45,28 +41,28 @@ class MainMenuButtonCreateRequest(BaseModel): action_value: str = Field(..., min_length=1, max_length=1024) visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL is_active: bool = True - display_order: Optional[int] = Field(None, ge=0) + display_order: int | None = Field(None, ge=0) - _normalize_text = validator("text", allow_reuse=True)(_clean_text) - _normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value) + _normalize_text = validator('text', allow_reuse=True)(_clean_text) + _normalize_action_value = validator('action_value', allow_reuse=True)(_validate_action_value) class MainMenuButtonUpdateRequest(BaseModel): - text: Optional[str] = Field(None, min_length=1, max_length=64) - action_type: Optional[MainMenuButtonActionType] = None - action_value: Optional[str] = Field(None, min_length=1, max_length=1024) - visibility: Optional[MainMenuButtonVisibility] = None - is_active: Optional[bool] = None - display_order: Optional[int] = Field(None, ge=0) + text: str | None = Field(None, min_length=1, max_length=64) + action_type: MainMenuButtonActionType | None = None + action_value: str | None = Field(None, min_length=1, max_length=1024) + visibility: MainMenuButtonVisibility | None = None + is_active: bool | None = None + display_order: int | None = Field(None, ge=0) - @validator("text") - def validate_text(cls, value): # noqa: D401,B902 + @validator('text') + def validate_text(cls, value): if value is None: return value return _clean_text(value) - @validator("action_value") - def validate_action_value(cls, value): # noqa: D401,B902 + @validator('action_value') + def validate_action_value(cls, value): if value is None: return value return _validate_action_value(value) diff --git a/app/webapi/schemas/media.py b/app/webapi/schemas/media.py index 5e7436d2..af2c1293 100644 --- a/app/webapi/schemas/media.py +++ b/app/webapi/schemas/media.py @@ -1,16 +1,10 @@ from __future__ import annotations -from typing import Optional - from pydantic import BaseModel, Field class MediaUploadResponse(BaseModel): - media_type: str = Field(description="Тип загруженного файла (photo, video, document)") - file_id: str = Field(description="Telegram file_id загруженного файла") - file_unique_id: Optional[str] = Field( - default=None, description="Уникальный идентификатор файла" - ) - media_url: Optional[str] = Field( - default=None, description="Прямая ссылка на файл для предпросмотра" - ) + media_type: str = Field(description='Тип загруженного файла (photo, video, document)') + file_id: str = Field(description='Telegram file_id загруженного файла') + file_unique_id: str | None = Field(default=None, description='Уникальный идентификатор файла') + media_url: str | None = Field(default=None, description='Прямая ссылка на файл для предпросмотра') diff --git a/app/webapi/schemas/menu_layout.py b/app/webapi/schemas/menu_layout.py index e8c89c3c..4a42844a 100644 --- a/app/webapi/schemas/menu_layout.py +++ b/app/webapi/schemas/menu_layout.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -12,187 +12,117 @@ from pydantic import BaseModel, ConfigDict, Field class ButtonType(str, Enum): """Тип кнопки меню.""" - BUILTIN = "builtin" # Встроенная кнопка с callback_data - URL = "url" # Внешняя ссылка - MINI_APP = "mini_app" # Telegram Mini App - CALLBACK = "callback" # Кастомная кнопка с любым callback_data + BUILTIN = 'builtin' # Встроенная кнопка с callback_data + URL = 'url' # Внешняя ссылка + MINI_APP = 'mini_app' # Telegram Mini App + CALLBACK = 'callback' # Кастомная кнопка с любым callback_data class ButtonVisibility(str, Enum): """Видимость кнопки.""" - ALL = "all" # Видна всем - ADMINS = "admins" # Только админам - MODERATORS = "moderators" # Только модераторам - SUBSCRIBERS = "subscribers" # Только подписчикам + ALL = 'all' # Видна всем + ADMINS = 'admins' # Только админам + MODERATORS = 'moderators' # Только модераторам + SUBSCRIBERS = 'subscribers' # Только подписчикам class ButtonOpenMode(str, Enum): """Режим открытия кнопки.""" - CALLBACK = "callback" # Отправляет callback_data боту (по умолчанию) - DIRECT = "direct" # Сразу открывает Mini App через WebAppInfo + CALLBACK = 'callback' # Отправляет callback_data боту (по умолчанию) + DIRECT = 'direct' # Сразу открывает Mini App через WebAppInfo class ButtonConditions(BaseModel): """Условия показа кнопки.""" # Существующие условия - has_active_subscription: Optional[bool] = Field( - default=None, description="Требуется активная подписка" - ) - subscription_is_active: Optional[bool] = Field( - default=None, description="Подписка должна быть активна (не приостановлена)" - ) - has_traffic_limit: Optional[bool] = Field( - default=None, description="Подписка с лимитом трафика" - ) - is_admin: Optional[bool] = Field(default=None, description="Пользователь - админ") - is_moderator: Optional[bool] = Field( - default=None, description="Пользователь - модератор" - ) - referral_enabled: Optional[bool] = Field( - default=None, description="Реферальная программа включена" - ) - contests_visible: Optional[bool] = Field( - default=None, description="Конкурсы видимы" - ) - support_enabled: Optional[bool] = Field( - default=None, description="Поддержка включена" - ) - language_selection_enabled: Optional[bool] = Field( - default=None, description="Выбор языка включен" - ) - happ_enabled: Optional[bool] = Field( - default=None, description="Кнопка Happ включена" - ) - simple_subscription_enabled: Optional[bool] = Field( - default=None, description="Простая подписка включена" - ) - show_trial: Optional[bool] = Field( - default=None, description="Показать пробный период" - ) - show_buy: Optional[bool] = Field( - default=None, description="Показать кнопку покупки" - ) - has_saved_cart: Optional[bool] = Field( - default=None, description="Есть сохраненная корзина" + has_active_subscription: bool | None = Field(default=None, description='Требуется активная подписка') + subscription_is_active: bool | None = Field( + default=None, description='Подписка должна быть активна (не приостановлена)' ) + has_traffic_limit: bool | None = Field(default=None, description='Подписка с лимитом трафика') + is_admin: bool | None = Field(default=None, description='Пользователь - админ') + is_moderator: bool | None = Field(default=None, description='Пользователь - модератор') + referral_enabled: bool | None = Field(default=None, description='Реферальная программа включена') + contests_visible: bool | None = Field(default=None, description='Конкурсы видимы') + support_enabled: bool | None = Field(default=None, description='Поддержка включена') + language_selection_enabled: bool | None = Field(default=None, description='Выбор языка включен') + happ_enabled: bool | None = Field(default=None, description='Кнопка Happ включена') + simple_subscription_enabled: bool | None = Field(default=None, description='Простая подписка включена') + show_trial: bool | None = Field(default=None, description='Показать пробный период') + show_buy: bool | None = Field(default=None, description='Показать кнопку покупки') + has_saved_cart: bool | None = Field(default=None, description='Есть сохраненная корзина') # Расширенные условия - min_balance_kopeks: Optional[int] = Field( - default=None, ge=0, description="Минимальный баланс в копейках" + min_balance_kopeks: int | None = Field(default=None, ge=0, description='Минимальный баланс в копейках') + max_balance_kopeks: int | None = Field(default=None, ge=0, description='Максимальный баланс в копейках') + min_registration_days: int | None = Field(default=None, ge=0, description='Минимум дней с регистрации') + max_registration_days: int | None = Field(default=None, ge=0, description='Максимум дней с регистрации') + min_referrals: int | None = Field(default=None, ge=0, description='Минимальное количество рефералов') + has_referrals: bool | None = Field(default=None, description='Есть рефералы') + promo_group_ids: list[str] | None = Field( + default=None, description='Список ID промо-групп (пользователь должен быть в одной из них)' ) - max_balance_kopeks: Optional[int] = Field( - default=None, ge=0, description="Максимальный баланс в копейках" + exclude_promo_group_ids: list[str] | None = Field( + default=None, description='Исключить пользователей из этих промо-групп' ) - min_registration_days: Optional[int] = Field( - default=None, ge=0, description="Минимум дней с регистрации" - ) - max_registration_days: Optional[int] = Field( - default=None, ge=0, description="Максимум дней с регистрации" - ) - min_referrals: Optional[int] = Field( - default=None, ge=0, description="Минимальное количество рефералов" - ) - has_referrals: Optional[bool] = Field( - default=None, description="Есть рефералы" - ) - promo_group_ids: Optional[List[str]] = Field( - default=None, description="Список ID промо-групп (пользователь должен быть в одной из них)" - ) - exclude_promo_group_ids: Optional[List[str]] = Field( - default=None, description="Исключить пользователей из этих промо-групп" - ) - has_subscription_days_left: Optional[int] = Field( - default=None, ge=0, description="Минимум дней до окончания подписки" - ) - max_subscription_days_left: Optional[int] = Field( - default=None, ge=0, description="Максимум дней до окончания подписки" - ) - is_trial_user: Optional[bool] = Field( - default=None, description="Пользователь на пробном периоде" - ) - has_autopay: Optional[bool] = Field( - default=None, description="Автоплатёж включён" + has_subscription_days_left: int | None = Field(default=None, ge=0, description='Минимум дней до окончания подписки') + max_subscription_days_left: int | None = Field( + default=None, ge=0, description='Максимум дней до окончания подписки' ) + is_trial_user: bool | None = Field(default=None, description='Пользователь на пробном периоде') + has_autopay: bool | None = Field(default=None, description='Автоплатёж включён') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuButtonConfig(BaseModel): """Конфигурация отдельной кнопки.""" - type: ButtonType = Field(..., description="Тип кнопки") - builtin_id: Optional[str] = Field( - default=None, description="ID встроенной кнопки (для type=builtin)" - ) - text: Dict[str, str] = Field( - ..., description="Локализованные тексты кнопки: {lang_code: text}" - ) - icon: Optional[str] = Field( - default=None, max_length=10, description="Эмодзи/иконка кнопки (отдельно от текста)" - ) - action: str = Field( - ..., description="callback_data или URL в зависимости от типа" - ) - enabled: bool = Field(default=True, description="Кнопка активна") - visibility: ButtonVisibility = Field( - default=ButtonVisibility.ALL, description="Видимость кнопки" - ) - conditions: Optional[ButtonConditions] = Field( - default=None, description="Дополнительные условия показа" - ) - dynamic_text: bool = Field( - default=False, description="Текст содержит плейсхолдеры ({balance}, {username} и т.д.)" - ) + type: ButtonType = Field(..., description='Тип кнопки') + builtin_id: str | None = Field(default=None, description='ID встроенной кнопки (для type=builtin)') + text: dict[str, str] = Field(..., description='Локализованные тексты кнопки: {lang_code: text}') + icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки (отдельно от текста)') + action: str = Field(..., description='callback_data или URL в зависимости от типа') + enabled: bool = Field(default=True, description='Кнопка активна') + visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость кнопки') + conditions: ButtonConditions | None = Field(default=None, description='Дополнительные условия показа') + dynamic_text: bool = Field(default=False, description='Текст содержит плейсхолдеры ({balance}, {username} и т.д.)') open_mode: ButtonOpenMode = Field( default=ButtonOpenMode.CALLBACK, - description="Режим открытия: callback (через бота) или direct (сразу Mini App)", + description='Режим открытия: callback (через бота) или direct (сразу Mini App)', ) - webapp_url: Optional[str] = Field( + webapp_url: str | None = Field( default=None, - description="URL для Mini App при open_mode=direct", - ) - description: Optional[str] = Field( - default=None, max_length=200, description="Описание кнопки для админ-панели" - ) - sort_order: Optional[int] = Field( - default=None, description="Порядок сортировки (для отображения в админке)" + description='URL для Mini App при open_mode=direct', ) + description: str | None = Field(default=None, max_length=200, description='Описание кнопки для админ-панели') + sort_order: int | None = Field(default=None, description='Порядок сортировки (для отображения в админке)') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuRowConfig(BaseModel): """Конфигурация строки меню.""" - id: str = Field(..., min_length=1, max_length=50, description="Уникальный ID строки") - buttons: List[str] = Field( - ..., description="Список ID кнопок в строке" - ) - conditions: Optional[ButtonConditions] = Field( - default=None, description="Условия показа всей строки" - ) - max_per_row: int = Field( - default=2, ge=1, le=4, description="Максимум кнопок в строке" - ) + id: str = Field(..., min_length=1, max_length=50, description='Уникальный ID строки') + buttons: list[str] = Field(..., description='Список ID кнопок в строке') + conditions: ButtonConditions | None = Field(default=None, description='Условия показа всей строки') + max_per_row: int = Field(default=2, ge=1, le=4, description='Максимум кнопок в строке') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuLayoutConfig(BaseModel): """Полная конфигурация меню.""" - version: int = Field(default=1, description="Версия формата конфигурации") - rows: List[MenuRowConfig] = Field( - default_factory=list, description="Строки меню" - ) - buttons: Dict[str, MenuButtonConfig] = Field( - default_factory=dict, description="Конфигурации кнопок" - ) + version: int = Field(default=1, description='Версия формата конфигурации') + rows: list[MenuRowConfig] = Field(default_factory=list, description='Строки меню') + buttons: dict[str, MenuButtonConfig] = Field(default_factory=dict, description='Конфигурации кнопок') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') # --- Response schemas --- @@ -202,33 +132,27 @@ class MenuLayoutResponse(BaseModel): """Ответ с конфигурацией меню.""" version: int - rows: List[MenuRowConfig] - buttons: Dict[str, MenuButtonConfig] - is_enabled: bool = Field(description="Включен ли конструктор меню") - updated_at: Optional[datetime] = None + rows: list[MenuRowConfig] + buttons: dict[str, MenuButtonConfig] + is_enabled: bool = Field(description='Включен ли конструктор меню') + updated_at: datetime | None = None class BuiltinButtonInfo(BaseModel): """Информация о встроенной кнопке.""" - id: str = Field(description="Идентификатор кнопки") - default_text: Dict[str, str] = Field(description="Текст по умолчанию") - callback_data: str = Field(description="callback_data кнопки") - default_conditions: Optional[ButtonConditions] = Field( - default=None, description="Условия показа по умолчанию" - ) - supports_dynamic_text: bool = Field( - default=False, description="Поддерживает ли динамический текст" - ) - supports_direct_open: bool = Field( - default=False, description="Поддерживает ли прямое открытие Mini App" - ) + id: str = Field(description='Идентификатор кнопки') + default_text: dict[str, str] = Field(description='Текст по умолчанию') + callback_data: str = Field(description='callback_data кнопки') + default_conditions: ButtonConditions | None = Field(default=None, description='Условия показа по умолчанию') + supports_dynamic_text: bool = Field(default=False, description='Поддерживает ли динамический текст') + supports_direct_open: bool = Field(default=False, description='Поддерживает ли прямое открытие Mini App') class BuiltinButtonsListResponse(BaseModel): """Список встроенных кнопок.""" - items: List[BuiltinButtonInfo] + items: list[BuiltinButtonInfo] total: int @@ -238,126 +162,78 @@ class BuiltinButtonsListResponse(BaseModel): class MenuLayoutUpdateRequest(BaseModel): """Запрос на обновление конфигурации меню.""" - rows: Optional[List[MenuRowConfig]] = Field( - default=None, description="Новая конфигурация строк" - ) - buttons: Optional[Dict[str, MenuButtonConfig]] = Field( - default=None, description="Новая конфигурация кнопок" - ) + rows: list[MenuRowConfig] | None = Field(default=None, description='Новая конфигурация строк') + buttons: dict[str, MenuButtonConfig] | None = Field(default=None, description='Новая конфигурация кнопок') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class ButtonUpdateRequest(BaseModel): """Запрос на обновление отдельной кнопки.""" - text: Optional[Dict[str, str]] = Field( - default=None, description="Новые локализованные тексты" - ) - icon: Optional[str] = Field( - default=None, max_length=10, description="Эмодзи/иконка кнопки" - ) - enabled: Optional[bool] = Field(default=None, description="Включить/выключить") - visibility: Optional[ButtonVisibility] = Field( - default=None, description="Новая видимость" - ) - conditions: Optional[ButtonConditions] = Field( - default=None, description="Новые условия показа" - ) - action: Optional[str] = Field( - default=None, description="Новый action (callback_data или URL)" - ) - dynamic_text: Optional[bool] = Field( - default=None, description="Текст содержит плейсхолдеры" - ) - open_mode: Optional[ButtonOpenMode] = Field( - default=None, description="Режим открытия: callback или direct" - ) - webapp_url: Optional[str] = Field( - default=None, description="URL для Mini App при open_mode=direct" - ) - description: Optional[str] = Field( - default=None, max_length=200, description="Описание кнопки" - ) - sort_order: Optional[int] = Field( - default=None, description="Порядок сортировки" - ) + text: dict[str, str] | None = Field(default=None, description='Новые локализованные тексты') + icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки') + enabled: bool | None = Field(default=None, description='Включить/выключить') + visibility: ButtonVisibility | None = Field(default=None, description='Новая видимость') + conditions: ButtonConditions | None = Field(default=None, description='Новые условия показа') + action: str | None = Field(default=None, description='Новый action (callback_data или URL)') + dynamic_text: bool | None = Field(default=None, description='Текст содержит плейсхолдеры') + open_mode: ButtonOpenMode | None = Field(default=None, description='Режим открытия: callback или direct') + webapp_url: str | None = Field(default=None, description='URL для Mini App при open_mode=direct') + description: str | None = Field(default=None, max_length=200, description='Описание кнопки') + sort_order: int | None = Field(default=None, description='Порядок сортировки') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class RowsReorderRequest(BaseModel): """Запрос на изменение порядка строк.""" - ordered_ids: List[str] = Field( - ..., min_length=1, description="Список ID строк в новом порядке" - ) + ordered_ids: list[str] = Field(..., min_length=1, description='Список ID строк в новом порядке') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class AddRowRequest(BaseModel): """Запрос на добавление новой строки.""" - id: str = Field(..., min_length=1, max_length=50, description="ID новой строки") - buttons: List[str] = Field(..., description="Список ID кнопок") - conditions: Optional[ButtonConditions] = Field( - default=None, description="Условия показа" - ) - max_per_row: int = Field(default=2, ge=1, le=4, description="Макс. кнопок в строке") - position: Optional[int] = Field( - default=None, ge=0, description="Позиция вставки (по умолчанию - в конец)" - ) + id: str = Field(..., min_length=1, max_length=50, description='ID новой строки') + buttons: list[str] = Field(..., description='Список ID кнопок') + conditions: ButtonConditions | None = Field(default=None, description='Условия показа') + max_per_row: int = Field(default=2, ge=1, le=4, description='Макс. кнопок в строке') + position: int | None = Field(default=None, ge=0, description='Позиция вставки (по умолчанию - в конец)') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class AddCustomButtonRequest(BaseModel): """Запрос на добавление кастомной кнопки.""" - id: str = Field( - ..., min_length=1, max_length=50, description="ID кнопки (уникальный)" - ) - type: ButtonType = Field(..., description="Тип кнопки (url, mini_app или callback)") - text: Dict[str, str] = Field(..., description="Локализованные тексты") - icon: Optional[str] = Field( - default=None, max_length=10, description="Эмодзи/иконка кнопки" - ) - action: str = Field(..., min_length=1, description="URL или callback_data") - visibility: ButtonVisibility = Field( - default=ButtonVisibility.ALL, description="Видимость" - ) - conditions: Optional[ButtonConditions] = Field( - default=None, description="Условия показа" - ) - dynamic_text: bool = Field( - default=False, description="Текст содержит плейсхолдеры" - ) - row_id: Optional[str] = Field( - default=None, description="ID строки для добавления кнопки" - ) - description: Optional[str] = Field( - default=None, max_length=200, description="Описание кнопки для админ-панели" - ) + id: str = Field(..., min_length=1, max_length=50, description='ID кнопки (уникальный)') + type: ButtonType = Field(..., description='Тип кнопки (url, mini_app или callback)') + text: dict[str, str] = Field(..., description='Локализованные тексты') + icon: str | None = Field(default=None, max_length=10, description='Эмодзи/иконка кнопки') + action: str = Field(..., min_length=1, description='URL или callback_data') + visibility: ButtonVisibility = Field(default=ButtonVisibility.ALL, description='Видимость') + conditions: ButtonConditions | None = Field(default=None, description='Условия показа') + dynamic_text: bool = Field(default=False, description='Текст содержит плейсхолдеры') + row_id: str | None = Field(default=None, description='ID строки для добавления кнопки') + description: str | None = Field(default=None, max_length=200, description='Описание кнопки для админ-панели') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuPreviewRequest(BaseModel): """Запрос на предпросмотр меню.""" - language: str = Field(default="ru", description="Язык для предпросмотра") - is_admin: bool = Field(default=False, description="Режим админа") - is_moderator: bool = Field(default=False, description="Режим модератора") - has_active_subscription: bool = Field( - default=False, description="Есть активная подписка" - ) - subscription_is_active: bool = Field( - default=False, description="Подписка активна" - ) - balance_kopeks: int = Field(default=0, ge=0, description="Баланс в копейках") + language: str = Field(default='ru', description='Язык для предпросмотра') + is_admin: bool = Field(default=False, description='Режим админа') + is_moderator: bool = Field(default=False, description='Режим модератора') + has_active_subscription: bool = Field(default=False, description='Есть активная подписка') + subscription_is_active: bool = Field(default=False, description='Подписка активна') + balance_kopeks: int = Field(default=0, ge=0, description='Баланс в копейках') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuPreviewButton(BaseModel): @@ -371,13 +247,13 @@ class MenuPreviewButton(BaseModel): class MenuPreviewRow(BaseModel): """Строка в предпросмотре.""" - buttons: List[MenuPreviewButton] + buttons: list[MenuPreviewButton] class MenuPreviewResponse(BaseModel): """Ответ с предпросмотром меню.""" - rows: List[MenuPreviewRow] + rows: list[MenuPreviewRow] total_buttons: int @@ -387,54 +263,50 @@ class MenuPreviewResponse(BaseModel): class MoveButtonToRowRequest(BaseModel): """Запрос на перемещение кнопки в другую строку.""" - target_row_id: str = Field(..., description="ID целевой строки") - position: Optional[int] = Field( - default=None, ge=0, description="Позиция в строке (по умолчанию - в конец)" - ) + target_row_id: str = Field(..., description='ID целевой строки') + position: int | None = Field(default=None, ge=0, description='Позиция в строке (по умолчанию - в конец)') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class ReorderButtonsInRowRequest(BaseModel): """Запрос на изменение порядка кнопок в строке.""" - ordered_button_ids: List[str] = Field( - ..., min_length=1, description="Список ID кнопок в новом порядке" - ) + ordered_button_ids: list[str] = Field(..., min_length=1, description='Список ID кнопок в новом порядке') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class SwapButtonsRequest(BaseModel): """Запрос на обмен местами двух кнопок.""" - button_id_1: str = Field(..., description="ID первой кнопки") - button_id_2: str = Field(..., description="ID второй кнопки") + button_id_1: str = Field(..., description='ID первой кнопки') + button_id_2: str = Field(..., description='ID второй кнопки') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MoveButtonResponse(BaseModel): """Ответ на перемещение кнопки.""" button_id: str - new_row_index: Optional[int] = None - target_row_id: Optional[str] = None - position: Optional[int] = None + new_row_index: int | None = None + target_row_id: str | None = None + position: int | None = None class SwapButtonsResponse(BaseModel): """Ответ на обмен кнопок.""" - button_1: Dict[str, Any] - button_2: Dict[str, Any] + button_1: dict[str, Any] + button_2: dict[str, Any] class ReorderButtonsResponse(BaseModel): """Ответ на изменение порядка кнопок.""" row_id: str - buttons: List[str] + buttons: list[str] # --- Схемы для доступных callback_data --- @@ -443,22 +315,22 @@ class ReorderButtonsResponse(BaseModel): class AvailableCallback(BaseModel): """Информация о доступном callback_data.""" - callback_data: str = Field(description="callback_data для кнопки") - name: str = Field(description="Человекочитаемое название") - description: Optional[str] = Field(default=None, description="Описание действия") - category: str = Field(description="Категория: menu, subscription, balance, referral, support, etc.") - default_text: Optional[Dict[str, str]] = Field(default=None, description="Текст по умолчанию") - default_icon: Optional[str] = Field(default=None, description="Иконка по умолчанию") - requires_subscription: bool = Field(default=False, description="Требует активную подписку") - is_in_menu: bool = Field(default=False, description="Уже добавлена в меню") + callback_data: str = Field(description='callback_data для кнопки') + name: str = Field(description='Человекочитаемое название') + description: str | None = Field(default=None, description='Описание действия') + category: str = Field(description='Категория: menu, subscription, balance, referral, support, etc.') + default_text: dict[str, str] | None = Field(default=None, description='Текст по умолчанию') + default_icon: str | None = Field(default=None, description='Иконка по умолчанию') + requires_subscription: bool = Field(default=False, description='Требует активную подписку') + is_in_menu: bool = Field(default=False, description='Уже добавлена в меню') class AvailableCallbacksResponse(BaseModel): """Список всех доступных callback_data.""" - items: List[AvailableCallback] + items: list[AvailableCallback] total: int - categories: List[str] = Field(description="Список всех категорий") + categories: list[str] = Field(description='Список всех категорий') # --- Схемы для импорта/экспорта --- @@ -468,24 +340,21 @@ class MenuLayoutExportResponse(BaseModel): """Экспорт конфигурации меню.""" version: int - rows: List[MenuRowConfig] - buttons: Dict[str, MenuButtonConfig] + rows: list[MenuRowConfig] + buttons: dict[str, MenuButtonConfig] exported_at: datetime - bot_version: Optional[str] = None + bot_version: str | None = None class MenuLayoutImportRequest(BaseModel): """Импорт конфигурации меню.""" version: int - rows: List[MenuRowConfig] - buttons: Dict[str, MenuButtonConfig] - merge_mode: str = Field( - default="replace", - description="Режим импорта: replace (заменить всё), merge (объединить)" - ) + rows: list[MenuRowConfig] + buttons: dict[str, MenuButtonConfig] + merge_mode: str = Field(default='replace', description='Режим импорта: replace (заменить всё), merge (объединить)') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuLayoutImportResponse(BaseModel): @@ -494,7 +363,7 @@ class MenuLayoutImportResponse(BaseModel): success: bool imported_rows: int imported_buttons: int - warnings: List[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) # --- Схемы для истории изменений --- @@ -505,24 +374,24 @@ class MenuLayoutHistoryEntry(BaseModel): id: int created_at: datetime - action: str = Field(description="Тип действия: update, reset, import") - changes_summary: str = Field(description="Краткое описание изменений") - user_info: Optional[str] = Field(default=None, description="Информация о пользователе") + action: str = Field(description='Тип действия: update, reset, import') + changes_summary: str = Field(description='Краткое описание изменений') + user_info: str | None = Field(default=None, description='Информация о пользователе') class MenuLayoutHistoryResponse(BaseModel): """История изменений.""" - items: List[MenuLayoutHistoryEntry] + items: list[MenuLayoutHistoryEntry] total: int class MenuLayoutRollbackRequest(BaseModel): """Запрос на откат к предыдущей версии.""" - history_id: int = Field(description="ID записи в истории для отката") + history_id: int = Field(description='ID записи в истории для отката') - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') # --- Схемы для валидации --- @@ -533,24 +402,24 @@ class ValidationError(BaseModel): field: str message: str - severity: str = Field(description="error или warning") + severity: str = Field(description='error или warning') class MenuLayoutValidateRequest(BaseModel): """Запрос на валидацию конфигурации.""" - rows: Optional[List[MenuRowConfig]] = None - buttons: Optional[Dict[str, MenuButtonConfig]] = None + rows: list[MenuRowConfig] | None = None + buttons: dict[str, MenuButtonConfig] | None = None - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra='forbid') class MenuLayoutValidateResponse(BaseModel): """Результат валидации.""" is_valid: bool - errors: List[ValidationError] = Field(default_factory=list) - warnings: List[ValidationError] = Field(default_factory=list) + errors: list[ValidationError] = Field(default_factory=list) + warnings: list[ValidationError] = Field(default_factory=list) # --- Схемы для статистики кликов --- @@ -564,8 +433,8 @@ class ButtonClickStats(BaseModel): clicks_today: int = Field(default=0) clicks_week: int = Field(default=0) clicks_month: int = Field(default=0) - last_click_at: Optional[datetime] = None - unique_users: int = Field(default=0, description="Уникальные пользователи") + last_click_at: datetime | None = None + unique_users: int = Field(default=0, description='Уникальные пользователи') class ButtonClickStatsResponse(BaseModel): @@ -573,15 +442,13 @@ class ButtonClickStatsResponse(BaseModel): button_id: str stats: ButtonClickStats - clicks_by_day: List[Dict[str, Any]] = Field( - default_factory=list, description="Клики по дням [{date, count}]" - ) + clicks_by_day: list[dict[str, Any]] = Field(default_factory=list, description='Клики по дням [{date, count}]') class MenuClickStatsResponse(BaseModel): """Общая статистика кликов по всем кнопкам.""" - items: List[ButtonClickStats] + items: list[ButtonClickStats] total_clicks: int period_start: datetime period_end: datetime @@ -598,7 +465,7 @@ class ButtonTypeStats(BaseModel): class ButtonTypeStatsResponse(BaseModel): """Статистика кликов по типам кнопок.""" - items: List[ButtonTypeStats] + items: list[ButtonTypeStats] total_clicks: int @@ -612,8 +479,8 @@ class HourlyStats(BaseModel): class HourlyStatsResponse(BaseModel): """Статистика кликов по часам дня.""" - items: List[HourlyStats] - button_id: Optional[str] = None + items: list[HourlyStats] + button_id: str | None = None class WeekdayStats(BaseModel): @@ -627,8 +494,8 @@ class WeekdayStats(BaseModel): class WeekdayStatsResponse(BaseModel): """Статистика кликов по дням недели.""" - items: List[WeekdayStats] - button_id: Optional[str] = None + items: list[WeekdayStats] + button_id: str | None = None class TopUserStats(BaseModel): @@ -636,31 +503,31 @@ class TopUserStats(BaseModel): user_id: int clicks_count: int - last_click_at: Optional[datetime] = None + last_click_at: datetime | None = None class TopUsersResponse(BaseModel): """Топ пользователей по кликам.""" - items: List[TopUserStats] - button_id: Optional[str] = None + items: list[TopUserStats] + button_id: str | None = None limit: int class PeriodComparisonResponse(BaseModel): """Сравнение периодов.""" - current_period: Dict[str, Any] - previous_period: Dict[str, Any] - change: Dict[str, Any] - button_id: Optional[str] = None + current_period: dict[str, Any] + previous_period: dict[str, Any] + change: dict[str, Any] + button_id: str | None = None class UserClickSequence(BaseModel): """Последовательность кликов пользователя.""" button_id: str - button_text: Optional[str] = None + button_text: str | None = None clicked_at: datetime @@ -668,7 +535,7 @@ class UserClickSequencesResponse(BaseModel): """Последовательности кликов пользователя.""" user_id: int - items: List[UserClickSequence] + items: list[UserClickSequence] total: int @@ -678,14 +545,14 @@ class UserClickSequencesResponse(BaseModel): class DynamicPlaceholder(BaseModel): """Информация о динамическом плейсхолдере.""" - placeholder: str = Field(description="Плейсхолдер, например {balance}") - description: str = Field(description="Описание") - example: str = Field(description="Пример значения") - category: str = Field(description="Категория: user, subscription, referral, etc.") + placeholder: str = Field(description='Плейсхолдер, например {balance}') + description: str = Field(description='Описание') + example: str = Field(description='Пример значения') + category: str = Field(description='Категория: user, subscription, referral, etc.') class DynamicPlaceholdersResponse(BaseModel): """Список доступных плейсхолдеров.""" - items: List[DynamicPlaceholder] + items: list[DynamicPlaceholder] total: int diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 8ba602be..5ff43448 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -2,58 +2,58 @@ from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, Field, model_validator class MiniAppBranding(BaseModel): - service_name: Dict[str, Optional[str]] = Field(default_factory=dict) - service_description: Dict[str, Optional[str]] = Field(default_factory=dict) + service_name: dict[str, str | None] = Field(default_factory=dict) + service_description: dict[str, str | None] = Field(default_factory=dict) class MiniAppSubscriptionRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') class MiniAppMaintenanceStatusResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) - is_active: bool = Field(..., alias="isActive") - message: Optional[str] = None - reason: Optional[str] = None + is_active: bool = Field(..., alias='isActive') + message: str | None = None + reason: str | None = None class MiniAppSubscriptionUser(BaseModel): - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None display_name: str - language: Optional[str] = None + language: str | None = None status: str subscription_status: str subscription_actual_status: str status_label: str - expires_at: Optional[datetime] = None - device_limit: Optional[int] = None + expires_at: datetime | None = None + device_limit: int | None = None traffic_used_gb: float = 0.0 traffic_used_label: str - traffic_limit_gb: Optional[int] = None + traffic_limit_gb: int | None = None traffic_limit_label: str lifetime_used_traffic_gb: float = 0.0 has_active_subscription: bool = False promo_offer_discount_percent: int = 0 - promo_offer_discount_expires_at: Optional[datetime] = None - promo_offer_discount_source: Optional[str] = None + promo_offer_discount_expires_at: datetime | None = None + promo_offer_discount_source: str | None = None # Суточные тарифы is_daily_tariff: bool = False is_daily_paused: bool = False - daily_tariff_name: Optional[str] = None - daily_price_kopeks: Optional[int] = None - daily_price_label: Optional[str] = None - daily_next_charge_at: Optional[datetime] = None # Время следующего списания + daily_tariff_name: str | None = None + daily_price_kopeks: int | None = None + daily_price_label: str | None = None + daily_next_charge_at: datetime | None = None # Время следующего списания class MiniAppPromoGroup(BaseModel): @@ -62,7 +62,7 @@ class MiniAppPromoGroup(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[str, int] = Field(default_factory=dict) + period_discounts: dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -77,7 +77,7 @@ class MiniAppAutoPromoGroupLevel(BaseModel): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - period_discounts: Dict[str, int] = Field(default_factory=dict) + period_discounts: dict[str, int] = Field(default_factory=dict) apply_discounts_to_addons: bool = True @@ -87,22 +87,22 @@ class MiniAppConnectedServer(BaseModel): class MiniAppDevice(BaseModel): - hwid: Optional[str] = None - platform: Optional[str] = None - device_model: Optional[str] = None - app_version: Optional[str] = None - last_seen: Optional[str] = None - last_ip: Optional[str] = None + hwid: str | None = None + platform: str | None = None + device_model: str | None = None + app_version: str | None = None + last_seen: str | None = None + last_ip: str | None = None class MiniAppDeviceRemovalRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') hwid: str class MiniAppDeviceRemovalResponse(BaseModel): success: bool = True - message: Optional[str] = None + message: str | None = None class MiniAppTransaction(BaseModel): @@ -110,187 +110,187 @@ class MiniAppTransaction(BaseModel): type: str amount_kopeks: int amount_rubles: float - description: Optional[str] = None - payment_method: Optional[str] = None - external_id: Optional[str] = None + description: str | None = None + payment_method: str | None = None + external_id: str | None = None is_completed: bool created_at: datetime - completed_at: Optional[datetime] = None + completed_at: datetime | None = None class MiniAppPromoOffer(BaseModel): id: int status: str - notification_type: Optional[str] = None - offer_type: Optional[str] = None - effect_type: Optional[str] = None + notification_type: str | None = None + offer_type: str | None = None + effect_type: str | None = None discount_percent: int = 0 bonus_amount_kopeks: int = 0 - bonus_amount_label: Optional[str] = None - expires_at: Optional[datetime] = None - claimed_at: Optional[datetime] = None + bonus_amount_label: str | None = None + expires_at: datetime | None = None + claimed_at: datetime | None = None is_active: bool = False - template_id: Optional[int] = None - template_name: Optional[str] = None - button_text: Optional[str] = None - title: Optional[str] = None - message_text: Optional[str] = None - icon: Optional[str] = None - test_squads: List[MiniAppConnectedServer] = Field(default_factory=list) - active_discount_expires_at: Optional[datetime] = None - active_discount_started_at: Optional[datetime] = None - active_discount_duration_seconds: Optional[int] = None + template_id: int | None = None + template_name: str | None = None + button_text: str | None = None + title: str | None = None + message_text: str | None = None + icon: str | None = None + test_squads: list[MiniAppConnectedServer] = Field(default_factory=list) + active_discount_expires_at: datetime | None = None + active_discount_started_at: datetime | None = None + active_discount_duration_seconds: int | None = None class MiniAppPromoOfferClaimRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') class MiniAppPromoOfferClaimResponse(BaseModel): success: bool = True - code: Optional[str] = None + code: str | None = None class MiniAppSubscriptionAutopay(BaseModel): enabled: bool = False - autopay_enabled: Optional[bool] = None - autopay_enabled_at: Optional[datetime] = None - days_before: Optional[int] = None - autopay_days_before: Optional[int] = None - default_days_before: Optional[int] = None - autopay_days_options: List[int] = Field(default_factory=list) - days_options: List[int] = Field(default_factory=list) - options: List[int] = Field(default_factory=list) + autopay_enabled: bool | None = None + autopay_enabled_at: datetime | None = None + days_before: int | None = None + autopay_days_before: int | None = None + default_days_before: int | None = None + autopay_days_options: list[int] = Field(default_factory=list) + days_options: list[int] = Field(default_factory=list) + options: list[int] = Field(default_factory=list) - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class MiniAppSubscriptionRenewalPeriod(BaseModel): id: str - days: Optional[int] = None - months: Optional[int] = None - price_kopeks: Optional[int] = Field(default=None, alias="priceKopeks") - price_label: Optional[str] = Field(default=None, alias="priceLabel") - original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks") - original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel") - discount_percent: int = Field(default=0, alias="discountPercent") - price_per_month_kopeks: Optional[int] = Field(default=None, alias="pricePerMonthKopeks") - price_per_month_label: Optional[str] = Field(default=None, alias="pricePerMonthLabel") - is_recommended: bool = Field(default=False, alias="isRecommended") - description: Optional[str] = None - badge: Optional[str] = None - title: Optional[str] = None + days: int | None = None + months: int | None = None + price_kopeks: int | None = Field(default=None, alias='priceKopeks') + price_label: str | None = Field(default=None, alias='priceLabel') + original_price_kopeks: int | None = Field(default=None, alias='originalPriceKopeks') + original_price_label: str | None = Field(default=None, alias='originalPriceLabel') + discount_percent: int = Field(default=0, alias='discountPercent') + price_per_month_kopeks: int | None = Field(default=None, alias='pricePerMonthKopeks') + price_per_month_label: str | None = Field(default=None, alias='pricePerMonthLabel') + is_recommended: bool = Field(default=False, alias='isRecommended') + description: str | None = None + badge: str | None = None + title: str | None = None model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionRenewalOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + init_data: str = Field(..., alias='initData') + subscription_id: int | None = Field(default=None, alias='subscriptionId') model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionRenewalOptionsResponse(BaseModel): success: bool = True - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + subscription_id: int | None = Field(default=None, alias='subscriptionId') currency: str - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - promo_group: Optional[MiniAppPromoGroup] = Field(default=None, alias="promoGroup") - promo_offer: Optional[Dict[str, Any]] = Field(default=None, alias="promoOffer") - periods: List[MiniAppSubscriptionRenewalPeriod] = Field(default_factory=list) - default_period_id: Optional[str] = Field(default=None, alias="defaultPeriodId") - missing_amount_kopeks: Optional[int] = Field(default=None, alias="missingAmountKopeks") - status_message: Optional[str] = Field(default=None, alias="statusMessage") + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') + promo_group: MiniAppPromoGroup | None = Field(default=None, alias='promoGroup') + promo_offer: dict[str, Any] | None = Field(default=None, alias='promoOffer') + periods: list[MiniAppSubscriptionRenewalPeriod] = Field(default_factory=list) + default_period_id: str | None = Field(default=None, alias='defaultPeriodId') + missing_amount_kopeks: int | None = Field(default=None, alias='missingAmountKopeks') + status_message: str | None = Field(default=None, alias='statusMessage') autopay_enabled: bool = False - autopay_days_before: Optional[int] = None - autopay_days_options: List[int] = Field(default_factory=list) - autopay: Optional[MiniAppSubscriptionAutopay] = None - autopay_settings: Optional[MiniAppSubscriptionAutopay] = None + autopay_days_before: int | None = None + autopay_days_options: list[int] = Field(default_factory=list) + autopay: MiniAppSubscriptionAutopay | None = None + autopay_settings: MiniAppSubscriptionAutopay | None = None # Флаги для определения типа действия (покупка vs продление) - is_trial: bool = Field(default=False, alias="isTrial") - sales_mode: str = Field(default="classic", alias="salesMode") + is_trial: bool = Field(default=False, alias='isTrial') + sales_mode: str = Field(default='classic', alias='salesMode') - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict(populate_by_name=True, extra='allow') class MiniAppSubscriptionRenewalRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - period_id: Optional[str] = Field(default=None, alias="periodId") - period_days: Optional[int] = Field(default=None, alias="periodDays") - method: Optional[str] = None + init_data: str = Field(..., alias='initData') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + period_id: str | None = Field(default=None, alias='periodId') + period_days: int | None = Field(default=None, alias='periodDays') + method: str | None = None model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionRenewalResponse(BaseModel): success: bool = True - message: Optional[str] = None - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil") - requires_payment: bool = Field(default=False, alias="requiresPayment") - payment_method: Optional[str] = Field(default=None, alias="paymentMethod") - payment_url: Optional[str] = Field(default=None, alias="paymentUrl") - payment_amount_kopeks: Optional[int] = Field(default=None, alias="paymentAmountKopeks") - payment_id: Optional[int] = Field(default=None, alias="paymentId") - invoice_id: Optional[str] = Field(default=None, alias="invoiceId") - payment_payload: Optional[str] = Field(default=None, alias="paymentPayload") - payment_extra: Optional[Dict[str, Any]] = Field(default=None, alias="paymentExtra") + message: str | None = None + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + renewed_until: datetime | None = Field(default=None, alias='renewedUntil') + requires_payment: bool = Field(default=False, alias='requiresPayment') + payment_method: str | None = Field(default=None, alias='paymentMethod') + payment_url: str | None = Field(default=None, alias='paymentUrl') + payment_amount_kopeks: int | None = Field(default=None, alias='paymentAmountKopeks') + payment_id: int | None = Field(default=None, alias='paymentId') + invoice_id: str | None = Field(default=None, alias='invoiceId') + payment_payload: str | None = Field(default=None, alias='paymentPayload') + payment_extra: dict[str, Any] | None = Field(default=None, alias='paymentExtra') model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionAutopayRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - enabled: Optional[bool] = None - days_before: Optional[int] = Field(default=None, alias="daysBefore") + init_data: str = Field(..., alias='initData') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + enabled: bool | None = None + days_before: int | None = Field(default=None, alias='daysBefore') model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionAutopayResponse(BaseModel): success: bool = True - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + subscription_id: int | None = Field(default=None, alias='subscriptionId') autopay_enabled: bool = False - autopay_days_before: Optional[int] = None - autopay_days_options: List[int] = Field(default_factory=list) - autopay: Optional[MiniAppSubscriptionAutopay] = None - autopay_settings: Optional[MiniAppSubscriptionAutopay] = None + autopay_days_before: int | None = None + autopay_days_options: list[int] = Field(default_factory=list) + autopay: MiniAppSubscriptionAutopay | None = None + autopay_settings: MiniAppSubscriptionAutopay | None = None - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict(populate_by_name=True, extra='allow') class MiniAppPromoCode(BaseModel): code: str - type: Optional[str] = None + type: str | None = None balance_bonus_kopeks: int = 0 subscription_days: int = 0 - max_uses: Optional[int] = None - current_uses: Optional[int] = None - valid_until: Optional[datetime] = None + max_uses: int | None = None + current_uses: int | None = None + valid_until: datetime | None = None class MiniAppPromoCodeActivationRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') code: str class MiniAppPromoCodeActivationResponse(BaseModel): success: bool = True - description: Optional[str] = None - promocode: Optional[MiniAppPromoCode] = None + description: str | None = None + promocode: MiniAppPromoCode | None = None class MiniAppFaqItem(BaseModel): id: int - title: Optional[str] = None - content: Optional[str] = None - display_order: Optional[int] = None + title: str | None = None + content: str | None = None + display_order: int | None = None class MiniAppFaq(BaseModel): @@ -298,32 +298,32 @@ class MiniAppFaq(BaseModel): language: str is_enabled: bool = True total: int = 0 - items: List[MiniAppFaqItem] = Field(default_factory=list) + items: list[MiniAppFaqItem] = Field(default_factory=list) class MiniAppRichTextDocument(BaseModel): requested_language: str language: str - title: Optional[str] = None + title: str | None = None is_enabled: bool = True - content: str = "" - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + content: str = '' + created_at: datetime | None = None + updated_at: datetime | None = None class MiniAppLegalDocuments(BaseModel): - public_offer: Optional[MiniAppRichTextDocument] = None - service_rules: Optional[MiniAppRichTextDocument] = None - privacy_policy: Optional[MiniAppRichTextDocument] = None + public_offer: MiniAppRichTextDocument | None = None + service_rules: MiniAppRichTextDocument | None = None + privacy_policy: MiniAppRichTextDocument | None = None class MiniAppReferralTerms(BaseModel): minimum_topup_kopeks: int = 0 - minimum_topup_label: Optional[str] = None + minimum_topup_label: str | None = None first_topup_bonus_kopeks: int = 0 - first_topup_bonus_label: Optional[str] = None + first_topup_bonus_label: str | None = None inviter_bonus_kopeks: int = 0 - inviter_bonus_label: Optional[str] = None + inviter_bonus_label: str | None = None commission_percent: float = 0.0 @@ -332,36 +332,36 @@ class MiniAppReferralStats(BaseModel): paid_referrals_count: int = 0 active_referrals_count: int = 0 total_earned_kopeks: int = 0 - total_earned_label: Optional[str] = None + total_earned_label: str | None = None month_earned_kopeks: int = 0 - month_earned_label: Optional[str] = None + month_earned_label: str | None = None conversion_rate: float = 0.0 class MiniAppReferralRecentEarning(BaseModel): amount_kopeks: int = 0 - amount_label: Optional[str] = None - reason: Optional[str] = None - referral_name: Optional[str] = None - created_at: Optional[datetime] = None + amount_label: str | None = None + reason: str | None = None + referral_name: str | None = None + created_at: datetime | None = None class MiniAppReferralItem(BaseModel): id: int - telegram_id: Optional[int] = None - full_name: Optional[str] = None - username: Optional[str] = None - created_at: Optional[datetime] = None - last_activity: Optional[datetime] = None + telegram_id: int | None = None + full_name: str | None = None + username: str | None = None + created_at: datetime | None = None + last_activity: datetime | None = None has_made_first_topup: bool = False balance_kopeks: int = 0 - balance_label: Optional[str] = None + balance_label: str | None = None total_earned_kopeks: int = 0 - total_earned_label: Optional[str] = None + total_earned_label: str | None = None topups_count: int = 0 - days_since_registration: Optional[int] = None - days_since_activity: Optional[int] = None - status: Optional[str] = None + days_since_registration: int | None = None + days_since_activity: int | None = None + status: str | None = None class MiniAppReferralList(BaseModel): @@ -370,34 +370,34 @@ class MiniAppReferralList(BaseModel): has_prev: bool = False current_page: int = 1 total_pages: int = 1 - items: List[MiniAppReferralItem] = Field(default_factory=list) + items: list[MiniAppReferralItem] = Field(default_factory=list) class MiniAppReferralInfo(BaseModel): - referral_code: Optional[str] = None - referral_link: Optional[str] = None - terms: Optional[MiniAppReferralTerms] = None - stats: Optional[MiniAppReferralStats] = None - recent_earnings: List[MiniAppReferralRecentEarning] = Field(default_factory=list) - referrals: Optional[MiniAppReferralList] = None + referral_code: str | None = None + referral_link: str | None = None + terms: MiniAppReferralTerms | None = None + stats: MiniAppReferralStats | None = None + recent_earnings: list[MiniAppReferralRecentEarning] = Field(default_factory=list) + referrals: MiniAppReferralList | None = None class MiniAppPaymentMethodsRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') class MiniAppPaymentIntegrationType(str, Enum): - IFRAME = "iframe" - REDIRECT = "redirect" + IFRAME = 'iframe' + REDIRECT = 'redirect' class MiniAppPaymentOption(BaseModel): id: str - icon: Optional[str] = None - title: Optional[str] = None - description: Optional[str] = None - title_key: Optional[str] = Field(default=None, alias="titleKey") - description_key: Optional[str] = Field(default=None, alias="descriptionKey") + icon: str | None = None + title: str | None = None + description: str | None = None + title_key: str | None = Field(default=None, alias='titleKey') + description_key: str | None = Field(default=None, alias='descriptionKey') model_config = ConfigDict(populate_by_name=True) @@ -405,159 +405,159 @@ class MiniAppPaymentOption(BaseModel): class MiniAppPaymentIframeConfig(BaseModel): expected_origin: str - @model_validator(mode="after") - def _normalize_expected_origin( - cls, values: "MiniAppPaymentIframeConfig" - ) -> "MiniAppPaymentIframeConfig": - origin = (values.expected_origin or "").strip() + @model_validator(mode='after') + def _normalize_expected_origin(cls, values: MiniAppPaymentIframeConfig) -> MiniAppPaymentIframeConfig: + origin = (values.expected_origin or '').strip() if not origin: - raise ValueError("expected_origin must not be empty") + raise ValueError('expected_origin must not be empty') parsed = urlparse(origin) if not parsed.scheme or not parsed.netloc: - raise ValueError("expected_origin must include scheme and host") + raise ValueError('expected_origin must include scheme and host') - values.expected_origin = f"{parsed.scheme}://{parsed.netloc}" + values.expected_origin = f'{parsed.scheme}://{parsed.netloc}' return values class MiniAppPaymentMethod(BaseModel): id: str - name: Optional[str] = None - icon: Optional[str] = None + name: str | None = None + icon: str | None = None requires_amount: bool = False - currency: str = "RUB" - min_amount_kopeks: Optional[int] = None - max_amount_kopeks: Optional[int] = None - amount_step_kopeks: Optional[int] = None + currency: str = 'RUB' + min_amount_kopeks: int | None = None + max_amount_kopeks: int | None = None + amount_step_kopeks: int | None = None integration_type: MiniAppPaymentIntegrationType - options: List[MiniAppPaymentOption] = Field(default_factory=list) - iframe_config: Optional[MiniAppPaymentIframeConfig] = None + options: list[MiniAppPaymentOption] = Field(default_factory=list) + iframe_config: MiniAppPaymentIframeConfig | None = None - @model_validator(mode="after") - def _ensure_iframe_config(cls, values: "MiniAppPaymentMethod") -> "MiniAppPaymentMethod": - if ( - values.integration_type == MiniAppPaymentIntegrationType.IFRAME - and values.iframe_config is None - ): + @model_validator(mode='after') + def _ensure_iframe_config(cls, values: MiniAppPaymentMethod) -> MiniAppPaymentMethod: + if values.integration_type == MiniAppPaymentIntegrationType.IFRAME and values.iframe_config is None: raise ValueError("iframe_config is required when integration_type is 'iframe'") return values class MiniAppPaymentMethodsResponse(BaseModel): - methods: List[MiniAppPaymentMethod] = Field(default_factory=list) + methods: list[MiniAppPaymentMethod] = Field(default_factory=list) class MiniAppPaymentCreateRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') method: str - amount_rubles: Optional[float] = Field(default=None, alias="amountRubles") - amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks") - payment_option: Optional[str] = Field(default=None, alias="option") + amount_rubles: float | None = Field(default=None, alias='amountRubles') + amount_kopeks: int | None = Field(default=None, alias='amountKopeks') + payment_option: str | None = Field(default=None, alias='option') class MiniAppPaymentCreateResponse(BaseModel): success: bool = True method: str - payment_url: Optional[str] = None - amount_kopeks: Optional[int] = None - extra: Dict[str, Any] = Field(default_factory=dict) + payment_url: str | None = None + amount_kopeks: int | None = None + extra: dict[str, Any] = Field(default_factory=dict) class MiniAppPaymentStatusQuery(BaseModel): method: str - local_payment_id: Optional[int] = Field(default=None, alias="localPaymentId") - payment_link_id: Optional[str] = Field(default=None, alias="paymentLinkId") - invoice_id: Optional[str] = Field(default=None, alias="invoiceId") - payment_id: Optional[str] = Field(default=None, alias="paymentId") - payload: Optional[str] = None - amount_kopeks: Optional[int] = Field(default=None, alias="amountKopeks") - started_at: Optional[str] = Field(default=None, alias="startedAt") + local_payment_id: int | None = Field(default=None, alias='localPaymentId') + payment_link_id: str | None = Field(default=None, alias='paymentLinkId') + invoice_id: str | None = Field(default=None, alias='invoiceId') + payment_id: str | None = Field(default=None, alias='paymentId') + payload: str | None = None + amount_kopeks: int | None = Field(default=None, alias='amountKopeks') + started_at: str | None = Field(default=None, alias='startedAt') class MiniAppPaymentStatusRequest(BaseModel): - init_data: str = Field(..., alias="initData") - payments: List[MiniAppPaymentStatusQuery] = Field(default_factory=list) + init_data: str = Field(..., alias='initData') + payments: list[MiniAppPaymentStatusQuery] = Field(default_factory=list) class MiniAppPaymentStatusResult(BaseModel): method: str status: str is_paid: bool = False - amount_kopeks: Optional[int] = None - currency: Optional[str] = None - completed_at: Optional[datetime] = None - transaction_id: Optional[int] = None - external_id: Optional[str] = None - message: Optional[str] = None - extra: Dict[str, Any] = Field(default_factory=dict) + amount_kopeks: int | None = None + currency: str | None = None + completed_at: datetime | None = None + transaction_id: int | None = None + external_id: str | None = None + message: str | None = None + extra: dict[str, Any] = Field(default_factory=dict) class MiniAppPaymentStatusResponse(BaseModel): - results: List[MiniAppPaymentStatusResult] = Field(default_factory=list) + results: list[MiniAppPaymentStatusResult] = Field(default_factory=list) # ============================================================================= # Тарифы для режима продаж "Тарифы" # ============================================================================= + class MiniAppTariffPeriod(BaseModel): """Период тарифа с ценой.""" + days: int - months: Optional[int] = None + months: int | None = None label: str price_kopeks: int price_label: str - price_per_month_kopeks: Optional[int] = None - price_per_month_label: Optional[str] = None + price_per_month_kopeks: int | None = None + price_per_month_label: str | None = None # Скидка промогруппы - original_price_kopeks: Optional[int] = None # Цена без скидки - original_price_label: Optional[str] = None + original_price_kopeks: int | None = None # Цена без скидки + original_price_label: str | None = None discount_percent: int = 0 # Процент скидки class MiniAppTariff(BaseModel): """Тариф для отображения в miniapp.""" + id: int name: str - description: Optional[str] = None + description: str | None = None tier_level: int = 1 traffic_limit_gb: int traffic_limit_label: str is_unlimited_traffic: bool = False device_limit: int servers_count: int - servers: List[MiniAppConnectedServer] = Field(default_factory=list) - periods: List[MiniAppTariffPeriod] = Field(default_factory=list) + servers: list[MiniAppConnectedServer] = Field(default_factory=list) + periods: list[MiniAppTariffPeriod] = Field(default_factory=list) is_current: bool = False is_available: bool = True # Для режима мгновенного переключения тарифа - switch_cost_kopeks: Optional[int] = None # Стоимость переключения (None если не в режиме switch) - switch_cost_label: Optional[str] = None # Форматированная стоимость - is_upgrade: Optional[bool] = None # True = повышение, False = понижение - is_switch_free: Optional[bool] = None # True = бесплатное переключение + switch_cost_kopeks: int | None = None # Стоимость переключения (None если не в режиме switch) + switch_cost_label: str | None = None # Форматированная стоимость + is_upgrade: bool | None = None # True = повышение, False = понижение + is_switch_free: bool | None = None # True = бесплатное переключение # Суточные тарифы is_daily: bool = False daily_price_kopeks: int = 0 - daily_price_label: Optional[str] = None + daily_price_label: str | None = None class MiniAppTrafficTopupPackage(BaseModel): """Пакет докупки трафика.""" + gb: int price_kopeks: int price_label: str # Скидка промогруппы на трафик - original_price_kopeks: Optional[int] = None - original_price_label: Optional[str] = None + original_price_kopeks: int | None = None + original_price_label: str | None = None discount_percent: int = 0 class MiniAppCurrentTariff(BaseModel): """Текущий тариф пользователя.""" + id: int name: str - description: Optional[str] = None + description: str | None = None tier_level: int = 1 traffic_limit_gb: int traffic_limit_label: str @@ -568,27 +568,29 @@ class MiniAppCurrentTariff(BaseModel): monthly_price_kopeks: int = 0 # Докупка трафика traffic_topup_enabled: bool = False - traffic_topup_packages: List[MiniAppTrafficTopupPackage] = Field(default_factory=list) + traffic_topup_packages: list[MiniAppTrafficTopupPackage] = Field(default_factory=list) # Лимит докупки трафика (0 = без лимита) max_topup_traffic_gb: int = 0 - available_topup_gb: Optional[int] = None # Сколько еще можно докупить (None = без лимита) + available_topup_gb: int | None = None # Сколько еще можно докупить (None = без лимита) # Суточные тарифы is_daily: bool = False daily_price_kopeks: int = 0 - daily_price_label: Optional[str] = None + daily_price_label: str | None = None class MiniAppTrafficTopupRequest(BaseModel): """Запрос на докупку трафика.""" - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(None, alias="subscriptionId") + + init_data: str = Field(..., alias='initData') + subscription_id: int | None = Field(None, alias='subscriptionId') gb: int class MiniAppTrafficTopupResponse(BaseModel): """Ответ на докупку трафика.""" + success: bool = True - message: str = "" + message: str = '' new_traffic_limit_gb: int = 0 new_balance_kopeks: int = 0 charged_kopeks: int = 0 @@ -596,91 +598,101 @@ class MiniAppTrafficTopupResponse(BaseModel): class MiniAppTariffsRequest(BaseModel): """Запрос списка тарифов.""" - init_data: str = Field(..., alias="initData") + + init_data: str = Field(..., alias='initData') class MiniAppTariffsResponse(BaseModel): """Ответ со списком тарифов.""" + success: bool = True - sales_mode: str = "tariffs" - tariffs: List[MiniAppTariff] = Field(default_factory=list) - current_tariff: Optional[MiniAppCurrentTariff] = None + sales_mode: str = 'tariffs' + tariffs: list[MiniAppTariff] = Field(default_factory=list) + current_tariff: MiniAppCurrentTariff | None = None balance_kopeks: int = 0 - balance_label: Optional[str] = None - promo_group: Optional[MiniAppPromoGroup] = None # Промогруппа пользователя для отображения скидок + balance_label: str | None = None + promo_group: MiniAppPromoGroup | None = None # Промогруппа пользователя для отображения скидок class MiniAppTariffPurchaseRequest(BaseModel): """Запрос на покупку/смену тарифа.""" - init_data: str = Field(..., alias="initData") - tariff_id: int = Field(..., alias="tariffId") - period_days: int = Field(..., alias="periodDays") + + init_data: str = Field(..., alias='initData') + tariff_id: int = Field(..., alias='tariffId') + period_days: int = Field(..., alias='periodDays') class MiniAppTariffPurchaseResponse(BaseModel): """Ответ на покупку тарифа.""" + success: bool = True - message: Optional[str] = None - subscription_id: Optional[int] = None - tariff_id: Optional[int] = None - tariff_name: Optional[str] = None - new_end_date: Optional[datetime] = None - balance_kopeks: Optional[int] = None - balance_label: Optional[str] = None + message: str | None = None + subscription_id: int | None = None + tariff_id: int | None = None + tariff_name: str | None = None + new_end_date: datetime | None = None + balance_kopeks: int | None = None + balance_label: str | None = None class MiniAppTariffSwitchRequest(BaseModel): """Запрос на переключение тарифа (без выбора периода).""" + init_data: str = Field(...) tariff_id: int = Field(...) class MiniAppTariffSwitchPreviewResponse(BaseModel): """Предпросмотр переключения тарифа.""" + can_switch: bool = True - current_tariff_id: Optional[int] = None - current_tariff_name: Optional[str] = None + current_tariff_id: int | None = None + current_tariff_name: str | None = None new_tariff_id: int new_tariff_name: str remaining_days: int = 0 upgrade_cost_kopeks: int = 0 # 0 если даунгрейд или равная цена - upgrade_cost_label: str = "" + upgrade_cost_label: str = '' balance_kopeks: int = 0 - balance_label: str = "" + balance_label: str = '' has_enough_balance: bool = True missing_amount_kopeks: int = 0 - missing_amount_label: str = "" + missing_amount_label: str = '' is_upgrade: bool = False # True если новый тариф дороже - message: Optional[str] = None + message: str | None = None class MiniAppTariffSwitchResponse(BaseModel): """Ответ на переключение тарифа.""" + success: bool = True - message: Optional[str] = None + message: str | None = None tariff_id: int tariff_name: str charged_kopeks: int = 0 balance_kopeks: int = 0 - balance_label: str = "" + balance_label: str = '' class MiniAppDailySubscriptionToggleRequest(BaseModel): """Запрос на паузу/возобновление суточной подписки.""" + init_data: str = Field(...) class MiniAppDailySubscriptionToggleResponse(BaseModel): """Ответ на паузу/возобновление суточной подписки.""" + success: bool = True - message: Optional[str] = None + message: str | None = None is_paused: bool = False balance_kopeks: int = 0 - balance_label: str = "" + balance_label: str = '' class MiniAppTrafficPurchase(BaseModel): """Докупка трафика с индивидуальной датой истечения.""" + id: int traffic_gb: int expires_at: datetime @@ -691,135 +703,135 @@ class MiniAppTrafficPurchase(BaseModel): class MiniAppSubscriptionResponse(BaseModel): success: bool = True - subscription_id: Optional[int] = None - remnawave_short_uuid: Optional[str] = None + subscription_id: int | None = None + remnawave_short_uuid: str | None = None user: MiniAppSubscriptionUser - traffic_purchases: List[MiniAppTrafficPurchase] = Field(default_factory=list) - subscription_url: Optional[str] = None + traffic_purchases: list[MiniAppTrafficPurchase] = Field(default_factory=list) + subscription_url: str | None = None hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают) - subscription_crypto_link: Optional[str] = None - subscription_purchase_url: Optional[str] = None - links: List[str] = Field(default_factory=list) - ss_conf_links: Dict[str, str] = Field(default_factory=dict) - connected_squads: List[str] = Field(default_factory=list) - connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list) + subscription_crypto_link: str | None = None + subscription_purchase_url: str | None = None + links: list[str] = Field(default_factory=list) + ss_conf_links: dict[str, str] = Field(default_factory=dict) + connected_squads: list[str] = Field(default_factory=list) + connected_servers: list[MiniAppConnectedServer] = Field(default_factory=list) connected_devices_count: int = 0 - connected_devices: List[MiniAppDevice] = Field(default_factory=list) - happ: Optional[Dict[str, Any]] = None - happ_link: Optional[str] = None - happ_crypto_link: Optional[str] = None - happ_cryptolink_redirect_link: Optional[str] = None - happ_cryptolink_redirect_template: Optional[str] = None + connected_devices: list[MiniAppDevice] = Field(default_factory=list) + happ: dict[str, Any] | None = None + happ_link: str | None = None + happ_crypto_link: str | None = None + happ_cryptolink_redirect_link: str | None = None + happ_cryptolink_redirect_template: str | None = None balance_kopeks: int = 0 balance_rubles: float = 0.0 - balance_currency: Optional[str] = None - transactions: List[MiniAppTransaction] = Field(default_factory=list) - promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list) - promo_group: Optional[MiniAppPromoGroup] = None - auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list) + balance_currency: str | None = None + transactions: list[MiniAppTransaction] = Field(default_factory=list) + promo_offers: list[MiniAppPromoOffer] = Field(default_factory=list) + promo_group: MiniAppPromoGroup | None = None + auto_assign_promo_groups: list[MiniAppAutoPromoGroupLevel] = Field(default_factory=list) total_spent_kopeks: int = 0 total_spent_rubles: float = 0.0 - total_spent_label: Optional[str] = None + total_spent_label: str | None = None subscription_type: str autopay_enabled: bool = False - autopay_days_before: Optional[int] = None - autopay_days_options: List[int] = Field(default_factory=list) - autopay: Optional[MiniAppSubscriptionAutopay] = None - autopay_settings: Optional[MiniAppSubscriptionAutopay] = None - branding: Optional[MiniAppBranding] = None - faq: Optional[MiniAppFaq] = None - legal_documents: Optional[MiniAppLegalDocuments] = None - referral: Optional[MiniAppReferralInfo] = None + autopay_days_before: int | None = None + autopay_days_options: list[int] = Field(default_factory=list) + autopay: MiniAppSubscriptionAutopay | None = None + autopay_settings: MiniAppSubscriptionAutopay | None = None + branding: MiniAppBranding | None = None + faq: MiniAppFaq | None = None + legal_documents: MiniAppLegalDocuments | None = None + referral: MiniAppReferralInfo | None = None subscription_missing: bool = False - subscription_missing_reason: Optional[str] = None + subscription_missing_reason: str | None = None trial_available: bool = False - trial_duration_days: Optional[int] = None - trial_status: Optional[str] = None - trial_payment_required: bool = Field(default=False, alias="trialPaymentRequired") - trial_price_kopeks: Optional[int] = Field(default=None, alias="trialPriceKopeks") - trial_price_label: Optional[str] = Field(default=None, alias="trialPriceLabel") + trial_duration_days: int | None = None + trial_status: str | None = None + trial_payment_required: bool = Field(default=False, alias='trialPaymentRequired') + trial_price_kopeks: int | None = Field(default=None, alias='trialPriceKopeks') + trial_price_label: str | None = Field(default=None, alias='trialPriceLabel') # Режим продаж и тариф - sales_mode: str = Field(default="classic", alias="salesMode") - current_tariff: Optional[MiniAppCurrentTariff] = Field(default=None, alias="currentTariff") + sales_mode: str = Field(default='classic', alias='salesMode') + current_tariff: MiniAppCurrentTariff | None = Field(default=None, alias='currentTariff') - model_config = ConfigDict(extra="allow", populate_by_name=True) + model_config = ConfigDict(extra='allow', populate_by_name=True) class MiniAppSubscriptionServerOption(BaseModel): uuid: str - name: Optional[str] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None - discount_percent: Optional[int] = None + name: str | None = None + price_kopeks: int | None = None + price_label: str | None = None + discount_percent: int | None = None is_connected: bool = False is_available: bool = True - disabled_reason: Optional[str] = None + disabled_reason: str | None = None class MiniAppSubscriptionTrafficOption(BaseModel): - value: Optional[int] = None - label: Optional[str] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None + value: int | None = None + label: str | None = None + price_kopeks: int | None = None + price_label: str | None = None is_current: bool = False is_available: bool = True - description: Optional[str] = None + description: str | None = None class MiniAppSubscriptionDeviceOption(BaseModel): value: int - label: Optional[str] = None - price_kopeks: Optional[int] = None - price_label: Optional[str] = None + label: str | None = None + price_kopeks: int | None = None + price_label: str | None = None class MiniAppSubscriptionCurrentSettings(BaseModel): - servers: List[MiniAppConnectedServer] = Field(default_factory=list) - traffic_limit_gb: Optional[int] = None - traffic_limit_label: Optional[str] = None + servers: list[MiniAppConnectedServer] = Field(default_factory=list) + traffic_limit_gb: int | None = None + traffic_limit_label: str | None = None device_limit: int = 0 class MiniAppSubscriptionServersSettings(BaseModel): - available: List[MiniAppSubscriptionServerOption] = Field(default_factory=list) + available: list[MiniAppSubscriptionServerOption] = Field(default_factory=list) min: int = 0 max: int = 0 can_update: bool = True - hint: Optional[str] = None + hint: str | None = None class MiniAppSubscriptionTrafficSettings(BaseModel): - options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) + options: list[MiniAppSubscriptionTrafficOption] = Field(default_factory=list) can_update: bool = True - current_value: Optional[int] = None + current_value: int | None = None class MiniAppSubscriptionDevicesSettings(BaseModel): - options: List[MiniAppSubscriptionDeviceOption] = Field(default_factory=list) + options: list[MiniAppSubscriptionDeviceOption] = Field(default_factory=list) can_update: bool = True min: int = 0 max: int = 0 step: int = 1 current: int = 0 - price_kopeks: Optional[int] = None - price_label: Optional[str] = None + price_kopeks: int | None = None + price_label: str | None = None class MiniAppSubscriptionBillingContext(BaseModel): months_remaining: int = 1 - period_hint_days: Optional[int] = None - renews_at: Optional[datetime] = None + period_hint_days: int | None = None + renews_at: datetime | None = None class MiniAppSubscriptionSettings(BaseModel): subscription_id: int - currency: str = "RUB" + currency: str = 'RUB' current: MiniAppSubscriptionCurrentSettings servers: MiniAppSubscriptionServersSettings traffic: MiniAppSubscriptionTrafficSettings devices: MiniAppSubscriptionDevicesSettings - billing: Optional[MiniAppSubscriptionBillingContext] = None + billing: MiniAppSubscriptionBillingContext | None = None class MiniAppSubscriptionSettingsResponse(BaseModel): @@ -828,38 +840,38 @@ class MiniAppSubscriptionSettingsResponse(BaseModel): class MiniAppSubscriptionSettingsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None + init_data: str = Field(..., alias='initData') + subscription_id: int | None = None model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def _populate_aliases(cls, values: Any) -> Any: if isinstance(values, dict): - if "subscriptionId" in values and "subscription_id" not in values: - values["subscription_id"] = values["subscriptionId"] + if 'subscriptionId' in values and 'subscription_id' not in values: + values['subscription_id'] = values['subscriptionId'] return values class MiniAppSubscriptionServersUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - servers: Optional[List[str]] = None - squads: Optional[List[str]] = None - server_uuids: Optional[List[str]] = None - squad_uuids: Optional[List[str]] = None + init_data: str = Field(..., alias='initData') + subscription_id: int | None = None + servers: list[str] | None = None + squads: list[str] | None = None + server_uuids: list[str] | None = None + squad_uuids: list[str] | None = None model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def _populate_aliases(cls, values: Any) -> Any: if isinstance(values, dict): alias_map = { - "subscriptionId": "subscription_id", - "serverUuids": "server_uuids", - "squadUuids": "squad_uuids", + 'subscriptionId': 'subscription_id', + 'serverUuids': 'server_uuids', + 'squadUuids': 'squad_uuids', } for alias, target in alias_map.items(): if alias in values and target not in values: @@ -868,20 +880,20 @@ class MiniAppSubscriptionServersUpdateRequest(BaseModel): class MiniAppSubscriptionTrafficUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - traffic: Optional[int] = None - traffic_gb: Optional[int] = None + init_data: str = Field(..., alias='initData') + subscription_id: int | None = None + traffic: int | None = None + traffic_gb: int | None = None model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def _populate_aliases(cls, values: Any) -> Any: if isinstance(values, dict): alias_map = { - "subscriptionId": "subscription_id", - "trafficGb": "traffic_gb", + 'subscriptionId': 'subscription_id', + 'trafficGb': 'traffic_gb', } for alias, target in alias_map.items(): if alias in values and target not in values: @@ -890,20 +902,20 @@ class MiniAppSubscriptionTrafficUpdateRequest(BaseModel): class MiniAppSubscriptionDevicesUpdateRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = None - devices: Optional[int] = None - device_limit: Optional[int] = None + init_data: str = Field(..., alias='initData') + subscription_id: int | None = None + devices: int | None = None + device_limit: int | None = None model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def _populate_aliases(cls, values: Any) -> Any: if isinstance(values, dict): alias_map = { - "subscriptionId": "subscription_id", - "deviceLimit": "device_limit", + 'subscriptionId': 'subscription_id', + 'deviceLimit': 'device_limit', } for alias, target in alias_map.items(): if alias in values and target not in values: @@ -913,11 +925,11 @@ class MiniAppSubscriptionDevicesUpdateRequest(BaseModel): class MiniAppSubscriptionUpdateResponse(BaseModel): success: bool = True - message: Optional[str] = None + message: str | None = None class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') model_config = ConfigDict(populate_by_name=True) @@ -925,48 +937,48 @@ class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel): success: bool = True currency: str - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - data: Dict[str, Any] = Field(default_factory=dict) + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + data: dict[str, Any] = Field(default_factory=dict) model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionPurchasePreviewRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - selection: Optional[Dict[str, Any]] = None - period_id: Optional[str] = Field(default=None, alias="periodId") - period_days: Optional[int] = Field(default=None, alias="periodDays") - period: Optional[str] = None - traffic_value: Optional[int] = Field(default=None, alias="trafficValue") - traffic: Optional[int] = None - traffic_gb: Optional[int] = Field(default=None, alias="trafficGb") - servers: Optional[List[str]] = None - countries: Optional[List[str]] = None - server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids") - devices: Optional[int] = None - device_limit: Optional[int] = Field(default=None, alias="deviceLimit") + init_data: str = Field(..., alias='initData') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + selection: dict[str, Any] | None = None + period_id: str | None = Field(default=None, alias='periodId') + period_days: int | None = Field(default=None, alias='periodDays') + period: str | None = None + traffic_value: int | None = Field(default=None, alias='trafficValue') + traffic: int | None = None + traffic_gb: int | None = Field(default=None, alias='trafficGb') + servers: list[str] | None = None + countries: list[str] | None = None + server_uuids: list[str] | None = Field(default=None, alias='serverUuids') + devices: int | None = None + device_limit: int | None = Field(default=None, alias='deviceLimit') model_config = ConfigDict(populate_by_name=True) - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def _merge_selection(cls, values: Any) -> Any: if not isinstance(values, dict): return values - selection = values.get("selection") + selection = values.get('selection') if isinstance(selection, dict): merged = {**selection, **values} else: merged = dict(values) aliases = { - "period_id": ("periodId", "period", "code"), - "period_days": ("periodDays",), - "traffic_value": ("trafficValue", "traffic", "trafficGb"), - "servers": ("countries", "server_uuids", "serverUuids"), - "devices": ("deviceLimit",), + 'period_id': ('periodId', 'period', 'code'), + 'period_days': ('periodDays',), + 'traffic_value': ('trafficValue', 'traffic', 'trafficGb'), + 'servers': ('countries', 'server_uuids', 'serverUuids'), + 'devices': ('deviceLimit',), } for target, sources in aliases.items(): if merged.get(target) is not None: @@ -980,9 +992,9 @@ class MiniAppSubscriptionPurchasePreviewRequest(BaseModel): class MiniAppSubscriptionPurchasePreviewResponse(BaseModel): success: bool = True - preview: Dict[str, Any] = Field(default_factory=dict) - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") + preview: dict[str, Any] = Field(default_factory=dict) + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') model_config = ConfigDict(populate_by_name=True) @@ -993,30 +1005,29 @@ class MiniAppSubscriptionPurchaseRequest(MiniAppSubscriptionPurchasePreviewReque class MiniAppSubscriptionPurchaseResponse(BaseModel): success: bool = True - message: Optional[str] = None - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") + message: str | None = None + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') + subscription_id: int | None = Field(default=None, alias='subscriptionId') model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionTrialRequest(BaseModel): - init_data: str = Field(..., alias="initData") + init_data: str = Field(..., alias='initData') model_config = ConfigDict(populate_by_name=True) class MiniAppSubscriptionTrialResponse(BaseModel): success: bool = True - message: Optional[str] = None - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - trial_status: Optional[str] = Field(default=None, alias="trialStatus") - trial_duration_days: Optional[int] = Field(default=None, alias="trialDurationDays") - charged_amount_kopeks: Optional[int] = Field(default=None, alias="chargedAmountKopeks") - charged_amount_label: Optional[str] = Field(default=None, alias="chargedAmountLabel") - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") + message: str | None = None + subscription_id: int | None = Field(default=None, alias='subscriptionId') + trial_status: str | None = Field(default=None, alias='trialStatus') + trial_duration_days: int | None = Field(default=None, alias='trialDurationDays') + charged_amount_kopeks: int | None = Field(default=None, alias='chargedAmountKopeks') + charged_amount_label: str | None = Field(default=None, alias='chargedAmountLabel') + balance_kopeks: int | None = Field(default=None, alias='balanceKopeks') + balance_label: str | None = Field(default=None, alias='balanceLabel') model_config = ConfigDict(populate_by_name=True) - diff --git a/app/webapi/schemas/pages.py b/app/webapi/schemas/pages.py index da723e0b..3b4b1ffb 100644 --- a/app/webapi/schemas/pages.py +++ b/app/webapi/schemas/pages.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field @@ -9,38 +8,38 @@ from pydantic import BaseModel, Field class RichTextPageResponse(BaseModel): """Generic representation for rich text informational pages.""" - requested_language: str = Field(..., description="Язык, запрошенный клиентом") - language: str = Field(..., description="Фактический язык найденной записи") - is_enabled: Optional[bool] = Field( + requested_language: str = Field(..., description='Язык, запрошенный клиентом') + language: str = Field(..., description='Фактический язык найденной записи') + is_enabled: bool | None = Field( default=None, - description="Текущий статус публикации страницы (если применимо)", + description='Текущий статус публикации страницы (если применимо)', ) - content: str = Field(..., description="Полное содержимое страницы") - content_pages: List[str] = Field( + content: str = Field(..., description='Полное содержимое страницы') + content_pages: list[str] = Field( default_factory=list, - description="Содержимое, разбитое на страницы фиксированной длины", + description='Содержимое, разбитое на страницы фиксированной длины', ) - created_at: Optional[datetime] = Field( + created_at: datetime | None = Field( default=None, - description="Дата создания записи", + description='Дата создания записи', ) - updated_at: Optional[datetime] = Field( + updated_at: datetime | None = Field( default=None, - description="Дата последнего обновления записи", + description='Дата последнего обновления записи', ) class RichTextPageUpdateRequest(BaseModel): language: str = Field( - default="ru", + default='ru', min_length=2, max_length=10, - description="Язык, для которого выполняется обновление", + description='Язык, для которого выполняется обновление', ) - content: str = Field(..., description="Новое содержимое страницы") - is_enabled: Optional[bool] = Field( + content: str = Field(..., description='Новое содержимое страницы') + is_enabled: bool | None = Field( default=None, - description="Если указано — обновить статус публикации", + description='Если указано — обновить статус публикации', ) @@ -49,7 +48,7 @@ class FaqPageResponse(BaseModel): language: str title: str content: str - content_pages: List[str] = Field(default_factory=list) + content_pages: list[str] = Field(default_factory=list) display_order: int is_active: bool created_at: datetime @@ -61,34 +60,34 @@ class FaqPageListResponse(BaseModel): language: str is_enabled: bool total: int - items: List[FaqPageResponse] + items: list[FaqPageResponse] class FaqPageCreateRequest(BaseModel): language: str = Field( - default="ru", + default='ru', min_length=2, max_length=10, - description="Язык создаваемой страницы", + description='Язык создаваемой страницы', ) title: str = Field(..., min_length=1, max_length=255) content: str = Field(...) - display_order: Optional[int] = Field( + display_order: int | None = Field( default=None, ge=0, - description="Порядок отображения (если не указан — будет рассчитан автоматически)", + description='Порядок отображения (если не указан — будет рассчитан автоматически)', ) - is_active: Optional[bool] = Field( + is_active: bool | None = Field( default=True, - description="Начальный статус активности страницы", + description='Начальный статус активности страницы', ) class FaqPageUpdateRequest(BaseModel): - title: Optional[str] = Field(default=None, min_length=1, max_length=255) - content: Optional[str] = None - display_order: Optional[int] = Field(default=None, ge=0) - is_active: Optional[bool] = None + title: str | None = Field(default=None, min_length=1, max_length=255) + content: str | None = None + display_order: int | None = Field(default=None, ge=0) + is_active: bool | None = None class FaqReorderItem(BaseModel): @@ -98,12 +97,12 @@ class FaqReorderItem(BaseModel): class FaqReorderRequest(BaseModel): language: str = Field( - default="ru", + default='ru', min_length=2, max_length=10, - description="Язык, для которого применяется сортировка", + description='Язык, для которого применяется сортировка', ) - items: List[FaqReorderItem] + items: list[FaqReorderItem] class FaqStatusResponse(BaseModel): @@ -114,7 +113,7 @@ class FaqStatusResponse(BaseModel): class FaqStatusUpdateRequest(BaseModel): language: str = Field( - default="ru", + default='ru', min_length=2, max_length=10, ) @@ -133,13 +132,13 @@ class ServiceRulesResponse(BaseModel): class ServiceRulesUpdateRequest(BaseModel): language: str = Field( - default="ru", + default='ru', min_length=2, max_length=10, - description="Язык, для которого обновляются правила", + description='Язык, для которого обновляются правила', ) - title: Optional[str] = Field( - default="Правила сервиса", + title: str | None = Field( + default='Правила сервиса', min_length=1, max_length=255, ) @@ -149,5 +148,4 @@ class ServiceRulesUpdateRequest(BaseModel): class ServiceRulesHistoryResponse(BaseModel): language: str total: int - items: List[ServiceRulesResponse] - + items: list[ServiceRulesResponse] diff --git a/app/webapi/schemas/partners.py b/app/webapi/schemas/partners.py index bcda4edf..962cdd6b 100644 --- a/app/webapi/schemas/partners.py +++ b/app/webapi/schemas/partners.py @@ -1,19 +1,18 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field class PartnerReferrerItem(BaseModel): id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - referral_code: Optional[str] = None - referral_commission_percent: Optional[int] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None + referral_code: str | None = None + referral_commission_percent: int | None = None effective_referral_commission_percent: int invited_count: int active_referrals: int @@ -22,11 +21,11 @@ class PartnerReferrerItem(BaseModel): month_earned_kopeks: int month_earned_rubles: float created_at: datetime - last_activity: Optional[datetime] = None + last_activity: datetime | None = None class PartnerReferrerListResponse(BaseModel): - items: List[PartnerReferrerItem] = Field(default_factory=list) + items: list[PartnerReferrerItem] = Field(default_factory=list) total: int limit: int offset: int @@ -34,11 +33,11 @@ class PartnerReferrerListResponse(BaseModel): class PartnerReferralItem(BaseModel): id: int - telegram_id: Optional[int] = None + telegram_id: int | None = None full_name: str - username: Optional[str] = None + username: str | None = None created_at: datetime - last_activity: Optional[datetime] = None + last_activity: datetime | None = None has_made_first_topup: bool balance_kopeks: int balance_rubles: float @@ -46,12 +45,12 @@ class PartnerReferralItem(BaseModel): total_earned_rubles: float topups_count: int days_since_registration: int - days_since_activity: Optional[int] = None + days_since_activity: int | None = None status: str class PartnerReferralList(BaseModel): - items: List[PartnerReferralItem] = Field(default_factory=list) + items: list[PartnerReferralItem] = Field(default_factory=list) total: int limit: int offset: int @@ -67,11 +66,11 @@ class PartnerReferrerDetail(BaseModel): class PartnerReferralCommissionUpdate(BaseModel): - referral_commission_percent: Optional[int] = Field( + referral_commission_percent: int | None = Field( default=None, ge=0, le=100, - description="Индивидуальный процент реферальной комиссии для пользователя", + description='Индивидуальный процент реферальной комиссии для пользователя', ) @@ -82,6 +81,7 @@ class PartnerReferralCommissionUpdate(BaseModel): class EarningsByPeriod(BaseModel): """Заработки по периодам.""" + all_time_kopeks: int year_kopeks: int month_kopeks: int @@ -91,6 +91,7 @@ class EarningsByPeriod(BaseModel): class ReferralsCountByPeriod(BaseModel): """Количество рефералов по периодам.""" + all_time: int year: int month: int @@ -100,6 +101,7 @@ class ReferralsCountByPeriod(BaseModel): class ReferrerSummary(BaseModel): """Сводка по рефереру.""" + total_referrals: int paid_referrals: int active_referrals: int @@ -110,6 +112,7 @@ class ReferrerSummary(BaseModel): class ReferrerDetailedStats(BaseModel): """Детальная статистика реферера.""" + user_id: int summary: ReferrerSummary earnings: EarningsByPeriod @@ -118,6 +121,7 @@ class ReferrerDetailedStats(BaseModel): class DailyStats(BaseModel): """Статистика за день.""" + date: str referrals_count: int earnings_kopeks: int @@ -125,18 +129,20 @@ class DailyStats(BaseModel): class DailyStatsResponse(BaseModel): """Ответ со статистикой по дням.""" - items: List[DailyStats] + + items: list[DailyStats] days: int - user_id: Optional[int] = None + user_id: int | None = None class TopReferralItem(BaseModel): """Топ реферал.""" + id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None full_name: str created_at: datetime has_made_first_topup: bool @@ -146,12 +152,14 @@ class TopReferralItem(BaseModel): class TopReferralsResponse(BaseModel): """Топ рефералов реферера.""" - items: List[TopReferralItem] + + items: list[TopReferralItem] user_id: int class PeriodData(BaseModel): """Данные за период.""" + days: int start: str end: str @@ -161,6 +169,7 @@ class PeriodData(BaseModel): class ChangeData(BaseModel): """Данные об изменении.""" + absolute: int percent: float trend: str # up, down, stable @@ -168,20 +177,23 @@ class ChangeData(BaseModel): class PeriodChange(BaseModel): """Изменения между периодами.""" + referrals: ChangeData earnings: ChangeData class PeriodComparisonResponse(BaseModel): """Сравнение периодов.""" + current_period: PeriodData previous_period: PeriodData change: PeriodChange - user_id: Optional[int] = None + user_id: int | None = None class GlobalPartnerSummary(BaseModel): """Глобальная сводка партнёрской программы.""" + total_referrers: int total_referrals: int paid_referrals: int @@ -191,6 +203,7 @@ class GlobalPartnerSummary(BaseModel): class PayoutsByPeriod(BaseModel): """Выплаты по периодам.""" + all_time_kopeks: int year_kopeks: int month_kopeks: int @@ -200,6 +213,7 @@ class PayoutsByPeriod(BaseModel): class NewReferralsByPeriod(BaseModel): """Новые рефералы по периодам.""" + today: int week: int month: int @@ -207,6 +221,7 @@ class NewReferralsByPeriod(BaseModel): class GlobalPartnerStats(BaseModel): """Глобальная статистика партнёрской программы.""" + summary: GlobalPartnerSummary payouts: PayoutsByPeriod new_referrals: NewReferralsByPeriod @@ -214,18 +229,20 @@ class GlobalPartnerStats(BaseModel): class TopReferrerItem(BaseModel): """Топ реферер.""" + id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None full_name: str - referral_code: Optional[str] = None + referral_code: str | None = None referrals_count: int total_earnings_kopeks: int class TopReferrersResponse(BaseModel): """Топ рефереров.""" - items: List[TopReferrerItem] - days: Optional[int] = None + + items: list[TopReferrerItem] + days: int | None = None diff --git a/app/webapi/schemas/pinned_messages.py b/app/webapi/schemas/pinned_messages.py index db8f0dc3..9a7500cc 100644 --- a/app/webapi/schemas/pinned_messages.py +++ b/app/webapi/schemas/pinned_messages.py @@ -1,50 +1,49 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field class PinnedMessageMedia(BaseModel): - type: str = Field(pattern=r"^(photo|video)$") + type: str = Field(pattern=r'^(photo|video)$') file_id: str class PinnedMessageBase(BaseModel): - content: Optional[str] = Field(None, max_length=4000) + content: str | None = Field(None, max_length=4000) send_before_menu: bool = True send_on_every_start: bool = True class PinnedMessageCreateRequest(PinnedMessageBase): content: str = Field(..., min_length=1, max_length=4000) - media: Optional[PinnedMessageMedia] = None + media: PinnedMessageMedia | None = None class PinnedMessageUpdateRequest(BaseModel): - content: Optional[str] = Field(None, max_length=4000) - send_before_menu: Optional[bool] = None - send_on_every_start: Optional[bool] = None - media: Optional[PinnedMessageMedia] = None + content: str | None = Field(None, max_length=4000) + send_before_menu: bool | None = None + send_on_every_start: bool | None = None + media: PinnedMessageMedia | None = None class PinnedMessageSettingsRequest(BaseModel): - send_before_menu: Optional[bool] = None - send_on_every_start: Optional[bool] = None + send_before_menu: bool | None = None + send_on_every_start: bool | None = None class PinnedMessageResponse(BaseModel): id: int - content: Optional[str] - media_type: Optional[str] = None - media_file_id: Optional[str] = None + content: str | None + media_type: str | None = None + media_file_id: str | None = None send_before_menu: bool send_on_every_start: bool is_active: bool - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime - updated_at: Optional[datetime] = None + updated_at: datetime | None = None class PinnedMessageBroadcastResponse(BaseModel): diff --git a/app/webapi/schemas/polls.py b/app/webapi/schemas/polls.py index c6b7bcf5..015f75ad 100644 --- a/app/webapi/schemas/polls.py +++ b/app/webapi/schemas/polls.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field, field_validator, model_validator @@ -9,12 +8,12 @@ from pydantic import BaseModel, Field, field_validator, model_validator class PollOptionCreate(BaseModel): text: str = Field(..., min_length=1, max_length=500) - @field_validator("text") + @field_validator('text') @classmethod def strip_text(cls, value: str) -> str: text = value.strip() if not text: - raise ValueError("Option text cannot be empty") + raise ValueError('Option text cannot be empty') return text @@ -22,53 +21,53 @@ class PollQuestionCreate(BaseModel): text: str = Field(..., min_length=1, max_length=1000) options: list[PollOptionCreate] = Field(..., min_length=2) - @field_validator("text") + @field_validator('text') @classmethod def strip_question_text(cls, value: str) -> str: text = value.strip() if not text: - raise ValueError("Question text cannot be empty") + raise ValueError('Question text cannot be empty') return text - @field_validator("options") + @field_validator('options') @classmethod def validate_options(cls, value: list[PollOptionCreate]) -> list[PollOptionCreate]: seen: set[str] = set() for option in value: normalized = option.text.lower() if normalized in seen: - raise ValueError("Option texts must be unique within a question") + raise ValueError('Option texts must be unique within a question') seen.add(normalized) return value class PollCreateRequest(BaseModel): title: str = Field(..., min_length=1, max_length=255) - description: Optional[str] = Field(default=None, max_length=4000) + description: str | None = Field(default=None, max_length=4000) reward_enabled: bool = False reward_amount_kopeks: int = Field(default=0, ge=0, le=1_000_000_000) questions: list[PollQuestionCreate] = Field(..., min_length=1) - @field_validator("title") + @field_validator('title') @classmethod def strip_title(cls, value: str) -> str: title = value.strip() if not title: - raise ValueError("Title cannot be empty") + raise ValueError('Title cannot be empty') return title - @field_validator("description") + @field_validator('description') @classmethod - def normalize_description(cls, value: Optional[str]) -> Optional[str]: + def normalize_description(cls, value: str | None) -> str | None: if value is None: return None description = value.strip() return description or None - @model_validator(mode="after") - def validate_reward(self) -> "PollCreateRequest": + @model_validator(mode='after') + def validate_reward(self) -> PollCreateRequest: if self.reward_enabled and self.reward_amount_kopeks <= 0: - raise ValueError("Reward amount must be positive when rewards are enabled") + raise ValueError('Reward amount must be positive when rewards are enabled') if not self.reward_enabled: self.reward_amount_kopeks = 0 return self @@ -90,7 +89,7 @@ class PollQuestionResponse(BaseModel): class PollSummaryResponse(BaseModel): id: int title: str - description: Optional[str] + description: str | None reward_enabled: bool reward_amount_kopeks: int reward_amount_rubles: float @@ -103,7 +102,7 @@ class PollSummaryResponse(BaseModel): class PollDetailResponse(BaseModel): id: int title: str - description: Optional[str] + description: str | None reward_enabled: bool reward_amount_kopeks: int reward_amount_rubles: float @@ -143,21 +142,21 @@ class PollStatisticsResponse(BaseModel): class PollAnswerResponse(BaseModel): - question_id: Optional[int] - question_text: Optional[str] - option_id: Optional[int] - option_text: Optional[str] + question_id: int | None + question_text: str | None + option_id: int | None + option_text: str | None created_at: datetime class PollUserResponse(BaseModel): id: int - user_id: Optional[int] - user_telegram_id: Optional[int] - user_username: Optional[str] + user_id: int | None + user_telegram_id: int | None + user_username: str | None sent_at: datetime - started_at: Optional[datetime] - completed_at: Optional[datetime] + started_at: datetime | None + completed_at: datetime | None reward_given: bool reward_amount_kopeks: int reward_amount_rubles: float @@ -174,10 +173,7 @@ class PollResponsesListResponse(BaseModel): class PollSendRequest(BaseModel): target: str = Field( ..., - description=( - "Аудитория для отправки опроса (например: all, active, trial, " - "custom_today и т.д.)" - ), + description=('Аудитория для отправки опроса (например: all, active, trial, custom_today и т.д.)'), max_length=100, ) diff --git a/app/webapi/schemas/promo_groups.py b/app/webapi/schemas/promo_groups.py index d006f9e7..ccd55242 100644 --- a/app/webapi/schemas/promo_groups.py +++ b/app/webapi/schemas/promo_groups.py @@ -1,16 +1,15 @@ from __future__ import annotations from datetime import datetime -from typing import Dict, Optional from pydantic import BaseModel, ConfigDict, Field, validator -def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]: +def _normalize_period_discounts(value: dict[object, object] | None) -> dict[int, int] | None: if value is None: return None - normalized: Dict[int, int] = {} + normalized: dict[int, int] = {} if isinstance(value, dict): for raw_key, raw_value in value.items(): try: @@ -30,27 +29,27 @@ class PromoGroupResponse(BaseModel): 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 + period_discounts: dict[int, int] = Field(default_factory=dict) + auto_assign_total_spent_kopeks: int | None = None apply_discounts_to_addons: bool is_default: bool members_count: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None class _PromoGroupBase(BaseModel): - period_discounts: Optional[Dict[int, int]] = Field( + period_discounts: dict[int, int] | None = Field( default=None, description=( - "Словарь скидок по длительности подписки. Ключ — количество месяцев, " - "значение — скидка в процентах. Например: {1: 10, 6: 20}." + 'Словарь скидок по длительности подписки. Ключ — количество месяцев, ' + 'значение — скидка в процентах. Например: {1: 10, 6: 20}.' ), example={1: 10, 6: 20}, ) - @validator("period_discounts", pre=True) - def validate_period_discounts(cls, value): # noqa: D401,B902 + @validator('period_discounts', pre=True) + def validate_period_discounts(cls, value): return _normalize_period_discounts(value) @@ -59,19 +58,19 @@ class PromoGroupCreateRequest(_PromoGroupBase): server_discount_percent: int = 0 traffic_discount_percent: int = 0 device_discount_percent: int = 0 - auto_assign_total_spent_kopeks: Optional[int] = None + auto_assign_total_spent_kopeks: int | None = 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 + name: str | None = None + server_discount_percent: int | None = None + traffic_discount_percent: int | None = None + device_discount_percent: int | None = None + auto_assign_total_spent_kopeks: int | None = None + apply_discounts_to_addons: bool | None = None + is_default: bool | None = None class PromoGroupListResponse(BaseModel): diff --git a/app/webapi/schemas/promo_offers.py b/app/webapi/schemas/promo_offers.py index d6f11e2b..9ca30c41 100644 --- a/app/webapi/schemas/promo_offers.py +++ b/app/webapi/schemas/promo_offers.py @@ -1,18 +1,18 @@ from __future__ import annotations from datetime import datetime -from typing import Any, ClassVar, Dict, List, Optional +from typing import Any, ClassVar from pydantic import BaseModel, Field, validator class PromoOfferUserInfo(BaseModel): id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - full_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None + full_name: str | None = None class PromoOfferSubscriptionInfo(BaseModel): @@ -27,89 +27,89 @@ class PromoOfferSubscriptionInfo(BaseModel): class PromoOfferResponse(BaseModel): id: int user_id: int - subscription_id: Optional[int] = None + subscription_id: int | None = None notification_type: str discount_percent: int bonus_amount_kopeks: int expires_at: datetime - claimed_at: Optional[datetime] = None + claimed_at: datetime | None = None is_active: bool effect_type: str - extra_data: Dict[str, Any] = Field(default_factory=dict) + extra_data: dict[str, Any] = Field(default_factory=dict) created_at: datetime updated_at: datetime - user: Optional[PromoOfferUserInfo] = None - subscription: Optional[PromoOfferSubscriptionInfo] = None + user: PromoOfferUserInfo | None = None + subscription: PromoOfferSubscriptionInfo | None = None class PromoOfferListResponse(BaseModel): - items: List[PromoOfferResponse] + items: list[PromoOfferResponse] total: int limit: int offset: int class PromoOfferCreateRequest(BaseModel): - user_id: Optional[int] = Field(None, ge=1) - telegram_id: Optional[int] = Field(None, ge=1) + user_id: int | None = Field(None, ge=1) + telegram_id: int | None = Field(None, ge=1) notification_type: str = Field(..., min_length=1) - valid_hours: int = Field(..., ge=1, description="Срок действия предложения в часах") + valid_hours: int = Field(..., ge=1, description='Срок действия предложения в часах') discount_percent: int = Field(0, ge=0) bonus_amount_kopeks: int = Field(0, ge=0) - subscription_id: Optional[int] = None - effect_type: str = Field("percent_discount", min_length=1) - extra_data: Dict[str, Any] = Field(default_factory=dict) + subscription_id: int | None = None + effect_type: str = Field('percent_discount', min_length=1) + extra_data: dict[str, Any] = Field(default_factory=dict) class PromoOfferBroadcastRequest(PromoOfferCreateRequest): - target: Optional[str] = Field( + target: str | None = Field( None, description=( - "Категория пользователей для рассылки. Поддерживает те же сегменты, что " - "и API рассылок (all, active, trial, custom_today и т.д.)." + 'Категория пользователей для рассылки. Поддерживает те же сегменты, что ' + 'и API рассылок (all, active, trial, custom_today и т.д.).' ), ) _ALLOWED_TARGETS: ClassVar[set[str]] = { - "all", - "active", - "trial", - "trial_ending", - "trial_expired", - "no", - "expiring", - "expiring_subscribers", - "expired", - "expired_subscribers", - "canceled_subscribers", - "active_zero", - "trial_zero", - "zero", - "autopay_failed", - "low_balance", - "inactive_30d", - "inactive_60d", - "inactive_90d", + 'all', + 'active', + 'trial', + 'trial_ending', + 'trial_expired', + 'no', + 'expiring', + 'expiring_subscribers', + 'expired', + 'expired_subscribers', + 'canceled_subscribers', + 'active_zero', + 'trial_zero', + 'zero', + 'autopay_failed', + 'low_balance', + 'inactive_30d', + 'inactive_60d', + 'inactive_90d', } _CUSTOM_TARGETS: ClassVar[set[str]] = { - "today", - "week", - "month", - "active_today", - "inactive_week", - "inactive_month", - "referrals", - "direct", + 'today', + 'week', + 'month', + 'active_today', + 'inactive_week', + 'inactive_month', + 'referrals', + 'direct', } _TARGET_ALIASES: ClassVar[dict[str, str]] = { - "no_sub": "no", - "all_users": "all", - "active_subscribers": "active", - "trial_users": "trial", + 'no_sub': 'no', + 'all_users': 'all', + 'active_subscribers': 'active', + 'trial_users': 'trial', } - @validator("target") - def validate_target(cls, value: Optional[str]) -> Optional[str]: + @validator('target') + def validate_target(cls, value: str | None) -> str | None: if value is None: return None @@ -119,18 +119,18 @@ class PromoOfferBroadcastRequest(PromoOfferCreateRequest): if normalized in cls._ALLOWED_TARGETS: return normalized - if normalized.startswith("custom_"): - criteria = normalized[len("custom_"):] + if normalized.startswith('custom_'): + criteria = normalized[len('custom_') :] if criteria in cls._CUSTOM_TARGETS: return normalized - raise ValueError("Unsupported target value") + raise ValueError('Unsupported target value') class PromoOfferBroadcastResponse(BaseModel): created_offers: int - user_ids: List[int] - target: Optional[str] = None + user_ids: list[int] + target: str | None = None class PromoOfferTemplateResponse(BaseModel): @@ -142,59 +142,59 @@ class PromoOfferTemplateResponse(BaseModel): valid_hours: int discount_percent: int bonus_amount_kopeks: int - active_discount_hours: Optional[int] = None - test_duration_hours: Optional[int] = None - test_squad_uuids: List[str] + active_discount_hours: int | None = None + test_duration_hours: int | None = None + test_squad_uuids: list[str] is_active: bool - created_by: Optional[int] = None + created_by: int | None = None created_at: datetime updated_at: datetime class PromoOfferTemplateListResponse(BaseModel): - items: List[PromoOfferTemplateResponse] + items: list[PromoOfferTemplateResponse] class PromoOfferTemplateUpdateRequest(BaseModel): - name: Optional[str] = None - message_text: Optional[str] = None - button_text: Optional[str] = None - valid_hours: Optional[int] = Field(None, ge=1) - discount_percent: Optional[int] = Field(None, ge=0) - bonus_amount_kopeks: Optional[int] = Field(None, ge=0) - active_discount_hours: Optional[int] = Field(None, ge=1) - test_duration_hours: Optional[int] = Field(None, ge=1) - test_squad_uuids: Optional[List[str]] = None - is_active: Optional[bool] = None + name: str | None = None + message_text: str | None = None + button_text: str | None = None + valid_hours: int | None = Field(None, ge=1) + discount_percent: int | None = Field(None, ge=0) + bonus_amount_kopeks: int | None = Field(None, ge=0) + active_discount_hours: int | None = Field(None, ge=1) + test_duration_hours: int | None = Field(None, ge=1) + test_squad_uuids: list[str] | None = None + is_active: bool | None = None class PromoOfferLogOfferInfo(BaseModel): id: int - notification_type: Optional[str] = None - discount_percent: Optional[int] = None - bonus_amount_kopeks: Optional[int] = None - effect_type: Optional[str] = None - expires_at: Optional[datetime] = None - claimed_at: Optional[datetime] = None - is_active: Optional[bool] = None + notification_type: str | None = None + discount_percent: int | None = None + bonus_amount_kopeks: int | None = None + effect_type: str | None = None + expires_at: datetime | None = None + claimed_at: datetime | None = None + is_active: bool | None = None class PromoOfferLogResponse(BaseModel): id: int - user_id: Optional[int] = None - offer_id: Optional[int] = None + user_id: int | None = None + offer_id: int | None = None action: str - source: Optional[str] = None - percent: Optional[int] = None - effect_type: Optional[str] = None - details: Dict[str, Any] = Field(default_factory=dict) + source: str | None = None + percent: int | None = None + effect_type: str | None = None + details: dict[str, Any] = Field(default_factory=dict) created_at: datetime - user: Optional[PromoOfferUserInfo] = None - offer: Optional[PromoOfferLogOfferInfo] = None + user: PromoOfferUserInfo | None = None + offer: PromoOfferLogOfferInfo | None = None class PromoOfferLogListResponse(BaseModel): - items: List[PromoOfferLogResponse] + items: list[PromoOfferLogResponse] total: int limit: int offset: int diff --git a/app/webapi/schemas/promocodes.py b/app/webapi/schemas/promocodes.py index fdf9ac66..62d2ae66 100644 --- a/app/webapi/schemas/promocodes.py +++ b/app/webapi/schemas/promocodes.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field @@ -21,8 +20,8 @@ class PromoCodeResponse(BaseModel): is_active: bool is_valid: bool valid_from: datetime - valid_until: Optional[datetime] = None - created_by: Optional[int] = None + valid_until: datetime | None = None + created_by: int | None = None created_at: datetime updated_at: datetime @@ -40,29 +39,29 @@ class PromoCodeCreateRequest(BaseModel): 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 + valid_from: datetime | None = None + valid_until: datetime | None = None is_active: bool = True - created_by: Optional[int] = None + created_by: int | None = 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 + code: str | None = None + type: PromoCodeType | None = None + balance_bonus_kopeks: int | None = None + subscription_days: int | None = None + max_uses: int | None = Field(default=None, ge=0) + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = 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 + user_username: str | None = None + user_full_name: str | None = None + user_telegram_id: int | None = None used_at: datetime @@ -70,4 +69,3 @@ class PromoCodeDetailResponse(PromoCodeResponse): total_uses: int today_uses: int recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list) - diff --git a/app/webapi/schemas/remnawave.py b/app/webapi/schemas/remnawave.py index 77fbc3ac..8a09fd45 100644 --- a/app/webapi/schemas/remnawave.py +++ b/app/webapi/schemas/remnawave.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, Field @@ -9,67 +9,67 @@ 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 + api_url: str | None = None + status_code: int | None = None + system_info: dict[str, Any] | None = None class RemnaWaveStatusResponse(BaseModel): is_configured: bool - configuration_error: Optional[str] = None - connection: Optional[RemnaWaveConnectionStatus] = None + configuration_error: str | None = None + connection: RemnaWaveConnectionStatus | None = None class RemnaWaveNode(BaseModel): uuid: str name: str address: str - country_code: Optional[str] = None + country_code: str | None = 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 - last_status_change: Optional[datetime] = None - last_status_message: Optional[str] = None - xray_uptime: Optional[str] = None + users_online: int | None = None + traffic_used_bytes: int | None = None + traffic_limit_bytes: int | None = None + last_status_change: datetime | None = None + last_status_message: str | None = None + xray_uptime: str | None = None is_traffic_tracking_active: bool = False - traffic_reset_day: Optional[int] = None - notify_percent: Optional[int] = None + traffic_reset_day: int | None = None + notify_percent: int | None = None consumption_multiplier: float = 1.0 - cpu_count: Optional[int] = None - cpu_model: Optional[str] = None - total_ram: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - provider_uuid: Optional[str] = None + cpu_count: int | None = None + cpu_model: str | None = None + total_ram: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + provider_uuid: str | None = None class RemnaWaveNodeListResponse(BaseModel): - items: List[RemnaWaveNode] + items: list[RemnaWaveNode] total: int class RemnaWaveNodeActionRequest(BaseModel): - action: Literal["enable", "disable", "restart"] + action: Literal['enable', 'disable', 'restart'] class RemnaWaveNodeActionResponse(BaseModel): success: bool - detail: Optional[str] = None + detail: str | None = 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 + realtime: dict[str, Any] | None = None + usage_history: list[dict[str, Any]] = Field(default_factory=list) + last_updated: datetime | None = None class RemnaWaveNodeUsageResponse(BaseModel): - items: List[Dict[str, Any]] = Field(default_factory=list) + items: list[dict[str, Any]] = Field(default_factory=list) class RemnaWaveBandwidth(BaseModel): @@ -81,7 +81,7 @@ class RemnaWaveBandwidth(BaseModel): class RemnaWaveTrafficPeriod(BaseModel): current: int previous: int - difference: Optional[str] = None + difference: str | None = None class RemnaWaveTrafficPeriods(BaseModel): @@ -115,13 +115,13 @@ class RemnaWaveServerInfo(BaseModel): class RemnaWaveSystemStatsResponse(BaseModel): system: RemnaWaveSystemSummary - users_by_status: Dict[str, int] + 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 + nodes_realtime: list[dict[str, Any]] = Field(default_factory=list) + nodes_weekly: list[dict[str, Any]] = Field(default_factory=list) + last_updated: datetime | None = None class RemnaWaveSquad(BaseModel): @@ -129,66 +129,66 @@ class RemnaWaveSquad(BaseModel): name: str members_count: int inbounds_count: int - inbounds: List[Dict[str, Any]] = Field(default_factory=list) + inbounds: list[dict[str, Any]] = Field(default_factory=list) class RemnaWaveSquadListResponse(BaseModel): - items: List[RemnaWaveSquad] + items: list[RemnaWaveSquad] total: int class RemnaWaveSquadCreateRequest(BaseModel): name: str - inbound_uuids: List[str] = Field(default_factory=list) + inbound_uuids: list[str] = Field(default_factory=list) class RemnaWaveSquadUpdateRequest(BaseModel): - name: Optional[str] = None - inbound_uuids: Optional[List[str]] = None + name: str | None = None + inbound_uuids: list[str] | None = 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 + action: Literal['add_all_users', 'remove_all_users', 'delete', 'rename', 'update_inbounds'] + name: str | None = None + inbound_uuids: list[str] | None = None class RemnaWaveOperationResponse(BaseModel): success: bool - detail: Optional[str] = None - data: Optional[Dict[str, Any]] = None + detail: str | None = None + data: dict[str, Any] | None = None class RemnaWaveInboundsResponse(BaseModel): - items: List[Dict[str, Any]] = Field(default_factory=list) + items: list[dict[str, Any]] = Field(default_factory=list) class RemnaWaveUserTrafficResponse(BaseModel): - telegram_id: Optional[int] = None + telegram_id: int | None = None 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 + subscription_url: str | None = None class RemnaWaveSyncFromPanelRequest(BaseModel): - mode: Literal["all", "new_only", "update_only"] = "all" + mode: Literal['all', 'new_only', 'update_only'] = 'all' class RemnaWaveGenericSyncResponse(BaseModel): success: bool - detail: Optional[str] = None - data: Optional[Dict[str, Any]] = None + detail: str | None = None + data: dict[str, Any] | None = None class RemnaWaveSquadMigrationPreviewResponse(BaseModel): squad_uuid: str squad_name: str current_users: int - max_users: Optional[int] = None + max_users: int | None = None users_to_migrate: int @@ -210,6 +210,6 @@ class RemnaWaveSquadMigrationStats(BaseModel): class RemnaWaveSquadMigrationResponse(BaseModel): success: bool - detail: Optional[str] = None - error: Optional[str] = None - data: Optional[RemnaWaveSquadMigrationStats] = None + detail: str | None = None + error: str | None = None + data: RemnaWaveSquadMigrationStats | None = None diff --git a/app/webapi/schemas/servers.py b/app/webapi/schemas/servers.py index 713c9447..cccb237e 100644 --- a/app/webapi/schemas/servers.py +++ b/app/webapi/schemas/servers.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -16,29 +15,27 @@ class ServerResponse(BaseModel): model_config = ConfigDict(from_attributes=True, populate_by_name=True) id: int - squad_uuid: str = Field(alias="squadUuid") - display_name: str = Field(alias="displayName") - original_name: Optional[str] = Field(default=None, alias="originalName") - country_code: Optional[str] = Field(default=None, alias="countryCode") - is_available: bool = Field(alias="isAvailable") - is_trial_eligible: bool = Field(default=False, alias="isTrialEligible") - price_kopeks: int = Field(alias="priceKopeks") - price_rubles: float = Field(alias="priceRubles") - description: Optional[str] = None - sort_order: int = Field(default=0, alias="sortOrder") - max_users: Optional[int] = Field(default=None, alias="maxUsers") - current_users: int = Field(default=0, alias="currentUsers") - created_at: Optional[datetime] = Field(default=None, alias="createdAt") - updated_at: Optional[datetime] = Field(default=None, alias="updatedAt") - promo_groups: List[PromoGroupSummary] = Field( - default_factory=list, alias="promoGroups" - ) + squad_uuid: str = Field(alias='squadUuid') + display_name: str = Field(alias='displayName') + original_name: str | None = Field(default=None, alias='originalName') + country_code: str | None = Field(default=None, alias='countryCode') + is_available: bool = Field(alias='isAvailable') + is_trial_eligible: bool = Field(default=False, alias='isTrialEligible') + price_kopeks: int = Field(alias='priceKopeks') + price_rubles: float = Field(alias='priceRubles') + description: str | None = None + sort_order: int = Field(default=0, alias='sortOrder') + max_users: int | None = Field(default=None, alias='maxUsers') + current_users: int = Field(default=0, alias='currentUsers') + created_at: datetime | None = Field(default=None, alias='createdAt') + updated_at: datetime | None = Field(default=None, alias='updatedAt') + promo_groups: list[PromoGroupSummary] = Field(default_factory=list, alias='promoGroups') class ServerListResponse(BaseModel): """Список серверов с пагинацией.""" - items: List[ServerResponse] + items: list[ServerResponse] total: int page: int limit: int @@ -47,41 +44,39 @@ class ServerListResponse(BaseModel): class ServerCreateRequest(BaseModel): """Запрос на создание сервера.""" - squad_uuid: str = Field(alias="squadUuid") - display_name: str = Field(alias="displayName") - original_name: Optional[str] = Field(default=None, alias="originalName") - country_code: Optional[str] = Field(default=None, alias="countryCode") - price_kopeks: int = Field(default=0, alias="priceKopeks") - description: Optional[str] = None - max_users: Optional[int] = Field(default=None, alias="maxUsers") - is_available: bool = Field(default=True, alias="isAvailable") - is_trial_eligible: bool = Field(default=False, alias="isTrialEligible") - sort_order: int = Field(default=0, alias="sortOrder") - promo_group_ids: Optional[List[int]] = Field( + squad_uuid: str = Field(alias='squadUuid') + display_name: str = Field(alias='displayName') + original_name: str | None = Field(default=None, alias='originalName') + country_code: str | None = Field(default=None, alias='countryCode') + price_kopeks: int = Field(default=0, alias='priceKopeks') + description: str | None = None + max_users: int | None = Field(default=None, alias='maxUsers') + is_available: bool = Field(default=True, alias='isAvailable') + is_trial_eligible: bool = Field(default=False, alias='isTrialEligible') + sort_order: int = Field(default=0, alias='sortOrder') + promo_group_ids: list[int] | None = Field( default=None, - alias="promoGroupIds", - description="Список идентификаторов промогрупп, доступных на сервере.", + alias='promoGroupIds', + description='Список идентификаторов промогрупп, доступных на сервере.', ) class ServerUpdateRequest(BaseModel): """Запрос на обновление свойств сервера.""" - display_name: Optional[str] = Field(default=None, alias="displayName") - original_name: Optional[str] = Field(default=None, alias="originalName") - country_code: Optional[str] = Field(default=None, alias="countryCode") - price_kopeks: Optional[int] = Field(default=None, alias="priceKopeks") - description: Optional[str] = None - max_users: Optional[int] = Field(default=None, alias="maxUsers") - is_available: Optional[bool] = Field(default=None, alias="isAvailable") - is_trial_eligible: Optional[bool] = Field( - default=None, alias="isTrialEligible" - ) - sort_order: Optional[int] = Field(default=None, alias="sortOrder") - promo_group_ids: Optional[List[int]] = Field( + display_name: str | None = Field(default=None, alias='displayName') + original_name: str | None = Field(default=None, alias='originalName') + country_code: str | None = Field(default=None, alias='countryCode') + price_kopeks: int | None = Field(default=None, alias='priceKopeks') + description: str | None = None + max_users: int | None = Field(default=None, alias='maxUsers') + is_available: bool | None = Field(default=None, alias='isAvailable') + is_trial_eligible: bool | None = Field(default=None, alias='isTrialEligible') + sort_order: int | None = Field(default=None, alias='sortOrder') + promo_group_ids: list[int] | None = Field( default=None, - alias="promoGroupIds", - description="Если передан список, он заменит текущие промогруппы сервера.", + alias='promoGroupIds', + description='Если передан список, он заменит текущие промогруппы сервера.', ) @@ -101,12 +96,12 @@ class ServerStatisticsResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) - total_servers: int = Field(alias="totalServers") - available_servers: int = Field(alias="availableServers") - unavailable_servers: int = Field(alias="unavailableServers") - servers_with_connections: int = Field(alias="serversWithConnections") - total_revenue_kopeks: int = Field(alias="totalRevenueKopeks") - total_revenue_rubles: float = Field(alias="totalRevenueRubles") + total_servers: int = Field(alias='totalServers') + available_servers: int = Field(alias='availableServers') + unavailable_servers: int = Field(alias='unavailableServers') + servers_with_connections: int = Field(alias='serversWithConnections') + total_revenue_kopeks: int = Field(alias='totalRevenueKopeks') + total_revenue_rubles: float = Field(alias='totalRevenueRubles') class ServerCountsSyncResponse(BaseModel): @@ -123,20 +118,16 @@ class ServerConnectedUser(BaseModel): model_config = ConfigDict(populate_by_name=True) id: int - telegram_id: Optional[int] = Field(default=None, alias="telegramId") - username: Optional[str] = None - first_name: Optional[str] = Field(default=None, alias="firstName") - last_name: Optional[str] = Field(default=None, alias="lastName") + telegram_id: int | None = Field(default=None, alias='telegramId') + username: str | None = None + first_name: str | None = Field(default=None, alias='firstName') + last_name: str | None = Field(default=None, alias='lastName') status: str - balance_kopeks: int = Field(alias="balanceKopeks") - balance_rubles: float = Field(alias="balanceRubles") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - subscription_status: Optional[str] = Field( - default=None, alias="subscriptionStatus" - ) - subscription_end_date: Optional[datetime] = Field( - default=None, alias="subscriptionEndDate" - ) + balance_kopeks: int = Field(alias='balanceKopeks') + balance_rubles: float = Field(alias='balanceRubles') + subscription_id: int | None = Field(default=None, alias='subscriptionId') + subscription_status: str | None = Field(default=None, alias='subscriptionStatus') + subscription_end_date: datetime | None = Field(default=None, alias='subscriptionEndDate') class ServerConnectedUsersResponse(BaseModel): @@ -144,7 +135,7 @@ class ServerConnectedUsersResponse(BaseModel): model_config = ConfigDict(populate_by_name=True) - items: List[ServerConnectedUser] + items: list[ServerConnectedUser] total: int limit: int offset: int @@ -157,4 +148,3 @@ class ServerDeleteResponse(BaseModel): success: bool message: str - diff --git a/app/webapi/schemas/subscription_events.py b/app/webapi/schemas/subscription_events.py index 0c4d3480..7dea47e5 100644 --- a/app/webapi/schemas/subscription_events.py +++ b/app/webapi/schemas/subscription_events.py @@ -1,33 +1,33 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, Field, field_validator class SubscriptionEventCreate(BaseModel): event_type: Literal[ - "activation", - "purchase", - "renewal", - "balance_topup", - "promocode_activation", - "referral_link_visit", - "promo_group_change", + 'activation', + 'purchase', + 'renewal', + 'balance_topup', + 'promocode_activation', + 'referral_link_visit', + 'promo_group_change', ] user_id: int = Field(..., ge=1) - subscription_id: Optional[int] = Field(default=None, ge=1) - transaction_id: Optional[int] = Field(default=None, ge=1) - amount_kopeks: Optional[int] = Field(default=None, ge=0) - currency: Optional[str] = Field(default=None, min_length=1, max_length=16) - message: Optional[str] = Field(default=None, max_length=2000) - occurred_at: Optional[datetime] = None - extra: Dict[str, Any] = Field(default_factory=dict) + subscription_id: int | None = Field(default=None, ge=1) + transaction_id: int | None = Field(default=None, ge=1) + amount_kopeks: int | None = Field(default=None, ge=0) + currency: str | None = Field(default=None, min_length=1, max_length=16) + message: str | None = Field(default=None, max_length=2000) + occurred_at: datetime | None = None + extra: dict[str, Any] = Field(default_factory=dict) - @field_validator("message") + @field_validator('message') @classmethod - def _strip_message(cls, value: Optional[str]) -> Optional[str]: + def _strip_message(cls, value: str | None) -> str | None: if value is None: return None stripped = value.strip() @@ -39,16 +39,16 @@ class SubscriptionEventResponse(BaseModel): event_type: str user_id: int user_full_name: str - user_username: Optional[str] = None - user_telegram_id: Optional[int] = None - subscription_id: Optional[int] = None - transaction_id: Optional[int] = None - amount_kopeks: Optional[int] = None - currency: Optional[str] = None - message: Optional[str] = None + user_username: str | None = None + user_telegram_id: int | None = None + subscription_id: int | None = None + transaction_id: int | None = None + amount_kopeks: int | None = None + currency: str | None = None + message: str | None = None occurred_at: datetime created_at: datetime - extra: Dict[str, Any] = Field(default_factory=dict) + extra: dict[str, Any] = Field(default_factory=dict) class SubscriptionEventListResponse(BaseModel): diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py index b8e3f27a..811cff60 100644 --- a/app/webapi/schemas/subscriptions.py +++ b/app/webapi/schemas/subscriptions.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field @@ -19,22 +18,22 @@ class SubscriptionResponse(BaseModel): device_limit: int modem_enabled: bool = False autopay_enabled: bool - autopay_days_before: Optional[int] = None - subscription_url: Optional[str] = None - subscription_crypto_link: Optional[str] = None - connected_squads: List[str] = Field(default_factory=list) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + autopay_days_before: int | None = None + subscription_url: str | None = None + subscription_crypto_link: str | None = None + connected_squads: list[str] = Field(default_factory=list) + created_at: datetime | None = None + updated_at: datetime | None = None 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 + duration_days: int | None = None + traffic_limit_gb: int | None = None + device_limit: int | None = None + squad_uuid: str | None = None + connected_squads: list[str] | None = None replace_existing: bool = False diff --git a/app/webapi/schemas/tickets.py b/app/webapi/schemas/tickets.py index 9c99455c..36f3cc88 100644 --- a/app/webapi/schemas/tickets.py +++ b/app/webapi/schemas/tickets.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field @@ -12,9 +11,9 @@ class TicketMessageResponse(BaseModel): message_text: str is_from_admin: bool has_media: bool - media_type: Optional[str] = None - media_file_id: Optional[str] = None - media_caption: Optional[str] = None + media_type: str | None = None + media_file_id: str | None = None + media_caption: str | None = None created_at: datetime @@ -26,10 +25,10 @@ class TicketResponse(BaseModel): priority: str created_at: datetime updated_at: datetime - closed_at: Optional[datetime] = None + closed_at: datetime | None = None user_reply_block_permanent: bool - user_reply_block_until: Optional[datetime] = None - messages: List[TicketMessageResponse] = Field(default_factory=list) + user_reply_block_until: datetime | None = None + messages: list[TicketMessageResponse] = Field(default_factory=list) class TicketStatusUpdateRequest(BaseModel): @@ -42,18 +41,18 @@ class TicketPriorityUpdateRequest(BaseModel): class TicketReplyBlockRequest(BaseModel): permanent: bool = False - until: Optional[datetime] = None + until: datetime | None = None class TicketReplyRequest(BaseModel): - message_text: Optional[str] = Field(default=None, max_length=4000) - media_type: Optional[str] = Field( + message_text: str | None = Field(default=None, max_length=4000) + media_type: str | None = Field( default=None, - description="Тип медиа (photo, video, document, voice и т.д.)", + description='Тип медиа (photo, video, document, voice и т.д.)', max_length=32, ) - media_file_id: Optional[str] = Field(default=None, max_length=255) - media_caption: Optional[str] = Field(default=None, max_length=4000) + media_file_id: str | None = Field(default=None, max_length=255) + media_caption: str | None = Field(default=None, max_length=4000) class TicketReplyResponse(BaseModel): @@ -66,5 +65,5 @@ class TicketMediaResponse(BaseModel): ticket_id: int media_type: str media_file_id: str - media_caption: Optional[str] = None - media_url: Optional[str] = None + media_caption: str | None = None + media_url: str | None = None diff --git a/app/webapi/schemas/tokens.py b/app/webapi/schemas/tokens.py index d923ab65..6cc1e28c 100644 --- a/app/webapi/schemas/tokens.py +++ b/app/webapi/schemas/tokens.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field @@ -9,22 +8,22 @@ from pydantic import BaseModel, Field class TokenResponse(BaseModel): id: int name: str - prefix: str = Field(..., description="Первые символы токена для идентификации") - description: Optional[str] = None + prefix: str = Field(..., description='Первые символы токена для идентификации') + description: str | None = 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 + updated_at: datetime | None = None + expires_at: datetime | None = None + last_used_at: datetime | None = None + last_used_ip: str | None = None + created_by: str | None = None class TokenCreateRequest(BaseModel): name: str - description: Optional[str] = None - expires_at: Optional[datetime] = None + description: str | None = None + expires_at: datetime | None = None class TokenCreateResponse(TokenResponse): - token: str = Field(..., description="Полное значение токена (возвращается один раз)") + token: str = Field(..., description='Полное значение токена (возвращается один раз)') diff --git a/app/webapi/schemas/transactions.py b/app/webapi/schemas/transactions.py index 9408f6c9..2eb9ffa0 100644 --- a/app/webapi/schemas/transactions.py +++ b/app/webapi/schemas/transactions.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel @@ -12,12 +11,12 @@ class TransactionResponse(BaseModel): type: str amount_kopeks: int amount_rubles: float - description: Optional[str] = None - payment_method: Optional[str] = None - external_id: Optional[str] = None + description: str | None = None + payment_method: str | None = None + external_id: str | None = None is_completed: bool created_at: datetime - completed_at: Optional[datetime] = None + completed_at: datetime | None = None class TransactionListResponse(BaseModel): diff --git a/app/webapi/schemas/user_messages.py b/app/webapi/schemas/user_messages.py index 97f41c69..cbf8fce8 100644 --- a/app/webapi/schemas/user_messages.py +++ b/app/webapi/schemas/user_messages.py @@ -1,15 +1,14 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field, validator def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() + cleaned = (value or '').strip() if not cleaned: - raise ValueError("Message text cannot be empty") + raise ValueError('Message text cannot be empty') return cleaned @@ -18,7 +17,7 @@ class UserMessageResponse(BaseModel): message_text: str is_active: bool sort_order: int - created_by: Optional[int] + created_by: int | None created_at: datetime updated_at: datetime @@ -28,16 +27,16 @@ class UserMessageCreateRequest(BaseModel): is_active: bool = True sort_order: int = Field(0, ge=0) - _normalize_message_text = validator("message_text", allow_reuse=True)(_normalize_text) + _normalize_message_text = validator('message_text', allow_reuse=True)(_normalize_text) class UserMessageUpdateRequest(BaseModel): - message_text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_active: Optional[bool] = None - sort_order: Optional[int] = Field(None, ge=0) + message_text: str | None = Field(None, min_length=1, max_length=4000) + is_active: bool | None = None + sort_order: int | None = Field(None, ge=0) - @validator("message_text") - def validate_message_text(cls, value): # noqa: D401,B902 + @validator('message_text') + def validate_message_text(cls, value): if value is None: return value return _normalize_text(value) diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index bb2d27e5..0d3ff5ac 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field @@ -27,74 +26,75 @@ class SubscriptionSummary(BaseModel): device_limit: int modem_enabled: bool = False autopay_enabled: bool - autopay_days_before: Optional[int] = None - subscription_url: Optional[str] = None - subscription_crypto_link: Optional[str] = None - connected_squads: List[str] = Field(default_factory=list) + autopay_days_before: int | None = None + subscription_url: str | None = None + subscription_crypto_link: str | None = None + connected_squads: list[str] = Field(default_factory=list) class UserResponse(BaseModel): id: int - telegram_id: Optional[int] = None - username: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None status: str language: str balance_kopeks: int balance_rubles: float - referral_code: Optional[str] = None - referred_by_id: Optional[int] = None + referral_code: str | None = None + referred_by_id: int | None = 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 + last_activity: datetime | None = None + promo_group: PromoGroupSummary | None = None + subscription: SubscriptionSummary | None = None class UserListResponse(BaseModel): - items: List[UserResponse] + items: list[UserResponse] total: int limit: int offset: int class UserCreateRequest(BaseModel): - telegram_id: Optional[int] = None - 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 + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None + language: str = 'ru' + referred_by_id: int | None = None + promo_group_id: int | None = 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 + username: str | None = None + first_name: str | None = None + last_name: str | None = None + language: str | None = None + status: str | None = None + promo_group_id: int | None = None + referral_code: str | None = None + has_had_paid_subscription: bool | None = None + has_made_first_topup: bool | None = None class BalanceUpdateRequest(BaseModel): amount_kopeks: int - description: Optional[str] = Field(default="Корректировка через веб-API") + description: str | None = Field(default='Корректировка через веб-API') create_transaction: bool = True class UserSubscriptionCreateRequest(BaseModel): """Схема для создания подписки через users API (user_id берется из URL)""" + 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 - replace_existing: bool = False \ No newline at end of file + duration_days: int | None = None + traffic_limit_gb: int | None = None + device_limit: int | None = None + squad_uuid: str | None = None + connected_squads: list[str] | None = None + replace_existing: bool = False diff --git a/app/webapi/schemas/webhooks.py b/app/webapi/schemas/webhooks.py index 40620031..c88605e8 100644 --- a/app/webapi/schemas/webhooks.py +++ b/app/webapi/schemas/webhooks.py @@ -1,25 +1,25 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Optional +from typing import Any -from pydantic import BaseModel, Field, HttpUrl +from pydantic import BaseModel, Field class WebhookCreateRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) url: str = Field(..., min_length=1) event_type: str = Field(..., min_length=1, max_length=50) - secret: Optional[str] = Field(default=None, max_length=128) - description: Optional[str] = Field(default=None) + secret: str | None = Field(default=None, max_length=128) + description: str | None = Field(default=None) class WebhookUpdateRequest(BaseModel): - name: Optional[str] = Field(default=None, min_length=1, max_length=255) - url: Optional[str] = Field(default=None, min_length=1) - secret: Optional[str] = Field(default=None, max_length=128) - description: Optional[str] = None - is_active: Optional[bool] = None + name: str | None = Field(default=None, min_length=1, max_length=255) + url: str | None = Field(default=None, min_length=1) + secret: str | None = Field(default=None, max_length=128) + description: str | None = None + is_active: bool | None = None class WebhookResponse(BaseModel): @@ -28,10 +28,10 @@ class WebhookResponse(BaseModel): url: str event_type: str is_active: bool - description: Optional[str] + description: str | None created_at: datetime updated_at: datetime - last_triggered_at: Optional[datetime] + last_triggered_at: datetime | None failure_count: int success_count: int @@ -51,14 +51,14 @@ class WebhookDeliveryResponse(BaseModel): webhook_id: int event_type: str payload: dict[str, Any] - response_status: Optional[int] - response_body: Optional[str] + response_status: int | None + response_body: str | None status: str - error_message: Optional[str] + error_message: str | None attempt_number: int created_at: datetime - delivered_at: Optional[datetime] - next_retry_at: Optional[datetime] + delivered_at: datetime | None + next_retry_at: datetime | None class Config: from_attributes = True @@ -78,4 +78,3 @@ class WebhookStatsResponse(BaseModel): successful_deliveries: int failed_deliveries: int success_rate: float - diff --git a/app/webapi/schemas/welcome_texts.py b/app/webapi/schemas/welcome_texts.py index e68c9410..c5447fc4 100644 --- a/app/webapi/schemas/welcome_texts.py +++ b/app/webapi/schemas/welcome_texts.py @@ -1,15 +1,14 @@ from __future__ import annotations from datetime import datetime -from typing import Optional from pydantic import BaseModel, Field, validator def _normalize_text(value: str) -> str: - cleaned = (value or "").strip() + cleaned = (value or '').strip() if not cleaned: - raise ValueError("Text cannot be empty") + raise ValueError('Text cannot be empty') return cleaned @@ -18,7 +17,7 @@ class WelcomeTextResponse(BaseModel): text: str is_active: bool is_enabled: bool - created_by: Optional[int] + created_by: int | None created_at: datetime updated_at: datetime @@ -28,16 +27,16 @@ class WelcomeTextCreateRequest(BaseModel): is_enabled: bool = True is_active: bool = True - _normalize_text = validator("text", allow_reuse=True)(_normalize_text) + _normalize_text = validator('text', allow_reuse=True)(_normalize_text) class WelcomeTextUpdateRequest(BaseModel): - text: Optional[str] = Field(None, min_length=1, max_length=4000) - is_enabled: Optional[bool] = None - is_active: Optional[bool] = None + text: str | None = Field(None, min_length=1, max_length=4000) + is_enabled: bool | None = None + is_active: bool | None = None - @validator("text") - def validate_text(cls, value): # noqa: D401,B902 + @validator('text') + def validate_text(cls, value): if value is None: return value return _normalize_text(value) diff --git a/app/webapi/server.py b/app/webapi/server.py index 863ec5e2..db3f5305 100644 --- a/app/webapi/server.py +++ b/app/webapi/server.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import logging -from typing import Optional import uvicorn @@ -17,41 +16,41 @@ logger = logging.getLogger(__name__) class WebAPIServer: """Асинхронный uvicorn-сервер для административного API.""" - def __init__(self, app: Optional[object] = None) -> None: + def __init__(self, app: object | None = None) -> None: self._app = app or 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") + logger.warning('WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1') workers = 1 # Кастомный конфиг логирования - скрываем спам от WebSocket log_config = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "fmt": "%(levelprefix)s %(message)s", - "use_colors": None, + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'default': { + '()': 'uvicorn.logging.DefaultFormatter', + 'fmt': '%(levelprefix)s %(message)s', + 'use_colors': None, }, }, - "handlers": { - "default": { - "formatter": "default", - "class": "logging.StreamHandler", - "stream": "ext://sys.stderr", + 'handlers': { + 'default': { + 'formatter': 'default', + 'class': 'logging.StreamHandler', + 'stream': 'ext://sys.stderr', }, }, - "loggers": { - "uvicorn": {"handlers": ["default"], "level": "WARNING", "propagate": False}, - "uvicorn.error": {"level": "WARNING", "propagate": False}, - "uvicorn.access": {"level": "ERROR", "propagate": False}, - "uvicorn.protocols": {"level": "WARNING", "propagate": False}, - "uvicorn.protocols.websockets": {"level": "WARNING", "propagate": False}, - "uvicorn.protocols.websockets.websockets_impl": {"level": "WARNING", "propagate": False}, - "websockets": {"level": "WARNING", "propagate": False}, - "websockets.server": {"level": "WARNING", "propagate": False}, + 'loggers': { + 'uvicorn': {'handlers': ['default'], 'level': 'WARNING', 'propagate': False}, + 'uvicorn.error': {'level': 'WARNING', 'propagate': False}, + 'uvicorn.access': {'level': 'ERROR', 'propagate': False}, + 'uvicorn.protocols': {'level': 'WARNING', 'propagate': False}, + 'uvicorn.protocols.websockets': {'level': 'WARNING', 'propagate': False}, + 'uvicorn.protocols.websockets.websockets_impl': {'level': 'WARNING', 'propagate': False}, + 'websockets': {'level': 'WARNING', 'propagate': False}, + 'websockets.server': {'level': 'WARNING', 'propagate': False}, }, } @@ -59,43 +58,43 @@ class WebAPIServer: app=self._app, host=settings.WEB_API_HOST, port=int(settings.WEB_API_PORT or 8080), - log_level="warning", + log_level='warning', workers=workers, - lifespan="on", + lifespan='on', access_log=False, log_config=log_config, ) self._server = uvicorn.Server(self._config) - self._task: Optional[asyncio.Task[None]] = None + self._task: asyncio.Task[None] | None = None async def start(self) -> None: if self._task and not self._task.done(): - logger.info("🌐 Административное веб-API уже запущено") + 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.exception('❌ Ошибка работы веб-API: %s', error) raise logger.info( - "🌐 Запуск административного API на %s:%s", + '🌐 Запуск административного API на %s:%s', settings.WEB_API_HOST, settings.WEB_API_PORT, ) - self._task = asyncio.create_task(_serve(), name="web-api-server") + 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) + 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): + while not getattr(self._server, 'started', False): if self._task.done(): break await asyncio.sleep(0.1) @@ -107,7 +106,7 @@ class WebAPIServer: if not self._task: return - logger.info("🛑 Остановка административного API") + logger.info('🛑 Остановка административного API') self._server.should_exit = True await self._task self._task = None diff --git a/app/webserver/__init__.py b/app/webserver/__init__.py index 3aa403f3..34d371c1 100644 --- a/app/webserver/__init__.py +++ b/app/webserver/__init__.py @@ -1,10 +1,11 @@ from typing import Any -__all__ = ["create_unified_app"] + +__all__ = ['create_unified_app'] def __getattr__(name: str) -> Any: - if name == "create_unified_app": + if name == 'create_unified_app': from .unified_app import create_unified_app as _create_unified_app return _create_unified_app diff --git a/app/webserver/payments.py b/app/webserver/payments.py index 01d27c0f..f61e7020 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -5,20 +5,19 @@ import hashlib import hmac import json import logging -from typing import Iterable +from collections.abc import Iterable +from aiogram import Bot from fastapi import APIRouter, Request, Response, status from fastapi.responses import JSONResponse -from aiogram import Bot - from app.config import settings from app.database.database import get_db -from app.external.tribute import TributeService as TributeAPI from app.external import yookassa_webhook as yookassa_webhook_module -from app.external.wata_webhook import WataWebhookHandler from app.external.heleket_webhook import HeleketWebhookHandler from app.external.pal24_client import Pal24APIError +from app.external.tribute import TributeService as TributeAPI +from app.external.wata_webhook import WataWebhookHandler from app.services.pal24_service import Pal24Service from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService @@ -31,9 +30,9 @@ def _create_cors_response() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization', }, ) @@ -51,64 +50,64 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: display_name = settings.get_mulenpay_display_name() if not secret_key: - logger.error("%s secret key is not configured", display_name) + logger.error('%s secret key is not configured', display_name) return False signature = _extract_header( request, ( - "X-MulenPay-Signature", - "X-Mulenpay-Signature", - "X-MULENPAY-SIGNATURE", - "X-MulenPay-Webhook-Signature", - "X-Mulenpay-Webhook-Signature", - "X-MULENPAY-WEBHOOK-SIGNATURE", - "X-Signature", - "Signature", - "X-MulenPay-Sign", - "X-Mulenpay-Sign", - "X-MULENPAY-SIGN", - "MulenPay-Signature", - "Mulenpay-Signature", - "MULENPAY-SIGNATURE", - "signature", - "sign", + 'X-MulenPay-Signature', + 'X-Mulenpay-Signature', + 'X-MULENPAY-SIGNATURE', + 'X-MulenPay-Webhook-Signature', + 'X-Mulenpay-Webhook-Signature', + 'X-MULENPAY-WEBHOOK-SIGNATURE', + 'X-Signature', + 'Signature', + 'X-MulenPay-Sign', + 'X-Mulenpay-Sign', + 'X-MULENPAY-SIGN', + 'MulenPay-Signature', + 'Mulenpay-Signature', + 'MULENPAY-SIGNATURE', + 'signature', + 'sign', ), ) if signature: normalized_signature = signature - if normalized_signature.lower().startswith("sha256="): - normalized_signature = normalized_signature.split("=", 1)[1].strip() + if normalized_signature.lower().startswith('sha256='): + normalized_signature = normalized_signature.split('=', 1)[1].strip() - hmac_digest = hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).digest() + hmac_digest = hmac.new(secret_key.encode('utf-8'), raw_body, hashlib.sha256).digest() expected_hex = hmac_digest.hex() - expected_base64 = base64.b64encode(hmac_digest).decode("utf-8").strip() - expected_urlsafe = base64.urlsafe_b64encode(hmac_digest).decode("utf-8").strip() + expected_base64 = base64.b64encode(hmac_digest).decode('utf-8').strip() + expected_urlsafe = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip() normalized_lower = normalized_signature.lower() if hmac.compare_digest(normalized_lower, expected_hex.lower()): return True - normalized_no_padding = normalized_signature.rstrip("=") - if hmac.compare_digest(normalized_no_padding, expected_base64.rstrip("=")): + normalized_no_padding = normalized_signature.rstrip('=') + if hmac.compare_digest(normalized_no_padding, expected_base64.rstrip('=')): return True - if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip("=")): + if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')): return True - logger.error("Неверная подпись %s webhook", display_name) + logger.error('Неверная подпись %s webhook', display_name) return False - authorization_header = request.headers.get("Authorization") + authorization_header = request.headers.get('Authorization') if authorization_header: - scheme, _, value = authorization_header.partition(" ") + scheme, _, value = authorization_header.partition(' ') scheme_lower = scheme.lower() token = value.strip() if value else scheme.strip() - if scheme_lower in {"bearer", "token"}: + if scheme_lower in {'bearer', 'token'}: if hmac.compare_digest(token, secret_key): return True - logger.error("Неверный %s токен %s webhook", scheme, display_name) + logger.error('Неверный %s токен %s webhook', scheme, display_name) return False if not value and hmac.compare_digest(token, secret_key): @@ -117,15 +116,15 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool: fallback_token = _extract_header( request, ( - "X-MulenPay-Token", - "X-Mulenpay-Token", - "X-Webhook-Token", + 'X-MulenPay-Token', + 'X-Mulenpay-Token', + 'X-Webhook-Token', ), ) if fallback_token and hmac.compare_digest(fallback_token, secret_key): return True - logger.error("Отсутствует подпись %s webhook", display_name) + logger.error('Отсутствует подпись %s webhook', display_name) return False @@ -152,25 +151,25 @@ async def _process_payment_service_callback( async def _parse_pal24_payload(request: Request) -> dict[str, str]: try: - if request.headers.get("content-type", "").startswith("application/json"): + if request.headers.get('content-type', '').startswith('application/json'): data = await request.json() if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except json.JSONDecodeError: - logger.debug("Pal24 webhook JSON payload не удалось распарсить") + logger.debug('Pal24 webhook JSON payload не удалось распарсить') form = await request.form() if form: return {str(k): str(v) for k, v in form.multi_items()} - raw_body = (await request.body()).decode("utf-8") + raw_body = (await request.body()).decode('utf-8') if raw_body: try: data = json.loads(raw_body) if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except json.JSONDecodeError: - logger.debug("Pal24 webhook body не удалось распарсить как JSON: %s", raw_body) + logger.debug('Pal24 webhook body не удалось распарсить как JSON: %s', raw_body) return {} @@ -191,20 +190,22 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def tribute_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: - return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) + return JSONResponse( + {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST + ) - payload = raw_body.decode("utf-8") + payload = raw_body.decode('utf-8') - signature = request.headers.get("trbt-signature") + signature = request.headers.get('trbt-signature') if not signature: return JSONResponse( - {"status": "error", "reason": "missing_signature"}, + {'status': 'error', 'reason': 'missing_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) if settings.TRIBUTE_API_KEY and not tribute_api.verify_webhook_signature(payload, signature): return JSONResponse( - {"status": "error", "reason": "invalid_signature"}, + {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) @@ -212,16 +213,16 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute json.loads(payload) except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) result = await tribute_service.process_webhook(payload) if result: - return JSONResponse({"status": "ok", "result": result}) + return JSONResponse({'status': 'ok', 'result': result}) return JSONResponse( - {"status": "error", "reason": "processing_failed"}, + {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -237,32 +238,34 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def mulenpay_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: - return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) + return JSONResponse( + {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST + ) if not _verify_mulenpay_signature(request, raw_body): return JSONResponse( - {"status": "error", "reason": "invalid_signature"}, + {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: - payload = json.loads(raw_body.decode("utf-8")) + payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) success = await _process_payment_service_callback( payment_service, payload, - "process_mulenpay_callback", + 'process_mulenpay_callback', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "processing_failed"}, + {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -278,23 +281,25 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def cryptobot_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: - return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) + return JSONResponse( + {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST + ) - payload_text = raw_body.decode("utf-8") + payload_text = raw_body.decode('utf-8') try: payload = json.loads(payload_text) except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) - signature = request.headers.get("Crypto-Pay-API-Signature") + signature = request.headers.get('Crypto-Pay-API-Signature') secret = settings.CRYPTOBOT_WEBHOOK_SECRET if secret: if not signature: return JSONResponse( - {"status": "error", "reason": "missing_signature"}, + {'status': 'error', 'reason': 'missing_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) @@ -302,20 +307,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if not CryptoBotService().verify_webhook_signature(payload_text, signature): return JSONResponse( - {"status": "error", "reason": "invalid_signature"}, + {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) success = await _process_payment_service_callback( payment_service, payload, - "process_cryptobot_webhook", + 'process_cryptobot_webhook', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "processing_failed"}, + {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -328,9 +333,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-YooKassa-Signature, Signature", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature, Signature', }, ) @@ -338,18 +343,18 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def yookassa_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "yookassa_webhook", - "enabled": settings.is_yookassa_enabled(), + 'status': 'ok', + 'service': 'yookassa_webhook', + 'enabled': settings.is_yookassa_enabled(), } ) @router.post(settings.YOOKASSA_WEBHOOK_PATH) async def yookassa_webhook(request: Request) -> JSONResponse: header_ip_candidates = yookassa_webhook_module.collect_yookassa_ip_candidates( - request.headers.get("X-Forwarded-For"), - request.headers.get("X-Real-IP"), - request.headers.get("Cf-Connecting-Ip"), + request.headers.get('X-Forwarded-For'), + request.headers.get('X-Real-IP'), + request.headers.get('Cf-Connecting-Ip'), ) remote_ip = request.client.host if request.client else None client_ip = yookassa_webhook_module.resolve_yookassa_ip( @@ -360,9 +365,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if client_ip is None: return JSONResponse( { - "status": "error", - "reason": "unknown_ip", - "candidates": header_ip_candidates + ([remote_ip] if remote_ip else []), + 'status': 'error', + 'reason': 'unknown_ip', + 'candidates': header_ip_candidates + ([remote_ip] if remote_ip else []), }, status_code=status.HTTP_403_FORBIDDEN, ) @@ -370,55 +375,57 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if not yookassa_webhook_module.is_yookassa_ip_allowed(client_ip): return JSONResponse( { - "status": "error", - "reason": "forbidden_ip", - "ip": str(client_ip), + 'status': 'error', + 'reason': 'forbidden_ip', + 'ip': str(client_ip), }, status_code=status.HTTP_403_FORBIDDEN, ) body_bytes = await request.body() if not body_bytes: - return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) + return JSONResponse( + {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST + ) - body = body_bytes.decode("utf-8") + body = body_bytes.decode('utf-8') - signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature") + signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature') if signature: - logger.info("ℹ️ Получена подпись YooKassa: %s", signature) + logger.info('ℹ️ Получена подпись YooKassa: %s', signature) try: webhook_data = json.loads(body) except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) - event_type = webhook_data.get("event") + event_type = webhook_data.get('event') if not event_type: return JSONResponse( - {"status": "error", "reason": "missing_event"}, + {'status': 'error', 'reason': 'missing_event'}, status_code=status.HTTP_400_BAD_REQUEST, ) if event_type not in { - "payment.succeeded", - "payment.waiting_for_capture", - "payment.canceled", + 'payment.succeeded', + 'payment.waiting_for_capture', + 'payment.canceled', }: - return JSONResponse({"status": "ok", "ignored": event_type}) + return JSONResponse({'status': 'ok', 'ignored': event_type}) success = await _process_payment_service_callback( payment_service, webhook_data, - "process_yookassa_webhook", + 'process_yookassa_webhook', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "processing_failed"}, + {'status': 'error', 'reason': 'processing_failed'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @@ -432,9 +439,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-Signature", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Signature', }, ) @@ -442,9 +449,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def wata_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "wata_webhook", - "enabled": settings.is_wata_enabled(), + 'status': 'ok', + 'service': 'wata_webhook', + 'enabled': settings.is_wata_enabled(), } ) @@ -452,33 +459,35 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def wata_webhook(request: Request) -> JSONResponse: raw_body = await request.body() if not raw_body: - return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST) - - signature = request.headers.get("X-Signature") or "" - if not await wata_handler._verify_signature(raw_body.decode("utf-8"), signature): # type: ignore[attr-defined] return JSONResponse( - {"status": "error", "reason": "invalid_signature"}, + {'status': 'error', 'reason': 'empty_body'}, status_code=status.HTTP_400_BAD_REQUEST + ) + + signature = request.headers.get('X-Signature') or '' + if not await wata_handler._verify_signature(raw_body.decode('utf-8'), signature): # type: ignore[attr-defined] + return JSONResponse( + {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) try: - payload = json.loads(raw_body.decode("utf-8")) + payload = json.loads(raw_body.decode('utf-8')) except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) success = await _process_payment_service_callback( payment_service, payload, - "process_wata_webhook", + 'process_wata_webhook', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "not_processed"}, + {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -492,9 +501,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, ) @@ -502,9 +511,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def heleket_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "heleket_webhook", - "enabled": settings.is_heleket_enabled(), + 'status': 'ok', + 'service': 'heleket_webhook', + 'enabled': settings.is_heleket_enabled(), } ) @@ -514,26 +523,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute payload = await request.json() except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) if not heleket_handler.service.verify_webhook_signature(payload): return JSONResponse( - {"status": "error", "reason": "invalid_signature"}, + {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, ) success = await _process_payment_service_callback( payment_service, payload, - "process_heleket_webhook", + 'process_heleket_webhook', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "not_processed"}, + {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -547,9 +556,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', }, ) @@ -557,9 +566,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def pal24_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "pal24_webhook", - "enabled": settings.is_pal24_enabled(), + 'status': 'ok', + 'service': 'pal24_webhook', + 'enabled': settings.is_pal24_enabled(), } ) @@ -567,14 +576,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def pal24_webhook(request: Request) -> JSONResponse: if not pal24_service.is_configured: return JSONResponse( - {"status": "error", "reason": "service_not_configured"}, + {'status': 'error', 'reason': 'service_not_configured'}, status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) payload = await _parse_pal24_payload(request) if not payload: return JSONResponse( - {"status": "error", "reason": "empty_payload"}, + {'status': 'error', 'reason': 'empty_payload'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -582,20 +591,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute parsed_payload = pal24_service.parse_callback(payload) except Pal24APIError as error: return JSONResponse( - {"status": "error", "reason": str(error)}, + {'status': 'error', 'reason': str(error)}, status_code=status.HTTP_400_BAD_REQUEST, ) success = await _process_payment_service_callback( payment_service, parsed_payload, - "process_pal24_callback", + 'process_pal24_callback', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "not_processed"}, + {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -607,22 +616,19 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def platega_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "platega_webhook", - "enabled": settings.is_platega_enabled(), + 'status': 'ok', + 'service': 'platega_webhook', + 'enabled': settings.is_platega_enabled(), } ) @router.post(settings.PLATEGA_WEBHOOK_PATH) async def platega_webhook(request: Request) -> JSONResponse: - merchant_id = request.headers.get("X-MerchantId", "") - secret = request.headers.get("X-Secret", "") - if ( - merchant_id != (settings.PLATEGA_MERCHANT_ID or "") - or secret != (settings.PLATEGA_SECRET or "") - ): + merchant_id = request.headers.get('X-MerchantId', '') + secret = request.headers.get('X-Secret', '') + if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''): return JSONResponse( - {"status": "error", "reason": "unauthorized"}, + {'status': 'error', 'reason': 'unauthorized'}, status_code=status.HTTP_401_UNAUTHORIZED, ) @@ -630,20 +636,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute payload = await request.json() except json.JSONDecodeError: return JSONResponse( - {"status": "error", "reason": "invalid_json"}, + {'status': 'error', 'reason': 'invalid_json'}, status_code=status.HTTP_400_BAD_REQUEST, ) success = await _process_payment_service_callback( payment_service, payload, - "process_platega_webhook", + 'process_platega_webhook', ) if success: - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) return JSONResponse( - {"status": "error", "reason": "not_processed"}, + {'status': 'error', 'reason': 'not_processed'}, status_code=status.HTTP_400_BAD_REQUEST, ) @@ -659,9 +665,9 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, X-Content-HMAC", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Content-HMAC', }, ) @@ -669,14 +675,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def cloudpayments_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "cloudpayments_webhook", - "enabled": settings.is_cloudpayments_enabled(), + 'status': 'ok', + 'service': 'cloudpayments_webhook', + 'enabled': settings.is_cloudpayments_enabled(), } ) # CloudPayments Check webhook (перед списанием) - @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/check") + @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/check') async def cloudpayments_check_webhook(request: Request) -> JSONResponse: """Check webhook - вызывается перед списанием, можно отклонить платёж.""" try: @@ -684,98 +690,98 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute # Логируем для диагностики logger.info( - "CloudPayments check webhook received, body_len=%d, all_headers=%s", + 'CloudPayments check webhook received, body_len=%d, all_headers=%s', len(raw_body), dict(request.headers), ) # Проверяем подпись только если она пришла и API_SECRET настроен # CloudPayments использует заголовок X-Content-HMAC или Content-HMAC - signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or "" + signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET and signature: if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): logger.warning( - "CloudPayments check webhook: invalid signature, sig=%s...", - signature[:20] if signature else "empty", + 'CloudPayments check webhook: invalid signature, sig=%s...', + signature[:20] if signature else 'empty', ) - return JSONResponse({"code": 13}) # Отклонить + return JSONResponse({'code': 13}) # Отклонить elif settings.CLOUDPAYMENTS_API_SECRET and not signature: # Подпись не пришла, но API_SECRET настроен - пропускаем проверку с предупреждением - logger.warning("CloudPayments check webhook: no signature header, skipping verification") + logger.warning('CloudPayments check webhook: no signature header, skipping verification') # Разрешаем платёж - logger.info("CloudPayments check webhook: allowing payment, returning code=0") - return JSONResponse({"code": 0}) + logger.info('CloudPayments check webhook: allowing payment, returning code=0') + return JSONResponse({'code': 0}) except Exception as e: - logger.exception("CloudPayments check webhook error: %s", e) + logger.exception('CloudPayments check webhook error: %s', e) # В случае ошибки всё равно разрешаем платёж - return JSONResponse({"code": 0}) + return JSONResponse({'code': 0}) # CloudPayments Pay webhook (успешная оплата) - @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/pay") + @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/pay') async def cloudpayments_pay_webhook(request: Request) -> JSONResponse: """Pay webhook - вызывается после успешной оплаты.""" raw_body = await request.body() # Проверяем подпись только если она пришла и API_SECRET настроен - signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or "" + signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET and signature: if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning("CloudPayments pay webhook: invalid signature") - return JSONResponse({"code": 13}) + logger.warning('CloudPayments pay webhook: invalid signature') + return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: - logger.error("CloudPayments pay webhook parse error: %s", error) - return JSONResponse({"code": 0}) # Возвращаем 0, чтобы не было повторов + logger.error('CloudPayments pay webhook parse error: %s', error) + return JSONResponse({'code': 0}) # Возвращаем 0, чтобы не было повторов # Обрабатываем платёж - success = await _process_payment_service_callback( + await _process_payment_service_callback( payment_service, webhook_data, - "process_cloudpayments_pay_webhook", + 'process_cloudpayments_pay_webhook', ) - return JSONResponse({"code": 0}) + return JSONResponse({'code': 0}) # CloudPayments Fail webhook (неуспешная оплата) - @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + "/fail") + @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH + '/fail') async def cloudpayments_fail_webhook(request: Request) -> JSONResponse: """Fail webhook - вызывается при неуспешной оплате.""" raw_body = await request.body() # Проверяем подпись только если она пришла и API_SECRET настроен - signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or "" + signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET and signature: if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning("CloudPayments fail webhook: invalid signature") - return JSONResponse({"code": 13}) + logger.warning('CloudPayments fail webhook: invalid signature') + return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) except Exception as error: - logger.error("CloudPayments fail webhook parse error: %s", error) - return JSONResponse({"code": 0}) + logger.error('CloudPayments fail webhook parse error: %s', error) + return JSONResponse({'code': 0}) # Обрабатываем неуспешный платёж await _process_payment_service_callback( payment_service, webhook_data, - "process_cloudpayments_fail_webhook", + 'process_cloudpayments_fail_webhook', ) - return JSONResponse({"code": 0}) + return JSONResponse({'code': 0}) # Универсальный endpoint для всех webhooks @router.post(settings.CLOUDPAYMENTS_WEBHOOK_PATH) @@ -786,67 +792,68 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute # Логируем для диагностики logger.info( - "CloudPayments universal webhook received, body_len=%d, headers=%s", + 'CloudPayments universal webhook received, body_len=%d, headers=%s', len(raw_body), dict(request.headers), ) # Проверяем подпись только если она пришла и API_SECRET настроен - signature = request.headers.get("X-Content-HMAC") or request.headers.get("Content-HMAC") or "" + signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' if settings.CLOUDPAYMENTS_API_SECRET and signature: if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning("CloudPayments webhook: invalid signature") - return JSONResponse({"code": 13}) + logger.warning('CloudPayments webhook: invalid signature') + return JSONResponse({'code': 13}) # Парсим данные формы try: form_data = await request.form() webhook_data = cloudpayments_service.parse_webhook_data(dict(form_data)) - logger.info("CloudPayments webhook parsed data: %s", webhook_data) + logger.info('CloudPayments webhook parsed data: %s', webhook_data) except Exception as error: - logger.error("CloudPayments webhook parse error: %s", error) + logger.error('CloudPayments webhook parse error: %s', error) # Может быть это Check уведомление - просто разрешаем - return JSONResponse({"code": 0}) + return JSONResponse({'code': 0}) # Определяем тип webhook по статусу - status_value = webhook_data.get("status", "") + status_value = webhook_data.get('status', '') - if status_value in ("Completed", "Authorized"): + if status_value in ('Completed', 'Authorized'): # Успешная оплата await _process_payment_service_callback( payment_service, webhook_data, - "process_cloudpayments_pay_webhook", + 'process_cloudpayments_pay_webhook', ) - elif status_value in ("Declined", "Cancelled"): + elif status_value in ('Declined', 'Cancelled'): # Неуспешная оплата await _process_payment_service_callback( payment_service, webhook_data, - "process_cloudpayments_fail_webhook", + 'process_cloudpayments_fail_webhook', ) else: # Check или другой тип уведомления - просто разрешаем - logger.info("CloudPayments webhook: status=%s, allowing (code=0)", status_value) + logger.info('CloudPayments webhook: status=%s, allowing (code=0)', status_value) - return JSONResponse({"code": 0}) + return JSONResponse({'code': 0}) except Exception as e: - logger.exception("CloudPayments universal webhook error: %s", e) - return JSONResponse({"code": 0}) + logger.exception('CloudPayments universal webhook error: %s', e) + return JSONResponse({'code': 0}) routes_registered = True if settings.is_freekassa_enabled(): + @router.options(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_options() -> Response: return Response( status_code=status.HTTP_200_OK, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', }, ) @@ -854,43 +861,43 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute async def freekassa_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "freekassa_webhook", - "enabled": settings.is_freekassa_enabled(), + 'status': 'ok', + 'service': 'freekassa_webhook', + 'enabled': settings.is_freekassa_enabled(), } ) @router.post(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_webhook(request: Request) -> Response: # Получаем IP клиента с учетом прокси - x_forwarded_for = request.headers.get("X-Forwarded-For") + x_forwarded_for = request.headers.get('X-Forwarded-For') if x_forwarded_for: - client_ip = x_forwarded_for.split(",")[0].strip() + client_ip = x_forwarded_for.split(',')[0].strip() else: - real_ip = request.headers.get("X-Real-IP") + real_ip = request.headers.get('X-Real-IP') if real_ip: client_ip = real_ip.strip() else: - client_ip = request.client.host if request.client else "127.0.0.1" + client_ip = request.client.host if request.client else '127.0.0.1' # Получаем данные формы try: form_data = await request.form() except Exception: - logger.error("Freekassa webhook: не удалось прочитать данные формы") - return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST) + logger.error('Freekassa webhook: не удалось прочитать данные формы') + return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры - merchant_id = form_data.get("MERCHANT_ID") - amount = form_data.get("AMOUNT") - order_id = form_data.get("MERCHANT_ORDER_ID") - sign = form_data.get("SIGN") - intid = form_data.get("intid") - cur_id = form_data.get("CUR_ID") + merchant_id = form_data.get('MERCHANT_ID') + amount = form_data.get('AMOUNT') + order_id = form_data.get('MERCHANT_ORDER_ID') + sign = form_data.get('SIGN') + intid = form_data.get('intid') + cur_id = form_data.get('CUR_ID') if not all([merchant_id, amount, order_id, sign, intid]): - logger.warning("Freekassa webhook: отсутствуют обязательные параметры") - return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST) + logger.warning('Freekassa webhook: отсутствуют обязательные параметры') + return Response('Missing parameters', status_code=status.HTTP_400_BAD_REQUEST) # Преобразуем типы try: @@ -898,15 +905,15 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute amount_float = float(amount) cur_id_int = int(cur_id) if cur_id else None except ValueError: - logger.warning("Freekassa webhook: неверный формат параметров") - return Response("Invalid parameters format", status_code=status.HTTP_400_BAD_REQUEST) + logger.warning('Freekassa webhook: неверный формат параметров') + return Response('Invalid parameters format', status_code=status.HTTP_400_BAD_REQUEST) # Обрабатываем callback db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: - return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: success = await payment_service.process_freekassa_webhook( @@ -926,21 +933,22 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute pass if success: - return Response("YES", status_code=status.HTTP_200_OK) + return Response('YES', status_code=status.HTTP_200_OK) - return Response("Error", status_code=status.HTTP_400_BAD_REQUEST) + return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) routes_registered = True # KassaAI webhook if settings.is_kassa_ai_enabled(): + @router.get(settings.KASSA_AI_WEBHOOK_PATH) async def kassa_ai_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "service": "kassa_ai_webhook", - "enabled": settings.is_kassa_ai_enabled(), + 'status': 'ok', + 'service': 'kassa_ai_webhook', + 'enabled': settings.is_kassa_ai_enabled(), } ) @@ -950,35 +958,35 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute try: form_data = await request.form() except Exception: - logger.error("KassaAI webhook: не удалось прочитать данные формы") - return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST) + logger.error('KassaAI webhook: не удалось прочитать данные формы') + return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST) # Извлекаем параметры (те же что и у Freekassa) - merchant_id = form_data.get("MERCHANT_ID") - amount = form_data.get("AMOUNT") - order_id = form_data.get("MERCHANT_ORDER_ID") - sign = form_data.get("SIGN") - intid = form_data.get("intid") - cur_id = form_data.get("CUR_ID") + merchant_id = form_data.get('MERCHANT_ID') + amount = form_data.get('AMOUNT') + order_id = form_data.get('MERCHANT_ORDER_ID') + sign = form_data.get('SIGN') + intid = form_data.get('intid') + cur_id = form_data.get('CUR_ID') if not all([merchant_id, amount, order_id, sign, intid]): - logger.warning("KassaAI webhook: отсутствуют обязательные параметры") - return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST) + logger.warning('KassaAI webhook: отсутствуют обязательные параметры') + return Response('Missing parameters', status_code=status.HTTP_400_BAD_REQUEST) try: merchant_id_int = int(merchant_id) amount_float = float(amount) cur_id_int = int(cur_id) if cur_id else None except (ValueError, TypeError) as e: - logger.error("KassaAI webhook: некорректные параметры - %s", e) - return Response("Invalid parameters", status_code=status.HTTP_400_BAD_REQUEST) + logger.error('KassaAI webhook: некорректные параметры - %s', e) + return Response('Invalid parameters', status_code=status.HTTP_400_BAD_REQUEST) # Обрабатываем webhook db_generator = get_db() try: db = await db_generator.__anext__() except StopAsyncIteration: - return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response('DB Error', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) try: success = await payment_service.process_kassa_ai_webhook( @@ -997,29 +1005,30 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute pass if success: - return Response("YES", status_code=status.HTTP_200_OK) + return Response('YES', status_code=status.HTTP_200_OK) - return Response("Error", status_code=status.HTTP_400_BAD_REQUEST) + return Response('Error', status_code=status.HTTP_400_BAD_REQUEST) routes_registered = True if routes_registered: - @router.get("/health/payment-webhooks") + + @router.get('/health/payment-webhooks') async def payment_webhooks_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "tribute_enabled": settings.TRIBUTE_ENABLED, - "mulenpay_enabled": settings.is_mulenpay_enabled(), - "cryptobot_enabled": settings.is_cryptobot_enabled(), - "yookassa_enabled": settings.is_yookassa_enabled(), - "wata_enabled": settings.is_wata_enabled(), - "heleket_enabled": settings.is_heleket_enabled(), - "pal24_enabled": settings.is_pal24_enabled(), - "platega_enabled": settings.is_platega_enabled(), - "cloudpayments_enabled": settings.is_cloudpayments_enabled(), - "freekassa_enabled": settings.is_freekassa_enabled(), - "kassa_ai_enabled": settings.is_kassa_ai_enabled(), + 'status': 'ok', + 'tribute_enabled': settings.TRIBUTE_ENABLED, + 'mulenpay_enabled': settings.is_mulenpay_enabled(), + 'cryptobot_enabled': settings.is_cryptobot_enabled(), + 'yookassa_enabled': settings.is_yookassa_enabled(), + 'wata_enabled': settings.is_wata_enabled(), + 'heleket_enabled': settings.is_heleket_enabled(), + 'pal24_enabled': settings.is_pal24_enabled(), + 'platega_enabled': settings.is_platega_enabled(), + 'cloudpayments_enabled': settings.is_cloudpayments_enabled(), + 'freekassa_enabled': settings.is_freekassa_enabled(), + 'kassa_ai_enabled': settings.is_kassa_ai_enabled(), } ) diff --git a/app/webserver/telegram.py b/app/webserver/telegram.py index d31d20ac..6b0f4795 100644 --- a/app/webserver/telegram.py +++ b/app/webserver/telegram.py @@ -4,11 +4,10 @@ import asyncio import logging from typing import Any -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.responses import JSONResponse - from aiogram import Bot, Dispatcher from aiogram.types import Update +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse from app.config import settings @@ -69,20 +68,18 @@ class TelegramWebhookProcessor: for index in range(self._worker_count): task = asyncio.create_task( self._worker_loop(index), - name=f"telegram-webhook-worker-{index}", + name=f'telegram-webhook-worker-{index}', ) self._workers.append(task) if self._worker_count: logger.info( - "🚀 Telegram webhook processor запущен: %s воркеров, очередь %s", + '🚀 Telegram webhook processor запущен: %s воркеров, очередь %s', self._worker_count, self._queue_maxsize, ) else: - logger.warning( - "Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться" - ) + logger.warning('Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться') async def stop(self) -> None: async with self._lifecycle_lock: @@ -94,9 +91,9 @@ class TelegramWebhookProcessor: if self._worker_count > 0: try: await asyncio.wait_for(self._queue.join(), timeout=self._shutdown_timeout) - except asyncio.TimeoutError: + except TimeoutError: logger.warning( - "⏱️ Не удалось дождаться завершения очереди Telegram webhook за %s секунд", + '⏱️ Не удалось дождаться завершения очереди Telegram webhook за %s секунд', self._shutdown_timeout, ) else: @@ -111,7 +108,7 @@ class TelegramWebhookProcessor: self._queue.task_done() if drained: logger.warning( - "Очередь Telegram webhook остановлена без воркеров, потеряно %s обновлений", + 'Очередь Telegram webhook остановлена без воркеров, потеряно %s обновлений', drained, ) @@ -125,7 +122,7 @@ class TelegramWebhookProcessor: if self._workers: await asyncio.gather(*self._workers, return_exceptions=True) self._workers.clear() - logger.info("🛑 Telegram webhook processor остановлен") + logger.info('🛑 Telegram webhook processor остановлен') async def enqueue(self, update: Update) -> None: if not self._running: @@ -138,7 +135,7 @@ class TelegramWebhookProcessor: await asyncio.wait_for(self._queue.put(update), timeout=self._enqueue_timeout) except asyncio.QueueFull as error: # pragma: no cover - защитный сценарий raise TelegramWebhookOverloadedError from error - except asyncio.TimeoutError as error: + except TimeoutError as error: raise TelegramWebhookOverloadedError from error async def wait_until_drained(self, timeout: float | None = None) -> None: @@ -155,7 +152,7 @@ class TelegramWebhookProcessor: try: item = await self._queue.get() except asyncio.CancelledError: # pragma: no cover - остановка приложения - logger.debug("Worker %s cancelled", worker_id) + logger.debug('Worker %s cancelled', worker_id) raise if item is self._stop_sentinel: @@ -166,14 +163,14 @@ class TelegramWebhookProcessor: try: await self._dispatcher.feed_update(self._bot, update) # type: ignore[arg-type] except asyncio.CancelledError: # pragma: no cover - остановка приложения - logger.debug("Worker %s cancelled during processing", worker_id) + logger.debug('Worker %s cancelled during processing', worker_id) raise except Exception as error: # pragma: no cover - логируем сбой обработчика - logger.exception("Ошибка обработки Telegram update в worker %s: %s", worker_id, error) + logger.exception('Ошибка обработки Telegram update в worker %s: %s', worker_id, error) finally: self._queue.task_done() finally: - logger.debug("Worker %s завершён", worker_id) + logger.debug('Worker %s завершён', worker_id) async def _dispatch_update( @@ -187,11 +184,13 @@ async def _dispatch_update( try: await processor.enqueue(update) except TelegramWebhookOverloadedError as error: - logger.warning("Очередь Telegram webhook переполнена: %s", error) - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_queue_full") from error + logger.warning('Очередь Telegram webhook переполнена: %s', error) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_queue_full') from error except TelegramWebhookProcessorNotRunningError as error: - logger.error("Telegram webhook processor неактивен: %s", error) - raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_processor_unavailable") from error + logger.error('Telegram webhook processor неактивен: %s', error) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_processor_unavailable' + ) from error return await dispatcher.feed_update(bot, update) @@ -210,40 +209,40 @@ def create_telegram_router( @router.post(webhook_path) async def telegram_webhook(request: Request) -> JSONResponse: if secret_token: - header_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token") + header_token = request.headers.get('X-Telegram-Bot-Api-Secret-Token') if header_token != secret_token: - logger.warning("Получен Telegram webhook с неверным секретом") - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_secret_token") + logger.warning('Получен Telegram webhook с неверным секретом') + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='invalid_secret_token') - content_type = request.headers.get("content-type", "") - if content_type and "application/json" not in content_type.lower(): - raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="invalid_content_type") + content_type = request.headers.get('content-type', '') + if content_type and 'application/json' not in content_type.lower(): + raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='invalid_content_type') try: payload: Any = await request.json() except Exception as error: # pragma: no cover - defensive logging - logger.error("Ошибка чтения Telegram webhook: %s", error) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_payload") from error + logger.error('Ошибка чтения Telegram webhook: %s', error) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_payload') from error try: update = Update.model_validate(payload) except Exception as error: # pragma: no cover - defensive logging - logger.error("Ошибка валидации Telegram update: %s", error) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_update") from error + logger.error('Ошибка валидации Telegram update: %s', error) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_update') from error await _dispatch_update(update, dispatcher=dispatcher, bot=bot, processor=processor) - return JSONResponse({"status": "ok"}) + return JSONResponse({'status': 'ok'}) - @router.get("/health/telegram-webhook") + @router.get('/health/telegram-webhook') async def telegram_webhook_health() -> JSONResponse: return JSONResponse( { - "status": "ok", - "mode": settings.get_bot_run_mode(), - "path": webhook_path, - "webhook_configured": bool(settings.get_telegram_webhook_url()), - "queue_maxsize": settings.get_webhook_queue_maxsize(), - "workers": settings.get_webhook_worker_count(), + 'status': 'ok', + 'mode': settings.get_bot_run_mode(), + 'path': webhook_path, + 'webhook_configured': bool(settings.get_telegram_webhook_url()), + 'queue_maxsize': settings.get_webhook_queue_maxsize(), + 'workers': settings.get_webhook_worker_count(), } ) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index d93a52b7..a9d761d5 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -3,21 +3,18 @@ from __future__ import annotations import logging from pathlib import Path +from aiogram import Bot, Dispatcher from fastapi import FastAPI, status from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from aiogram import Bot -from aiogram import Dispatcher - +from app.cabinet.routes import router as cabinet_router from app.config import settings from app.services.payment_service import PaymentService from app.webapi.app import create_web_api_app from app.webapi.docs import add_redoc_endpoint -from app.cabinet.routes import router as cabinet_router -from . import payments -from . import telegram +from . import payments, telegram logger = logging.getLogger(__name__) @@ -27,12 +24,12 @@ def _attach_docs_alias(app: FastAPI, docs_url: str | None) -> None: if not docs_url: return - alias_path = "/doc" + alias_path = '/doc' if alias_path == docs_url: return for route in app.router.routes: - if getattr(route, "path", None) == alias_path: + if getattr(route, 'path', None) == alias_path: return target_url = docs_url @@ -49,30 +46,31 @@ def _create_base_app() -> FastAPI: app = create_web_api_app() else: app = FastAPI( - title="Bedolaga Unified Server", + title='Bedolaga Unified Server', version=settings.WEB_API_VERSION, - docs_url=docs_config.get("docs_url"), + docs_url=docs_config.get('docs_url'), redoc_url=None, - openapi_url=docs_config.get("openapi_url"), + openapi_url=docs_config.get('openapi_url'), ) add_redoc_endpoint( app, - redoc_url=docs_config.get("redoc_url"), - openapi_url=docs_config.get("openapi_url"), - title="Bedolaga Unified Server", + redoc_url=docs_config.get('redoc_url'), + openapi_url=docs_config.get('openapi_url'), + title='Bedolaga Unified Server', ) # Add cabinet routes even when web API is disabled if settings.is_cabinet_enabled(): from fastapi.middleware.cors import CORSMiddleware + cabinet_origins = settings.get_cabinet_allowed_origins() app.add_middleware( CORSMiddleware, - allow_origins=["*"] if "*" in cabinet_origins else cabinet_origins, + allow_origins=['*'] if '*' in cabinet_origins else cabinet_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) app.include_router(cabinet_router) @@ -83,14 +81,14 @@ def _create_base_app() -> FastAPI: def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]: static_path: Path = settings.get_miniapp_static_path() if not static_path.exists(): - logger.debug("Miniapp static path %s does not exist, skipping mount", static_path) + logger.debug('Miniapp static path %s does not exist, skipping mount', static_path) return False, static_path try: - app.mount("/miniapp/static", StaticFiles(directory=static_path), name="miniapp-static") - logger.info("📦 Miniapp static files mounted at /miniapp/static from %s", static_path) + app.mount('/miniapp/static', StaticFiles(directory=static_path), name='miniapp-static') + logger.info('📦 Miniapp static files mounted at /miniapp/static from %s', static_path) except RuntimeError as error: # pragma: no cover - defensive guard - logger.warning("Не удалось смонтировать статические файлы миниаппа: %s", error) + logger.warning('Не удалось смонтировать статические файлы миниаппа: %s', error) return False, static_path return True, static_path @@ -113,14 +111,14 @@ def create_unified_app( if payments_router: app.include_router(payments_router) payment_providers_state = { - "tribute": settings.TRIBUTE_ENABLED, - "mulenpay": settings.is_mulenpay_enabled(), - "cryptobot": settings.is_cryptobot_enabled(), - "yookassa": settings.is_yookassa_enabled(), - "pal24": settings.is_pal24_enabled(), - "wata": settings.is_wata_enabled(), - "heleket": settings.is_heleket_enabled(), - "freekassa": settings.is_freekassa_enabled(), + 'tribute': settings.TRIBUTE_ENABLED, + 'mulenpay': settings.is_mulenpay_enabled(), + 'cryptobot': settings.is_cryptobot_enabled(), + 'yookassa': settings.is_yookassa_enabled(), + 'pal24': settings.is_pal24_enabled(), + 'wata': settings.is_wata_enabled(), + 'heleket': settings.is_heleket_enabled(), + 'freekassa': settings.is_freekassa_enabled(), } if enable_telegram_webhook: @@ -134,11 +132,11 @@ def create_unified_app( ) app.state.telegram_webhook_processor = telegram_processor - @app.on_event("startup") + @app.on_event('startup') async def start_telegram_webhook_processor() -> None: # pragma: no cover - event hook await telegram_processor.start() - @app.on_event("shutdown") + @app.on_event('shutdown') async def stop_telegram_webhook_processor() -> None: # pragma: no cover - event hook await telegram_processor.stop() @@ -148,40 +146,40 @@ def create_unified_app( miniapp_mounted, miniapp_path = _mount_miniapp_static(app) - unified_health_path = "/health/unified" if settings.is_web_api_enabled() else "/health" + unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health' @app.get(unified_health_path) async def unified_health() -> JSONResponse: webhook_path = settings.get_telegram_webhook_path() if enable_telegram_webhook else None telegram_state = { - "enabled": enable_telegram_webhook, - "running": bool(telegram_processor and telegram_processor.is_running), - "url": settings.get_telegram_webhook_url(), - "path": webhook_path, - "secret_configured": bool(settings.WEBHOOK_SECRET_TOKEN), - "queue_maxsize": settings.get_webhook_queue_maxsize(), - "workers": settings.get_webhook_worker_count(), + 'enabled': enable_telegram_webhook, + 'running': bool(telegram_processor and telegram_processor.is_running), + 'url': settings.get_telegram_webhook_url(), + 'path': webhook_path, + 'secret_configured': bool(settings.WEBHOOK_SECRET_TOKEN), + 'queue_maxsize': settings.get_webhook_queue_maxsize(), + 'workers': settings.get_webhook_worker_count(), } payment_state = { - "enabled": bool(payments_router), - "providers": payment_providers_state, + 'enabled': bool(payments_router), + 'providers': payment_providers_state, } miniapp_state = { - "mounted": miniapp_mounted, - "path": str(miniapp_path), + 'mounted': miniapp_mounted, + 'path': str(miniapp_path), } return JSONResponse( { - "status": "ok", - "bot_run_mode": settings.get_bot_run_mode(), - "web_api_enabled": settings.is_web_api_enabled(), - "payment_webhooks": payment_state, - "telegram_webhook": telegram_state, - "miniapp_static": miniapp_state, + 'status': 'ok', + 'bot_run_mode': settings.get_bot_run_mode(), + 'web_api_enabled': settings.is_web_api_enabled(), + 'payment_webhooks': payment_state, + 'telegram_webhook': telegram_state, + 'miniapp_static': miniapp_state, } ) diff --git a/main.py b/main.py index c17b3c73..b16ed7c5 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,29 @@ import asyncio import logging -import sys import os import signal +import sys from pathlib import Path + sys.path.append(str(Path(__file__).parent)) from app.bot import setup_bot from app.config import settings from app.database.database import init_db -from app.services.monitoring_service import monitoring_service +from app.database.models import PaymentMethod +from app.database.universal_migration import run_universal_migration +from app.localization.loader import ensure_locale_templates +from app.services.backup_service import backup_service +from app.services.ban_notification_service import ban_notification_service +from app.services.broadcast_service import broadcast_service +from app.services.contest_rotation_service import contest_rotation_service +from app.services.daily_subscription_service import daily_subscription_service +from app.services.external_admin_service import ensure_external_admin_token +from app.services.log_rotation_service import log_rotation_service from app.services.maintenance_service import maintenance_service +from app.services.monitoring_service import monitoring_service +from app.services.nalogo_queue_service import nalogo_queue_service from app.services.payment_service import PaymentService from app.services.payment_verification_service import ( PENDING_MAX_AGE, @@ -20,38 +32,26 @@ from app.services.payment_verification_service import ( get_enabled_auto_methods, method_display_name, ) -from app.database.models import PaymentMethod -from app.services.version_service import version_service -from app.webapi.server import WebAPIServer -from app.webserver.unified_app import create_unified_app -from app.database.universal_migration import run_universal_migration -from app.services.backup_service import backup_service -from app.services.reporting_service import reporting_service -from app.services.remnawave_sync_service import remnawave_sync_service -from app.localization.loader import ensure_locale_templates -from app.services.system_settings_service import bot_configuration_service -from app.services.external_admin_service import ensure_external_admin_token -from app.services.broadcast_service import broadcast_service from app.services.referral_contest_service import referral_contest_service -from app.services.contest_rotation_service import contest_rotation_service -from app.services.nalogo_queue_service import nalogo_queue_service +from app.services.remnawave_sync_service import remnawave_sync_service +from app.services.reporting_service import reporting_service +from app.services.system_settings_service import bot_configuration_service from app.services.traffic_monitoring_service import traffic_monitoring_scheduler -from app.services.daily_subscription_service import daily_subscription_service +from app.services.version_service import version_service +from app.utils.log_handlers import ExcludePaymentFilter, LevelFilterHandler +from app.utils.payment_logger import configure_payment_logger from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter -from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter -from app.utils.payment_logger import payment_logger, configure_payment_logger -from app.services.log_rotation_service import log_rotation_service -from app.services.ban_notification_service import ban_notification_service +from app.webapi.server import WebAPIServer +from app.webserver.unified_app import create_unified_app class GracefulExit: - def __init__(self): self.exit = False - + def exit_gracefully(self, signum, frame): - logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...") + logging.getLogger(__name__).info(f'Получен сигнал {signum}. Корректное завершение работы...') self.exit = True @@ -72,7 +72,7 @@ async def main(): log_dir.mkdir(parents=True, exist_ok=True) # 1. Общий лог (bot.log) - все уровни, без платежей - bot_handler = logging.FileHandler(log_dir / "bot.log", encoding='utf-8') + bot_handler = logging.FileHandler(log_dir / 'bot.log', encoding='utf-8') bot_handler.setFormatter(formatter) bot_handler.addFilter(ExcludePaymentFilter()) log_handlers.append(bot_handler) @@ -141,40 +141,38 @@ async def main(): ) # Установим более высокий уровень логирования для "мусорных" логов - logging.getLogger("aiohttp.access").setLevel(logging.ERROR) - logging.getLogger("aiohttp.client").setLevel(logging.WARNING) - logging.getLogger("aiohttp.internal").setLevel(logging.WARNING) - logging.getLogger("app.external.remnawave_api").setLevel(logging.WARNING) - logging.getLogger("aiogram").setLevel(logging.WARNING) - logging.getLogger("uvicorn.access").setLevel(logging.ERROR) - logging.getLogger("uvicorn.error").setLevel(logging.WARNING) + logging.getLogger('aiohttp.access').setLevel(logging.ERROR) + logging.getLogger('aiohttp.client').setLevel(logging.WARNING) + logging.getLogger('aiohttp.internal').setLevel(logging.WARNING) + logging.getLogger('app.external.remnawave_api').setLevel(logging.WARNING) + logging.getLogger('aiogram').setLevel(logging.WARNING) + logging.getLogger('uvicorn.access').setLevel(logging.ERROR) + logging.getLogger('uvicorn.error').setLevel(logging.WARNING) # Скрываем спам от WebSocket подключений (connection open/closed) - logging.getLogger("uvicorn.protocols.websockets.websockets_impl").setLevel(logging.WARNING) - logging.getLogger("websockets.server").setLevel(logging.WARNING) - logging.getLogger("websockets").setLevel(logging.WARNING) + logging.getLogger('uvicorn.protocols.websockets.websockets_impl').setLevel(logging.WARNING) + logging.getLogger('websockets.server').setLevel(logging.WARNING) + logging.getLogger('websockets').setLevel(logging.WARNING) logger = logging.getLogger(__name__) - timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot") + timeline = StartupTimeline(logger, 'Bedolaga Remnawave Bot') timeline.log_banner( [ - ("Уровень логирования", settings.LOG_LEVEL), - ("Режим БД", settings.DATABASE_MODE), + ('Уровень логирования', settings.LOG_LEVEL), + ('Режим БД', settings.DATABASE_MODE), ] ) - async with timeline.stage( - "Подготовка локализаций", "🗂️", success_message="Шаблоны локализаций готовы" - ) as stage: + async with timeline.stage('Подготовка локализаций', '🗂️', success_message='Шаблоны локализаций готовы') as stage: try: ensure_locale_templates() except Exception as error: - stage.warning(f"Не удалось подготовить шаблоны локализаций: {error}") - logger.warning("Failed to prepare locale templates: %s", error) + stage.warning(f'Не удалось подготовить шаблоны локализаций: {error}') + logger.warning('Failed to prepare locale templates: %s', error) killer = GracefulExit() signal.signal(signal.SIGINT, killer.exit_gracefully) signal.signal(signal.SIGTERM, killer.exit_gracefully) - + web_app = None monitoring_task = None maintenance_task = None @@ -190,86 +188,82 @@ async def main(): summary_logged = False try: - async with timeline.stage( - "Инициализация базы данных", "🗄️", success_message="База данных готова" - ): + async with timeline.stage('Инициализация базы данных', '🗄️', success_message='База данных готова'): await init_db() skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true' if not skip_migration: async with timeline.stage( - "Проверка и миграция базы данных", - "🧬", - success_message="Миграция завершена успешно", + 'Проверка и миграция базы данных', + '🧬', + success_message='Миграция завершена успешно', ) as stage: try: migration_success = await run_universal_migration() if migration_success: - stage.success("Миграция завершена успешно") + stage.success('Миграция завершена успешно') else: - stage.warning( - "Миграция завершилась с предупреждениями, запуск продолжится" - ) - logger.warning( - "⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск" - ) + stage.warning('Миграция завершилась с предупреждениями, запуск продолжится') + logger.warning('⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск') except Exception as migration_error: - stage.warning(f"Ошибка выполнения миграции: {migration_error}") - logger.error(f"❌ Ошибка выполнения миграции: {migration_error}") - logger.warning("⚠️ Продолжаем запуск без миграции") + stage.warning(f'Ошибка выполнения миграции: {migration_error}') + logger.error(f'❌ Ошибка выполнения миграции: {migration_error}') + logger.warning('⚠️ Продолжаем запуск без миграции') else: timeline.add_manual_step( - "Проверка и миграция базы данных", - "⏭️", - "Пропущено", - "SKIP_MIGRATION=true", + 'Проверка и миграция базы данных', + '⏭️', + 'Пропущено', + 'SKIP_MIGRATION=true', ) async with timeline.stage( - "Синхронизация тарифов из конфига", - "💰", - success_message="Тарифы синхронизированы", + 'Синхронизация тарифов из конфига', + '💰', + success_message='Тарифы синхронизированы', ) as stage: try: from app.database.crud.tariff import ensure_tariffs_synced from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: await ensure_tariffs_synced(db) except Exception as error: - stage.warning(f"Не удалось синхронизировать тарифы: {error}") - logger.error(f"❌ Не удалось синхронизировать тарифы: {error}") + stage.warning(f'Не удалось синхронизировать тарифы: {error}') + logger.error(f'❌ Не удалось синхронизировать тарифы: {error}') async with timeline.stage( - "Синхронизация серверов из RemnaWave", - "🖥️", - success_message="Серверы синхронизированы", + 'Синхронизация серверов из RemnaWave', + '🖥️', + success_message='Серверы синхронизированы', ) as stage: try: from app.database.crud.server_squad import ensure_servers_synced from app.database.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: await ensure_servers_synced(db) except Exception as error: - stage.warning(f"Не удалось синхронизировать серверы: {error}") - logger.error(f"❌ Не удалось синхронизировать серверы: {error}") + stage.warning(f'Не удалось синхронизировать серверы: {error}') + logger.error(f'❌ Не удалось синхронизировать серверы: {error}') async with timeline.stage( - "Загрузка конфигурации из БД", - "⚙️", - success_message="Конфигурация загружена", + 'Загрузка конфигурации из БД', + '⚙️', + success_message='Конфигурация загружена', ) as stage: try: await bot_configuration_service.initialize() except Exception as error: - stage.warning(f"Не удалось загрузить конфигурацию: {error}") - logger.error(f"❌ Не удалось загрузить конфигурацию: {error}") + stage.warning(f'Не удалось загрузить конфигурацию: {error}') + logger.error(f'❌ Не удалось загрузить конфигурацию: {error}') bot = None dp = None - async with timeline.stage("Настройка бота", "🤖", success_message="Бот настроен") as stage: + async with timeline.stage('Настройка бота', '🤖', success_message='Бот настроен') as stage: bot, dp = await setup_bot() - stage.log("Кеш и FSM подготовлены") + stage.log('Кеш и FSM подготовлены') monitoring_service.bot = bot maintenance_service.set_bot(bot) @@ -281,22 +275,22 @@ async def main(): from app.services.admin_notification_service import AdminNotificationService async with timeline.stage( - "Интеграция сервисов", - "🔗", - success_message="Сервисы подключены", + 'Интеграция сервисов', + '🔗', + success_message='Сервисы подключены', ) as stage: admin_notification_service = AdminNotificationService(bot) version_service.bot = bot version_service.set_notification_service(admin_notification_service) referral_contest_service.set_bot(bot) - stage.log(f"Репозиторий версий: {version_service.repo}") - stage.log(f"Текущая версия: {version_service.current_version}") - stage.success("Мониторинг, уведомления и рассылки подключены") + stage.log(f'Репозиторий версий: {version_service.repo}') + stage.log(f'Текущая версия: {version_service.current_version}') + stage.success('Мониторинг, уведомления и рассылки подключены') async with timeline.stage( - "Сервис бекапов", - "🗄️", - success_message="Сервис бекапов инициализирован", + 'Сервис бекапов', + '🗄️', + success_message='Сервис бекапов инициализирован', ) as stage: try: backup_service.bot = bot @@ -304,103 +298,102 @@ async def main(): if settings_obj.auto_backup_enabled: await backup_service.start_auto_backup() stage.log( - "Автобекапы включены: интервал " - f"{settings_obj.backup_interval_hours}ч, запуск {settings_obj.backup_time}" + 'Автобекапы включены: интервал ' + f'{settings_obj.backup_interval_hours}ч, запуск {settings_obj.backup_time}' ) else: - stage.log("Автобекапы отключены настройками") - stage.success("Сервис бекапов инициализирован") + stage.log('Автобекапы отключены настройками') + stage.success('Сервис бекапов инициализирован') except Exception as e: - stage.warning(f"Ошибка инициализации сервиса бекапов: {e}") - logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}") + stage.warning(f'Ошибка инициализации сервиса бекапов: {e}') + logger.error(f'❌ Ошибка инициализации сервиса бекапов: {e}') async with timeline.stage( - "Сервис отчетов", - "📊", - success_message="Сервис отчетов готов", + 'Сервис отчетов', + '📊', + success_message='Сервис отчетов готов', ) as stage: try: reporting_service.set_bot(bot) await reporting_service.start() except Exception as e: - stage.warning(f"Ошибка запуска сервиса отчетов: {e}") - logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}") + stage.warning(f'Ошибка запуска сервиса отчетов: {e}') + logger.error(f'❌ Ошибка запуска сервиса отчетов: {e}') async with timeline.stage( - "Реферальные конкурсы", - "🏆", - success_message="Сервис конкурсов готов", + 'Реферальные конкурсы', + '🏆', + success_message='Сервис конкурсов готов', ) as stage: try: await referral_contest_service.start() if referral_contest_service.is_running(): - stage.log("Автосводки по конкурсам запущены") + stage.log('Автосводки по конкурсам запущены') else: - stage.skip("Сервис конкурсов выключен настройками") + stage.skip('Сервис конкурсов выключен настройками') except Exception as e: - stage.warning(f"Ошибка запуска сервиса конкурсов: {e}") - logger.error(f"❌ Ошибка запуска сервиса конкурсов: {e}") + stage.warning(f'Ошибка запуска сервиса конкурсов: {e}') + logger.error(f'❌ Ошибка запуска сервиса конкурсов: {e}') async with timeline.stage( - "Ротация игр", - "🎲", - success_message="Мини-игры готовы", + 'Ротация игр', + '🎲', + success_message='Мини-игры готовы', ) as stage: try: contest_rotation_service.set_bot(bot) await contest_rotation_service.start() if contest_rotation_service.is_running(): - stage.log("Ротационные игры запущены") + stage.log('Ротационные игры запущены') else: - stage.skip("Ротация игр выключена настройками") + stage.skip('Ротация игр выключена настройками') except Exception as e: - stage.warning(f"Ошибка запуска ротации игр: {e}") - logger.error(f"❌ Ошибка запуска ротации игр: {e}") + stage.warning(f'Ошибка запуска ротации игр: {e}') + logger.error(f'❌ Ошибка запуска ротации игр: {e}') if settings.is_log_rotation_enabled(): async with timeline.stage( - "Ротация логов", - "📋", - success_message="Сервис ротации логов готов", + 'Ротация логов', + '📋', + success_message='Сервис ротации логов готов', ) as stage: try: log_rotation_service.set_bot(bot) await log_rotation_service.start() status = log_rotation_service.get_status() - stage.log(f"Время ротации: {status.rotation_time}") - stage.log(f"Хранение архивов: {status.keep_days} дней") + stage.log(f'Время ротации: {status.rotation_time}') + stage.log(f'Хранение архивов: {status.keep_days} дней') if status.send_to_telegram: - stage.log("Отправка в Telegram: включена") + stage.log('Отправка в Telegram: включена') if status.next_rotation: from datetime import datetime + next_dt = datetime.fromisoformat(status.next_rotation) - stage.log(f"Следующая ротация: {next_dt.strftime('%d.%m.%Y %H:%M')}") + stage.log(f'Следующая ротация: {next_dt.strftime("%d.%m.%Y %H:%M")}') except Exception as e: - stage.warning(f"Ошибка запуска сервиса ротации логов: {e}") - logger.error(f"❌ Ошибка запуска сервиса ротации логов: {e}") + stage.warning(f'Ошибка запуска сервиса ротации логов: {e}') + logger.error(f'❌ Ошибка запуска сервиса ротации логов: {e}') async with timeline.stage( - "Автосинхронизация RemnaWave", - "🔄", - success_message="Сервис автосинхронизации готов", + 'Автосинхронизация RemnaWave', + '🔄', + success_message='Сервис автосинхронизации готов', ) as stage: try: await remnawave_sync_service.initialize() status = remnawave_sync_service.get_status() if status.enabled: - times_text = ", ".join(t.strftime("%H:%M") for t in status.times) or "—" + times_text = ', '.join(t.strftime('%H:%M') for t in status.times) or '—' if status.next_run: - next_run_text = status.next_run.strftime("%d.%m.%Y %H:%M") - stage.log( - f"Активирована: расписание {times_text}, ближайший запуск {next_run_text}" - ) + next_run_text = status.next_run.strftime('%d.%m.%Y %H:%M') + stage.log(f'Активирована: расписание {times_text}, ближайший запуск {next_run_text}') else: - stage.log(f"Активирована: расписание {times_text}") + stage.log(f'Активирована: расписание {times_text}') else: - stage.log("Автосинхронизация отключена настройками") + stage.log('Автосинхронизация отключена настройками') except Exception as e: - stage.warning(f"Ошибка запуска автосинхронизации: {e}") - logger.error(f"❌ Ошибка запуска автосинхронизации RemnaWave: {e}") + stage.warning(f'Ошибка запуска автосинхронизации: {e}') + logger.error(f'❌ Ошибка запуска автосинхронизации RemnaWave: {e}') payment_service = PaymentService(bot) auto_payment_verification_service.set_payment_service(payment_service) @@ -413,67 +406,52 @@ async def main(): verification_providers: list[str] = [] auto_verification_active = False async with timeline.stage( - "Сервис проверки пополнений", - "💳", - success_message="Ручная проверка активна", + 'Сервис проверки пополнений', + '💳', + success_message='Ручная проверка активна', ) as stage: for method in SUPPORTED_MANUAL_CHECK_METHODS: if method == PaymentMethod.YOOKASSA and settings.is_yookassa_enabled(): - verification_providers.append("YooKassa") + verification_providers.append('YooKassa') elif method == PaymentMethod.MULENPAY and settings.is_mulenpay_enabled(): verification_providers.append(settings.get_mulenpay_display_name()) elif method == PaymentMethod.PAL24 and settings.is_pal24_enabled(): - verification_providers.append("PayPalych") + verification_providers.append('PayPalych') elif method == PaymentMethod.WATA and settings.is_wata_enabled(): - verification_providers.append("WATA") + verification_providers.append('WATA') elif method == PaymentMethod.HELEKET and settings.is_heleket_enabled(): - verification_providers.append("Heleket") + verification_providers.append('Heleket') elif method == PaymentMethod.CRYPTOBOT and settings.is_cryptobot_enabled(): - verification_providers.append("CryptoBot") + verification_providers.append('CryptoBot') if verification_providers: hours = int(PENDING_MAX_AGE.total_seconds() // 3600) - stage.log( - "Ожидающие пополнения автоматически отбираются не старше " - f"{hours}ч" - ) - stage.log( - "Доступна ручная проверка для: " - + ", ".join(sorted(verification_providers)) - ) - stage.success( - f"Активно провайдеров: {len(verification_providers)}" - ) + stage.log(f'Ожидающие пополнения автоматически отбираются не старше {hours}ч') + stage.log('Доступна ручная проверка для: ' + ', '.join(sorted(verification_providers))) + stage.success(f'Активно провайдеров: {len(verification_providers)}') else: - stage.skip("Нет активных провайдеров для ручной проверки") + stage.skip('Нет активных провайдеров для ручной проверки') if settings.is_payment_verification_auto_check_enabled(): auto_methods = get_enabled_auto_methods() if auto_methods: interval_minutes = settings.get_payment_verification_auto_check_interval() - auto_labels = ", ".join( - sorted(method_display_name(method) for method in auto_methods) - ) - stage.log( - "Автопроверка каждые " - f"{interval_minutes} мин: {auto_labels}" - ) + auto_labels = ', '.join(sorted(method_display_name(method) for method in auto_methods)) + stage.log(f'Автопроверка каждые {interval_minutes} мин: {auto_labels}') else: - stage.log( - "Автопроверка включена, но нет активных провайдеров" - ) + stage.log('Автопроверка включена, но нет активных провайдеров') else: - stage.log("Автопроверка отключена настройками") + stage.log('Автопроверка отключена настройками') await auto_payment_verification_service.start() auto_verification_active = auto_payment_verification_service.is_running() if auto_verification_active: - stage.log("Фоновая автопроверка запущена") + stage.log('Фоновая автопроверка запущена') async with timeline.stage( - "Очередь чеков NaloGO", - "🧾", - success_message="Сервис очереди чеков запущен", + 'Очередь чеков NaloGO', + '🧾', + success_message='Сервис очереди чеков запущен', ) as stage: if settings.is_nalogo_enabled(): try: @@ -481,20 +459,20 @@ async def main(): if nalogo_queue_service.is_running(): queue_len = await payment_service.nalogo_service.get_queue_length() if queue_len > 0: - stage.log(f"В очереди ожидает {queue_len} чек(ов)") - stage.success("Фоновая обработка чеков активна") + stage.log(f'В очереди ожидает {queue_len} чек(ов)') + stage.success('Фоновая обработка чеков активна') else: - stage.skip("Сервис не запущен") + stage.skip('Сервис не запущен') except Exception as e: - stage.warning(f"Ошибка запуска очереди чеков: {e}") - logger.error(f"❌ Ошибка запуска очереди чеков NaloGO: {e}") + stage.warning(f'Ошибка запуска очереди чеков: {e}') + logger.error(f'❌ Ошибка запуска очереди чеков NaloGO: {e}') else: - stage.skip("NaloGO отключен настройками") + stage.skip('NaloGO отключен настройками') async with timeline.stage( - "Внешняя админка", - "🛡️", - success_message="Токен внешней админки готов", + 'Внешняя админка', + '🛡️', + success_message='Токен внешней админки готов', ) as stage: try: bot_user = await bot.get_me() @@ -503,16 +481,16 @@ async def main(): bot_user.id, ) if token: - stage.log("Токен синхронизирован") + stage.log('Токен синхронизирован') else: - stage.warning("Не удалось получить токен внешней админки") + stage.warning('Не удалось получить токен внешней админки') except Exception as error: # pragma: no cover - защитный блок - stage.warning(f"Ошибка подготовки внешней админки: {error}") - logger.error("❌ Ошибка подготовки внешней админки: %s", error) + stage.warning(f'Ошибка подготовки внешней админки: {error}') + logger.error('❌ Ошибка подготовки внешней админки: %s', error) bot_run_mode = settings.get_bot_run_mode() - polling_enabled = bot_run_mode in {"polling", "both"} - telegram_webhook_enabled = bot_run_mode in {"webhook", "both"} + polling_enabled = bot_run_mode in {'polling', 'both'} + telegram_webhook_enabled = bot_run_mode in {'webhook', 'both'} payment_webhooks_enabled = any( [ @@ -527,9 +505,9 @@ async def main(): ) async with timeline.stage( - "Единый веб-сервер", - "🌐", - success_message="Веб-сервер запущен", + 'Единый веб-сервер', + '🌐', + success_message='Веб-сервер запущен', ) as stage: should_start_web_app = ( settings.is_web_api_enabled() @@ -549,34 +527,34 @@ async def main(): web_api_server = WebAPIServer(app=web_app) await web_api_server.start() - base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}" - stage.log(f"Базовый URL: {base_url}") + base_url = settings.WEBHOOK_URL or f'http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}' + stage.log(f'Базовый URL: {base_url}') features: list[str] = [] if settings.is_web_api_enabled(): - features.append("админка") + features.append('админка') if payment_webhooks_enabled: - features.append("платежные webhook-и") + features.append('платежные webhook-и') if telegram_webhook_enabled: - features.append("Telegram webhook") + features.append('Telegram webhook') if settings.get_miniapp_static_path().exists(): - features.append("статические файлы миниаппа") + features.append('статические файлы миниаппа') if features: - stage.log("Активные сервисы: " + ", ".join(features)) - stage.success("HTTP-сервисы активны") + stage.log('Активные сервисы: ' + ', '.join(features)) + stage.success('HTTP-сервисы активны') else: - stage.skip("HTTP-сервисы отключены настройками") + stage.skip('HTTP-сервисы отключены настройками') async with timeline.stage( - "Telegram webhook", - "🤖", - success_message="Telegram webhook настроен", + 'Telegram webhook', + '🤖', + success_message='Telegram webhook настроен', ) as stage: if telegram_webhook_enabled: webhook_url = settings.get_telegram_webhook_url() if not webhook_url: - stage.warning("WEBHOOK_URL не задан, пропускаем настройку webhook") + stage.warning('WEBHOOK_URL не задан, пропускаем настройку webhook') else: allowed_updates = dp.resolve_used_update_types() await bot.set_webhook( @@ -585,184 +563,167 @@ async def main(): drop_pending_updates=False, # Обрабатываем накопившиеся обновления allowed_updates=allowed_updates, ) - stage.log(f"Webhook установлен: {webhook_url}") - stage.log(f"Allowed updates: {', '.join(sorted(allowed_updates)) if allowed_updates else 'all'}") - stage.success("Telegram webhook активен") + stage.log(f'Webhook установлен: {webhook_url}') + stage.log(f'Allowed updates: {", ".join(sorted(allowed_updates)) if allowed_updates else "all"}') + stage.success('Telegram webhook активен') else: - stage.skip("Режим webhook отключен") + stage.skip('Режим webhook отключен') async with timeline.stage( - "Служба мониторинга", - "📈", - success_message="Служба мониторинга запущена", + 'Служба мониторинга', + '📈', + success_message='Служба мониторинга запущена', ) as stage: monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) - stage.log(f"Интервал опроса: {settings.MONITORING_INTERVAL}с") + stage.log(f'Интервал опроса: {settings.MONITORING_INTERVAL}с') async with timeline.stage( - "Служба техработ", - "🛡️", - success_message="Служба техработ запущена", + 'Служба техработ', + '🛡️', + success_message='Служба техработ запущена', ) as stage: if not settings.is_maintenance_monitoring_enabled(): maintenance_task = None - stage.skip("Мониторинг техработ отключен настройками") + stage.skip('Мониторинг техработ отключен настройками') elif not maintenance_service._check_task or maintenance_service._check_task.done(): maintenance_task = asyncio.create_task(maintenance_service.start_monitoring()) - stage.log(f"Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с") - stage.log( - f"Повторных попыток проверки: {settings.get_maintenance_retry_attempts()}" - ) + stage.log(f'Интервал проверки: {settings.MAINTENANCE_CHECK_INTERVAL}с') + stage.log(f'Повторных попыток проверки: {settings.get_maintenance_retry_attempts()}') else: maintenance_task = None - stage.skip("Служба техработ уже активна") + stage.skip('Служба техработ уже активна') async with timeline.stage( - "Мониторинг трафика", - "📊", - success_message="Мониторинг трафика запущен", + 'Мониторинг трафика', + '📊', + success_message='Мониторинг трафика запущен', ) as stage: if traffic_monitoring_scheduler.is_enabled(): - traffic_monitoring_task = asyncio.create_task( - traffic_monitoring_scheduler.start_monitoring() - ) + traffic_monitoring_task = asyncio.create_task(traffic_monitoring_scheduler.start_monitoring()) # Показываем информацию о новом мониторинге v2 status_info = traffic_monitoring_scheduler.get_status_info() stage.log(status_info) else: traffic_monitoring_task = None - stage.skip("Мониторинг трафика отключен настройками") + stage.skip('Мониторинг трафика отключен настройками') async with timeline.stage( - "Суточные подписки", - "💳", - success_message="Сервис суточных подписок запущен", + 'Суточные подписки', + '💳', + success_message='Сервис суточных подписок запущен', ) as stage: if daily_subscription_service.is_enabled(): - daily_subscription_task = asyncio.create_task( - daily_subscription_service.start_monitoring() - ) + daily_subscription_task = asyncio.create_task(daily_subscription_service.start_monitoring()) interval_minutes = daily_subscription_service.get_check_interval_minutes() - stage.log(f"Интервал проверки: {interval_minutes} мин") + stage.log(f'Интервал проверки: {interval_minutes} мин') else: daily_subscription_task = None - stage.skip("Суточные подписки отключены настройками") + stage.skip('Суточные подписки отключены настройками') async with timeline.stage( - "Сервис проверки версий", - "📄", - success_message="Проверка версий запущена", + 'Сервис проверки версий', + '📄', + success_message='Проверка версий запущена', ) as stage: if settings.is_version_check_enabled(): version_check_task = asyncio.create_task(version_service.start_periodic_check()) - stage.log( - f"Интервал проверки: {settings.VERSION_CHECK_INTERVAL_HOURS}ч" - ) + stage.log(f'Интервал проверки: {settings.VERSION_CHECK_INTERVAL_HOURS}ч') else: version_check_task = None - stage.skip("Проверка версий отключена настройками") + stage.skip('Проверка версий отключена настройками') async with timeline.stage( - "Запуск polling", - "🤖", - success_message="Aiogram polling запущен", + 'Запуск polling', + '🤖', + success_message='Aiogram polling запущен', ) as stage: if polling_enabled: polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=False)) - stage.log("skip_updates=False — накопившиеся обновления будут обработаны") + stage.log('skip_updates=False — накопившиеся обновления будут обработаны') else: polling_task = None - stage.skip("Polling отключен режимом работы") + stage.skip('Polling отключен режимом работы') webhook_lines: list[str] = [] - base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}" + base_url = settings.WEBHOOK_URL or f'http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}' def _fmt(path: str) -> str: - return f"{base_url}{path if path.startswith('/') else '/' + path}" + return f'{base_url}{path if path.startswith("/") else "/" + path}' telegram_webhook_url = settings.get_telegram_webhook_url() if telegram_webhook_enabled and telegram_webhook_url: - webhook_lines.append(f"Telegram: {telegram_webhook_url}") + webhook_lines.append(f'Telegram: {telegram_webhook_url}') if settings.TRIBUTE_ENABLED: - webhook_lines.append(f"Tribute: {_fmt(settings.TRIBUTE_WEBHOOK_PATH)}") + webhook_lines.append(f'Tribute: {_fmt(settings.TRIBUTE_WEBHOOK_PATH)}') if settings.is_mulenpay_enabled(): - webhook_lines.append( - f"{settings.get_mulenpay_display_name()}: {_fmt(settings.MULENPAY_WEBHOOK_PATH)}" - ) + webhook_lines.append(f'{settings.get_mulenpay_display_name()}: {_fmt(settings.MULENPAY_WEBHOOK_PATH)}') if settings.is_cryptobot_enabled(): - webhook_lines.append(f"CryptoBot: {_fmt(settings.CRYPTOBOT_WEBHOOK_PATH)}") + webhook_lines.append(f'CryptoBot: {_fmt(settings.CRYPTOBOT_WEBHOOK_PATH)}') if settings.is_yookassa_enabled(): - webhook_lines.append(f"YooKassa: {_fmt(settings.YOOKASSA_WEBHOOK_PATH)}") + webhook_lines.append(f'YooKassa: {_fmt(settings.YOOKASSA_WEBHOOK_PATH)}') if settings.is_pal24_enabled(): - webhook_lines.append(f"PayPalych: {_fmt(settings.PAL24_WEBHOOK_PATH)}") + webhook_lines.append(f'PayPalych: {_fmt(settings.PAL24_WEBHOOK_PATH)}') if settings.is_wata_enabled(): - webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}") + webhook_lines.append(f'WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}') if settings.is_heleket_enabled(): - webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}") + webhook_lines.append(f'Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}') if settings.is_freekassa_enabled(): - webhook_lines.append(f"Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}") + webhook_lines.append(f'Freekassa: {_fmt(settings.FREEKASSA_WEBHOOK_PATH)}') timeline.log_section( - "Активные webhook endpoints", - webhook_lines if webhook_lines else ["Нет активных endpoints"], - icon="🎯", + 'Активные webhook endpoints', + webhook_lines if webhook_lines else ['Нет активных endpoints'], + icon='🎯', ) services_lines = [ - f"Мониторинг: {'Включен' if monitoring_task else 'Отключен'}", - f"Техработы: {'Включен' if maintenance_task else 'Отключен'}", - f"Мониторинг трафика: {'Включен' if traffic_monitoring_task else 'Отключен'}", - f"Суточные подписки: {'Включен' if daily_subscription_task else 'Отключен'}", - f"Проверка версий: {'Включен' if version_check_task else 'Отключен'}", - f"Отчеты: {'Включен' if reporting_service.is_running() else 'Отключен'}", + f'Мониторинг: {"Включен" if monitoring_task else "Отключен"}', + f'Техработы: {"Включен" if maintenance_task else "Отключен"}', + f'Мониторинг трафика: {"Включен" if traffic_monitoring_task else "Отключен"}', + f'Суточные подписки: {"Включен" if daily_subscription_task else "Отключен"}', + f'Проверка версий: {"Включен" if version_check_task else "Отключен"}', + f'Отчеты: {"Включен" if reporting_service.is_running() else "Отключен"}', ] + services_lines.append('Проверка пополнений: ' + ('Включена' if verification_providers else 'Отключена')) services_lines.append( - "Проверка пополнений: " - + ("Включена" if verification_providers else "Отключена") + 'Автопроверка пополнений: ' + + ('Включена' if auto_payment_verification_service.is_running() else 'Отключена') ) - services_lines.append( - "Автопроверка пополнений: " - + ( - "Включена" - if auto_payment_verification_service.is_running() - else "Отключена" - ) - ) - timeline.log_section("Активные фоновые сервисы", services_lines, icon="📄") + timeline.log_section('Активные фоновые сервисы', services_lines, icon='📄') timeline.log_summary() summary_logged = True - + try: while not killer.exit: await asyncio.sleep(1) - + if monitoring_task.done(): exception = monitoring_task.exception() if exception: - logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}") + logger.error(f'Служба мониторинга завершилась с ошибкой: {exception}') monitoring_task = asyncio.create_task(monitoring_service.start_monitoring()) - + if maintenance_task and maintenance_task.done(): exception = maintenance_task.exception() if exception: - logger.error(f"Служба техработ завершилась с ошибкой: {exception}") + logger.error(f'Служба техработ завершилась с ошибкой: {exception}') maintenance_task = asyncio.create_task(maintenance_service.start_monitoring()) - + if version_check_task and version_check_task.done(): exception = version_check_task.exception() if exception: - logger.error(f"Сервис проверки версий завершился с ошибкой: {exception}") + logger.error(f'Сервис проверки версий завершился с ошибкой: {exception}') if settings.is_version_check_enabled(): - logger.info("🔄 Перезапуск сервиса проверки версий...") + logger.info('🔄 Перезапуск сервиса проверки версий...') version_check_task = asyncio.create_task(version_service.start_periodic_check()) if traffic_monitoring_task and traffic_monitoring_task.done(): exception = traffic_monitoring_task.exception() if exception: - logger.error(f"Мониторинг трафика завершился с ошибкой: {exception}") + logger.error(f'Мониторинг трафика завершился с ошибкой: {exception}') if traffic_monitoring_scheduler.is_enabled(): - logger.info("🔄 Перезапуск мониторинга трафика...") + logger.info('🔄 Перезапуск мониторинга трафика...') traffic_monitoring_task = asyncio.create_task( traffic_monitoring_scheduler.start_monitoring() ) @@ -770,49 +731,43 @@ async def main(): if daily_subscription_task and daily_subscription_task.done(): exception = daily_subscription_task.exception() if exception: - logger.error(f"Сервис суточных подписок завершился с ошибкой: {exception}") + logger.error(f'Сервис суточных подписок завершился с ошибкой: {exception}') if daily_subscription_service.is_enabled(): - logger.info("🔄 Перезапуск сервиса суточных подписок...") - daily_subscription_task = asyncio.create_task( - daily_subscription_service.start_monitoring() - ) + logger.info('🔄 Перезапуск сервиса суточных подписок...') + daily_subscription_task = asyncio.create_task(daily_subscription_service.start_monitoring()) if auto_verification_active and not auto_payment_verification_service.is_running(): - logger.warning( - "Сервис автопроверки пополнений остановился, пробуем перезапустить..." - ) + logger.warning('Сервис автопроверки пополнений остановился, пробуем перезапустить...') await auto_payment_verification_service.start() auto_verification_active = auto_payment_verification_service.is_running() if polling_task and polling_task.done(): exception = polling_task.exception() if exception: - logger.error(f"Polling завершился с ошибкой: {exception}") + logger.error(f'Polling завершился с ошибкой: {exception}') break - + except Exception as e: - logger.error(f"Ошибка в основном цикле: {e}") - + logger.error(f'Ошибка в основном цикле: {e}') + except Exception as e: - logger.error(f"❌ Критическая ошибка при запуске: {e}") + logger.error(f'❌ Критическая ошибка при запуске: {e}') raise - + finally: if not summary_logged: timeline.log_summary() summary_logged = True - logger.info("🛑 Начинается корректное завершение работы...") + logger.info('🛑 Начинается корректное завершение работы...') - logger.info("ℹ️ Остановка сервиса автопроверки пополнений...") + logger.info('ℹ️ Остановка сервиса автопроверки пополнений...') try: await auto_payment_verification_service.stop() except Exception as error: - logger.error( - f"Ошибка остановки сервиса автопроверки пополнений: {error}" - ) + logger.error(f'Ошибка остановки сервиса автопроверки пополнений: {error}') if monitoring_task and not monitoring_task.done(): - logger.info("ℹ️ Остановка службы мониторинга...") + logger.info('ℹ️ Остановка службы мониторинга...') monitoring_service.stop_monitoring() monitoring_task.cancel() try: @@ -821,16 +776,16 @@ async def main(): pass if maintenance_task and not maintenance_task.done(): - logger.info("ℹ️ Остановка службы техработ...") + logger.info('ℹ️ Остановка службы техработ...') await maintenance_service.stop_monitoring() maintenance_task.cancel() try: await maintenance_task except asyncio.CancelledError: pass - + if version_check_task and not version_check_task.done(): - logger.info("ℹ️ Остановка сервиса проверки версий...") + logger.info('ℹ️ Остановка сервиса проверки версий...') version_check_task.cancel() try: await version_check_task @@ -838,7 +793,7 @@ async def main(): pass if traffic_monitoring_task and not traffic_monitoring_task.done(): - logger.info("ℹ️ Остановка мониторинга трафика...") + logger.info('ℹ️ Остановка мониторинга трафика...') traffic_monitoring_scheduler.stop_monitoring() traffic_monitoring_task.cancel() try: @@ -847,7 +802,7 @@ async def main(): pass if daily_subscription_task and not daily_subscription_task.done(): - logger.info("ℹ️ Остановка сервиса суточных подписок...") + logger.info('ℹ️ Остановка сервиса суточных подписок...') daily_subscription_service.stop_monitoring() daily_subscription_task.cancel() try: @@ -855,87 +810,87 @@ async def main(): except asyncio.CancelledError: pass - logger.info("ℹ️ Остановка сервиса отчетов...") + logger.info('ℹ️ Остановка сервиса отчетов...') try: await reporting_service.stop() except Exception as e: - logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.error(f'Ошибка остановки сервиса отчетов: {e}') - logger.info("ℹ️ Остановка сервиса конкурсов...") + logger.info('ℹ️ Остановка сервиса конкурсов...') try: await referral_contest_service.stop() except Exception as e: - logger.error(f"Ошибка остановки сервиса конкурсов: {e}") + logger.error(f'Ошибка остановки сервиса конкурсов: {e}') - logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...") + logger.info('ℹ️ Остановка сервиса автосинхронизации RemnaWave...') try: await remnawave_sync_service.stop() except Exception as e: - logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}") + logger.error(f'Ошибка остановки автосинхронизации RemnaWave: {e}') - logger.info("ℹ️ Остановка ротации игр...") + logger.info('ℹ️ Остановка ротации игр...') try: await contest_rotation_service.stop() except Exception as e: - logger.error(f"Ошибка остановки ротации игр: {e}") + logger.error(f'Ошибка остановки ротации игр: {e}') if settings.is_log_rotation_enabled(): - logger.info("ℹ️ Остановка сервиса ротации логов...") + logger.info('ℹ️ Остановка сервиса ротации логов...') try: await log_rotation_service.stop() except Exception as e: - logger.error(f"Ошибка остановки сервиса ротации логов: {e}") + logger.error(f'Ошибка остановки сервиса ротации логов: {e}') - logger.info("ℹ️ Остановка очереди чеков NaloGO...") + logger.info('ℹ️ Остановка очереди чеков NaloGO...') try: await nalogo_queue_service.stop() except Exception as e: - logger.error(f"Ошибка остановки очереди чеков NaloGO: {e}") + logger.error(f'Ошибка остановки очереди чеков NaloGO: {e}') - logger.info("ℹ️ Остановка сервиса бекапов...") + logger.info('ℹ️ Остановка сервиса бекапов...') try: await backup_service.stop_auto_backup() except Exception as e: - logger.error(f"Ошибка остановки сервиса бекапов: {e}") - + logger.error(f'Ошибка остановки сервиса бекапов: {e}') + if polling_task and not polling_task.done(): - logger.info("ℹ️ Остановка polling...") + logger.info('ℹ️ Остановка polling...') polling_task.cancel() try: await polling_task except asyncio.CancelledError: pass - + if telegram_webhook_enabled and 'bot' in locals(): - logger.info("ℹ️ Снятие Telegram webhook...") + logger.info('ℹ️ Снятие Telegram webhook...') try: await bot.delete_webhook(drop_pending_updates=False) - logger.info("✅ Telegram webhook удалён") + logger.info('✅ Telegram webhook удалён') except Exception as error: - logger.error(f"Ошибка удаления Telegram webhook: {error}") + logger.error(f'Ошибка удаления Telegram webhook: {error}') if web_api_server: try: await web_api_server.stop() - logger.info("✅ Административное веб-API остановлено") + logger.info('✅ Административное веб-API остановлено') except Exception as error: - logger.error(f"Ошибка остановки веб-API: {error}") - + logger.error(f'Ошибка остановки веб-API: {error}') + if 'bot' in locals(): try: await bot.session.close() - logger.info("✅ Сессия бота закрыта") + logger.info('✅ Сессия бота закрыта') except Exception as e: - logger.error(f"Ошибка закрытия сессии бота: {e}") - - logger.info("✅ Завершение работы бота завершено") + logger.error(f'Ошибка закрытия сессии бота: {e}') + + logger.info('✅ Завершение работы бота завершено') -if __name__ == "__main__": +if __name__ == '__main__': try: asyncio.run(main()) except KeyboardInterrupt: - print("\n🛑 Бот остановлен пользователем") + print('\n🛑 Бот остановлен пользователем') except Exception as e: - print(f"❌ Критическая ошибка: {e}") + print(f'❌ Критическая ошибка: {e}') sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..1d5afe57 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,299 @@ +[project] +name = 'remnawave-bedolaga-telegram-bot' +version = '3.1.3' +description = 'Telegram bot for RemnaWave VPN service' +readme = 'README.md' +license = { text = 'MIT' } +requires-python = '==3.13.*' +dependencies = [ + 'aiogram>=3.22.0', + 'sqlalchemy>=2.0.43', + 'alembic>=1.16.5', + 'asyncpg>=0.30.0', + 'aiosqlite>=0.21.0', + 'fastapi[standard]>=0.115.6', + 'redis>=5.0.1', + 'pyyaml>=6.0.2', + 'yookassa>=3.9.0', + 'python-dateutil>=2.9.0.post0', + 'cryptography>=41.0.0', + 'qrcode[pil]>=7.4.2', + 'packaging>=23.2', + 'bcrypt>=4.2.0', + 'pyjwt>=2.8.0', + 'flask>=3.1.0', + 'pyzipper>=0.3.6', +] + +[dependency-groups] +dev = [ + 'ruff', + 'pytest', + 'pytest-asyncio', + 'pytest-cov', +] + +[tool.uv] +package = false + +[tool.ruff] +target-version = 'py313' +line-length = 120 +exclude = [ + '.bzr', + '.direnv', + '.eggs', + '.git', + '.git-rewrite', + '.hg', + '.mypy_cache', + '.nox', + '.pants.d', + '.pytype', + '.ruff_cache', + '.svn', + '.tox', + '.venv', + '__pypackages__', + '_build', + 'buck-out', + 'build', + 'dist', + 'node_modules', + 'venv', + 'migrations', +] + +[tool.ruff.lint] +select = [ + 'F', + 'E', + 'W', + 'C90', + 'I', + 'N', + 'UP', + 'YTT', + 'ASYNC', + 'S', + 'B', + 'A', + 'COM', + 'C4', + 'DTZ', + 'T10', + 'EXE', + 'FA', + 'ISC', + 'ICN', + 'LOG', + 'G', + 'PIE', + 'T20', + 'PYI', + 'PT', + 'Q', + 'RSE', + 'RET', + 'SLF', + 'SLOT', + 'SIM', + 'TID', + 'TCH', + 'INT', + 'PTH', + 'ERA', + 'PGH', + 'PL', + 'TRY', + 'FLY', + 'FAST', + 'PERF', + 'FURB', + 'RUF', +] +ignore = [ + 'COM812', + 'ISC001', + 'RUF001', + 'RUF002', + 'RUF003', + 'RUF005', + 'RUF006', + 'RUF012', + 'RUF013', + 'RUF046', + 'RUF059', + 'G003', + 'G004', + 'G201', + 'PLR0913', + 'PLR0915', + 'PLR0912', + 'PLR0911', + 'PLR2004', + 'PLR1714', + 'PLW0603', + 'PLW2901', + 'TRY003', + 'TRY400', + 'TRY300', + 'TRY301', + 'TRY401', + 'E501', + 'E712', + 'E722', + 'B007', + 'B008', + 'B904', + 'TID252', + 'DTZ001', + 'DTZ003', + 'DTZ005', + 'FAST001', + 'FAST002', + 'FURB162', + 'C408', + 'C901', + 'SLF001', + 'S104', + 'S110', + 'S311', + 'SIM102', + 'SIM105', + 'SIM108', + 'SIM118', + 'TC001', + 'TC002', + 'TC003', + 'N805', + 'N815', + 'RET504', + 'ERA001', + 'PERF401', + 'PIE810', + 'PTH123', + 'SIM103', + 'S608', + 'DTZ006', + 'DTZ007', + 'UP045', + 'TRY002', + 'TRY201', + 'T201', + 'S324', + 'E711', + 'C416', + 'S105', + 'PGH003', + 'S112', + 'S307', + 'S310', + 'SIM117', + 'PERF403', + 'N806', + 'N802', + 'N818', + 'ISC002', + 'DTZ004', + 'DTZ011', + 'DTZ901', + 'ASYNC109', + 'ASYNC110', + 'ASYNC230', + 'A002', + 'UP007', + 'RUF034', + 'PTH110', + 'PTH117', + 'F821', + 'F823', + 'F811', + 'B023', + 'TRY004', + 'TRY203', + 'S202', + 'S106', + 'COM818', +] + +[tool.ruff.lint.per-file-ignores] +'tests/**/*.py' = [ + 'S101', + 'ARG', + 'S105', + 'S106', + 'PT', + 'E402', + 'PLC0415', + 'EM101', + 'EM102', + 'TRY003', + 'INP001', +] +'**/migrations/**/*.py' = [ + 'ALL', +] +'app/lib/**/*.py' = [ + 'ALL', +] +'main.py' = [ + 'E402', + 'PLC0415', +] +'app/bot.py' = [ + 'E402', + 'PLC0415', +] +'app/**/*.py' = [ + 'ARG001', + 'ARG002', + 'BLE001', + 'PLC0415', + 'E402', + 'EM101', + 'EM102', + 'INP001', + 'S101', +] +'*.py' = [ + 'INP001', +] +'**/__init__.py' = [ + 'F401', + 'F403', +] + +[tool.ruff.lint.isort] +known-first-party = ['app'] +force-single-line = false +lines-after-imports = 2 +combine-as-imports = true + +[tool.ruff.lint.mccabe] +max-complexity = 12 + +[tool.ruff.lint.pylint] +max-args = 15 +max-branches = 25 +max-returns = 10 +max-statements = 100 +max-locals = 25 + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = 'all' + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = 'double' +inline-quotes = 'single' + +[tool.ruff.lint.pydocstyle] +convention = 'google' + +[tool.ruff.format] +quote-style = 'single' +indent-style = 'space' +skip-magic-trailing-comma = false +line-ending = 'auto' +docstring-code-format = true +docstring-code-line-length = 'dynamic' diff --git a/tests/conftest.py b/tests/conftest.py index 5a7a5cf9..308287d0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,28 +5,29 @@ import inspect import os import sys import types -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path import pytest + # Add project root to Python path for imports project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) # Подменяем параметры подключения к БД, чтобы SQLAlchemy не требовал aiosqlite. -os.environ.setdefault("DATABASE_MODE", "postgresql") -os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/test_db") -os.environ.setdefault("BOT_TOKEN", "test-token") +os.environ.setdefault('DATABASE_MODE', 'postgresql') +os.environ.setdefault('DATABASE_URL', 'postgresql+asyncpg://user:pass@localhost/test_db') +os.environ.setdefault('BOT_TOKEN', 'test-token') # Создаём заглушки для драйверов, которых может не быть в окружении тестов. -sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg")) -sys.modules.setdefault("aiosqlite", types.ModuleType("aiosqlite")) +sys.modules.setdefault('asyncpg', types.ModuleType('asyncpg')) +sys.modules.setdefault('aiosqlite', types.ModuleType('aiosqlite')) # Эмуляция redis.asyncio, чтобы модуль кеша мог импортироваться. -if "redis.asyncio" not in sys.modules: - redis_module = types.ModuleType("redis") - redis_async_module = types.ModuleType("redis.asyncio") +if 'redis.asyncio' not in sys.modules: + redis_module = types.ModuleType('redis') + redis_async_module = types.ModuleType('redis.asyncio') class _FakeRedisClient: async def ping(self): @@ -36,38 +37,38 @@ if "redis.asyncio" not in sys.modules: async def close(self): """Закрытие соединения ничего не делает.""" - async def get(self, key): # noqa: ANN001 + async def get(self, key): return None - async def set(self, key, value, ex=None): # noqa: ANN001 + async def set(self, key, value, ex=None): return True - async def delete(self, *keys): # noqa: ANN001 + async def delete(self, *keys): return 0 - async def keys(self, pattern="*"): # noqa: ANN001 + async def keys(self, pattern='*'): return [] - async def exists(self, key): # noqa: ANN001 + async def exists(self, key): return False - async def expire(self, key, seconds): # noqa: ANN001 + async def expire(self, key, seconds): return True - async def incr(self, key): # noqa: ANN001 + async def incr(self, key): return 1 - def _from_url(url): # noqa: ANN001 + def _from_url(url): return _FakeRedisClient() redis_async_module.from_url = _from_url redis_async_module.Redis = _FakeRedisClient - sys.modules["redis"] = redis_module - sys.modules["redis.asyncio"] = redis_async_module + sys.modules['redis'] = redis_module + sys.modules['redis.asyncio'] = redis_async_module # Минимальная реализация SDK YooKassa, чтобы импорт сервисов не падал. -if "yookassa" not in sys.modules: - fake_yookassa = types.ModuleType("yookassa") +if 'yookassa' not in sys.modules: + fake_yookassa = types.ModuleType('yookassa') class _FakeConfiguration: @staticmethod @@ -80,102 +81,102 @@ if "yookassa" not in sys.modules: """Возвращает объект с минимально необходимыми атрибутами.""" class _Response: - id = "yk_fake" - status = "pending" + id = 'yk_fake' + status = 'pending' paid = False refundable = False metadata = {} - amount = types.SimpleNamespace(value="0.00", currency="RUB") - confirmation = types.SimpleNamespace(confirmation_url="https://example.com") + amount = types.SimpleNamespace(value='0.00', currency='RUB') + confirmation = types.SimpleNamespace(confirmation_url='https://example.com') created_at = datetime.utcnow() - description = "" + description = '' test = False return _Response() fake_yookassa.Configuration = _FakeConfiguration fake_yookassa.Payment = _FakePayment - sys.modules["yookassa"] = fake_yookassa + sys.modules['yookassa'] = fake_yookassa # Подготавливаем вложенные пакеты, используемые сервисом. - domain_module = types.ModuleType("yookassa.domain") - request_module = types.ModuleType("yookassa.domain.request") - payment_builder_module = types.ModuleType("yookassa.domain.request.payment_request_builder") - common_module = types.ModuleType("yookassa.domain.common") - confirmation_module = types.ModuleType("yookassa.domain.common.confirmation_type") + domain_module = types.ModuleType('yookassa.domain') + request_module = types.ModuleType('yookassa.domain.request') + payment_builder_module = types.ModuleType('yookassa.domain.request.payment_request_builder') + common_module = types.ModuleType('yookassa.domain.common') + confirmation_module = types.ModuleType('yookassa.domain.common.confirmation_type') class _FakePaymentRequestBuilder: def __init__(self): self.data: dict = {} - def set_amount(self, value): # noqa: ANN001 - упрощённая заглушка - self.data["amount"] = value + def set_amount(self, value): + self.data['amount'] = value return self - def set_capture(self, value): # noqa: ANN001 - self.data["capture"] = value + def set_capture(self, value): + self.data['capture'] = value return self - def set_confirmation(self, value): # noqa: ANN001 - self.data["confirmation"] = value + def set_confirmation(self, value): + self.data['confirmation'] = value return self - def set_description(self, value): # noqa: ANN001 - self.data["description"] = value + def set_description(self, value): + self.data['description'] = value return self - def set_metadata(self, value): # noqa: ANN001 - self.data["metadata"] = value + def set_metadata(self, value): + self.data['metadata'] = value return self - def set_receipt(self, value): # noqa: ANN001 - self.data["receipt"] = value + def set_receipt(self, value): + self.data['receipt'] = value return self - def set_payment_method_data(self, value): # noqa: ANN001 - self.data["payment_method_data"] = value + def set_payment_method_data(self, value): + self.data['payment_method_data'] = value return self def build(self): return self.data class _FakeConfirmationType: - REDIRECT = "redirect" + REDIRECT = 'redirect' payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder confirmation_module.ConfirmationType = _FakeConfirmationType - sys.modules["yookassa.domain"] = domain_module - sys.modules["yookassa.domain.request"] = request_module - sys.modules["yookassa.domain.request.payment_request_builder"] = payment_builder_module - sys.modules["yookassa.domain.common"] = common_module - sys.modules["yookassa.domain.common.confirmation_type"] = confirmation_module + sys.modules['yookassa.domain'] = domain_module + sys.modules['yookassa.domain.request'] = request_module + sys.modules['yookassa.domain.request.payment_request_builder'] = payment_builder_module + sys.modules['yookassa.domain.common'] = common_module + sys.modules['yookassa.domain.common.confirmation_type'] = confirmation_module @pytest.fixture def fixed_datetime() -> datetime: """Возвращает фиксированную отметку времени для воспроизводимых проверок.""" - return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + return datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) def pytest_configure(config: pytest.Config) -> None: """Регистрируем маркеры для асинхронных тестов.""" config.addinivalue_line( - "markers", - "asyncio: запуск асинхронного теста через встроенный цикл событий", + 'markers', + 'asyncio: запуск асинхронного теста через встроенный цикл событий', ) config.addinivalue_line( - "markers", - "anyio: запуск асинхронного теста через встроенный цикл событий", + 'markers', + 'anyio: запуск асинхронного теста через встроенный цикл событий', ) -def _unwrap_test(obj): # noqa: ANN001 - вспомогательная функция для определения coroutine +def _unwrap_test(obj): """Возвращает исходную функцию, снимая обёртки pytest и декораторов.""" unwrapped = obj - while hasattr(unwrapped, "__wrapped__"): + while hasattr(unwrapped, '__wrapped__'): unwrapped = unwrapped.__wrapped__ return unwrapped @@ -185,10 +186,10 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: """Позволяет запускать async def тесты без дополнительных плагинов.""" # Пропускаем если pytest-asyncio уже обработал этот тест - if hasattr(pyfuncitem, "_request") and hasattr(pyfuncitem._request, "_pyfuncitem"): + if hasattr(pyfuncitem, '_request') and hasattr(pyfuncitem._request, '_pyfuncitem'): markers = list(pyfuncitem.iter_markers()) for marker in markers: - if marker.name in ("asyncio", "anyio"): + if marker.name in ('asyncio', 'anyio'): # pytest-asyncio обработает этот тест return None @@ -202,11 +203,7 @@ def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> bool | None: try: asyncio.set_event_loop(loop) signature = inspect.signature(test_func) - call_kwargs = { - name: value - for name, value in pyfuncitem.funcargs.items() - if name in signature.parameters - } + call_kwargs = {name: value for name, value in pyfuncitem.funcargs.items() if name in signature.parameters} coro = pyfuncitem.obj(**call_kwargs) if coro is None: # Уже обработано другим плагином diff --git a/tests/crud/test_promocode_crud.py b/tests/crud/test_promocode_crud.py index f462d860..05292667 100644 --- a/tests/crud/test_promocode_crud.py +++ b/tests/crud/test_promocode_crud.py @@ -1,23 +1,18 @@ """ Tests for Promocode CRUD operations - focus on promo_group_id integration """ -import pytest + from unittest.mock import AsyncMock, MagicMock -from types import SimpleNamespace from app.database.crud.promocode import ( create_promocode, get_promocode_by_code, get_promocodes_list, ) -from app.database.models import PromoCodeType, PromoCode +from app.database.models import PromoCodeType + # Import fixtures -from tests.fixtures.promocode_fixtures import ( - sample_promo_group, - sample_promocode_promo_group, - mock_db_session, -) async def test_create_promocode_with_promo_group_id( @@ -35,18 +30,18 @@ async def test_create_promocode_with_promo_group_id( # Execute promocode = await create_promocode( db=mock_db_session, - code="TESTGROUP", + code='TESTGROUP', type=PromoCodeType.PROMO_GROUP, balance_bonus_kopeks=0, subscription_days=0, max_uses=100, valid_until=None, created_by=1, - promo_group_id=sample_promo_group.id + promo_group_id=sample_promo_group.id, ) # Assertions - assert promocode.code == "TESTGROUP" + assert promocode.code == 'TESTGROUP' assert promocode.type == PromoCodeType.PROMO_GROUP.value assert promocode.promo_group_id == sample_promo_group.id @@ -67,18 +62,18 @@ async def test_create_promocode_without_promo_group_id(mock_db_session): # Execute promocode = await create_promocode( db=mock_db_session, - code="BALANCE100", + code='BALANCE100', type=PromoCodeType.BALANCE, balance_bonus_kopeks=10000, subscription_days=0, max_uses=50, valid_until=None, created_by=1, - promo_group_id=None + promo_group_id=None, ) # Assertions - assert promocode.code == "BALANCE100" + assert promocode.code == 'BALANCE100' assert promocode.type == PromoCodeType.BALANCE.value assert promocode.promo_group_id is None @@ -101,13 +96,13 @@ async def test_get_promocode_by_code_loads_promo_group( mock_db_session.execute = AsyncMock(return_value=mock_result) # Execute - promocode = await get_promocode_by_code(mock_db_session, "VIPGROUP") + promocode = await get_promocode_by_code(mock_db_session, 'VIPGROUP') # Assertions assert promocode is not None - assert promocode.code == "VIPGROUP" + assert promocode.code == 'VIPGROUP' assert promocode.promo_group is not None - assert promocode.promo_group.name == "Test VIP Group" + assert promocode.promo_group.name == 'Test VIP Group' # Verify execute was called (query was executed) mock_db_session.execute.assert_awaited_once() @@ -136,7 +131,7 @@ async def test_get_promocodes_list_loads_promo_groups( # Assertions assert len(promocodes) == 1 assert promocodes[0].promo_group is not None - assert promocodes[0].promo_group.name == "Test VIP Group" + assert promocodes[0].promo_group.name == 'Test VIP Group' # Verify execute was called mock_db_session.execute.assert_awaited_once() diff --git a/tests/external/test_cryptobot_service.py b/tests/external/test_cryptobot_service.py index a419fa3a..96e901e5 100644 --- a/tests/external/test_cryptobot_service.py +++ b/tests/external/test_cryptobot_service.py @@ -2,84 +2,85 @@ from __future__ import annotations -from pathlib import Path -from typing import Any, Dict, Optional -import sys import hashlib import hmac +import sys +from pathlib import Path +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.config import settings # noqa: E402 -from app.external.cryptobot import CryptoBotService # noqa: E402 +from app.config import settings +from app.external.cryptobot import CryptoBotService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False) - monkeypatch.setattr(type(settings), "get_cryptobot_base_url", lambda self: "https://cryptobot.test", raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False) + monkeypatch.setattr(type(settings), 'get_cryptobot_base_url', lambda self: 'https://cryptobot.test', raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_invoice_uses_make_request(monkeypatch: pytest.MonkeyPatch) -> None: _enable_token(monkeypatch) service = CryptoBotService() - captured: Dict[str, Any] = {} + captured: dict[str, Any] = {} - async def fake_make_request(method: str, endpoint: str, data: Optional[Dict[str, Any]] = None): - captured["method"] = method - captured["endpoint"] = endpoint - captured["data"] = data - return {"invoice_id": 1} + async def fake_make_request(method: str, endpoint: str, data: dict[str, Any] | None = None): + captured['method'] = method + captured['endpoint'] = endpoint + captured['data'] = data + return {'invoice_id': 1} - monkeypatch.setattr(service, "_make_request", fake_make_request, raising=False) + monkeypatch.setattr(service, '_make_request', fake_make_request, raising=False) result = await service.create_invoice( - amount="10.00", - asset="USDT", - description="Пополнение", - payload="payload", + amount='10.00', + asset='USDT', + description='Пополнение', + payload='payload', expires_in=600, ) - assert result == {"invoice_id": 1} - assert captured["method"] == "POST" - assert captured["endpoint"] == "createInvoice" - assert captured["data"]["amount"] == "10.00" - assert captured["data"]["payload"] == "payload" + assert result == {'invoice_id': 1} + assert captured['method'] == 'POST' + assert captured['endpoint'] == 'createInvoice' + assert captured['data']['amount'] == '10.00' + assert captured['data']['payload'] == 'payload' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_make_request_returns_none_without_token(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', '', raising=False) service = CryptoBotService() - result = await service._make_request("GET", "getMe") + result = await service._make_request('GET', 'getMe') assert result is None def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "supersecret", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'supersecret', raising=False) service = CryptoBotService() body = '{"invoice_id":1}' - secret_hash = hashlib.sha256(b"supersecret").digest() + secret_hash = hashlib.sha256(b'supersecret').digest() signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest() assert service.verify_webhook_signature(body, signature) is True - assert service.verify_webhook_signature(body, "invalid") is False + assert service.verify_webhook_signature(body, 'invalid') is False def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', '', raising=False) service = CryptoBotService() - assert service.verify_webhook_signature("{}", "anything") is True + assert service.verify_webhook_signature('{}', 'anything') is True diff --git a/tests/external/test_wata_webhook.py b/tests/external/test_wata_webhook.py index 7708d684..107497a0 100644 --- a/tests/external/test_wata_webhook.py +++ b/tests/external/test_wata_webhook.py @@ -3,19 +3,19 @@ from __future__ import annotations import base64 -from pathlib import Path import sys -from typing import Optional +from pathlib import Path import pytest from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.external.wata_webhook import WataWebhookHandler # noqa: E402 +from app.external.wata_webhook import WataWebhookHandler class DummyPaymentService: @@ -24,34 +24,38 @@ class DummyPaymentService: class StubPublicKeyProvider: - def __init__(self, public_key_pem: Optional[str]) -> None: + def __init__(self, public_key_pem: str | None) -> None: self.public_key_pem = public_key_pem - async def get_public_key(self) -> Optional[str]: + async def get_public_key(self) -> str | None: return self.public_key_pem @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_verify_signature_success() -> None: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key = private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("utf-8") + public_key = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) - payload = "{\"status\": \"Paid\"}" + payload = '{"status": "Paid"}' signature = base64.b64encode( private_key.sign( - payload.encode("utf-8"), + payload.encode('utf-8'), padding.PKCS1v15(), hashes.SHA512(), ) - ).decode("utf-8") + ).decode('utf-8') handler = WataWebhookHandler( DummyPaymentService(), @@ -61,16 +65,20 @@ async def test_verify_signature_success() -> None: assert await handler._verify_signature(payload, signature) is True -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_verify_signature_fails_with_invalid_signature() -> None: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key = private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ).decode("utf-8") + public_key = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode('utf-8') + ) - payload = "{\"status\": \"Paid\"}" - bad_signature = base64.b64encode(b"not-a-signature").decode("utf-8") + payload = '{"status": "Paid"}' + bad_signature = base64.b64encode(b'not-a-signature').decode('utf-8') handler = WataWebhookHandler( DummyPaymentService(), @@ -80,11 +88,11 @@ async def test_verify_signature_fails_with_invalid_signature() -> None: assert await handler._verify_signature(payload, bad_signature) is False -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_verify_signature_fails_without_public_key() -> None: handler = WataWebhookHandler( DummyPaymentService(), public_key_provider=StubPublicKeyProvider(None), ) - assert await handler._verify_signature("{}", "signature") is False + assert await handler._verify_signature('{}', 'signature') is False diff --git a/tests/external/test_yookassa_webhook.py b/tests/external/test_yookassa_webhook.py index 3ae1f6ff..203b9430 100644 --- a/tests/external/test_yookassa_webhook.py +++ b/tests/external/test_yookassa_webhook.py @@ -13,38 +13,40 @@ from app.external.yookassa_webhook import ( ) -ALLOWED_IP = "185.71.76.10" +ALLOWED_IP = '185.71.76.10' class DummyDB: async def close(self) -> None: # pragma: no cover - simple stub pass + + @pytest.fixture(autouse=True) def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa-webhook", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SHOP_ID', 'shop', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SECRET_KEY', 'key', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_WEBHOOK_PATH', '/yookassa-webhook', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '', raising=False) def _build_headers(**overrides: str) -> dict[str, str]: headers = { - "Content-Type": "application/json", - "X-Forwarded-For": ALLOWED_IP, - "Cf-Connecting-Ip": ALLOWED_IP, + 'Content-Type': 'application/json', + 'X-Forwarded-For': ALLOWED_IP, + 'Cf-Connecting-Ip': ALLOWED_IP, } headers.update(overrides) return headers @pytest.mark.parametrize( - ("remote", "expected"), + ('remote', 'expected'), ( - ("185.71.76.10", "185.71.76.10"), - ("8.8.8.8", "8.8.8.8"), - ("10.0.0.5", "185.71.76.10"), - (None, "185.71.76.10"), + ('185.71.76.10', '185.71.76.10'), + ('8.8.8.8', '8.8.8.8'), + ('10.0.0.5', '185.71.76.10'), + (None, '185.71.76.10'), ), ) def test_resolve_yookassa_ip_trust_rules(remote: str | None, expected: str) -> None: @@ -56,40 +58,40 @@ def test_resolve_yookassa_ip_trust_rules(remote: str | None, expected: str) -> N def test_resolve_yookassa_ip_prefers_last_forwarded_candidate() -> None: - candidates = ["185.71.76.10", "8.8.8.8"] + candidates = ['185.71.76.10', '8.8.8.8'] - ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + ip_object = resolve_yookassa_ip(candidates, remote='10.0.0.5') assert ip_object is not None - assert str(ip_object) == "8.8.8.8" + assert str(ip_object) == '8.8.8.8' def test_resolve_yookassa_ip_accepts_allowed_last_forwarded_candidate() -> None: - candidates = ["8.8.8.8", ALLOWED_IP] + candidates = ['8.8.8.8', ALLOWED_IP] - ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + ip_object = resolve_yookassa_ip(candidates, remote='10.0.0.5') assert ip_object is not None assert str(ip_object) == ALLOWED_IP def test_resolve_yookassa_ip_skips_trusted_proxy_hops(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '203.0.113.0/24', raising=False) - candidates = [ALLOWED_IP, "203.0.113.10"] + candidates = [ALLOWED_IP, '203.0.113.10'] - ip_object = resolve_yookassa_ip(candidates, remote="10.0.0.5") + ip_object = resolve_yookassa_ip(candidates, remote='10.0.0.5') assert ip_object is not None assert str(ip_object) == ALLOWED_IP def test_resolve_yookassa_ip_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '198.51.100.0/24', raising=False) - candidates = [ALLOWED_IP, "198.51.100.10"] + candidates = [ALLOWED_IP, '198.51.100.10'] - ip_object = resolve_yookassa_ip(candidates, remote="198.51.100.20") + ip_object = resolve_yookassa_ip(candidates, remote='198.51.100.20') assert ip_object is not None assert str(ip_object) == ALLOWED_IP @@ -103,7 +105,7 @@ async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> we body = json.dumps(payload, ensure_ascii=False) return await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=body.encode("utf-8"), + data=body.encode('utf-8'), headers=_build_headers(**headers), ) @@ -112,7 +114,7 @@ def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_get_db(): yield DummyDB() - monkeypatch.setattr("app.external.yookassa_webhook.get_db", fake_get_db) + monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db) @pytest.mark.asyncio @@ -124,18 +126,18 @@ async def test_handle_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: app = create_yookassa_webhook_app(service) async with TestClient(TestServer(app)) as client: - payload = {"event": "payment.succeeded"} + payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=body.encode("utf-8"), + data=body.encode('utf-8'), headers=_build_headers(), ) status = response.status text = await response.text() assert status == 400 - assert text == "No payment id" + assert text == 'No payment id' process_mock.assert_not_awaited() @@ -148,20 +150,20 @@ async def test_handle_webhook_trusts_cf_connecting_ip(monkeypatch: pytest.Monkey app = create_yookassa_webhook_app(service) async with TestClient(TestServer(app)) as client: - payload = {"event": "payment.succeeded"} + payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) headers = _build_headers() - headers.pop("X-Forwarded-For") + headers.pop('X-Forwarded-For') response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=body.encode("utf-8"), + data=body.encode('utf-8'), headers=headers, ) status = response.status text = await response.text() assert status == 400 - assert text == "No payment id" + assert text == 'No payment id' process_mock.assert_not_awaited() @@ -174,18 +176,18 @@ async def test_handle_webhook_with_optional_signature(monkeypatch: pytest.Monkey app = create_yookassa_webhook_app(service) async with TestClient(TestServer(app)) as client: - payload = {"event": "payment.succeeded"} + payload = {'event': 'payment.succeeded'} body = json.dumps(payload, ensure_ascii=False) response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=body.encode("utf-8"), - headers=_build_headers(Signature="test-signature"), + data=body.encode('utf-8'), + headers=_build_headers(Signature='test-signature'), ) status = response.status text = await response.text() assert status == 400 - assert text == "No payment id" + assert text == 'No payment id' process_mock.assert_not_awaited() @@ -198,10 +200,10 @@ async def test_handle_webhook_accepts_canceled_event(monkeypatch: pytest.MonkeyP app = create_yookassa_webhook_app(service) async with TestClient(TestServer(app)) as client: - payload = {"event": "payment.canceled", "object": {"id": "yk_1"}} + payload = {'event': 'payment.canceled', 'object': {'id': 'yk_1'}} response = await client.post( settings.YOOKASSA_WEBHOOK_PATH, - data=json.dumps(payload).encode("utf-8"), + data=json.dumps(payload).encode('utf-8'), headers=_build_headers(), ) diff --git a/tests/fixtures/promocode_fixtures.py b/tests/fixtures/promocode_fixtures.py index ad57ab55..3059c277 100644 --- a/tests/fixtures/promocode_fixtures.py +++ b/tests/fixtures/promocode_fixtures.py @@ -1,10 +1,12 @@ """ Fixtures for promocode and promo group testing """ -import pytest + +from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock -from datetime import datetime, timedelta + +import pytest from app.database.models import PromoCodeType @@ -14,7 +16,7 @@ def sample_promo_group(): """Sample PromoGroup object for testing""" return SimpleNamespace( id=1, - name="Test VIP Group", + name='Test VIP Group', priority=50, server_discount_percent=20, traffic_discount_percent=15, @@ -23,7 +25,7 @@ def sample_promo_group(): is_default=False, auto_assign_total_spent_kopeks=None, auto_assign_enabled=False, - addon_discount_enabled=True + addon_discount_enabled=True, ) @@ -33,12 +35,12 @@ def sample_user(): return SimpleNamespace( id=1, telegram_id=123456789, - username="testuser", - full_name="Test User", + username='testuser', + full_name='Test User', balance_kopeks=0, - language="ru", + language='ru', has_had_paid_subscription=False, - total_spent_kopeks=0 + total_spent_kopeks=0, ) @@ -47,7 +49,7 @@ def sample_promocode_balance(): """Balance type promocode""" return SimpleNamespace( id=1, - code="BALANCE100", + code='BALANCE100', type=PromoCodeType.BALANCE.value, balance_bonus_kopeks=10000, # 100 rubles subscription_days=0, @@ -59,7 +61,7 @@ def sample_promocode_balance(): valid_until=None, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), - created_by=1 + created_by=1, ) @@ -68,7 +70,7 @@ def sample_promocode_subscription(): """Subscription days type promocode""" return SimpleNamespace( id=2, - code="SUB30", + code='SUB30', type=PromoCodeType.SUBSCRIPTION_DAYS.value, balance_bonus_kopeks=0, subscription_days=30, @@ -80,7 +82,7 @@ def sample_promocode_subscription(): valid_until=datetime.utcnow() + timedelta(days=60), created_at=datetime.utcnow(), updated_at=datetime.utcnow(), - created_by=1 + created_by=1, ) @@ -89,7 +91,7 @@ def sample_promocode_promo_group(sample_promo_group): """Promo group type promocode""" return SimpleNamespace( id=3, - code="VIPGROUP", + code='VIPGROUP', type=PromoCodeType.PROMO_GROUP.value, balance_bonus_kopeks=0, subscription_days=0, @@ -101,7 +103,7 @@ def sample_promocode_promo_group(sample_promo_group): valid_until=None, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), - created_by=1 + created_by=1, ) @@ -110,7 +112,7 @@ def sample_promocode_invalid(): """Invalid/expired promocode""" return SimpleNamespace( id=4, - code="EXPIRED", + code='EXPIRED', type=PromoCodeType.BALANCE.value, balance_bonus_kopeks=5000, subscription_days=0, @@ -122,7 +124,7 @@ def sample_promocode_invalid(): valid_until=datetime.utcnow() - timedelta(days=1), # Expired created_at=datetime.utcnow() - timedelta(days=30), updated_at=datetime.utcnow(), - created_by=1 + created_by=1, ) diff --git a/tests/integration/test_promocode_promo_group_flow.py b/tests/integration/test_promocode_promo_group_flow.py index 2b19fcee..f2e7667e 100644 --- a/tests/integration/test_promocode_promo_group_flow.py +++ b/tests/integration/test_promocode_promo_group_flow.py @@ -5,20 +5,15 @@ These tests validate the complete flow from creating a promo group, creating a promocode, to activating it and verifying the user receives the promo group assignment. """ -import pytest -from unittest.mock import AsyncMock, patch -from types import SimpleNamespace -from app.services.promocode_service import PromoCodeService +from types import SimpleNamespace +from unittest.mock import AsyncMock + from app.database.models import PromoCodeType +from app.services.promocode_service import PromoCodeService + # Import fixtures -from tests.fixtures.promocode_fixtures import ( - sample_promo_group, - sample_user, - sample_promocode_promo_group, - mock_db_session, -) async def test_promo_group_promocode_full_workflow( @@ -47,7 +42,7 @@ async def test_promo_group_promocode_full_workflow( # Setup: Create a PROMO_GROUP promocode promocode = SimpleNamespace( id=1, - code="INTEGRATIONTEST", + code='INTEGRATIONTEST', type=PromoCodeType.PROMO_GROUP.value, balance_bonus_kopeks=0, subscription_days=0, @@ -57,89 +52,53 @@ async def test_promo_group_promocode_full_workflow( is_valid=True, promo_group_id=sample_promo_group.id, promo_group=sample_promo_group, - valid_until=None + valid_until=None, ) # Mock all CRUD operations get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=promocode) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute: User activates promocode service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "INTEGRATIONTEST" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'INTEGRATIONTEST') # Verify: Activation successful - assert result["success"] is True - assert "Test VIP Group" in result["description"] + assert result['success'] is True + assert 'Test VIP Group' in result['description'] # Verify: All steps were executed in correct order get_user_mock.assert_awaited_once_with(mock_db_session, sample_user.id) - get_promocode_mock.assert_awaited_once_with(mock_db_session, "INTEGRATIONTEST") + get_promocode_mock.assert_awaited_once_with(mock_db_session, 'INTEGRATIONTEST') check_usage_mock.assert_awaited_once_with(mock_db_session, sample_user.id, promocode.id) # Verify: Promo group assignment flow get_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_promo_group.id) - has_promo_group_mock.assert_awaited_once_with( - mock_db_session, - sample_user.id, - sample_promo_group.id - ) + has_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_user.id, sample_promo_group.id) add_promo_group_mock.assert_awaited_once_with( - mock_db_session, - sample_user.id, - sample_promo_group.id, - assigned_by="promocode" + mock_db_session, sample_user.id, sample_promo_group.id, assigned_by='promocode' ) # Verify: Usage recorded - create_usage_mock.assert_awaited_once_with( - mock_db_session, - promocode.id, - sample_user.id - ) + create_usage_mock.assert_awaited_once_with(mock_db_session, promocode.id, sample_user.id) # Verify: Counter incremented assert promocode.current_uses == 1 @@ -166,7 +125,7 @@ async def test_duplicate_promo_group_assignment_edge_case( """ promocode = SimpleNamespace( id=2, - code="DUPLICATE", + code='DUPLICATE', type=PromoCodeType.PROMO_GROUP.value, balance_bonus_kopeks=0, subscription_days=0, @@ -176,57 +135,35 @@ async def test_duplicate_promo_group_assignment_edge_case( is_valid=True, promo_group_id=sample_promo_group.id, promo_group=sample_promo_group, - valid_until=None + valid_until=None, ) # Mock CRUD operations get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=promocode) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) # User ALREADY HAS this promo group has_promo_group_mock = AsyncMock(return_value=True) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "DUPLICATE" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'DUPLICATE') # Verify: Activation still successful - assert result["success"] is True + assert result['success'] is True # Verify: add_user_to_promo_group was NOT called (no duplicate) add_promo_group_mock.assert_not_awaited() @@ -257,7 +194,7 @@ async def test_missing_promo_group_graceful_failure( # Promocode with non-existent promo_group_id promocode = SimpleNamespace( id=3, - code="ORPHANED", + code='ORPHANED', type=PromoCodeType.PROMO_GROUP.value, balance_bonus_kopeks=0, subscription_days=0, @@ -267,63 +204,38 @@ async def test_missing_promo_group_graceful_failure( is_valid=True, promo_group_id=999, # Non-existent promo_group=None, - valid_until=None + valid_until=None, ) # Mock CRUD operations get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=promocode) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) # Promo group NOT FOUND get_promo_group_mock = AsyncMock(return_value=None) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "ORPHANED" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'ORPHANED') # Verify: Activation STILL successful (graceful degradation) - assert result["success"] is True + assert result['success'] is True # Verify: Attempted to fetch promo group get_promo_group_mock.assert_awaited_once_with(mock_db_session, 999) diff --git a/tests/middlewares/test_channel_checker_payload.py b/tests/middlewares/test_channel_checker_payload.py index 3bd55f6b..d78569d8 100644 --- a/tests/middlewares/test_channel_checker_payload.py +++ b/tests/middlewares/test_channel_checker_payload.py @@ -1,17 +1,16 @@ """Тесты для функций сохранения/получения pending_start_payload в channel_checker.""" -from pathlib import Path import sys +from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch, create_autospec +from unittest.mock import AsyncMock, MagicMock, patch -import pytest ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from aiogram.types import Message, User +from aiogram.types import Message class TestRedisPayloadFunctions: @@ -25,27 +24,27 @@ class TestRedisPayloadFunctions: mock_redis.set = AsyncMock(return_value=True) mock_redis.aclose = AsyncMock() - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: mock_aioredis.from_url = MagicMock(return_value=mock_redis) - result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123") + result = await channel_checker.save_pending_payload_to_redis(123456, 'ref_test123') assert result is True mock_redis.set.assert_awaited_once() call_args = mock_redis.set.await_args - assert "pending_start_payload:123456" in call_args.args[0] - assert call_args.args[1] == "ref_test123" - assert call_args.kwargs.get("ex") == 3600 + assert 'pending_start_payload:123456' in call_args.args[0] + assert call_args.args[1] == 'ref_test123' + assert call_args.kwargs.get('ex') == 3600 mock_redis.aclose.assert_awaited_once() async def test_save_pending_payload_to_redis_failure(self, monkeypatch): """Тест обработки ошибки при сохранении в Redis.""" from app.middlewares import channel_checker - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: - mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed")) + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: + mock_aioredis.from_url = MagicMock(side_effect=Exception('Redis connection failed')) - result = await channel_checker.save_pending_payload_to_redis(123456, "ref_test123") + result = await channel_checker.save_pending_payload_to_redis(123456, 'ref_test123') assert result is False @@ -54,15 +53,15 @@ class TestRedisPayloadFunctions: from app.middlewares import channel_checker mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value=b"ref_test123") + mock_redis.get = AsyncMock(return_value=b'ref_test123') mock_redis.aclose = AsyncMock() - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: mock_aioredis.from_url = MagicMock(return_value=mock_redis) result = await channel_checker.get_pending_payload_from_redis(123456) - assert result == "ref_test123" + assert result == 'ref_test123' mock_redis.get.assert_awaited_once() mock_redis.aclose.assert_awaited_once() @@ -74,7 +73,7 @@ class TestRedisPayloadFunctions: mock_redis.get = AsyncMock(return_value=None) mock_redis.aclose = AsyncMock() - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: mock_aioredis.from_url = MagicMock(return_value=mock_redis) result = await channel_checker.get_pending_payload_from_redis(123456) @@ -85,8 +84,8 @@ class TestRedisPayloadFunctions: """Тест обработки ошибки при получении из Redis.""" from app.middlewares import channel_checker - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: - mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis connection failed")) + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: + mock_aioredis.from_url = MagicMock(side_effect=Exception('Redis connection failed')) result = await channel_checker.get_pending_payload_from_redis(123456) @@ -100,7 +99,7 @@ class TestRedisPayloadFunctions: mock_redis.delete = AsyncMock(return_value=1) mock_redis.aclose = AsyncMock() - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: mock_aioredis.from_url = MagicMock(return_value=mock_redis) # Не должно бросать исключение @@ -112,8 +111,8 @@ class TestRedisPayloadFunctions: """Тест что удаление не бросает исключение при ошибке.""" from app.middlewares import channel_checker - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: - mock_aioredis.from_url = MagicMock(side_effect=Exception("Redis error")) + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: + mock_aioredis.from_url = MagicMock(side_effect=Exception('Redis error')) # Не должно бросать исключение await channel_checker.delete_pending_payload_from_redis(123456) @@ -140,17 +139,19 @@ class TestCaptureStartPayload: mock_state.get_data = AsyncMock(return_value={}) mock_state.set_data = AsyncMock() - mock_message = _create_mock_message("/start ref_abc123", 123456) + mock_message = _create_mock_message('/start ref_abc123', 123456) - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis: + with patch( + 'app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock + ) as mock_save_redis: await middleware._capture_start_payload(mock_state, mock_message, None) mock_state.set_data.assert_awaited_once() saved_data = mock_state.set_data.await_args.args[0] - assert saved_data["pending_start_payload"] == "ref_abc123" + assert saved_data['pending_start_payload'] == 'ref_abc123' # Также должен сохраняться в Redis - mock_save_redis.assert_awaited_once_with(123456, "ref_abc123") + mock_save_redis.assert_awaited_once_with(123456, 'ref_abc123') async def test_capture_saves_to_redis_when_state_none(self, monkeypatch): """Тест сохранения payload в Redis когда FSM state недоступен.""" @@ -158,13 +159,15 @@ class TestCaptureStartPayload: middleware = ChannelCheckerMiddleware() - mock_message = _create_mock_message("/start ref_xyz789", 999888) + mock_message = _create_mock_message('/start ref_xyz789', 999888) - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis: + with patch( + 'app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock + ) as mock_save_redis: await middleware._capture_start_payload(None, mock_message, None) # Должен сохраняться в Redis даже если state=None - mock_save_redis.assert_awaited_once_with(999888, "ref_xyz789") + mock_save_redis.assert_awaited_once_with(999888, 'ref_xyz789') async def test_capture_ignores_message_without_payload(self, monkeypatch): """Тест что сообщение без payload игнорируется.""" @@ -176,9 +179,11 @@ class TestCaptureStartPayload: mock_state.get_data = AsyncMock(return_value={}) mock_state.set_data = AsyncMock() - mock_message = _create_mock_message("/start", 123456) # Без payload + mock_message = _create_mock_message('/start', 123456) # Без payload - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis: + with patch( + 'app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock + ) as mock_save_redis: await middleware._capture_start_payload(mock_state, mock_message, None) mock_state.set_data.assert_not_awaited() @@ -194,9 +199,11 @@ class TestCaptureStartPayload: mock_state.get_data = AsyncMock(return_value={}) mock_state.set_data = AsyncMock() - mock_message = _create_mock_message("/help something", 123456) # Не /start + mock_message = _create_mock_message('/help something', 123456) # Не /start - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis: + with patch( + 'app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock + ) as mock_save_redis: await middleware._capture_start_payload(mock_state, mock_message, None) mock_state.set_data.assert_not_awaited() @@ -209,12 +216,14 @@ class TestCaptureStartPayload: middleware = ChannelCheckerMiddleware() mock_state = AsyncMock() - mock_state.get_data = AsyncMock(return_value={"pending_start_payload": "ref_same"}) + mock_state.get_data = AsyncMock(return_value={'pending_start_payload': 'ref_same'}) mock_state.set_data = AsyncMock() - mock_message = _create_mock_message("/start ref_same", 123456) # Тот же payload + mock_message = _create_mock_message('/start ref_same', 123456) # Тот же payload - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock) as mock_save_redis: + with patch( + 'app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock + ) as mock_save_redis: await middleware._capture_start_payload(mock_state, mock_message, None) # FSM state не должен перезаписываться @@ -239,25 +248,25 @@ class TestPayloadIntegration: mock_state.get_data = AsyncMock(return_value=state_storage) mock_state.set_data = AsyncMock(side_effect=lambda d: state_storage.update(d)) - mock_message = _create_mock_message("/start ref_flow_test", 111222) + mock_message = _create_mock_message('/start ref_flow_test', 111222) - with patch("app.middlewares.channel_checker.save_pending_payload_to_redis", new_callable=AsyncMock): + with patch('app.middlewares.channel_checker.save_pending_payload_to_redis', new_callable=AsyncMock): await middleware._capture_start_payload(mock_state, mock_message, None) # Проверяем что payload сохранён - assert state_storage.get("pending_start_payload") == "ref_flow_test" + assert state_storage.get('pending_start_payload') == 'ref_flow_test' async def test_payload_retrieved_from_redis_fallback(self, monkeypatch): """Тест что payload восстанавливается из Redis если в FSM state его нет.""" from app.middlewares.channel_checker import get_pending_payload_from_redis mock_redis = AsyncMock() - mock_redis.get = AsyncMock(return_value=b"ref_from_redis") + mock_redis.get = AsyncMock(return_value=b'ref_from_redis') mock_redis.aclose = AsyncMock() - with patch("app.middlewares.channel_checker.aioredis") as mock_aioredis: + with patch('app.middlewares.channel_checker.aioredis') as mock_aioredis: mock_aioredis.from_url = MagicMock(return_value=mock_redis) result = await get_pending_payload_from_redis(333444) - assert result == "ref_from_redis" + assert result == 'ref_from_redis' diff --git a/tests/services/test_menu_layout_service.py b/tests/services/test_menu_layout_service.py index 94d877f4..32376197 100644 --- a/tests/services/test_menu_layout_service.py +++ b/tests/services/test_menu_layout_service.py @@ -1,40 +1,41 @@ """Тесты для MenuLayoutService.""" +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import AsyncMock, MagicMock, patch from aiogram.types import InlineKeyboardButton -from app.services.menu_layout.service import MenuLayoutService from app.services.menu_layout.context import MenuContext +from app.services.menu_layout.service import MenuLayoutService @pytest.mark.anyio async def test_build_button_connect_direct_mode_with_url(): """Тест: кнопка connect с open_mode=direct и валидным URL должна создавать WebAppInfo.""" button_config = { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться"}, - "action": "subscription_connect", - "open_mode": "direct", - "webapp_url": "https://example.com/miniapp", + 'type': 'builtin', + 'builtin_id': 'connect', + 'text': {'ru': '🔗 Подключиться'}, + 'action': 'subscription_connect', + 'open_mode': 'direct', + 'webapp_url': 'https://example.com/miniapp', } - + context = MenuContext( - language="ru", + language='ru', has_active_subscription=True, subscription_is_active=True, ) - + texts = MagicMock() texts.t = lambda key, default: default - + button = MenuLayoutService._build_button(button_config, context, texts) - + assert button is not None assert isinstance(button, InlineKeyboardButton) assert button.web_app is not None - assert button.web_app.url == "https://example.com/miniapp" + assert button.web_app.url == 'https://example.com/miniapp' assert button.callback_data is None @@ -42,66 +43,66 @@ async def test_build_button_connect_direct_mode_with_url(): async def test_build_button_connect_direct_mode_with_subscription_url(): """Тест: кнопка connect с open_mode=direct должна получать URL из подписки.""" button_config = { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться"}, - "action": "subscription_connect", - "open_mode": "direct", - "webapp_url": None, + 'type': 'builtin', + 'builtin_id': 'connect', + 'text': {'ru': '🔗 Подключиться'}, + 'action': 'subscription_connect', + 'open_mode': 'direct', + 'webapp_url': None, } - + # Мокаем подписку с URL mock_subscription = MagicMock() - mock_subscription.subscription_url = "https://subscription.example.com/link" + mock_subscription.subscription_url = 'https://subscription.example.com/link' mock_subscription.subscription_crypto_link = None - + context = MenuContext( - language="ru", + language='ru', has_active_subscription=True, subscription_is_active=True, subscription=mock_subscription, ) - + texts = MagicMock() texts.t = lambda key, default: default - + with patch('app.utils.subscription_utils.get_display_subscription_link') as mock_get_link: - mock_get_link.return_value = "https://subscription.example.com/link" - + mock_get_link.return_value = 'https://subscription.example.com/link' + button = MenuLayoutService._build_button(button_config, context, texts) - + assert button is not None assert isinstance(button, InlineKeyboardButton) assert button.web_app is not None - assert button.web_app.url == "https://subscription.example.com/link" + assert button.web_app.url == 'https://subscription.example.com/link' @pytest.mark.anyio async def test_build_button_connect_callback_mode(): """Тест: кнопка connect с open_mode=callback должна создавать callback кнопку.""" button_config = { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться"}, - "action": "subscription_connect", - "open_mode": "callback", - "webapp_url": None, + 'type': 'builtin', + 'builtin_id': 'connect', + 'text': {'ru': '🔗 Подключиться'}, + 'action': 'subscription_connect', + 'open_mode': 'callback', + 'webapp_url': None, } - + context = MenuContext( - language="ru", + language='ru', has_active_subscription=True, subscription_is_active=True, ) - + texts = MagicMock() texts.t = lambda key, default: default - + button = MenuLayoutService._build_button(button_config, context, texts) - + assert button is not None assert isinstance(button, InlineKeyboardButton) - assert button.callback_data == "subscription_connect" + assert button.callback_data == 'subscription_connect' assert button.web_app is None @@ -109,31 +110,30 @@ async def test_build_button_connect_callback_mode(): async def test_build_button_connect_direct_mode_fallback_to_callback(): """Тест: кнопка connect с open_mode=direct без URL должна fallback на callback.""" button_config = { - "type": "builtin", - "builtin_id": "connect", - "text": {"ru": "🔗 Подключиться"}, - "action": "subscription_connect", - "open_mode": "direct", - "webapp_url": None, + 'type': 'builtin', + 'builtin_id': 'connect', + 'text': {'ru': '🔗 Подключиться'}, + 'action': 'subscription_connect', + 'open_mode': 'direct', + 'webapp_url': None, } - + context = MenuContext( - language="ru", + language='ru', has_active_subscription=True, subscription_is_active=True, subscription=None, # Нет подписки ) - + texts = MagicMock() texts.t = lambda key, default: default - + with patch('app.services.menu_layout.service.settings') as mock_settings: mock_settings.MINIAPP_CUSTOM_URL = None - + button = MenuLayoutService._build_button(button_config, context, texts) - + assert button is not None assert isinstance(button, InlineKeyboardButton) # Должен fallback на callback_data, так как URL не найден - assert button.callback_data == "subscription_connect" - + assert button.callback_data == 'subscription_connect' diff --git a/tests/services/test_modem_service.py b/tests/services/test_modem_service.py index 731959c5..9f2dc33c 100644 --- a/tests/services/test_modem_service.py +++ b/tests/services/test_modem_service.py @@ -2,21 +2,14 @@ Тесты для ModemService - управление модемом в подписке. """ -import pytest from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock from app.services.modem_service import ( - ModemService, ModemError, - ModemAvailabilityResult, - ModemPriceResult, - ModemEnableResult, - ModemDisableResult, + ModemService, get_modem_service, - MODEM_WARNING_DAYS_CRITICAL, - MODEM_WARNING_DAYS_INFO, ) @@ -35,7 +28,7 @@ def create_sample_user(): id=1, telegram_id=123456789, balance_kopeks=50000, # 500 рублей - language="ru", + language='ru', subscription=None, ) return user @@ -246,16 +239,16 @@ class TestModemServicePeriodWarning: def test_warning_critical(self, monkeypatch): """Критическое предупреждение при <= 7 днях.""" modem_service, _ = create_modem_service(monkeypatch) - assert modem_service.get_period_warning_level(7) == "critical" - assert modem_service.get_period_warning_level(5) == "critical" - assert modem_service.get_period_warning_level(1) == "critical" + assert modem_service.get_period_warning_level(7) == 'critical' + assert modem_service.get_period_warning_level(5) == 'critical' + assert modem_service.get_period_warning_level(1) == 'critical' def test_warning_info(self, monkeypatch): """Информационное предупреждение при <= 30 днях.""" modem_service, _ = create_modem_service(monkeypatch) - assert modem_service.get_period_warning_level(30) == "info" - assert modem_service.get_period_warning_level(15) == "info" - assert modem_service.get_period_warning_level(8) == "info" + assert modem_service.get_period_warning_level(30) == 'info' + assert modem_service.get_period_warning_level(15) == 'info' + assert modem_service.get_period_warning_level(8) == 'info' def test_warning_none(self, monkeypatch): """Нет предупреждения при > 30 днях.""" @@ -281,19 +274,11 @@ class TestModemServiceEnable: mock_create_transaction = AsyncMock() mock_update_remnawave = AsyncMock() - monkeypatch.setattr( - 'app.services.modem_service.subtract_user_balance', - mock_subtract - ) - monkeypatch.setattr( - 'app.services.modem_service.create_transaction', - mock_create_transaction - ) + monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract) + monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction) modem_service._subscription_service.update_remnawave_user = mock_update_remnawave - result = await modem_service.enable_modem( - mock_db, sample_user, sample_subscription - ) + result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription) assert result.success assert result.error is None @@ -311,9 +296,7 @@ class TestModemServiceEnable: mock_db = AsyncMock() - result = await modem_service.enable_modem( - mock_db, sample_user, sample_subscription - ) + result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription) assert not result.success assert result.error == ModemError.INSUFFICIENT_FUNDS @@ -329,14 +312,9 @@ class TestModemServiceEnable: mock_db = AsyncMock() mock_subtract = AsyncMock(return_value=False) # ошибка списания - monkeypatch.setattr( - 'app.services.modem_service.subtract_user_balance', - mock_subtract - ) + monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract) - result = await modem_service.enable_modem( - mock_db, sample_user, sample_subscription - ) + result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription) assert not result.success assert result.error == ModemError.CHARGE_ERROR @@ -358,9 +336,7 @@ class TestModemServiceDisable: mock_update_remnawave = AsyncMock() modem_service._subscription_service.update_remnawave_user = mock_update_remnawave - result = await modem_service.disable_modem( - mock_db, sample_user, sample_subscription - ) + result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription) assert result.success assert result.error is None @@ -375,6 +351,7 @@ class TestModemServiceSingleton: """get_modem_service возвращает один и тот же экземпляр.""" # Сбрасываем глобальный экземпляр import app.services.modem_service as modem_module + modem_module._modem_service = None mock_settings = create_mock_settings() diff --git a/tests/services/test_mulenpay_service_adapter.py b/tests/services/test_mulenpay_service_adapter.py index fc2d64a5..ab354492 100644 --- a/tests/services/test_mulenpay_service_adapter.py +++ b/tests/services/test_mulenpay_service_adapter.py @@ -4,18 +4,20 @@ from __future__ import annotations import asyncio import json -from pathlib import Path -from typing import Any, Dict, Optional, Sequence import sys +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Self import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.config import settings # noqa: E402 -from app.services.mulenpay_service import MulenPayService # noqa: E402 +from app.config import settings +from app.services.mulenpay_service import MulenPayService class _DummyResponse: @@ -23,16 +25,16 @@ class _DummyResponse: self, *, status: int, - body: str = "{}", - headers: Optional[Dict[str, str]] = None, - url: str = "https://mulenpay.test/endpoint", + body: str = '{}', + headers: dict[str, str] | None = None, + url: str = 'https://mulenpay.test/endpoint', ) -> None: self.status = status self._body = body - self.headers = headers or {"Content-Type": "application/json"} + self.headers = headers or {'Content-Type': 'application/json'} self.url = url - async def __aenter__(self) -> "_DummyResponse": + async def __aenter__(self) -> Self: return self async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface @@ -46,7 +48,7 @@ class _DummySession: def __init__(self, result: Any) -> None: self._result = result - async def __aenter__(self) -> "_DummySession": + async def __aenter__(self) -> Self: return self async def __aexit__(self, exc_type, exc, tb) -> bool: # pragma: no cover - interface @@ -59,11 +61,11 @@ class _DummySession: def _session_factory(responses: Sequence[Any]) -> Any: - call_state = {"index": 0} + call_state = {'index': 0} def _factory(*_args: Any, **_kwargs: Any) -> _DummySession: - index = min(call_state["index"], len(responses) - 1) - call_state["index"] += 1 + index = min(call_state['index'], len(responses) - 1) + call_state['index'] += 1 return _DummySession(responses[index]) return _factory @@ -71,15 +73,15 @@ def _session_factory(responses: Sequence[Any]) -> Any: @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' def _enable_service(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False) - monkeypatch.setattr(settings, "MULENPAY_API_KEY", "api", raising=False) - monkeypatch.setattr(settings, "MULENPAY_SHOP_ID", "shop", raising=False) - monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "secret", raising=False) - monkeypatch.setattr(settings, "MULENPAY_BASE_URL", "https://mulenpay.test", raising=False) + monkeypatch.setattr(type(settings), 'is_mulenpay_enabled', lambda self: True, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_API_KEY', 'api', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_SHOP_ID', 'shop', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_SECRET_KEY', 'secret', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_BASE_URL', 'https://mulenpay.test', raising=False) def test_is_configured(monkeypatch: pytest.MonkeyPatch) -> None: @@ -94,90 +96,92 @@ def test_is_configured(monkeypatch: pytest.MonkeyPatch) -> None: def test_format_and_signature(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() - assert service._format_amount(12345) == "123.45" - signature = service._build_signature("rub", "100.00") + assert service._format_amount(12345) == '123.45' + signature = service._build_signature('rub', '100.00') assert isinstance(signature, str) and len(signature) == 40 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) - captured_payload: Dict[str, Any] = {} + captured_payload: dict[str, Any] = {} - async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]: - captured_payload.update({"method": method, "endpoint": endpoint, **kwargs}) - return {"success": True, "id": 101, "paymentUrl": "https://mulenpay/pay"} + async def fake_request(method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]: + captured_payload.update({'method': method, 'endpoint': endpoint, **kwargs}) + return {'success': True, 'id': 101, 'paymentUrl': 'https://mulenpay/pay'} service = MulenPayService() - monkeypatch.setattr(service, "_request", fake_request, raising=False) + monkeypatch.setattr(service, '_request', fake_request, raising=False) result = await service.create_payment( amount_kopeks=25000, - description="Пополнение", - uuid="uuid-1", - items=[{"description": "item", "quantity": 1, "price": 250.0}], - language="ru", - website_url="https://example.com", + description='Пополнение', + uuid='uuid-1', + items=[{'description': 'item', 'quantity': 1, 'price': 250.0}], + language='ru', + website_url='https://example.com', ) assert result is not None - assert result["id"] == 101 - assert captured_payload["method"] == "POST" - assert captured_payload["endpoint"] == "/v2/payments" - assert captured_payload["json_data"]["language"] == "ru" + assert result['id'] == 101 + assert captured_payload['method'] == 'POST' + assert captured_payload['endpoint'] == '/v2/payments' + assert captured_payload['json_data']['language'] == 'ru' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_failure(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() - async def fake_request(*args: Any, **kwargs: Any) -> Optional[Dict[str, Any]]: + async def fake_request(*args: Any, **kwargs: Any) -> dict[str, Any] | None: return None - monkeypatch.setattr(service, "_request", fake_request, raising=False) + monkeypatch.setattr(service, '_request', fake_request, raising=False) result = await service.create_payment( amount_kopeks=1000, - description="desc", - uuid="uuid", + description='desc', + uuid='uuid', items=[], ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() - async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]: - return {"id": 123, "status": "paid"} + async def fake_request(method: str, endpoint: str, **kwargs: Any) -> dict[str, Any]: + return {'id': 123, 'status': 'paid'} - monkeypatch.setattr(service, "_request", fake_request, raising=False) + monkeypatch.setattr(service, '_request', fake_request, raising=False) result = await service.get_payment(123) - assert result == {"id": 123, "status": "paid"} + assert result == {'id': 123, 'status': 'paid'} -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_request_success(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() - response_payload = {"ok": True} + response_payload = {'ok': True} monkeypatch.setattr( - "app.services.mulenpay_service.aiohttp.ClientSession", - _session_factory([ - _DummyResponse(status=200, body=json.dumps(response_payload)), - ]), + 'app.services.mulenpay_service.aiohttp.ClientSession', + _session_factory( + [ + _DummyResponse(status=200, body=json.dumps(response_payload)), + ] + ), ) - result = await service._request("GET", "/ping") + result = await service._request('GET', '/ping') assert result == response_payload -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_request_retries_on_server_error(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() @@ -189,26 +193,26 @@ async def test_request_retries_on_server_error(monkeypatch: pytest.MonkeyPatch) sleep_calls.append(delay) monkeypatch.setattr( - "app.services.mulenpay_service.asyncio.sleep", + 'app.services.mulenpay_service.asyncio.sleep', fake_sleep, ) monkeypatch.setattr( - "app.services.mulenpay_service.aiohttp.ClientSession", + 'app.services.mulenpay_service.aiohttp.ClientSession', _session_factory( [ - _DummyResponse(status=502, body="{\"error\": \"bad gateway\"}"), - _DummyResponse(status=200, body="{\"ok\": true}"), + _DummyResponse(status=502, body='{"error": "bad gateway"}'), + _DummyResponse(status=200, body='{"ok": true}'), ] ), ) - result = await service._request("GET", "/retry") - assert result == {"ok": True} + result = await service._request('GET', '/retry') + assert result == {'ok': True} assert sleep_calls == [service._retry_delay] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_request_returns_none_after_timeouts(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() @@ -218,28 +222,28 @@ async def test_request_returns_none_after_timeouts(monkeypatch: pytest.MonkeyPat return None monkeypatch.setattr( - "app.services.mulenpay_service.asyncio.sleep", + 'app.services.mulenpay_service.asyncio.sleep', fake_sleep, ) monkeypatch.setattr( - "app.services.mulenpay_service.aiohttp.ClientSession", - _session_factory([asyncio.TimeoutError()]), + 'app.services.mulenpay_service.aiohttp.ClientSession', + _session_factory([TimeoutError()]), ) - result = await service._request("GET", "/timeout") + result = await service._request('GET', '/timeout') assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_request_reraises_cancelled(monkeypatch: pytest.MonkeyPatch) -> None: _enable_service(monkeypatch) service = MulenPayService() monkeypatch.setattr( - "app.services.mulenpay_service.aiohttp.ClientSession", + 'app.services.mulenpay_service.aiohttp.ClientSession', _session_factory([asyncio.CancelledError()]), ) with pytest.raises(asyncio.CancelledError): - await service._request("GET", "/cancel") + await service._request('GET', '/cancel') diff --git a/tests/services/test_pal24_service_adapter.py b/tests/services/test_pal24_service_adapter.py index ff8ecb97..492127bc 100644 --- a/tests/services/test_pal24_service_adapter.py +++ b/tests/services/test_pal24_service_adapter.py @@ -2,89 +2,90 @@ from __future__ import annotations +import sys from datetime import datetime, timedelta from decimal import Decimal from pathlib import Path -from typing import Any, Dict, Optional -import sys +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.config import settings # noqa: E402 -from app.external.pal24_client import Pal24Client, Pal24APIError # noqa: E402 -from app.services.pal24_service import Pal24Service # noqa: E402 +from app.config import settings +from app.external.pal24_client import Pal24APIError, Pal24Client +from app.services.pal24_service import Pal24Service class StubPal24Client: - def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None: + def __init__(self, configured: bool = True, response: dict[str, Any] | None = None) -> None: self.is_configured = configured self.response = response or { - "success": True, - "bill_id": "BILL42", - "status": "NEW", - "transfer_url": "https://pal24/sbp", - "link_url": "https://pal24/card", - "currency": "RUB", + 'success': True, + 'bill_id': 'BILL42', + 'status': 'NEW', + 'transfer_url': 'https://pal24/sbp', + 'link_url': 'https://pal24/card', + 'currency': 'RUB', } - self.calls: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] - async def create_bill(self, **kwargs: Any) -> Dict[str, Any]: + async def create_bill(self, **kwargs: Any) -> dict[str, Any]: self.calls.append(kwargs) return self.response - async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: - return {"id": bill_id, "status": "NEW"} + async def get_bill_status(self, bill_id: str) -> dict[str, Any]: + return {'id': bill_id, 'status': 'NEW'} - async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: - return {"id": payment_id, "status": "SUCCESS"} + async def get_payment_status(self, payment_id: str) -> dict[str, Any]: + return {'id': payment_id, 'status': 'SUCCESS'} - async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]: - return {"id": bill_id, "payments": [{"id": "PAY-1"}]} + async def get_bill_payments(self, bill_id: str) -> dict[str, Any]: + return {'id': bill_id, 'payments': [{'id': 'PAY-1'}]} def _enable_pal24(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(type(settings), "is_pal24_enabled", lambda self: True, raising=False) - monkeypatch.setattr(settings, "PAL24_SHOP_ID", "shop42", raising=False) - monkeypatch.setattr(settings, "PAL24_SIGNATURE_TOKEN", "sigsecret", raising=False) + monkeypatch.setattr(type(settings), 'is_pal24_enabled', lambda self: True, raising=False) + monkeypatch.setattr(settings, 'PAL24_SHOP_ID', 'shop42', raising=False) + monkeypatch.setattr(settings, 'PAL24_SIGNATURE_TOKEN', 'sigsecret', raising=False) @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None: _enable_pal24(monkeypatch) client = StubPal24Client() service = Pal24Service(client) - monkeypatch.setattr(Pal24Client, "normalize_amount", staticmethod(lambda amount: Decimal("500.00")), raising=False) + monkeypatch.setattr(Pal24Client, 'normalize_amount', staticmethod(lambda amount: Decimal('500.00')), raising=False) result = await service.create_bill( amount_kopeks=50000, user_id=7, - order_id="order-7", - description="Пополнение", + order_id='order-7', + description='Пополнение', ttl_seconds=600, - custom_payload={"extra": "value"}, - payer_email="user@example.com", - payment_method="BANK_CARD", + custom_payload={'extra': 'value'}, + payer_email='user@example.com', + payment_method='BANK_CARD', ) - assert result["bill_id"] == "BILL42" - assert client.calls and client.calls[0]["amount"] == Decimal("500.00") - assert client.calls[0]["shop_id"] == "shop42" - assert client.calls[0]["description"] == "Пополнение" - assert client.calls[0]["custom"] == {"extra": "value"} - assert client.calls[0]["payment_method"] == "BANK_CARD" + assert result['bill_id'] == 'BILL42' + assert client.calls and client.calls[0]['amount'] == Decimal('500.00') + assert client.calls[0]['shop_id'] == 'shop42' + assert client.calls[0]['description'] == 'Пополнение' + assert client.calls[0]['custom'] == {'extra': 'value'} + assert client.calls[0]['payment_method'] == 'BANK_CARD' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None: _enable_pal24(monkeypatch) client = StubPal24Client(configured=False) @@ -94,43 +95,43 @@ async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatc await service.create_bill( amount_kopeks=1000, user_id=1, - order_id="order", - description="desc", + order_id='order', + description='desc', ) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_bill_payments(monkeypatch: pytest.MonkeyPatch) -> None: _enable_pal24(monkeypatch) client = StubPal24Client() service = Pal24Service(client) - result = await service.get_bill_payments("BILL42") + result = await service.get_bill_payments('BILL42') - assert result == {"id": "BILL42", "payments": [{"id": "PAY-1"}]} + assert result == {'id': 'BILL42', 'payments': [{'id': 'PAY-1'}]} def test_parse_callback_success(monkeypatch: pytest.MonkeyPatch) -> None: _enable_pal24(monkeypatch) - sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret") + sig = Pal24Client.calculate_signature('100.00', 'INV1', api_token='sigsecret') payload = { - "InvId": "INV1", - "OutSum": "100.00", - "Status": "SUCCESS", - "SignatureValue": sig, + 'InvId': 'INV1', + 'OutSum': '100.00', + 'Status': 'SUCCESS', + 'SignatureValue': sig, } result = Pal24Service.parse_callback(payload) - assert result["InvId"] == "INV1" + assert result['InvId'] == 'INV1' def test_parse_callback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None: _enable_pal24(monkeypatch) with pytest.raises(Pal24APIError): - Pal24Service.parse_callback({"InvId": "1"}) + Pal24Service.parse_callback({'InvId': '1'}) def test_convert_to_kopeks_and_expiration() -> None: - assert Pal24Service.convert_to_kopeks("10.50") == 1050 + assert Pal24Service.convert_to_kopeks('10.50') == 1050 expiration = Pal24Service.get_expiration(60) assert isinstance(expiration, datetime) assert expiration - datetime.utcnow() <= timedelta(seconds=61) diff --git a/tests/services/test_payment_common.py b/tests/services/test_payment_common.py index 1d5c6253..db5a9636 100644 --- a/tests/services/test_payment_common.py +++ b/tests/services/test_payment_common.py @@ -8,6 +8,7 @@ import pytest from aiogram.types import InlineKeyboardMarkup from sqlalchemy.exc import MissingGreenlet + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) @@ -17,7 +18,7 @@ from app.services.payment.common import PaymentCommonMixin @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class _FakeBot: @@ -31,11 +32,11 @@ class _FakeBot: class _LazyUser: id = 99 telegram_id = 555 - language = "ru" + language = 'ru' @property def subscription(self): # type: ignore[no-untyped-def] - raise MissingGreenlet("lazy load is not available") + raise MissingGreenlet('lazy load is not available') class _PaymentServiceStub(PaymentCommonMixin): @@ -60,7 +61,7 @@ async def test_send_payment_success_notification_recovers_missing_greenlet(monke subscription=SimpleNamespace( is_trial=False, is_active=True, - actual_status="active", + actual_status='active', ), ) @@ -75,11 +76,11 @@ async def test_send_payment_success_notification_recovers_missing_greenlet(monke yield object() monkeypatch.setattr( - "app.services.payment.common.get_user_by_telegram_id", + 'app.services.payment.common.get_user_by_telegram_id', fake_get_user_by_telegram_id, ) monkeypatch.setattr( - "app.services.payment.common.get_db", + 'app.services.payment.common.get_db', fake_get_db, ) await service._send_payment_success_notification( @@ -87,11 +88,11 @@ async def test_send_payment_success_notification_recovers_missing_greenlet(monke 12300, user=lazy_user, db=sentinel_db, - payment_method_title="Тестовый метод", + payment_method_title='Тестовый метод', ) - assert service.bot.messages, "Ожидалось, что уведомление будет отправлено" + assert service.bot.messages, 'Ожидалось, что уведомление будет отправлено' message = service.bot.messages[0] - assert "Тестовый метод" in message["text"] + assert 'Тестовый метод' in message['text'] assert service.keyboard_user is not None assert isinstance(service.keyboard_user, SimpleNamespace) diff --git a/tests/services/test_payment_service_cryptobot.py b/tests/services/test_payment_service_cryptobot.py index 1edd75df..fb29d45d 100644 --- a/tests/services/test_payment_service_cryptobot.py +++ b/tests/services/test_payment_service_cryptobot.py @@ -1,24 +1,25 @@ """Тесты сценариев CryptoBot в PaymentService.""" -from pathlib import Path -from typing import Any, Dict, Optional import sys from datetime import datetime +from pathlib import Path +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.config import settings # noqa: E402 -from app.database.crud import cryptobot as cryptobot_crud # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 +from app.config import settings +from app.database.crud import cryptobot as cryptobot_crud +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -42,16 +43,16 @@ class DummyLocalPayment: class StubCryptoBotService: - def __init__(self, response: Optional[Dict[str, Any]]) -> None: + def __init__(self, response: dict[str, Any] | None) -> None: self.response = response - self.calls: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] - async def create_invoice(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + async def create_invoice(self, **kwargs: Any) -> dict[str, Any] | None: self.calls.append(kwargs) return self.response -def _make_service(stub: Optional[StubCryptoBotService]) -> PaymentService: +def _make_service(stub: StubCryptoBotService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.cryptobot_service = stub @@ -62,19 +63,19 @@ def _make_service(stub: Optional[StubCryptoBotService]) -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_cryptobot_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: response = { - "invoice_id": 12345, - "bot_invoice_url": "https://t.me/invoice", - "mini_app_invoice_url": "https://mini.app/invoice", - "web_app_invoice_url": "https://web.app/invoice", + 'invoice_id': 12345, + 'bot_invoice_url': 'https://t.me/invoice', + 'mini_app_invoice_url': 'https://mini.app/invoice', + 'web_app_invoice_url': 'https://web.app/invoice', } stub = StubCryptoBotService(response) service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_cryptobot_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) @@ -82,13 +83,13 @@ async def test_create_cryptobot_payment_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr( cryptobot_crud, - "create_cryptobot_payment", + 'create_cryptobot_payment', fake_create_cryptobot_payment, raising=False, ) monkeypatch.setattr( type(settings), - "get_cryptobot_invoice_expires_seconds", + 'get_cryptobot_invoice_expires_seconds', lambda self: 600, raising=False, ) @@ -97,21 +98,21 @@ async def test_create_cryptobot_payment_success(monkeypatch: pytest.MonkeyPatch) db=db, user_id=9, amount_usd=12.5, - asset="USDT", - description="Пополнение", - payload="custom", + asset='USDT', + description='Пополнение', + payload='custom', ) assert result is not None - assert result["local_payment_id"] == 555 - assert result["invoice_id"] == "12345" - assert result["bot_invoice_url"] == "https://t.me/invoice" - assert stub.calls and stub.calls[0]["expires_in"] == 600 - assert captured_args["invoice_id"] == "12345" - assert captured_args["amount"] == "12.50" + assert result['local_payment_id'] == 555 + assert result['invoice_id'] == '12345' + assert result['bot_invoice_url'] == 'https://t.me/invoice' + assert stub.calls and stub.calls[0]['expires_in'] == 600 + assert captured_args['invoice_id'] == '12345' + assert captured_args['amount'] == '12.50' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_cryptobot_payment_returns_none_when_service_missing() -> None: service = _make_service(None) db = DummySession() @@ -123,7 +124,7 @@ async def test_create_cryptobot_payment_returns_none_when_service_missing() -> N assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_cryptobot_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubCryptoBotService(response=None) service = _make_service(stub) @@ -138,7 +139,7 @@ async def test_create_cryptobot_payment_handles_empty_response(monkeypatch: pyte monkeypatch.setattr( cryptobot_crud, - "create_cryptobot_payment", + 'create_cryptobot_payment', fake_create_cryptobot_payment, raising=False, ) diff --git a/tests/services/test_payment_service_heleket.py b/tests/services/test_payment_service_heleket.py index 80f1f1fd..c5918d4c 100644 --- a/tests/services/test_payment_service_heleket.py +++ b/tests/services/test_payment_service_heleket.py @@ -2,21 +2,22 @@ import sys from datetime import datetime from pathlib import Path from types import SimpleNamespace -from typing import Any, Dict, Optional +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.services.payment_service import PaymentService # noqa: E402 -from app.database.crud import heleket as heleket_crud # noqa: E402 +from app.database.crud import heleket as heleket_crud +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -42,44 +43,42 @@ class DummyLocalPayment: class StubHeleketService: def __init__( self, - response: Optional[Dict[str, Any]], + response: dict[str, Any] | None, *, - info_response: Optional[Dict[str, Any]] = None, + info_response: dict[str, Any] | None = None, ) -> None: self.response = response self.info_response = info_response - self.calls: list[Dict[str, Any]] = [] - self.info_calls: list[Dict[str, Optional[str]]] = [] - self.list_response: Optional[Dict[str, Any]] = None - self.list_calls: list[Dict[str, Optional[str]]] = [] + self.calls: list[dict[str, Any]] = [] + self.info_calls: list[dict[str, str | None]] = [] + self.list_response: dict[str, Any] | None = None + self.list_calls: list[dict[str, str | None]] = [] - async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + async def create_payment(self, payload: dict[str, Any]) -> dict[str, Any] | None: self.calls.append(payload) return self.response async def get_payment_info( self, *, - uuid: Optional[str] = None, - order_id: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - self.info_calls.append({"uuid": uuid, "order_id": order_id}) + uuid: str | None = None, + order_id: str | None = None, + ) -> dict[str, Any] | None: + self.info_calls.append({'uuid': uuid, 'order_id': order_id}) return self.info_response async def list_payments( self, *, - date_from: Optional[str] = None, - date_to: Optional[str] = None, - cursor: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - self.list_calls.append( - {"date_from": date_from, "date_to": date_to, "cursor": cursor} - ) + date_from: str | None = None, + date_to: str | None = None, + cursor: str | None = None, + ) -> dict[str, Any] | None: + self.list_calls.append({'date_from': date_from, 'date_to': date_to, 'cursor': cursor}) return self.list_response -def _make_service(stub: Optional[StubHeleketService]) -> PaymentService: +def _make_service(stub: StubHeleketService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.heleket_service = stub @@ -92,27 +91,27 @@ def _make_service(stub: Optional[StubHeleketService]) -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: response = { - "state": 0, - "result": { - "uuid": "heleket-uuid", - "order_id": "order-123", - "url": "https://heleket/pay", - "status": "check", - "payer_amount": "12.50", - "payer_currency": "USDT", - "discount_percent": -5, - "payer_amount_exchange_rate": "0.0125", - "expired_at": 1750000000, + 'state': 0, + 'result': { + 'uuid': 'heleket-uuid', + 'order_id': 'order-123', + 'url': 'https://heleket/pay', + 'status': 'check', + 'payer_amount': '12.50', + 'payer_currency': 'USDT', + 'discount_percent': -5, + 'payer_amount_exchange_rate': '0.0125', + 'expired_at': 1750000000, }, } stub = StubHeleketService(response) service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_heleket_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) @@ -120,7 +119,7 @@ async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr( heleket_crud, - "create_heleket_payment", + 'create_heleket_payment', fake_create_heleket_payment, raising=False, ) @@ -129,21 +128,21 @@ async def test_create_heleket_payment_success(monkeypatch: pytest.MonkeyPatch) - db=db, user_id=42, amount_kopeks=15000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result is not None - assert result["local_payment_id"] == 555 - assert result["uuid"] == "heleket-uuid" - assert result["order_id"] == "order-123" - assert result["payment_url"] == "https://heleket/pay" - assert stub.calls and stub.calls[0]["amount"] == "150.00" - assert captured_args["uuid"] == "heleket-uuid" - assert captured_args["user_id"] == 42 + assert result['local_payment_id'] == 555 + assert result['uuid'] == 'heleket-uuid' + assert result['order_id'] == 'order-123' + assert result['payment_url'] == 'https://heleket/pay' + assert stub.calls and stub.calls[0]['amount'] == '150.00' + assert captured_args['uuid'] == 'heleket-uuid' + assert captured_args['user_id'] == 42 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_heleket_payment_returns_none_without_service() -> None: service = _make_service(None) db = DummySession() @@ -152,13 +151,13 @@ async def test_create_heleket_payment_returns_none_without_service() -> None: db=db, user_id=1, amount_kopeks=10000, - description="Пополнение", + description='Пополнение', ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubHeleketService(response=None) service = _make_service(stub) @@ -173,7 +172,7 @@ async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest monkeypatch.setattr( heleket_crud, - "create_heleket_payment", + 'create_heleket_payment', fake_create_heleket_payment, raising=False, ) @@ -182,22 +181,22 @@ async def test_create_heleket_payment_handles_empty_response(monkeypatch: pytest db=db, user_id=1, amount_kopeks=20000, - description="Пополнение", + description='Пополнение', ) assert result is None assert called is False -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPatch) -> None: info_response = { - "state": 0, - "result": { - "uuid": "heleket-uuid", - "order_id": "order-123", - "status": "paid", - "payment_amount": "100.00", + 'state': 0, + 'result': { + 'uuid': 'heleket-uuid', + 'order_id': 'order-123', + 'status': 'paid', + 'payment_amount': '100.00', }, } stub = StubHeleketService(response=None, info_response=info_response) @@ -206,9 +205,9 @@ async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPat payment = SimpleNamespace( id=55, - uuid="heleket-uuid", - order_id="order-123", - status="check", + uuid='heleket-uuid', + order_id='order-123', + status='check', user_id=7, ) @@ -216,26 +215,26 @@ async def test_sync_heleket_payment_status_success(monkeypatch: pytest.MonkeyPat assert payment_id == payment.id return payment - captured: Dict[str, Any] = {} + captured: dict[str, Any] = {} async def fake_process(self, db, payload, *, metadata_key): - captured["payload"] = payload - captured["metadata_key"] = metadata_key + captured['payload'] = payload + captured['metadata_key'] = metadata_key return SimpleNamespace(transaction_id=999, **payload) - monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) - monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) + monkeypatch.setattr(heleket_crud, 'get_heleket_payment_by_id', fake_get_by_id, raising=False) + monkeypatch.setattr(PaymentService, '_process_heleket_payload', fake_process, raising=False) result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) assert result is not None assert result.transaction_id == 999 - assert captured["metadata_key"] == "last_status_check" - assert captured["payload"]["uuid"] == payment.uuid - assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}] + assert captured['metadata_key'] == 'last_status_check' + assert captured['payload']['uuid'] == payment.uuid + assert stub.info_calls == [{'uuid': payment.uuid, 'order_id': payment.order_id}] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubHeleketService(response=None, info_response=None) service = _make_service(stub) @@ -243,9 +242,9 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest. payment = SimpleNamespace( id=12, - uuid="heleket-uuid", - order_id="order-123", - status="check", + uuid='heleket-uuid', + order_id='order-123', + status='check', user_id=5, ) @@ -254,33 +253,33 @@ async def test_sync_heleket_payment_status_without_response(monkeypatch: pytest. return payment async def fake_process(*args, **kwargs): # pragma: no cover - ensure not called - raise AssertionError("_process_heleket_payload should not be called") + raise AssertionError('_process_heleket_payload should not be called') - monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) - monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) + monkeypatch.setattr(heleket_crud, 'get_heleket_payment_by_id', fake_get_by_id, raising=False) + monkeypatch.setattr(PaymentService, '_process_heleket_payload', fake_process, raising=False) result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) assert result is payment - assert stub.info_calls == [{"uuid": payment.uuid, "order_id": payment.order_id}] + assert stub.info_calls == [{'uuid': payment.uuid, 'order_id': payment.order_id}] assert stub.list_calls # fallback to history should be attempted -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubHeleketService(response=None, info_response=None) stub.list_response = { - "state": 0, - "result": { - "items": [ + 'state': 0, + 'result': { + 'items': [ { - "uuid": "heleket-uuid", - "order_id": "order-123", - "status": "paid", - "payment_amount": "150.00", + 'uuid': 'heleket-uuid', + 'order_id': 'order-123', + 'status': 'paid', + 'payment_amount': '150.00', } ], - "paginate": {"nextCursor": None}, + 'paginate': {'nextCursor': None}, }, } service = _make_service(stub) @@ -288,9 +287,9 @@ async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest. payment = SimpleNamespace( id=77, - uuid="heleket-uuid", - order_id="order-123", - status="check", + uuid='heleket-uuid', + order_id='order-123', + status='check', user_id=8, ) @@ -298,18 +297,18 @@ async def test_sync_heleket_payment_status_history_fallback(monkeypatch: pytest. assert payment_id == payment.id return payment - captured: Dict[str, Any] = {} + captured: dict[str, Any] = {} async def fake_process(self, db, payload, *, metadata_key): - captured["payload"] = payload - captured["metadata_key"] = metadata_key + captured['payload'] = payload + captured['metadata_key'] = metadata_key return SimpleNamespace(**payload) - monkeypatch.setattr(heleket_crud, "get_heleket_payment_by_id", fake_get_by_id, raising=False) - monkeypatch.setattr(PaymentService, "_process_heleket_payload", fake_process, raising=False) + monkeypatch.setattr(heleket_crud, 'get_heleket_payment_by_id', fake_get_by_id, raising=False) + monkeypatch.setattr(PaymentService, '_process_heleket_payload', fake_process, raising=False) result = await service.sync_heleket_payment_status(db, local_payment_id=payment.id) assert result is not None - assert captured["payload"]["status"] == "paid" + assert captured['payload']['status'] == 'paid' assert stub.list_calls diff --git a/tests/services/test_payment_service_modularity.py b/tests/services/test_payment_service_modularity.py index af694020..13d6dac4 100644 --- a/tests/services/test_payment_service_modularity.py +++ b/tests/services/test_payment_service_modularity.py @@ -1,15 +1,16 @@ """Проверяем, что PaymentService собирается из mixin-классов.""" -from pathlib import Path import sys +from pathlib import Path import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.services.payment import ( # noqa: E402 +from app.services.payment import ( CryptoBotPaymentMixin, HeleketPaymentMixin, MulenPayPaymentMixin, @@ -17,10 +18,10 @@ from app.services.payment import ( # noqa: E402 PaymentCommonMixin, TelegramStarsMixin, TributePaymentMixin, - YooKassaPaymentMixin, WataPaymentMixin, + YooKassaPaymentMixin, ) -from app.services.payment_service import PaymentService # noqa: E402 +from app.services.payment_service import PaymentService def test_payment_service_mro_contains_all_mixins() -> None: @@ -37,23 +38,23 @@ def test_payment_service_mro_contains_all_mixins() -> None: WataPaymentMixin, } service_mro = set(PaymentService.__mro__) - assert mixins.issubset(service_mro), "PaymentService должен содержать все mixin-классы" + assert mixins.issubset(service_mro), 'PaymentService должен содержать все mixin-классы' @pytest.mark.parametrize( - "attribute", + 'attribute', [ - "build_topup_success_keyboard", - "create_stars_invoice", - "create_yookassa_payment", - "create_tribute_payment", - "create_cryptobot_payment", - "create_heleket_payment", - "create_mulenpay_payment", - "create_pal24_payment", - "create_wata_payment", + 'build_topup_success_keyboard', + 'create_stars_invoice', + 'create_yookassa_payment', + 'create_tribute_payment', + 'create_cryptobot_payment', + 'create_heleket_payment', + 'create_mulenpay_payment', + 'create_pal24_payment', + 'create_wata_payment', ], ) def test_payment_service_exposes_provider_methods(attribute: str) -> None: """Каждый mixin обязан добавить публичный метод в PaymentService.""" - assert hasattr(PaymentService, attribute), f"Отсутствует метод {attribute}" + assert hasattr(PaymentService, attribute), f'Отсутствует метод {attribute}' diff --git a/tests/services/test_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py index 0eddaba8..1099caf6 100644 --- a/tests/services/test_payment_service_mulenpay.py +++ b/tests/services/test_payment_service_mulenpay.py @@ -1,25 +1,26 @@ """Тесты для сценариев MulenPay в PaymentService.""" -from pathlib import Path -from typing import Any, Dict, Optional -from types import ModuleType, SimpleNamespace import sys from datetime import datetime +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 +import app.services.payment_service as payment_service_module +from app.config import settings +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -37,16 +38,16 @@ class DummyLocalPayment: class StubMulenPayService: - def __init__(self, response: Optional[Dict[str, Any]]) -> None: + def __init__(self, response: dict[str, Any] | None) -> None: self.response = response - self.calls: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] - async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + async def create_payment(self, **kwargs: Any) -> dict[str, Any] | None: self.calls.append(kwargs) return self.response -def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService: +def _make_service(stub: StubMulenPayService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.mulenpay_service = stub @@ -58,14 +59,14 @@ def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_mulenpay_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: - response = {"id": 123, "paymentUrl": "https://mulenpay/pay"} + response = {'id': 123, 'paymentUrl': 'https://mulenpay/pay'} stub = StubMulenPayService(response) service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_mulenpay_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) @@ -73,51 +74,51 @@ async def test_create_mulenpay_payment_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr( payment_service_module, - "create_mulenpay_payment", + 'create_mulenpay_payment', fake_create_mulenpay_payment, raising=False, ) - monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 1000, raising=False) - monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) - monkeypatch.setattr(settings, "MULENPAY_VAT_CODE", 1, raising=False) - monkeypatch.setattr(settings, "MULENPAY_PAYMENT_SUBJECT", "service", raising=False) - monkeypatch.setattr(settings, "MULENPAY_PAYMENT_MODE", "full_payment", raising=False) - monkeypatch.setattr(settings, "MULENPAY_LANGUAGE", "ru", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) + monkeypatch.setattr(settings, 'MULENPAY_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_MAX_AMOUNT_KOPEKS', 1_000_000, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_VAT_CODE', 1, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_PAYMENT_SUBJECT', 'service', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_PAYMENT_MODE', 'full_payment', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_LANGUAGE', 'ru', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', 'https://example.com', raising=False) result = await service.create_mulenpay_payment( db=db, user_id=77, amount_kopeks=25000, - description="Пополнение", - language="en", + description='Пополнение', + language='en', ) assert result is not None - assert result["local_payment_id"] == 999 - assert result["mulen_payment_id"] == 123 - assert result["payment_url"] == "https://mulenpay/pay" - assert result["status"] == "created" - assert stub.calls and stub.calls[0]["language"] == "en" - assert captured_args["user_id"] == 77 - assert captured_args["amount_kopeks"] == 25000 - assert captured_args["uuid"].startswith("mulen_77_") + assert result['local_payment_id'] == 999 + assert result['mulen_payment_id'] == 123 + assert result['payment_url'] == 'https://mulenpay/pay' + assert result['status'] == 'created' + assert stub.calls and stub.calls[0]['language'] == 'en' + assert captured_args['user_id'] == 77 + assert captured_args['amount_kopeks'] == 25000 + assert captured_args['uuid'].startswith('mulen_77_') -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_mulenpay_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None: - stub = StubMulenPayService({"id": 1}) + stub = StubMulenPayService({'id': 1}) service = _make_service(stub) db = DummySession() - monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 5000, raising=False) - monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 10_000, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_MIN_AMOUNT_KOPEKS', 5000, raising=False) + monkeypatch.setattr(settings, 'MULENPAY_MAX_AMOUNT_KOPEKS', 10_000, raising=False) result_low = await service.create_mulenpay_payment( db=db, user_id=1, amount_kopeks=1000, - description="Пополнение", + description='Пополнение', ) assert result_low is None @@ -125,13 +126,13 @@ async def test_create_mulenpay_payment_respects_amount_limits(monkeypatch: pytes db=db, user_id=1, amount_kopeks=20_000, - description="Пополнение", + description='Пополнение', ) assert result_high is None assert not stub.calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_mulenpay_payment_returns_none_without_service() -> None: service = _make_service(None) db = DummySession() @@ -140,12 +141,12 @@ async def test_create_mulenpay_payment_returns_none_without_service() -> None: db=db, user_id=1, amount_kopeks=5000, - description="Пополнение", + description='Пополнение', ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_mulenpay_callback_avoids_duplicate_transactions( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -156,29 +157,25 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( def __init__(self) -> None: self.user_id = 42 self.amount_kopeks = 1500 - self.description = "Пополнение" - self.uuid = "mulen_1_test" - self.transaction_id: Optional[int] = None - self.mulen_payment_id: Optional[int] = None - self.status = "created" + self.description = 'Пополнение' + self.uuid = 'mulen_1_test' + self.transaction_id: int | None = None + self.mulen_payment_id: int | None = None + self.status = 'created' self.is_paid = False payment = DummyPayment() - async def fake_get_mulenpay_payment_by_uuid( - _db: DummySession, uuid: str - ) -> DummyPayment: + async def fake_get_mulenpay_payment_by_uuid(_db: DummySession, uuid: str) -> DummyPayment: assert uuid == payment.uuid return payment - async def fake_update_mulenpay_payment_status( - _db: DummySession, **kwargs: Any - ) -> DummyPayment: - payment.status = kwargs.get("status", payment.status) - payment.mulen_payment_id = kwargs.get("mulen_payment_id", payment.mulen_payment_id) + async def fake_update_mulenpay_payment_status(_db: DummySession, **kwargs: Any) -> DummyPayment: + payment.status = kwargs.get('status', payment.status) + payment.mulen_payment_id = kwargs.get('mulen_payment_id', payment.mulen_payment_id) return payment - transaction_calls: list[Dict[str, Any]] = [] + transaction_calls: list[dict[str, Any]] = [] class DummyTransaction: def __init__(self, transaction_id: int = 555) -> None: @@ -188,9 +185,7 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( transaction_calls.append(kwargs) return DummyTransaction() - async def fake_link_payment( - db: DummySession, *, payment: DummyPayment, transaction_id: int - ) -> DummyPayment: + async def fake_link_payment(db: DummySession, *, payment: DummyPayment, transaction_id: int) -> DummyPayment: payment.transaction_id = transaction_id return payment @@ -200,7 +195,7 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( self.telegram_id = 99 self.balance_kopeks = 0 self.has_made_first_topup = False - self.language = "ru" + self.language = 'ru' self.promo_group = None self.subscription = None self.user_promo_groups = [] @@ -214,7 +209,7 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( assert user_id == payment.user_id return dummy_user - balance_call: Dict[str, Any] = {} + balance_call: dict[str, Any] = {} async def fake_add_user_balance( _db: DummySession, @@ -227,9 +222,9 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( ) -> bool: balance_call.update( { - "create_transaction": create_transaction, - "description": description, - "amount_kopeks": amount_kopeks, + 'create_transaction': create_transaction, + 'description': description, + 'amount_kopeks': amount_kopeks, } ) user.balance_kopeks += amount_kopeks @@ -244,66 +239,66 @@ async def test_process_mulenpay_callback_avoids_duplicate_transactions( async def fake_has_user_cart(*_args: Any, **_kwargs: Any) -> bool: return False - referral_module = ModuleType("app.services.referral_service") + referral_module = ModuleType('app.services.referral_service') referral_module.process_referral_topup = fake_process_referral_topup # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_module) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_module) - auto_module = ModuleType("app.services.subscription_auto_purchase_service") + auto_module = ModuleType('app.services.subscription_auto_purchase_service') auto_module.auto_purchase_saved_cart_after_topup = ( # type: ignore[attr-defined] fake_auto_purchase_saved_cart_after_topup ) - monkeypatch.setitem(sys.modules, "app.services.subscription_auto_purchase_service", auto_module) + monkeypatch.setitem(sys.modules, 'app.services.subscription_auto_purchase_service', auto_module) - user_cart_module = ModuleType("app.services.user_cart_service") + user_cart_module = ModuleType('app.services.user_cart_service') user_cart_module.user_cart_service = SimpleNamespace( # type: ignore[attr-defined] has_user_cart=fake_has_user_cart ) - monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_module) + monkeypatch.setitem(sys.modules, 'app.services.user_cart_service', user_cart_module) monkeypatch.setattr( payment_service_module, - "get_mulenpay_payment_by_uuid", + 'get_mulenpay_payment_by_uuid', fake_get_mulenpay_payment_by_uuid, raising=False, ) monkeypatch.setattr( payment_service_module, - "update_mulenpay_payment_status", + 'update_mulenpay_payment_status', fake_update_mulenpay_payment_status, raising=False, ) monkeypatch.setattr( payment_service_module, - "create_transaction", + 'create_transaction', fake_create_transaction, raising=False, ) monkeypatch.setattr( payment_service_module, - "link_mulenpay_payment_to_transaction", + 'link_mulenpay_payment_to_transaction', fake_link_payment, raising=False, ) monkeypatch.setattr( payment_service_module, - "get_user_by_id", + 'get_user_by_id', fake_get_user_by_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "add_user_balance", + 'add_user_balance', fake_add_user_balance, raising=False, ) result = await service.process_mulenpay_callback( db, - {"uuid": payment.uuid, "payment_status": "success", "id": 123, "amount": 1500}, + {'uuid': payment.uuid, 'payment_status': 'success', 'id': 123, 'amount': 1500}, ) assert result is True - assert transaction_calls, "create_transaction should be called" - assert balance_call["create_transaction"] is False + assert transaction_calls, 'create_transaction should be called' + assert balance_call['create_transaction'] is False assert dummy_user.balance_kopeks == payment.amount_kopeks assert payment.transaction_id is not None diff --git a/tests/services/test_payment_service_pal24.py b/tests/services/test_payment_service_pal24.py index 124553b9..1e7d2576 100644 --- a/tests/services/test_payment_service_pal24.py +++ b/tests/services/test_payment_service_pal24.py @@ -1,26 +1,27 @@ """Тесты Pal24 сценариев PaymentService.""" -from pathlib import Path -from typing import Any, Dict, Optional import sys from datetime import datetime +from pathlib import Path from types import SimpleNamespace +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 -from app.services.pal24_service import Pal24APIError # noqa: E402 +import app.services.payment_service as payment_service_module +from app.config import settings +from app.services.pal24_service import Pal24APIError +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -39,45 +40,45 @@ class StubPal24Service: self, *, configured: bool = True, - response: Optional[Dict[str, Any]] = None, + response: dict[str, Any] | None = None, ) -> None: self.is_configured = configured self.response = response or { - "success": True, - "bill_id": "BILL-1", - "transfer_url": "https://pal24/sbp", - "link_url": "https://pal24/card", - "status": "NEW", + 'success': True, + 'bill_id': 'BILL-1', + 'transfer_url': 'https://pal24/sbp', + 'link_url': 'https://pal24/card', + 'status': 'NEW', } - self.calls: list[Dict[str, Any]] = [] - self.raise_error: Optional[Exception] = None - self.status_response: Optional[Dict[str, Any]] = {"status": "NEW"} - self.payment_status_response: Optional[Dict[str, Any]] = None - self.bill_payments_response: Optional[Dict[str, Any]] = None + self.calls: list[dict[str, Any]] = [] + self.raise_error: Exception | None = None + self.status_response: dict[str, Any] | None = {'status': 'NEW'} + self.payment_status_response: dict[str, Any] | None = None + self.bill_payments_response: dict[str, Any] | None = None self.status_calls: list[str] = [] self.payment_status_calls: list[str] = [] self.bill_payments_calls: list[str] = [] - async def create_bill(self, **kwargs: Any) -> Dict[str, Any]: + async def create_bill(self, **kwargs: Any) -> dict[str, Any]: self.calls.append(kwargs) if self.raise_error: raise self.raise_error return self.response - async def get_bill_status(self, bill_id: str) -> Optional[Dict[str, Any]]: + async def get_bill_status(self, bill_id: str) -> dict[str, Any] | None: self.status_calls.append(bill_id) return self.status_response - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: + async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: self.payment_status_calls.append(payment_id) return self.payment_status_response - async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]: + async def get_bill_payments(self, bill_id: str) -> dict[str, Any] | None: self.bill_payments_calls.append(bill_id) return self.bill_payments_response -def _make_service(stub: Optional[StubPal24Service]) -> PaymentService: +def _make_service(stub: StubPal24Service | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.pal24_service = stub @@ -89,52 +90,52 @@ def _make_service(stub: Optional[StubPal24Service]) -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPal24Service() service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) if args: - captured_args["db_arg"] = args[0] + captured_args['db_arg'] = args[0] return DummyLocalPayment(payment_id=321) monkeypatch.setattr( payment_service_module, - "create_pal24_payment", + 'create_pal24_payment', fake_create_pal24_payment, raising=False, ) - monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False) - monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 1_000_000, raising=False) result = await service.create_pal24_payment( db=db, user_id=15, amount_kopeks=50000, - description="Оплата подписки", - language="ru", + description='Оплата подписки', + language='ru', ttl_seconds=600, - payer_email="user@example.com", - payment_method="card", + payer_email='user@example.com', + payment_method='card', ) assert result is not None - assert result["local_payment_id"] == 321 - assert result["bill_id"] == "BILL-1" - assert result["payment_method"] == "card" - assert result["link_url"] == "https://pal24/sbp" - assert result["card_url"] == "https://pal24/card" - assert stub.calls and stub.calls[0]["payment_method"] == "BANK_CARD" - assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000 - assert "links" in captured_args["metadata"] + assert result['local_payment_id'] == 321 + assert result['bill_id'] == 'BILL-1' + assert result['payment_method'] == 'card' + assert result['link_url'] == 'https://pal24/sbp' + assert result['card_url'] == 'https://pal24/card' + assert stub.calls and stub.calls[0]['payment_method'] == 'BANK_CARD' + assert stub.calls and stub.calls[0]['amount_kopeks'] == 50000 + assert 'links' in captured_args['metadata'] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPal24Service() service = _make_service(stub) @@ -145,42 +146,42 @@ async def test_create_pal24_payment_default_method(monkeypatch: pytest.MonkeyPat monkeypatch.setattr( payment_service_module, - "create_pal24_payment", + 'create_pal24_payment', fake_create_pal24_payment, raising=False, ) - monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False) - monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 1_000_000, raising=False) result = await service.create_pal24_payment( db=db, user_id=42, amount_kopeks=10_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result is not None - assert stub.calls and stub.calls[0]["payment_method"] == "SBP" - assert result["payment_method"] == "sbp" + assert stub.calls and stub.calls[0]['payment_method'] == 'SBP' + assert result['payment_method'] == 'sbp' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPal24Service() service = _make_service(stub) db = DummySession() - monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 5000, raising=False) - monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 20_000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 5000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 20_000, raising=False) result_low = await service.create_pal24_payment( db=db, user_id=1, amount_kopeks=1000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result_low is None @@ -188,8 +189,8 @@ async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest db=db, user_id=1, amount_kopeks=50_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result_high is None @@ -198,53 +199,53 @@ async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest db=db, user_id=1, amount_kopeks=10_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result_config is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPal24Service() - stub.raise_error = Pal24APIError("api failed") + stub.raise_error = Pal24APIError('api failed') service = _make_service(stub) db = DummySession() - monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False) - monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 10_000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MIN_AMOUNT_KOPEKS', 1000, raising=False) + monkeypatch.setattr(settings, 'PAL24_MAX_AMOUNT_KOPEKS', 10_000, raising=False) result = await service.create_pal24_payment( db=db, user_id=5, amount_kopeks=2000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPal24Service() - stub.status_response = {"status": "SUCCESS"} + stub.status_response = {'status': 'SUCCESS'} stub.payment_status_response = { - "success": True, - "id": "PAY-1", - "bill_id": "BILL-1", - "status": "SUCCESS", - "payment_method": "SBP", - "account_amount": "700.00", - "from_card": "676754******1234", + 'success': True, + 'id': 'PAY-1', + 'bill_id': 'BILL-1', + 'status': 'SUCCESS', + 'payment_method': 'SBP', + 'account_amount': '700.00', + 'from_card': '676754******1234', } stub.bill_payments_response = { - "data": [ + 'data': [ { - "id": "PAY-1", - "bill_id": "BILL-1", - "status": "SUCCESS", - "from_card": "676754******1234", - "payment_method": "SBP", + 'id': 'PAY-1', + 'bill_id': 'BILL-1', + 'status': 'SUCCESS', + 'from_card': '676754******1234', + 'payment_method': 'SBP', } ] } @@ -254,14 +255,14 @@ async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest. payment = SimpleNamespace( id=99, - bill_id="BILL-1", + bill_id='BILL-1', payment_id=None, - payment_status="NEW", + payment_status='NEW', payment_method=None, balance_amount=None, balance_currency=None, payer_account=None, - status="NEW", + status='NEW', is_paid=False, paid_at=None, transaction_id=None, @@ -283,8 +284,8 @@ async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest. payment_obj.last_status = status for key, value in kwargs.items(): setattr(payment_obj, key, value) - if "is_paid" in kwargs: - payment_obj.is_paid = kwargs["is_paid"] + if 'is_paid' in kwargs: + payment_obj.is_paid = kwargs['is_paid'] await db.commit() return payment_obj @@ -293,26 +294,26 @@ async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest. db: DummySession, payment_obj: Any, *, - payment_id: Optional[str] = None, + payment_id: str | None = None, trigger: str, ) -> bool: return False monkeypatch.setattr( payment_service_module, - "get_pal24_payment_by_id", + 'get_pal24_payment_by_id', fake_get_by_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "update_pal24_payment_status", + 'update_pal24_payment_status', fake_update_status, raising=False, ) monkeypatch.setattr( PaymentService, - "_finalize_pal24_payment", + '_finalize_pal24_payment', fake_finalize, raising=False, ) @@ -320,12 +321,12 @@ async def test_get_pal24_payment_status_updates_from_remote(monkeypatch: pytest. result = await service.get_pal24_payment_status(db, local_payment_id=payment.id) assert result is not None - assert payment.status == "SUCCESS" - assert payment.payment_id == "PAY-1" - assert payment.payment_status == "SUCCESS" - assert payment.payment_method == "sbp" + assert payment.status == 'SUCCESS' + assert payment.payment_id == 'PAY-1' + assert payment.payment_status == 'SUCCESS' + assert payment.payment_method == 'sbp' assert payment.is_paid is True - assert stub.status_calls == ["BILL-1"] - assert stub.payment_status_calls in ([], ["PAY-1"]) - assert result["remote_status"] == "SUCCESS" - assert result["remote_data"] and "bill_status" in result["remote_data"] + assert stub.status_calls == ['BILL-1'] + assert stub.payment_status_calls in ([], ['PAY-1']) + assert result['remote_status'] == 'SUCCESS' + assert result['remote_data'] and 'bill_status' in result['remote_data'] diff --git a/tests/services/test_payment_service_platega.py b/tests/services/test_payment_service_platega.py index 373cc8ca..42711d27 100644 --- a/tests/services/test_payment_service_platega.py +++ b/tests/services/test_payment_service_platega.py @@ -2,25 +2,26 @@ from __future__ import annotations +import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional -import sys +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 +import app.services.payment_service as payment_service_module +from app.config import settings +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -42,32 +43,32 @@ class StubPlategaService: self, *, configured: bool = True, - response: Optional[Dict[str, Any]] = None, - transaction_payload: Optional[Dict[str, Any]] = None, + response: dict[str, Any] | None = None, + transaction_payload: dict[str, Any] | None = None, ) -> None: self.is_configured = configured self.response = response or { - "transactionId": "trx-001", - "redirect": "https://platega.example/pay", - "status": "PENDING", - "expiresIn": 900, + 'transactionId': 'trx-001', + 'redirect': 'https://platega.example/pay', + 'status': 'PENDING', + 'expiresIn': 900, } self.transaction_payload = transaction_payload - self.calls: list[Dict[str, Any]] = [] - self.raise_error: Optional[Exception] = None + self.calls: list[dict[str, Any]] = [] + self.raise_error: Exception | None = None - async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + async def create_payment(self, **kwargs: Any) -> dict[str, Any] | None: self.calls.append(kwargs) if self.raise_error: raise self.raise_error return self.response - async def get_transaction(self, transaction_id: str) -> Optional[Dict[str, Any]]: - self.calls.append({"transaction_lookup": transaction_id}) + async def get_transaction(self, transaction_id: str) -> dict[str, Any] | None: + self.calls.append({'transaction_lookup': transaction_id}) return self.transaction_payload -def _make_service(stub: Optional[StubPlategaService]) -> PaymentService: +def _make_service(stub: StubPlategaService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.platega_service = stub @@ -81,72 +82,72 @@ def _make_service(stub: Optional[StubPlategaService]) -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_platega_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPlategaService() service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_platega_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment: if args: - captured_args["db_arg"] = args[0] + captured_args['db_arg'] = args[0] captured_args.update(kwargs) return DummyLocalPayment(payment_id=777) monkeypatch.setattr( payment_service_module, - "create_platega_payment", + 'create_platega_payment', fake_create_platega_payment, raising=False, ) - monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 10_000, raising=False) - monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 500_000, raising=False) - monkeypatch.setattr(settings, "PLATEGA_CURRENCY", "RUB", raising=False) - monkeypatch.setattr(settings, "PLATEGA_RETURN_URL", "https://return", raising=False) - monkeypatch.setattr(settings, "PLATEGA_FAILED_URL", "https://failed", raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MIN_AMOUNT_KOPEKS', 10_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MAX_AMOUNT_KOPEKS', 500_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_CURRENCY', 'RUB', raising=False) + monkeypatch.setattr(settings, 'PLATEGA_RETURN_URL', 'https://return', raising=False) + monkeypatch.setattr(settings, 'PLATEGA_FAILED_URL', 'https://failed', raising=False) result = await service.create_platega_payment( db=db, user_id=42, amount_kopeks=50_000, - description="Пополнение счёта", - language="ru", + description='Пополнение счёта', + language='ru', payment_method_code=10, ) assert result is not None - assert result["local_payment_id"] == 777 - assert result["transaction_id"] == "trx-001" - assert result["redirect_url"] == "https://platega.example/pay" - assert result["status"] == "PENDING" - assert "correlation_id" in result and len(result["correlation_id"]) == 32 - assert captured_args["user_id"] == 42 - assert captured_args["amount_kopeks"] == 50_000 - assert captured_args["payment_method_code"] == 10 - assert captured_args["metadata"]["selected_method"] == 10 - assert stub.calls and stub.calls[0]["payment_method"] == 10 - assert stub.calls[0]["amount"] == pytest.approx(500.0) - assert stub.calls[0]["currency"] == "RUB" - assert captured_args["metadata"]["language"] == "ru" + assert result['local_payment_id'] == 777 + assert result['transaction_id'] == 'trx-001' + assert result['redirect_url'] == 'https://platega.example/pay' + assert result['status'] == 'PENDING' + assert 'correlation_id' in result and len(result['correlation_id']) == 32 + assert captured_args['user_id'] == 42 + assert captured_args['amount_kopeks'] == 50_000 + assert captured_args['payment_method_code'] == 10 + assert captured_args['metadata']['selected_method'] == 10 + assert stub.calls and stub.calls[0]['payment_method'] == 10 + assert stub.calls[0]['amount'] == pytest.approx(500.0) + assert stub.calls[0]['currency'] == 'RUB' + assert captured_args['metadata']['language'] == 'ru' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_platega_payment_respects_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPlategaService() service = _make_service(stub) db = DummySession() - monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 20_000, raising=False) - monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 40_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MIN_AMOUNT_KOPEKS', 20_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MAX_AMOUNT_KOPEKS', 40_000, raising=False) too_low = await service.create_platega_payment( db=db, user_id=1, amount_kopeks=10_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', payment_method_code=2, ) assert too_low is None @@ -155,8 +156,8 @@ async def test_create_platega_payment_respects_limits_and_configuration(monkeypa db=db, user_id=1, amount_kopeks=100_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', payment_method_code=2, ) assert too_high is None @@ -166,49 +167,49 @@ async def test_create_platega_payment_respects_limits_and_configuration(monkeypa db=db, user_id=1, amount_kopeks=30_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', payment_method_code=2, ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_platega_payment_handles_service_errors(monkeypatch: pytest.MonkeyPatch) -> None: stub = StubPlategaService() - stub.raise_error = RuntimeError("network down") + stub.raise_error = RuntimeError('network down') service = _make_service(stub) db = DummySession() async def fake_create_platega_payment(*_: Any, **__: Any) -> DummyLocalPayment: - pytest.fail("local payment must not be created when Platega call fails") + pytest.fail('local payment must not be created when Platega call fails') monkeypatch.setattr( payment_service_module, - "create_platega_payment", + 'create_platega_payment', fake_create_platega_payment, raising=False, ) - monkeypatch.setattr(settings, "PLATEGA_MIN_AMOUNT_KOPEKS", 1_000, raising=False) - monkeypatch.setattr(settings, "PLATEGA_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MIN_AMOUNT_KOPEKS', 1_000, raising=False) + monkeypatch.setattr(settings, 'PLATEGA_MAX_AMOUNT_KOPEKS', 1_000_000, raising=False) result = await service.create_platega_payment( db=db, user_id=5, amount_kopeks=25_000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', payment_method_code=13, ) assert result is None - assert stub.calls and "payment_method" in stub.calls[0] + assert stub.calls and 'payment_method' in stub.calls[0] def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( settings, - "PLATEGA_ACTIVE_METHODS", - " 2,10, 11 ;12,13,13,invalid ", + 'PLATEGA_ACTIVE_METHODS', + ' 2,10, 11 ;12,13,13,invalid ', raising=False, ) @@ -218,7 +219,7 @@ def test_get_platega_active_methods_parses_and_filters(monkeypatch: pytest.Monke def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "PLATEGA_ACTIVE_METHODS", "", raising=False) + monkeypatch.setattr(settings, 'PLATEGA_ACTIVE_METHODS', '', raising=False) methods = settings.get_platega_active_methods() @@ -226,7 +227,7 @@ def test_get_platega_active_methods_returns_default(monkeypatch: pytest.MonkeyPa def test_platega_method_display_helpers() -> None: - assert settings.get_platega_method_display_name(10) == "Банковские карты (RUB)" - assert settings.get_platega_method_display_title(10) == "💳 Карты (RUB)" - assert settings.get_platega_method_display_name(999) == "Метод 999" - assert settings.get_platega_method_display_title(999) == "Platega 999" + assert settings.get_platega_method_display_name(10) == 'Банковские карты (RUB)' + assert settings.get_platega_method_display_title(10) == '💳 Карты (RUB)' + assert settings.get_platega_method_display_name(999) == 'Метод 999' + assert settings.get_platega_method_display_title(999) == 'Platega 999' diff --git a/tests/services/test_payment_service_stars.py b/tests/services/test_payment_service_stars.py index d0770ea5..1e39bd7a 100644 --- a/tests/services/test_payment_service_stars.py +++ b/tests/services/test_payment_service_stars.py @@ -1,45 +1,46 @@ """Тесты для Telegram Stars-сценариев внутри PaymentService.""" +import sys from datetime import datetime, timedelta from decimal import Decimal from pathlib import Path -from typing import Any, Dict, Optional -import sys +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.services.payment_service import PaymentService # noqa: E402 -from app.config import settings # noqa: E402 +from app.config import settings +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: """Ограничиваем anyio тесты только бэкендом asyncio.""" - return "asyncio" + return 'asyncio' class DummyBot: """Минимальная заглушка aiogram.Bot для тестов.""" def __init__(self) -> None: - self.calls: list[Dict[str, Any]] = [] - self.sent_messages: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] + self.sent_messages: list[dict[str, Any]] = [] async def create_invoice_link(self, **kwargs: Any) -> str: """Эмулируем создание платежной ссылки и сохраняем параметры вызова.""" self.calls.append(kwargs) - return "https://t.me/invoice/stars" + return 'https://t.me/invoice/stars' async def send_message(self, **kwargs: Any) -> None: """Фиксируем отправленные сообщения пользователю.""" self.sent_messages.append(kwargs) -def _make_service(bot: Optional[DummyBot]) -> PaymentService: +def _make_service(bot: DummyBot | None) -> PaymentService: """Создаёт экземпляр PaymentService без выполнения полного конструктора.""" service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = bot @@ -51,17 +52,17 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService: class DummySession: """Минимальная заглушка AsyncSession для проверки сценариев Stars.""" - def __init__(self, pending_subscription: "DummySubscription") -> None: + def __init__(self, pending_subscription: 'DummySubscription') -> None: self.pending_subscription = pending_subscription self.commits: int = 0 self.refreshed: list[Any] = [] async def execute(self, *_args: Any, **_kwargs: Any) -> Any: class _Result: - def __init__(self, subscription: "DummySubscription") -> None: + def __init__(self, subscription: 'DummySubscription') -> None: self._subscription = subscription - def scalar_one_or_none(self) -> "DummySubscription": + def scalar_one_or_none(self) -> 'DummySubscription': return self._subscription return _Result(self.pending_subscription) @@ -87,7 +88,7 @@ class DummySubscription: self.id = subscription_id self.traffic_limit_gb = traffic_limit_gb self.device_limit = device_limit - self.status = "pending" + self.status = 'pending' self.start_date = datetime(2024, 1, 1) self.end_date = self.start_date + timedelta(days=period_days) @@ -98,7 +99,7 @@ class DummyUser: def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None: self.id = user_id self.telegram_id = telegram_id - self.language = "ru" + self.language = 'ru' self.balance_kopeks = 0 self.has_made_first_topup = False self.promo_group = None @@ -122,7 +123,8 @@ class DummySubscriptionService: self.calls.append((db, subscription)) return object() -@pytest.mark.anyio("asyncio") + +@pytest.mark.anyio('asyncio') async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None: """Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1.""" bot = DummyBot() @@ -130,100 +132,100 @@ async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyP monkeypatch.setattr( type(settings), - "get_stars_rate", + 'get_stars_rate', lambda self: 70, raising=False, ) monkeypatch.setattr( type(settings), - "format_price", - lambda self, amount: f"{amount / 100:.0f}₽", + 'format_price', + lambda self, amount: f'{amount / 100:.0f}₽', raising=False, ) result = await service.create_stars_invoice( amount_kopeks=14000, - description="Пополнение", - payload="custom_payload", + description='Пополнение', + payload='custom_payload', ) - assert result == "https://t.me/invoice/stars" + assert result == 'https://t.me/invoice/stars' assert len(bot.calls) == 1 call = bot.calls[0] - assert call["title"] == "Пополнение баланса VPN" - assert call["payload"] == "custom_payload" - prices = call["prices"] + assert call['title'] == 'Пополнение баланса VPN' + assert call['payload'] == 'custom_payload' + prices = call['prices'] assert len(prices) == 1 assert prices[0].amount == 2 # 14000 коп. → 140 ₽ → 2 звезды при курсе 70 - assert "≈2 ⭐" in call["description"] + assert '≈2 ⭐' in call['description'] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_stars_invoice_enforces_minimum_star(monkeypatch: pytest.MonkeyPatch) -> None: """При слишком маленькой сумме минимум должен составлять 1 звезду.""" bot = DummyBot() service = _make_service(bot) - monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 500, raising=False) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False) + monkeypatch.setattr(type(settings), 'get_stars_rate', lambda self: 500, raising=False) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: amount, raising=False) await service.create_stars_invoice( amount_kopeks=50, # 0.5 ₽ при курсе 500 => <1 звезды - description="Микроплатёж", + description='Микроплатёж', ) - prices = bot.calls[0]["prices"] + prices = bot.calls[0]['prices'] assert prices[0].amount == 1 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_stars_invoice_uses_explicit_stars(monkeypatch: pytest.MonkeyPatch) -> None: """Если передано значение stars_amount, функция должна использовать его напрямую.""" bot = DummyBot() service = _make_service(bot) # При явном указании звёзд курс не запрашивается. - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: amount, raising=False) await service.create_stars_invoice( amount_kopeks=1000, - description="Оплата подписки", + description='Оплата подписки', stars_amount=5, ) - prices = bot.calls[0]["prices"] + prices = bot.calls[0]['prices'] assert prices[0].amount == 5 - assert "≈5 ⭐" in bot.calls[0]["description"] + assert '≈5 ⭐' in bot.calls[0]['description'] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_stars_invoice_rejects_invalid_rate(monkeypatch: pytest.MonkeyPatch) -> None: """Отрицательный или нулевой курс должен приводить к исключению.""" bot = DummyBot() service = _make_service(bot) - monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 0, raising=False) + monkeypatch.setattr(type(settings), 'get_stars_rate', lambda self: 0, raising=False) - with pytest.raises(ValueError, match="Stars rate must be positive"): + with pytest.raises(ValueError, match='Stars rate must be positive'): await service.create_stars_invoice( amount_kopeks=1000, - description="Пополнение", + description='Пополнение', ) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_stars_invoice_requires_bot() -> None: """Без экземпляра бота и stars_service функция должна отказывать.""" service = _make_service(bot=None) - with pytest.raises(ValueError, match="Bot instance required"): + with pytest.raises(ValueError, match='Bot instance required'): await service.create_stars_invoice( amount_kopeks=1000, - description="Пополнение", + description='Пополнение', ) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_stars_payment_simple_subscription_success( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -237,11 +239,11 @@ async def test_process_stars_payment_simple_subscription_success( user = DummyUser(user_id=900, telegram_id=123456) activated_subscription = DummySubscription(subscription_id=321, device_limit=2) - transaction_holder: Dict[str, DummyTransaction] = {} + transaction_holder: dict[str, DummyTransaction] = {} async def fake_create_transaction(**kwargs: Any) -> DummyTransaction: - transaction = DummyTransaction(external_id=kwargs.get("external_id", "")) - transaction_holder["value"] = transaction + transaction = DummyTransaction(external_id=kwargs.get('external_id', '')) + transaction_holder['value'] = transaction return transaction async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser: @@ -250,16 +252,14 @@ async def test_process_stars_payment_simple_subscription_success( async def fake_activate_pending_subscription( db: Any, user_id: int, - period_days: Optional[int] = None, + period_days: int | None = None, ) -> DummySubscription: activated_subscription.start_date = pending_subscription.start_date - activated_subscription.end_date = activated_subscription.start_date + timedelta( - days=period_days or 30 - ) + activated_subscription.end_date = activated_subscription.start_date + timedelta(days=period_days or 30) return activated_subscription subscription_service_stub = DummySubscriptionService() - admin_calls: list[Dict[str, Any]] = [] + admin_calls: list[dict[str, Any]] = [] class AdminNotificationStub: def __init__(self, _bot: Any) -> None: @@ -276,72 +276,72 @@ async def test_process_stars_payment_simple_subscription_success( ) -> None: admin_calls.append( { - "user": user_obj, - "subscription": subscription, - "transaction": transaction, - "period": period_days, - "was_trial": was_trial_conversion, + 'user': user_obj, + 'subscription': subscription, + 'transaction': transaction, + 'period': period_days, + 'was_trial': was_trial_conversion, } ) monkeypatch.setattr( - "app.services.payment.stars.create_transaction", + 'app.services.payment.stars.create_transaction', fake_create_transaction, raising=False, ) monkeypatch.setattr( - "app.services.payment.stars.get_user_by_id", + 'app.services.payment.stars.get_user_by_id', fake_get_user_by_id, raising=False, ) monkeypatch.setattr( - "app.database.crud.subscription.activate_pending_subscription", + 'app.database.crud.subscription.activate_pending_subscription', fake_activate_pending_subscription, raising=False, ) monkeypatch.setattr( - "app.services.subscription_service.SubscriptionService", + 'app.services.subscription_service.SubscriptionService', lambda: subscription_service_stub, raising=False, ) monkeypatch.setattr( - "app.services.admin_notification_service.AdminNotificationService", + 'app.services.admin_notification_service.AdminNotificationService', AdminNotificationStub, raising=False, ) monkeypatch.setattr( type(settings), - "format_price", - lambda self, amount: f"{amount / 100:.0f}₽", + 'format_price', + lambda self, amount: f'{amount / 100:.0f}₽', raising=False, ) monkeypatch.setattr( settings, - "SIMPLE_SUBSCRIPTION_PERIOD_DAYS", + 'SIMPLE_SUBSCRIPTION_PERIOD_DAYS', 30, raising=False, ) monkeypatch.setattr( - "app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars", - lambda stars: Decimal("100"), + 'app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars', + lambda stars: Decimal(100), raising=False, ) - payload = f"simple_sub_{user.id}_{pending_subscription.id}_30" + payload = f'simple_sub_{user.id}_{pending_subscription.id}_30' result = await service.process_stars_payment( db=db, user_id=user.id, stars_amount=5, payload=payload, - telegram_payment_charge_id="charge12345", + telegram_payment_charge_id='charge12345', ) assert result is True - assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки" + assert user.balance_kopeks == 0, 'Баланс не должен меняться при оплате подписки' assert subscription_service_stub.calls == [(db, activated_subscription)] assert len(admin_calls) == 1 - assert admin_calls[0]["subscription"] is activated_subscription - assert admin_calls[0]["period"] == 30 - assert bot.sent_messages, "Пользователь должен получить уведомление" - assert "Подписка успешно активирована" in bot.sent_messages[0]["text"] - assert transaction_holder["value"].external_id == "charge12345" + assert admin_calls[0]['subscription'] is activated_subscription + assert admin_calls[0]['period'] == 30 + assert bot.sent_messages, 'Пользователь должен получить уведомление' + assert 'Подписка успешно активирована' in bot.sent_messages[0]['text'] + assert transaction_holder['value'].external_id == 'charge12345' diff --git a/tests/services/test_payment_service_tribute.py b/tests/services/test_payment_service_tribute.py index ff6f46fd..ae4f6778 100644 --- a/tests/services/test_payment_service_tribute.py +++ b/tests/services/test_payment_service_tribute.py @@ -1,23 +1,24 @@ """Тесты Tribute-платежей PaymentService.""" -from pathlib import Path -import sys -import hmac import hashlib +import hmac +import sys +from pathlib import Path import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.services.payment_service import PaymentService # noqa: E402 -from app.config import settings # noqa: E402 +from app.config import settings +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' def _make_service() -> PaymentService: @@ -32,49 +33,49 @@ def _make_service() -> PaymentService: return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_tribute_payment_requires_enabled(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service() - monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', False, raising=False) with pytest.raises(ValueError): - await service.create_tribute_payment(1000, 1, "Пополнение") + await service.create_tribute_payment(1000, 1, 'Пополнение') -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_tribute_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service() - monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', 'https://example.com', raising=False) result = await service.create_tribute_payment( amount_kopeks=15000, user_id=5, - description="Оплата подписки", + description='Оплата подписки', ) - assert "https://tribute.ru/pay" in result - assert "amount=15000" in result - assert "user=5" in result + assert 'https://tribute.ru/pay' in result + assert 'amount=15000' in result + assert 'user=5' in result def test_verify_tribute_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service() - monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "secret", raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_API_KEY', 'secret', raising=False) - payload = {"payment": "ok"} + payload = {'payment': 'ok'} signature = hmac.new( - b"secret", + b'secret', str(payload).encode(), hashlib.sha256, ).hexdigest() assert service.verify_tribute_webhook(payload, signature) is True - assert service.verify_tribute_webhook(payload, "invalid") is False + assert service.verify_tribute_webhook(payload, 'invalid') is False def test_verify_tribute_webhook_returns_false_without_key(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service() - monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "", raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_API_KEY', '', raising=False) - assert service.verify_tribute_webhook({}, "signature") is False + assert service.verify_tribute_webhook({}, 'signature') is False diff --git a/tests/services/test_payment_service_wata.py b/tests/services/test_payment_service_wata.py index 8b6688a8..f3612796 100644 --- a/tests/services/test_payment_service_wata.py +++ b/tests/services/test_payment_service_wata.py @@ -2,26 +2,27 @@ from __future__ import annotations -from datetime import datetime, timezone -from pathlib import Path import sys -from typing import Any, Dict, Optional +from datetime import datetime +from pathlib import Path +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 -from app.services.wata_service import WataService # noqa: E402 +import app.services.payment_service as payment_service_module +from app.config import settings +from app.services.payment_service import PaymentService +from app.services.wata_service import WataService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummySession: @@ -39,11 +40,11 @@ class DummyLocalPayment: class StubWataService: - def __init__(self, response: Optional[Dict[str, Any]]) -> None: + def __init__(self, response: dict[str, Any] | None) -> None: self.response = response - self.calls: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] - async def create_payment_link(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + async def create_payment_link(self, **kwargs: Any) -> dict[str, Any] | None: self.calls.append(kwargs) return self.response @@ -52,20 +53,20 @@ class DummyWataPayment: def __init__(self) -> None: self.id = 1 self.user_id = 42 - self.payment_link_id = "link-123" - self.order_id = "order-123" + self.payment_link_id = 'link-123' + self.order_id = 'order-123' self.amount_kopeks = 15_000 - self.currency = "RUB" - self.description = "Пополнение" - self.status = "Opened" + self.currency = 'RUB' + self.description = 'Пополнение' + self.status = 'Opened' self.is_paid = False - self.metadata_json: Dict[str, Any] = {} - self.transaction_id: Optional[int] = None - self.callback_payload: Optional[Dict[str, Any]] = None - self.terminal_public_id: Optional[str] = None + self.metadata_json: dict[str, Any] = {} + self.transaction_id: int | None = None + self.callback_payload: dict[str, Any] | None = None + self.terminal_public_id: str | None = None -def _make_service(stub: Optional[StubWataService]) -> PaymentService: +def _make_service(stub: StubWataService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.wata_service = stub @@ -81,77 +82,77 @@ def _make_service(stub: Optional[StubWataService]) -> PaymentService: def test_wata_service_format_datetime_accepts_naive_utc() -> None: value = datetime(2024, 5, 20, 12, 30, 0) formatted = WataService._format_datetime(value) - assert formatted == "2024-05-20T12:30:00Z" + assert formatted == '2024-05-20T12:30:00Z' def test_wata_service_parse_datetime_returns_naive_utc() -> None: - parsed = WataService._parse_datetime("2024-05-20T12:30:00Z") + parsed = WataService._parse_datetime('2024-05-20T12:30:00Z') assert parsed == datetime(2024, 5, 20, 12, 30, 0) assert parsed.tzinfo is None - parsed_with_offset = WataService._parse_datetime("2024-05-20T15:30:00+03:00") + parsed_with_offset = WataService._parse_datetime('2024-05-20T15:30:00+03:00') assert parsed_with_offset == datetime(2024, 5, 20, 12, 30, 0) assert parsed_with_offset.tzinfo is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_wata_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: response = { - "id": "123e4567-e89b-12d3-a456-426614174000", - "url": "https://wata.example/link", - "status": "Opened", - "type": "OneTime", - "terminalPublicId": "terminal-id", - "successRedirectUrl": "https://example.com/success", - "failRedirectUrl": "https://example.com/fail", - "expirationDateTime": "2030-01-01T00:00:00Z", + 'id': '123e4567-e89b-12d3-a456-426614174000', + 'url': 'https://wata.example/link', + 'status': 'Opened', + 'type': 'OneTime', + 'terminalPublicId': 'terminal-id', + 'successRedirectUrl': 'https://example.com/success', + 'failRedirectUrl': 'https://example.com/fail', + 'expirationDateTime': '2030-01-01T00:00:00Z', } stub = StubWataService(response) service = _make_service(stub) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_wata_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) return DummyLocalPayment(payment_id=777) - monkeypatch.setattr(payment_service_module, "create_wata_payment", fake_create_wata_payment, raising=False) - monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 5000, raising=False) - monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 500_000, raising=False) + monkeypatch.setattr(payment_service_module, 'create_wata_payment', fake_create_wata_payment, raising=False) + monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 5000, raising=False) + monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 500_000, raising=False) result = await service.create_wata_payment( db=db, user_id=101, amount_kopeks=15000, - description="Пополнение", - language="ru", + description='Пополнение', + language='ru', ) assert result is not None - assert result["local_payment_id"] == 777 - assert result["payment_link_id"] == response["id"] - assert result["payment_url"] == response["url"] - assert captured_args["user_id"] == 101 - assert captured_args["amount_kopeks"] == 15000 - assert captured_args["payment_link_id"] == response["id"] - assert stub.calls and stub.calls[0]["amount_kopeks"] == 15000 + assert result['local_payment_id'] == 777 + assert result['payment_link_id'] == response['id'] + assert result['payment_url'] == response['url'] + assert captured_args['user_id'] == 101 + assert captured_args['amount_kopeks'] == 15000 + assert captured_args['payment_link_id'] == response['id'] + assert stub.calls and stub.calls[0]['amount_kopeks'] == 15000 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_wata_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None: - stub = StubWataService({"id": "link"}) + stub = StubWataService({'id': 'link'}) service = _make_service(stub) db = DummySession() - monkeypatch.setattr(settings, "WATA_MIN_AMOUNT_KOPEKS", 10_000, raising=False) - monkeypatch.setattr(settings, "WATA_MAX_AMOUNT_KOPEKS", 20_000, raising=False) + monkeypatch.setattr(settings, 'WATA_MIN_AMOUNT_KOPEKS', 10_000, raising=False) + monkeypatch.setattr(settings, 'WATA_MAX_AMOUNT_KOPEKS', 20_000, raising=False) too_low = await service.create_wata_payment( db=db, user_id=1, amount_kopeks=5_000, - description="Пополнение", + description='Пополнение', ) assert too_low is None @@ -159,13 +160,13 @@ async def test_create_wata_payment_respects_amount_limits(monkeypatch: pytest.Mo db=db, user_id=1, amount_kopeks=25_000, - description="Пополнение", + description='Пополнение', ) assert too_high is None assert not stub.calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_wata_payment_returns_none_without_service() -> None: service = _make_service(None) db = DummySession() @@ -174,17 +175,17 @@ async def test_create_wata_payment_returns_none_without_service() -> None: db=db, user_id=5, amount_kopeks=10_000, - description="Пополнение", + description='Пополнение', ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service(None) db = DummySession() payment = DummyWataPayment() - update_kwargs: Dict[str, Any] = {} + update_kwargs: dict[str, Any] = {} link_lookup_called = False async def fake_get_by_order_id(db_arg: Any, order_id: str) -> DummyWataPayment: @@ -192,7 +193,7 @@ async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPat assert order_id == payment.order_id return payment - async def fake_get_by_link_id(*_: Any, **__: Any) -> Optional[DummyWataPayment]: + async def fake_get_by_link_id(*_: Any, **__: Any) -> DummyWataPayment | None: nonlocal link_lookup_called link_lookup_called = True return None @@ -205,57 +206,57 @@ async def test_process_wata_webhook_updates_status(monkeypatch: pytest.MonkeyPat ) -> DummyWataPayment: assert db_arg is db update_kwargs.update(kwargs) - if "status" in kwargs: - payment.status = kwargs["status"] - if "is_paid" in kwargs: - payment.is_paid = kwargs["is_paid"] - if "metadata" in kwargs: - payment.metadata_json = kwargs["metadata"] - if "callback_payload" in kwargs: - payment.callback_payload = kwargs["callback_payload"] - if "terminal_public_id" in kwargs: - payment.terminal_public_id = kwargs["terminal_public_id"] + if 'status' in kwargs: + payment.status = kwargs['status'] + if 'is_paid' in kwargs: + payment.is_paid = kwargs['is_paid'] + if 'metadata' in kwargs: + payment.metadata_json = kwargs['metadata'] + if 'callback_payload' in kwargs: + payment.callback_payload = kwargs['callback_payload'] + if 'terminal_public_id' in kwargs: + payment.terminal_public_id = kwargs['terminal_public_id'] return payment monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_order_id", + 'get_wata_payment_by_order_id', fake_get_by_order_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_link_id", + 'get_wata_payment_by_link_id', fake_get_by_link_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "update_wata_payment_status", + 'update_wata_payment_status', fake_update_status, raising=False, ) payload = { - "orderId": payment.order_id, - "transactionStatus": "Declined", - "terminalPublicId": "terminal-001", + 'orderId': payment.order_id, + 'transactionStatus': 'Declined', + 'terminalPublicId': 'terminal-001', } processed = await service.process_wata_webhook(db, payload) assert processed is True assert link_lookup_called is False - assert payment.status == "Declined" + assert payment.status == 'Declined' assert payment.is_paid is False - assert payment.metadata_json.get("last_webhook") == payload + assert payment.metadata_json.get('last_webhook') == payload assert payment.callback_payload == payload - assert payment.terminal_public_id == "terminal-001" - assert update_kwargs["status"] == "Declined" - assert update_kwargs["is_paid"] is False + assert payment.terminal_public_id == 'terminal-001' + assert update_kwargs['status'] == 'Declined' + assert update_kwargs['is_paid'] is False -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service(None) db = DummySession() @@ -271,18 +272,18 @@ async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPat payment: DummyWataPayment, **kwargs: Any, ) -> DummyWataPayment: - if "metadata" in kwargs: - payment.metadata_json = kwargs["metadata"] - if "callback_payload" in kwargs: - payment.callback_payload = kwargs["callback_payload"] - if "status" in kwargs: - payment.status = kwargs["status"] + if 'metadata' in kwargs: + payment.metadata_json = kwargs['metadata'] + if 'callback_payload' in kwargs: + payment.callback_payload = kwargs['callback_payload'] + if 'status' in kwargs: + payment.status = kwargs['status'] return payment async def fake_finalize( db_arg: Any, payment_arg: DummyWataPayment, - payload_arg: Dict[str, Any], + payload_arg: dict[str, Any], ) -> DummyWataPayment: nonlocal finalize_called finalize_called = True @@ -291,33 +292,33 @@ async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPat monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_order_id", + 'get_wata_payment_by_order_id', fake_get_by_order_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_link_id", + 'get_wata_payment_by_link_id', lambda *args, **kwargs: None, raising=False, ) monkeypatch.setattr( payment_service_module, - "update_wata_payment_status", + 'update_wata_payment_status', fake_update_status, raising=False, ) monkeypatch.setattr( service, - "_finalize_wata_payment", + '_finalize_wata_payment', fake_finalize, raising=False, ) payload = { - "orderId": payment.order_id, - "transactionStatus": "Paid", - "transactionId": "tx-001", + 'orderId': payment.order_id, + 'transactionStatus': 'Paid', + 'transactionId': 'tx-001', } processed = await service.process_wata_webhook(db, payload) @@ -325,10 +326,10 @@ async def test_process_wata_webhook_finalizes_paid(monkeypatch: pytest.MonkeyPat assert processed is True assert finalize_called is True assert payment.is_paid is True - assert payment.metadata_json.get("last_webhook") == payload + assert payment.metadata_json.get('last_webhook') == payload -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_wata_webhook_returns_false_when_payment_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -342,30 +343,30 @@ async def test_process_wata_webhook_returns_false_when_payment_missing( return None async def fail_update(*_: Any, **__: Any) -> None: - pytest.fail("update_wata_payment_status should not be called") + pytest.fail('update_wata_payment_status should not be called') monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_order_id", + 'get_wata_payment_by_order_id', fake_get_by_order_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "get_wata_payment_by_link_id", + 'get_wata_payment_by_link_id', fake_get_by_link_id, raising=False, ) monkeypatch.setattr( payment_service_module, - "update_wata_payment_status", + 'update_wata_payment_status', fail_update, raising=False, ) payload = { - "orderId": "missing-order", - "transactionStatus": "Paid", + 'orderId': 'missing-order', + 'transactionStatus': 'Paid', } processed = await service.process_wata_webhook(db, payload) diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 0b90e859..e44d0f0f 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -2,33 +2,34 @@ from __future__ import annotations -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace, ModuleType -from typing import Any, Dict, Optional import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock import pytest -from unittest.mock import AsyncMock + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -import app.services.payment.cryptobot as cryptobot_module # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 -from app.database.models import PaymentMethod # noqa: E402 -from app.config import settings # noqa: E402 +import app.services.payment.cryptobot as cryptobot_module +import app.services.payment_service as payment_service_module +from app.config import settings +from app.database.models import PaymentMethod +from app.services.payment_service import PaymentService class DummyBot: def __init__(self) -> None: - self.sent_messages: list[Dict[str, Any]] = [] + self.sent_messages: list[dict[str, Any]] = [] - async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - бизнес-логика тестируется через вызов - self.sent_messages.append({"args": args, "kwargs": kwargs}) + async def send_message( + self, *args: Any, **kwargs: Any + ) -> None: # pragma: no cover - бизнес-логика тестируется через вызов + self.sent_messages.append({'args': args, 'kwargs': kwargs}) class FakeScalarResult: @@ -43,14 +44,14 @@ class FakeScalarResult: def one(self) -> Any: # pragma: no cover - утилитарный метод if len(self._items) != 1: - raise ValueError("Expected exactly one result") + raise ValueError('Expected exactly one result') return self._items[0] def one_or_none(self) -> Any: # pragma: no cover - утилитарный метод if not self._items: return None if len(self._items) > 1: - raise ValueError("Expected zero or one result") + raise ValueError('Expected zero or one result') return self._items[0] def __iter__(self): # pragma: no cover - утилитарный метод @@ -77,7 +78,7 @@ class FakeResult: if not items: return None if len(items) > 1: - raise ValueError("Expected zero or one result") + raise ValueError('Expected zero or one result') return items[0] def first(self) -> Any: # pragma: no cover - утилитарный метод @@ -92,7 +93,7 @@ class FakeResult: if not items: return None if len(items) > 1: - raise ValueError("Expected zero or one result") + raise ValueError('Expected zero or one result') return items[0] def scalars(self) -> FakeScalarResult: # pragma: no cover - утилитарный метод @@ -148,19 +149,17 @@ def _make_service(bot: DummyBot) -> PaymentService: @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' -@pytest.mark.anyio("asyncio") -@pytest.mark.parametrize("status_field", ["payment_status", "status", "paymentStatus"]) -async def test_process_mulenpay_callback_success( - monkeypatch: pytest.MonkeyPatch, status_field: str -) -> None: +@pytest.mark.anyio('asyncio') +@pytest.mark.parametrize('status_field', ['payment_status', 'status', 'paymentStatus']) +async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch, status_field: str) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - uuid="mulen_uuid", + uuid='mulen_uuid', mulen_payment_id=123, amount_kopeks=5000, user_id=42, @@ -174,30 +173,30 @@ async def test_process_mulenpay_callback_success( async def fake_get_by_id(db, mid): return None - monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_uuid", fake_get_by_uuid) - monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_mulen_id", fake_get_by_id) + monkeypatch.setattr(payment_service_module, 'get_mulenpay_payment_by_uuid', fake_get_by_uuid) + monkeypatch.setattr(payment_service_module, 'get_mulenpay_payment_by_mulen_id', fake_get_by_id) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) return SimpleNamespace(id=777, **kwargs) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) updated_status: dict[str, Any] = {} async def fake_update_status(db, payment=None, status=None, **kwargs): payment.status = status - payment.is_paid = status == "success" - updated_status.update({"status": status, "kwargs": kwargs}) + payment.is_paid = status == 'success' + updated_status.update({'status': status, 'kwargs': kwargs}) - monkeypatch.setattr(payment_service_module, "update_mulenpay_payment_status", fake_update_status) + monkeypatch.setattr(payment_service_module, 'update_mulenpay_payment_status', fake_update_status) async def fake_link(db, payment=None, transaction_id=None): payment.transaction_id = transaction_id - monkeypatch.setattr(payment_service_module, "link_mulenpay_payment_to_transaction", fake_link) + monkeypatch.setattr(payment_service_module, 'link_mulenpay_payment_to_transaction', fake_link) user = SimpleNamespace( id=42, @@ -209,16 +208,16 @@ async def test_process_mulenpay_callback_success( referred_by_id=None, referrer=None, ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_mock) class DummyAdminService: def __init__(self, bot): @@ -229,40 +228,44 @@ async def test_process_mulenpay_callback_success( self.calls.append((args, kwargs)) admin_service = DummyAdminService(bot) - monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: admin_service)) + monkeypatch.setitem( + sys.modules, + 'app.services.admin_notification_service', + SimpleNamespace(AdminNotificationService=lambda bot: admin_service), + ) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "uuid": "mulen_uuid", - "id": 123, - "amount": "50.00", + 'uuid': 'mulen_uuid', + 'id': 123, + 'amount': '50.00', } - payload[status_field] = "success" + payload[status_field] = 'success' result = await service.process_mulenpay_callback(fake_session, payload) assert result is True - assert transactions and transactions[0]["user_id"] == 42 + assert transactions and transactions[0]['user_id'] == 42 assert payment.transaction_id == 777 - assert updated_status["status"] == "success" + assert updated_status['status'] == 'success' assert user.balance_kopeks == 5000 assert fake_session.commits >= 1 assert bot.sent_messages # сообщение пользователю отправлено -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - invoice_id="inv_1", + invoice_id='inv_1', user_id=7, - status="pending", + status='pending', transaction_id=None, - amount="12.50", - asset="USDT", + amount='12.50', + asset='USDT', amount_float=12.5, ) @@ -277,13 +280,13 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch async def fake_link(db, invoice_id, transaction_id): payment.transaction_id = transaction_id - fake_cryptobot_module = ModuleType("app.database.crud.cryptobot") + fake_cryptobot_module = ModuleType('app.database.crud.cryptobot') fake_cryptobot_module.get_cryptobot_payment_by_invoice_id = fake_get_crypto fake_cryptobot_module.update_cryptobot_payment_status = fake_update_status fake_cryptobot_module.link_cryptobot_payment_to_transaction = fake_link - monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module) + monkeypatch.setitem(sys.modules, 'app.database.crud.cryptobot', fake_cryptobot_module) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] created_transaction: SimpleNamespace | None = None async def fake_create_transaction(db, **kwargs): @@ -292,15 +295,15 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch created_transaction = SimpleNamespace(id=888, **kwargs) return created_transaction - fake_transaction_module = ModuleType("app.database.crud.transaction") + fake_transaction_module = ModuleType('app.database.crud.transaction') fake_transaction_module.create_transaction = fake_create_transaction async def fake_get_transaction_by_id(db, transaction_id): return created_transaction fake_transaction_module.get_transaction_by_id = fake_get_transaction_by_id - monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setitem(sys.modules, 'app.database.crud.transaction', fake_transaction_module) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=7, @@ -312,19 +315,19 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch referred_by_id=None, referrer=None, ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user_crypto(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user_crypto) - fake_user_module = ModuleType("app.database.crud.user") + fake_user_module = ModuleType('app.database.crud.user') fake_user_module.get_user_by_id = fake_get_user_crypto - monkeypatch.setitem(sys.modules, "app.database.crud.user", fake_user_module) + monkeypatch.setitem(sys.modules, 'app.database.crud.user', fake_user_module) referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_crypto) admin_calls: list[Any] = [] @@ -335,7 +338,11 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch async def send_balance_topup_notification(self, *args, **kwargs): admin_calls.append((args, kwargs)) - monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot))) + monkeypatch.setitem( + sys.modules, + 'app.services.admin_notification_service', + SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot)), + ) class DummyAsyncSession: async def __aenter__(self): @@ -347,43 +354,43 @@ async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch async def rollback(self): # pragma: no cover - defensive stub return None - monkeypatch.setattr(cryptobot_module, "AsyncSessionLocal", lambda: DummyAsyncSession()) - monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0)) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(cryptobot_module, 'AsyncSessionLocal', lambda: DummyAsyncSession()) + monkeypatch.setattr(payment_service_module.currency_converter, 'usd_to_rub', AsyncMock(return_value=140.0)) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "update_type": "invoice_paid", - "payload": { - "invoice_id": "inv_1", - "paid_at": "2024-01-01T12:00:00Z", + 'update_type': 'invoice_paid', + 'payload': { + 'invoice_id': 'inv_1', + 'paid_at': '2024-01-01T12:00:00Z', }, } result = await service.process_cryptobot_webhook(fake_session, payload) assert result is True - assert transactions and transactions[0]["amount_kopeks"] == 14000 + assert transactions and transactions[0]['amount_kopeks'] == 14000 assert user.balance_kopeks == 14000 assert payment.transaction_id == 888 assert bot.sent_messages assert admin_calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - uuid="heleket-uuid", - order_id="heleket-order", + uuid='heleket-uuid', + order_id='heleket-order', user_id=77, - amount="150.00", + amount='150.00', amount_float=150.0, amount_kopeks=15000, - status="check", + status='check', payer_amount=None, payer_currency=None, exchange_rate=None, @@ -432,20 +439,20 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) payment.transaction_id = transaction_id return payment - heleket_module = ModuleType("app.database.crud.heleket") + heleket_module = ModuleType('app.database.crud.heleket') heleket_module.get_heleket_payment_by_uuid = fake_get_by_uuid heleket_module.get_heleket_payment_by_order_id = fake_get_by_order heleket_module.update_heleket_payment = fake_update heleket_module.link_heleket_payment_to_transaction = fake_link - monkeypatch.setitem(sys.modules, "app.database.crud.heleket", heleket_module) + monkeypatch.setitem(sys.modules, 'app.database.crud.heleket', heleket_module) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) return SimpleNamespace(id=321, **kwargs) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=77, @@ -456,20 +463,20 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) subscription=None, referred_by_id=None, referrer=None, - language="ru", + language='ru', ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user if user_id == user.id else None - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr("app.services.payment.heleket.format_referrer_info", lambda u: "") + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr('app.services.payment.heleket.format_referrer_info', lambda u: '') - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_stub = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_stub) admin_calls: list[Any] = [] @@ -482,27 +489,27 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setitem( sys.modules, - "app.services.admin_notification_service", + 'app.services.admin_notification_service', SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), ) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "uuid": "heleket-uuid", - "status": "paid", - "payer_amount": "2.50", - "payer_currency": "USDT", - "discount_percent": -5, - "payer_amount_exchange_rate": "0.0166", - "paid_at": "2024-01-02T12:00:00Z", - "url": "https://pay.example", + 'uuid': 'heleket-uuid', + 'status': 'paid', + 'payer_amount': '2.50', + 'payer_currency': 'USDT', + 'discount_percent': -5, + 'payer_amount_exchange_rate': '0.0166', + 'paid_at': '2024-01-02T12:00:00Z', + 'url': 'https://pay.example', } result = await service.process_heleket_webhook(fake_session, payload) assert result is True - assert transactions and transactions[0]["payment_method"] == PaymentMethod.HELEKET + assert transactions and transactions[0]['payment_method'] == PaymentMethod.HELEKET assert payment.transaction_id == 321 assert user.balance_kopeks == 15000 assert user.has_made_first_topup is True @@ -511,17 +518,18 @@ async def test_process_heleket_webhook_success(monkeypatch: pytest.MonkeyPatch) assert admin_calls referral_stub.process_referral_topup.assert_awaited_once() -@pytest.mark.anyio("asyncio") + +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - yookassa_payment_id="yk_123", + yookassa_payment_id='yk_123', user_id=21, amount_kopeks=10000, transaction_id=None, - status="pending", + status='pending', is_paid=False, ) @@ -537,24 +545,24 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) async def fake_link(db, payment_id, transaction_id): payment.transaction_id = transaction_id - yk_module = ModuleType("app.database.crud.yookassa") + yk_module = ModuleType('app.database.crud.yookassa') yk_module.get_yookassa_payment_by_id = fake_get_payment yk_module.update_yookassa_payment_status = fake_update yk_module.link_yookassa_payment_to_transaction = fake_link - monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module) + monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', yk_module) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) return SimpleNamespace(id=999, **kwargs) - trx_module = ModuleType("app.database.crud.transaction") + trx_module = ModuleType('app.database.crud.transaction') trx_module.create_transaction = fake_create_transaction - monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setitem(sys.modules, 'app.database.crud.transaction', trx_module) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=21, @@ -566,16 +574,16 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) referred_by_id=None, referrer=None, ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_mock) admin_calls: list[Any] = [] @@ -586,22 +594,26 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) async def send_balance_topup_notification(self, *args, **kwargs): admin_calls.append((args, kwargs)) - monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) + monkeypatch.setitem( + sys.modules, + 'app.services.admin_notification_service', + SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + ) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "object": { - "id": "yk_123", - "status": "succeeded", - "paid": True, - "payment_method": {"type": "bank_card"}, + 'object': { + 'id': 'yk_123', + 'status': 'succeeded', + 'paid': True, + 'payment_method': {'type': 'bank_card'}, } } result = await service.process_yookassa_webhook(fake_session, payload) assert result is True - assert transactions and transactions[0]["amount_kopeks"] == 10000 + assert transactions and transactions[0]['amount_kopeks'] == 10000 assert payment.transaction_id == 999 assert payment.is_paid is True assert user.balance_kopeks == 10000 @@ -609,17 +621,17 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) assert admin_calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - yookassa_payment_id="yk_789", + yookassa_payment_id='yk_789', user_id=42, amount_kopeks=20000, transaction_id=None, - status="pending", + status='pending', is_paid=False, ) @@ -636,19 +648,19 @@ async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.M async def fake_link(db, payment_id, transaction_id): payment.transaction_id = transaction_id - yk_module = ModuleType("app.database.crud.yookassa") + yk_module = ModuleType('app.database.crud.yookassa') yk_module.get_yookassa_payment_by_id = fake_get_payment yk_module.update_yookassa_payment_status = fake_update yk_module.link_yookassa_payment_to_transaction = fake_link - monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module) + monkeypatch.setitem(sys.modules, 'app.database.crud.yookassa', yk_module) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) return SimpleNamespace(id=555, **kwargs) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=42, @@ -664,11 +676,11 @@ async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.M async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_mock) admin_calls: list[Any] = [] @@ -681,55 +693,55 @@ async def test_process_yookassa_webhook_uses_remote_status(monkeypatch: pytest.M monkeypatch.setitem( sys.modules, - "app.services.admin_notification_service", + 'app.services.admin_notification_service', SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), ) service.build_topup_success_keyboard = AsyncMock(return_value=None) remote_payload = { - "id": "yk_789", - "status": "succeeded", - "paid": True, - "amount_value": 200.0, - "amount_currency": "rub", - "payment_method_type": "bank_card", - "refundable": True, + 'id': 'yk_789', + 'status': 'succeeded', + 'paid': True, + 'amount_value': 200.0, + 'amount_currency': 'rub', + 'payment_method_type': 'bank_card', + 'refundable': True, } get_info_mock = AsyncMock(return_value=remote_payload) service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock) payload = { - "object": { - "id": "yk_789", - "status": "pending", - "paid": False, + 'object': { + 'id': 'yk_789', + 'status': 'pending', + 'paid': False, } } result = await service.process_yookassa_webhook(fake_session, payload) assert result is True - assert payment.status == "succeeded" + assert payment.status == 'succeeded' assert payment.is_paid is True - assert transactions and transactions[0]["amount_kopeks"] == 20000 + assert transactions and transactions[0]['amount_kopeks'] == 20000 assert payment.transaction_id == 555 - get_info_mock.assert_awaited_once_with("yk_789") + get_info_mock.assert_awaited_once_with('yk_789') assert admin_calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) fake_session = FakeSession() payment = SimpleNamespace( - yookassa_payment_id="yk_cancel", + yookassa_payment_id='yk_cancel', user_id=77, amount_kopeks=5000, transaction_id=None, - status="pending", + status='pending', is_paid=False, captured_at=None, payment_method_type=None, @@ -740,41 +752,41 @@ async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest monkeypatch.setattr( payment_service_module, - "get_yookassa_payment_by_id", + 'get_yookassa_payment_by_id', fake_get_payment, ) get_info_mock = AsyncMock( return_value={ - "id": "yk_cancel", - "status": "canceled", - "paid": False, - "amount_value": 50.0, - "amount_currency": "RUB", + 'id': 'yk_cancel', + 'status': 'canceled', + 'paid': False, + 'amount_value': 50.0, + 'amount_currency': 'RUB', } ) service.yookassa_service = SimpleNamespace(get_payment_info=get_info_mock) payload = { - "object": { - "id": "yk_cancel", - "status": "pending", - "paid": False, + 'object': { + 'id': 'yk_cancel', + 'status': 'pending', + 'paid': False, } } result = await service.process_yookassa_webhook(fake_session, payload) assert result is True - assert payment.status == "canceled" + assert payment.status == 'canceled' assert payment.is_paid is False assert fake_session.commits == 1 assert fake_session.refreshed and fake_session.refreshed[0] is payment assert bot.sent_messages == [] - get_info_mock.assert_awaited_once_with("yk_cancel") + get_info_mock.assert_awaited_once_with('yk_cancel') -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -783,13 +795,13 @@ async def test_process_yookassa_webhook_restores_missing_payment( fake_session = FakeSession() restored_payment = SimpleNamespace( - yookassa_payment_id="yk_456", + yookassa_payment_id='yk_456', user_id=21, amount_kopeks=0, - status="pending", + status='pending', is_paid=False, transaction_id=None, - description="", + description='', payment_method_type=None, confirmation_url=None, metadata_json=None, @@ -797,25 +809,25 @@ async def test_process_yookassa_webhook_restores_missing_payment( refundable=False, ) - get_calls = {"count": 0} + get_calls = {'count': 0} async def fake_get_payment(db, payment_id): - get_calls["count"] += 1 - if get_calls["count"] == 1: + get_calls['count'] += 1 + if get_calls['count'] == 1: return None return restored_payment async def fake_create_payment(**kwargs: Any): - restored_payment.user_id = kwargs["user_id"] - restored_payment.amount_kopeks = kwargs["amount_kopeks"] - restored_payment.status = kwargs["status"] - restored_payment.description = kwargs["description"] - restored_payment.payment_method_type = kwargs["payment_method_type"] - restored_payment.confirmation_url = kwargs["confirmation_url"] - restored_payment.metadata_json = kwargs["metadata_json"] - restored_payment.test_mode = kwargs["test_mode"] - restored_payment.yookassa_payment_id = kwargs["yookassa_payment_id"] - restored_payment.yookassa_created_at = kwargs["yookassa_created_at"] + restored_payment.user_id = kwargs['user_id'] + restored_payment.amount_kopeks = kwargs['amount_kopeks'] + restored_payment.status = kwargs['status'] + restored_payment.description = kwargs['description'] + restored_payment.payment_method_type = kwargs['payment_method_type'] + restored_payment.confirmation_url = kwargs['confirmation_url'] + restored_payment.metadata_json = kwargs['metadata_json'] + restored_payment.test_mode = kwargs['test_mode'] + restored_payment.yookassa_payment_id = kwargs['yookassa_payment_id'] + restored_payment.yookassa_created_at = kwargs['yookassa_created_at'] return restored_payment async def fake_update_status( @@ -837,18 +849,18 @@ async def test_process_yookassa_webhook_restores_missing_payment( async def fake_link(db, yookassa_payment_id, transaction_id): restored_payment.transaction_id = transaction_id - monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment) - monkeypatch.setattr(payment_service_module, "create_yookassa_payment", fake_create_payment) - monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", fake_update_status) - monkeypatch.setattr(payment_service_module, "link_yookassa_payment_to_transaction", fake_link) + monkeypatch.setattr(payment_service_module, 'get_yookassa_payment_by_id', fake_get_payment) + monkeypatch.setattr(payment_service_module, 'create_yookassa_payment', fake_create_payment) + monkeypatch.setattr(payment_service_module, 'update_yookassa_payment_status', fake_update_status) + monkeypatch.setattr(payment_service_module, 'link_yookassa_payment_to_transaction', fake_link) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) return SimpleNamespace(id=555, **kwargs) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=21, @@ -860,16 +872,16 @@ async def test_process_yookassa_webhook_restores_missing_payment( referred_by_id=None, referrer=None, ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_mock) admin_calls: list[Any] = [] @@ -880,38 +892,42 @@ async def test_process_yookassa_webhook_restores_missing_payment( async def send_balance_topup_notification(self, *args, **kwargs): admin_calls.append((args, kwargs)) - monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) + monkeypatch.setitem( + sys.modules, + 'app.services.admin_notification_service', + SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), + ) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "object": { - "id": "yk_456", - "status": "succeeded", - "paid": True, - "amount": {"value": "150.00", "currency": "RUB"}, - "metadata": {"user_id": "21", "payment_purpose": "balance_topup"}, - "description": "Пополнение", - "payment_method": {"type": "bank_card"}, - "created_at": "2024-01-02T12:00:00Z", - "captured_at": "2024-01-02T12:05:00Z", - "confirmation": {"confirmation_url": "https://pay.example"}, + 'object': { + 'id': 'yk_456', + 'status': 'succeeded', + 'paid': True, + 'amount': {'value': '150.00', 'currency': 'RUB'}, + 'metadata': {'user_id': '21', 'payment_purpose': 'balance_topup'}, + 'description': 'Пополнение', + 'payment_method': {'type': 'bank_card'}, + 'created_at': '2024-01-02T12:00:00Z', + 'captured_at': '2024-01-02T12:05:00Z', + 'confirmation': {'confirmation_url': 'https://pay.example'}, } } result = await service.process_yookassa_webhook(fake_session, payload) assert result is True - assert get_calls["count"] >= 2 # повторный запрос после восстановления + assert get_calls['count'] >= 2 # повторный запрос после восстановления assert restored_payment.amount_kopeks == 15000 assert restored_payment.is_paid is True - assert transactions and transactions[0]["amount_kopeks"] == 15000 + assert transactions and transactions[0]['amount_kopeks'] == 15000 assert restored_payment.transaction_id == 555 assert user.balance_kopeks == 15000 assert bot.sent_messages assert admin_calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.MonkeyPatch) -> None: service = _make_service(DummyBot()) db = FakeSession() @@ -922,11 +938,11 @@ async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.Mon create_mock = AsyncMock() update_mock = AsyncMock() - monkeypatch.setattr(payment_service_module, "get_yookassa_payment_by_id", fake_get_payment) - monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock) - monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock) + monkeypatch.setattr(payment_service_module, 'get_yookassa_payment_by_id', fake_get_payment) + monkeypatch.setattr(payment_service_module, 'create_yookassa_payment', create_mock) + monkeypatch.setattr(payment_service_module, 'update_yookassa_payment_status', update_mock) - payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}} + payload = {'object': {'id': 'yk_missing', 'status': 'succeeded', 'paid': True}} result = await service.process_yookassa_webhook(db, payload) @@ -935,30 +951,30 @@ async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.Mon update_mock.assert_not_awaited() -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) db = FakeSession() - result = await service.process_yookassa_webhook(db, {"object": {}}) + result = await service.process_yookassa_webhook(db, {'object': {}}) assert result is False -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) service.pal24_service = SimpleNamespace(is_configured=True) fake_session = FakeSession() payment = SimpleNamespace( - bill_id="BILL-1", - order_id="order-1", + bill_id='BILL-1', + order_id='order-1', amount_kopeks=5000, user_id=33, transaction_id=None, is_paid=False, - status="NEW", + status='NEW', metadata_json={}, payment_method=None, paid_at=None, @@ -971,34 +987,34 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) - return payment async def fake_update(db, payment_obj, **kwargs): - payment.status = kwargs.get("status", payment.status) - payment.is_paid = kwargs.get("is_paid", payment.is_paid) - payment.payment_status = kwargs.get("payment_status", payment.status) - payment.callback_payload = kwargs.get("callback_payload") + payment.status = kwargs.get('status', payment.status) + payment.is_paid = kwargs.get('is_paid', payment.is_paid) + payment.payment_status = kwargs.get('payment_status', payment.status) + payment.callback_payload = kwargs.get('callback_payload') return payment async def fake_link(db, payment_obj, transaction_id): payment.transaction_id = transaction_id - pal_module = ModuleType("app.database.crud.pal24") + pal_module = ModuleType('app.database.crud.pal24') pal_module.get_pal24_payment_by_order_id = fake_get_by_order pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill pal_module.update_pal24_payment_status = fake_update pal_module.link_pal24_payment_to_transaction = fake_link - monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module) - monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order) - monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill) - monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update) - monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link) + monkeypatch.setitem(sys.modules, 'app.database.crud.pal24', pal_module) + monkeypatch.setattr(payment_service_module, 'get_pal24_payment_by_order_id', fake_get_by_order) + monkeypatch.setattr(payment_service_module, 'get_pal24_payment_by_bill_id', fake_get_by_bill) + monkeypatch.setattr(payment_service_module, 'update_pal24_payment_status', fake_update) + monkeypatch.setattr(payment_service_module, 'link_pal24_payment_to_transaction', fake_link) async def fake_create_transaction(db, **kwargs): payment.transaction_id = 654 return SimpleNamespace(id=654, **kwargs) - trx_module = ModuleType("app.database.crud.transaction") + trx_module = ModuleType('app.database.crud.transaction') trx_module.create_transaction = fake_create_transaction - monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setitem(sys.modules, 'app.database.crud.transaction', trx_module) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=33, @@ -1009,18 +1025,18 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) - subscription=None, referred_by_id=None, referrer=None, - language="ru", + language='ru', ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_pal = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_pal) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_pal) admin_calls: list[Any] = [] @@ -1033,14 +1049,12 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setitem( sys.modules, - "app.services.admin_notification_service", + 'app.services.admin_notification_service', SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot)), ) - user_cart_stub = SimpleNamespace( - user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True)) - ) - monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=True))) + monkeypatch.setitem(sys.modules, 'app.services.user_cart_service', user_cart_stub) class DummyTypes: class InlineKeyboardMarkup: @@ -1053,20 +1067,20 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) - self.args = args self.kwargs = kwargs - monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes)) + monkeypatch.setitem(sys.modules, 'aiogram', SimpleNamespace(types=DummyTypes)) monkeypatch.setitem( sys.modules, - "app.localization.texts", + 'app.localization.texts', SimpleNamespace(get_texts=lambda language: SimpleNamespace(t=lambda key, default=None: default)), ) service.build_topup_success_keyboard = AsyncMock(return_value=None) payload = { - "InvId": "order-1", - "OutSum": "50.00", - "Status": "SUCCESS", - "TrsId": "trs-1", + 'InvId': 'order-1', + 'OutSum': '50.00', + 'Status': 'SUCCESS', + 'TrsId': 'trs-1', } result = await service.process_pal24_callback(fake_session, payload) @@ -1076,50 +1090,50 @@ async def test_process_pal24_callback_success(monkeypatch: pytest.MonkeyPatch) - assert user.balance_kopeks == 5000 assert bot.sent_messages saved_cart_message = bot.sent_messages[-1] - reply_markup = saved_cart_message["kwargs"].get("reply_markup") + reply_markup = saved_cart_message['kwargs'].get('reply_markup') assert reply_markup is not None - assert reply_markup.inline_keyboard[0][0].kwargs["callback_data"] == "return_to_saved_cart" + assert reply_markup.inline_keyboard[0][0].kwargs['callback_data'] == 'return_to_saved_cart' assert admin_calls -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) class DummyPal24Service: - BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"} - BILL_FAILED_STATES = {"FAIL"} - BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"} + BILL_SUCCESS_STATES = {'SUCCESS', 'OVERPAID'} + BILL_FAILED_STATES = {'FAIL'} + BILL_PENDING_STATES = {'NEW', 'PROCESS', 'UNDERPAID'} - async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + async def get_bill_status(self, bill_id: str) -> dict[str, Any]: return { - "status": "SUCCESS", - "bill": { - "status": "SUCCESS", - "payments": [ + 'status': 'SUCCESS', + 'bill': { + 'status': 'SUCCESS', + 'payments': [ { - "id": "trs-auto-1", - "status": "SUCCESS", - "method": "SBP", - "balance_amount": "50.00", - "balance_currency": "RUB", + 'id': 'trs-auto-1', + 'status': 'SUCCESS', + 'method': 'SBP', + 'balance_amount': '50.00', + 'balance_currency': 'RUB', } ], }, } - async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]: + async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None: return None - async def get_bill_payments(self, bill_id: str) -> Optional[Dict[str, Any]]: + async def get_bill_payments(self, bill_id: str) -> dict[str, Any] | None: return { - "data": [ + 'data': [ { - "id": "trs-auto-1", - "bill_id": bill_id, - "status": "SUCCESS", - "payment_method": "SBP", + 'id': 'trs-auto-1', + 'bill_id': bill_id, + 'status': 'SUCCESS', + 'payment_method': 'SBP', } ] } @@ -1129,13 +1143,13 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey fake_session = FakeSession() payment = SimpleNamespace( id=77, - bill_id="BILL-AUTO", - order_id="order-auto", + bill_id='BILL-AUTO', + order_id='order-auto', amount_kopeks=5000, user_id=91, transaction_id=None, is_paid=False, - status="NEW", + status='NEW', metadata_json={}, payment_id=None, payment_method=None, @@ -1154,18 +1168,18 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey payment.transaction_id = transaction_id return payment - monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_id", fake_get_payment_by_id) - monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update_payment) - monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link_payment) + monkeypatch.setattr(payment_service_module, 'get_pal24_payment_by_id', fake_get_payment_by_id) + monkeypatch.setattr(payment_service_module, 'update_pal24_payment_status', fake_update_payment) + monkeypatch.setattr(payment_service_module, 'link_pal24_payment_to_transaction', fake_link_payment) - transactions: list[Dict[str, Any]] = [] + transactions: list[dict[str, Any]] = [] async def fake_create_transaction(db, **kwargs): transactions.append(kwargs) payment.transaction_id = 999 return SimpleNamespace(id=999, **kwargs) - monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, 'create_transaction', fake_create_transaction) user = SimpleNamespace( id=91, @@ -1176,18 +1190,18 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey subscription=None, referred_by_id=None, referrer=None, - language="ru", + language='ru', ) - user.get_primary_promo_group = lambda: getattr(user, "promo_group", None) + user.get_primary_promo_group = lambda: getattr(user, 'promo_group', None) async def fake_get_user(db, user_id): return user - monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) - monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + monkeypatch.setattr(payment_service_module, 'get_user_by_id', fake_get_user) + monkeypatch.setattr(type(settings), 'format_price', lambda self, amount: f'{amount / 100:.2f}₽', raising=False) referral_stub = SimpleNamespace(process_referral_topup=AsyncMock()) - monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_stub) + monkeypatch.setitem(sys.modules, 'app.services.referral_service', referral_stub) admin_notifications: list[Any] = [] @@ -1200,14 +1214,12 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey monkeypatch.setitem( sys.modules, - "app.services.admin_notification_service", + 'app.services.admin_notification_service', SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot)), ) - user_cart_stub = SimpleNamespace( - user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False)) - ) - monkeypatch.setitem(sys.modules, "app.services.user_cart_service", user_cart_stub) + user_cart_stub = SimpleNamespace(user_cart_service=SimpleNamespace(has_user_cart=AsyncMock(return_value=False))) + monkeypatch.setitem(sys.modules, 'app.services.user_cart_service', user_cart_stub) class DummyTypes: class InlineKeyboardMarkup: @@ -1220,7 +1232,7 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey self.args = args self.kwargs = kwargs - monkeypatch.setitem(sys.modules, "aiogram", SimpleNamespace(types=DummyTypes)) + monkeypatch.setitem(sys.modules, 'aiogram', SimpleNamespace(types=DummyTypes)) service.build_topup_success_keyboard = AsyncMock(return_value=None) @@ -1231,9 +1243,10 @@ async def test_get_pal24_payment_status_auto_finalize(monkeypatch: pytest.Monkey assert user.balance_kopeks == 5000 assert bot.sent_messages assert admin_notifications - assert transactions and transactions[0]["user_id"] == 91 + assert transactions and transactions[0]['user_id'] == 91 -@pytest.mark.anyio("asyncio") + +@pytest.mark.anyio('asyncio') async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None: bot = DummyBot() service = _make_service(bot) @@ -1246,19 +1259,19 @@ async def test_process_pal24_callback_payment_not_found(monkeypatch: pytest.Monk async def fake_get_by_bill(db, bill_id): return None - pal_module = ModuleType("app.database.crud.pal24") + pal_module = ModuleType('app.database.crud.pal24') pal_module.get_pal24_payment_by_order_id = fake_get_by_order pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill pal_module.update_pal24_payment_status = AsyncMock() pal_module.link_pal24_payment_to_transaction = AsyncMock() - monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module) - monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order) - monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill) + monkeypatch.setitem(sys.modules, 'app.database.crud.pal24', pal_module) + monkeypatch.setattr(payment_service_module, 'get_pal24_payment_by_order_id', fake_get_by_order) + monkeypatch.setattr(payment_service_module, 'get_pal24_payment_by_bill_id', fake_get_by_bill) payload = { - "InvId": "order-unknown", - "OutSum": "10.00", - "Status": "SUCCESS", + 'InvId': 'order-unknown', + 'OutSum': '10.00', + 'Status': 'SUCCESS', } result = await service.process_pal24_callback(db, payload) diff --git a/tests/services/test_payment_service_yookassa.py b/tests/services/test_payment_service_yookassa.py index caedb087..88faa8a8 100644 --- a/tests/services/test_payment_service_yookassa.py +++ b/tests/services/test_payment_service_yookassa.py @@ -3,23 +3,24 @@ import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -import app.services.payment_service as payment_service_module # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.payment_service import PaymentService # noqa: E402 +import app.services.payment_service as payment_service_module +from app.config import settings +from app.services.payment_service import PaymentService @pytest.fixture def anyio_backend() -> str: """Запускаем async-тесты на asyncio, чтобы избежать зависимостей trio.""" - return "asyncio" + return 'asyncio' class DummySession: @@ -46,20 +47,20 @@ class DummyLocalPayment: class StubYooKassaService: """Заглушка для SDK, сохраняющая вызовы.""" - def __init__(self, response: Dict[str, Any]) -> None: + def __init__(self, response: dict[str, Any]) -> None: self.response = response - self.calls: list[Dict[str, Any]] = [] + self.calls: list[dict[str, Any]] = [] - async def create_payment(self, **kwargs: Any) -> Dict[str, Any]: + async def create_payment(self, **kwargs: Any) -> dict[str, Any]: self.calls.append(kwargs) return self.response - async def create_sbp_payment(self, **kwargs: Any) -> Dict[str, Any]: + async def create_sbp_payment(self, **kwargs: Any) -> dict[str, Any]: self.calls.append(kwargs) return self.response -def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentService: +def _make_service(yookassa_service: StubYooKassaService | None) -> PaymentService: service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] service.bot = None service.yookassa_service = yookassa_service @@ -72,23 +73,23 @@ def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentSer return service -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_yookassa_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: """Успешное создание платежа формирует корректные метаданные и локальную запись.""" response = { - "id": "yk_123", - "status": "pending", - "confirmation_url": "https://yookassa.ru/confirm", - "amount": {"value": "140.00", "currency": "RUB"}, - "metadata": {"existing": "value"}, - "created_at": "2024-01-01T12:00:00Z", - "test_mode": False, + 'id': 'yk_123', + 'status': 'pending', + 'confirmation_url': 'https://yookassa.ru/confirm', + 'amount': {'value': '140.00', 'currency': 'RUB'}, + 'metadata': {'existing': 'value'}, + 'created_at': '2024-01-01T12:00:00Z', + 'test_mode': False, } service = _make_service(StubYooKassaService(response)) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) @@ -96,14 +97,14 @@ async def test_create_yookassa_payment_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr( payment_service_module, - "create_yookassa_payment", + 'create_yookassa_payment', fake_create_yookassa_payment, raising=False, ) monkeypatch.setattr( type(settings), - "format_price", - lambda self, amount: f"{amount / 100:.0f}₽", + 'format_price', + lambda self, amount: f'{amount / 100:.0f}₽', raising=False, ) @@ -111,26 +112,26 @@ async def test_create_yookassa_payment_success(monkeypatch: pytest.MonkeyPatch) db=db, user_id=42, amount_kopeks=14000, - description="Пополнение", - receipt_email="user@example.com", - metadata={"custom": "data"}, + description='Пополнение', + receipt_email='user@example.com', + metadata={'custom': 'data'}, ) assert result is not None - assert result["local_payment_id"] == 555 - assert result["yookassa_payment_id"] == "yk_123" - assert result["amount_kopeks"] == 14000 - assert result["amount_rubles"] == 140 - assert result["status"] == "pending" + assert result['local_payment_id'] == 555 + assert result['yookassa_payment_id'] == 'yk_123' + assert result['amount_kopeks'] == 14000 + assert result['amount_rubles'] == 140 + assert result['status'] == 'pending' - assert captured_args["user_id"] == 42 - assert captured_args["metadata_json"]["custom"] == "data" - assert captured_args["metadata_json"]["user_id"] == "42" - assert captured_args["metadata_json"]["amount_kopeks"] == "14000" - assert isinstance(captured_args["yookassa_created_at"], datetime) + assert captured_args['user_id'] == 42 + assert captured_args['metadata_json']['custom'] == 'data' + assert captured_args['metadata_json']['user_id'] == '42' + assert captured_args['metadata_json']['amount_kopeks'] == '14000' + assert isinstance(captured_args['yookassa_created_at'], datetime) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_yookassa_payment_returns_none_when_service_missing() -> None: """Если сервис не настроен, метод должен вернуть None.""" service = _make_service(None) @@ -139,15 +140,15 @@ async def test_create_yookassa_payment_returns_none_when_service_missing() -> No db=db, user_id=1, amount_kopeks=1000, - description="Пополнение", + description='Пополнение', ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_yookassa_payment_handles_error_response(monkeypatch: pytest.MonkeyPatch) -> None: """Ответ с ключом error должен приводить к None без записи в БД.""" - response = {"error": True} + response = {'error': True} service = _make_service(StubYooKassaService(response)) db = DummySession() @@ -160,7 +161,7 @@ async def test_create_yookassa_payment_handles_error_response(monkeypatch: pytes monkeypatch.setattr( payment_service_module, - "create_yookassa_payment", + 'create_yookassa_payment', fake_create_yookassa_payment, raising=False, ) @@ -169,27 +170,27 @@ async def test_create_yookassa_payment_handles_error_response(monkeypatch: pytes db=db, user_id=1, amount_kopeks=5000, - description="Пополнение", + description='Пополнение', ) assert result is None assert called is False -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: """Проверяем SBP-сценарий, включая передачу confirmation_token.""" response = { - "id": "yk_sbp_001", - "status": "pending", - "confirmation_url": "https://yookassa.ru/confirm", - "confirmation": {"confirmation_token": "token123"}, - "created_at": "2024-02-01T10:00:00Z", + 'id': 'yk_sbp_001', + 'status': 'pending', + 'confirmation_url': 'https://yookassa.ru/confirm', + 'confirmation': {'confirmation_token': 'token123'}, + 'created_at': '2024-02-01T10:00:00Z', } service = _make_service(StubYooKassaService(response)) db = DummySession() - captured_args: Dict[str, Any] = {} + captured_args: dict[str, Any] = {} async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: captured_args.update(kwargs) @@ -197,7 +198,7 @@ async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPat monkeypatch.setattr( payment_service_module, - "create_yookassa_payment", + 'create_yookassa_payment', fake_create_yookassa_payment, raising=False, ) @@ -206,19 +207,19 @@ async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPat db=db, user_id=7, amount_kopeks=25000, - description="СБП пополнение", + description='СБП пополнение', ) assert result is not None - assert result["confirmation_token"] == "token123" - assert captured_args["payment_method_type"] == "bank_card" - assert captured_args["metadata_json"]["type"] == "balance_topup_sbp" + assert result['confirmation_token'] == 'token123' + assert captured_args['payment_method_type'] == 'bank_card' + assert captured_args['metadata_json']['type'] == 'balance_topup_sbp' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_yookassa_sbp_payment_returns_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None: """Ошибочный ответ СБП не должен создавать запись.""" - response = {"error": "invalid"} + response = {'error': 'invalid'} service = _make_service(StubYooKassaService(response)) db = DummySession() @@ -231,7 +232,7 @@ async def test_create_yookassa_sbp_payment_returns_none_on_error(monkeypatch: py monkeypatch.setattr( payment_service_module, - "create_yookassa_payment", + 'create_yookassa_payment', fake_create_yookassa_payment, raising=False, ) @@ -240,7 +241,7 @@ async def test_create_yookassa_sbp_payment_returns_none_on_error(monkeypatch: py db=db, user_id=1, amount_kopeks=1000, - description="СБП пополнение", + description='СБП пополнение', ) assert result is None assert called is False diff --git a/tests/services/test_platega_service.py b/tests/services/test_platega_service.py index 62ff1913..ba0442a9 100644 --- a/tests/services/test_platega_service.py +++ b/tests/services/test_platega_service.py @@ -7,19 +7,19 @@ from app.services.platega_service import PlategaService def test_sanitize_description_limits_utf8_bytes(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) - original = "Интернет-сервис - Пополнение баланса на 50 ₽ и ещё чуть-чуть" + original = 'Интернет-сервис - Пополнение баланса на 50 ₽ и ещё чуть-чуть' trimmed = PlategaService._sanitize_description(original, 64) - assert len(trimmed.encode("utf-8")) <= 64 + assert len(trimmed.encode('utf-8')) <= 64 assert trimmed != original - assert any("trimmed" in record.message for record in caplog.records) + assert any('trimmed' in record.message for record in caplog.records) def test_sanitize_description_returns_clean_value() -> None: - original = " Обычное описание " + original = ' Обычное описание ' trimmed = PlategaService._sanitize_description(original, 64) - assert trimmed == "Обычное описание" - assert len(trimmed.encode("utf-8")) <= 64 + assert trimmed == 'Обычное описание' + assert len(trimmed.encode('utf-8')) <= 64 diff --git a/tests/services/test_promocode_service.py b/tests/services/test_promocode_service.py index 58cb429d..cac5da67 100644 --- a/tests/services/test_promocode_service.py +++ b/tests/services/test_promocode_service.py @@ -1,27 +1,13 @@ """ Tests for PromoCodeService - focus on promo group integration """ -import pytest -from unittest.mock import AsyncMock, patch -from types import SimpleNamespace + +from unittest.mock import AsyncMock from app.services.promocode_service import PromoCodeService -from app.database.models import PromoCodeType + # Import fixtures -from tests.fixtures.promocode_fixtures import ( - sample_promo_group, - sample_user, - sample_promocode_promo_group, - mock_db_session, - mock_has_user_promo_group, - mock_add_user_to_promo_group, - mock_get_promo_group_by_id, - mock_get_user_by_id, - mock_get_promocode_by_code, - mock_check_user_promocode_usage, - mock_create_promocode_use, -) async def test_activate_promo_group_promocode_success( @@ -45,87 +31,48 @@ async def test_activate_promo_group_promocode_success( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Assertions - assert result["success"] is True - assert "Test VIP Group" in result["description"] - assert result["promocode"]["promo_group_id"] == sample_promo_group.id + assert result['success'] is True + assert 'Test VIP Group' in result['description'] + assert result['promocode']['promo_group_id'] == sample_promo_group.id # Verify promo group was fetched - get_promo_group_mock.assert_awaited_once_with( - mock_db_session, - sample_promo_group.id - ) + get_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_promo_group.id) # Verify user promo group check - has_promo_group_mock.assert_awaited_once_with( - mock_db_session, - sample_user.id, - sample_promo_group.id - ) + has_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_user.id, sample_promo_group.id) # Verify promo group assignment add_promo_group_mock.assert_awaited_once_with( - mock_db_session, - sample_user.id, - sample_promo_group.id, - assigned_by="promocode" + mock_db_session, sample_user.id, sample_promo_group.id, assigned_by='promocode' ) # Verify usage recorded - create_usage_mock.assert_awaited_once_with( - mock_db_session, - sample_promocode_promo_group.id, - sample_user.id - ) + create_usage_mock.assert_awaited_once_with(mock_db_session, sample_promocode_promo_group.id, sample_user.id) # Verify counter incremented assert sample_promocode_promo_group.current_uses == 21 @@ -152,52 +99,30 @@ async def test_activate_promo_group_user_already_has_group( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) # User ALREADY HAS the promo group has_promo_group_mock = AsyncMock(return_value=True) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Assertions - assert result["success"] is True + assert result['success'] is True # Verify promo group assignment was NOT called add_promo_group_mock.assert_not_awaited() @@ -225,58 +150,33 @@ async def test_activate_promo_group_group_not_found( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) # Promo group NOT FOUND get_promo_group_mock = AsyncMock(return_value=None) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Assertions - assert result["success"] is True # Still succeeds! + assert result['success'] is True # Still succeeds! # Verify promo group was attempted to fetch get_promo_group_mock.assert_awaited_once() @@ -307,58 +207,33 @@ async def test_activate_promo_group_assignment_error( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) # add_user_to_promo_group RAISES EXCEPTION - add_promo_group_mock = AsyncMock(side_effect=Exception("Database error")) - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + add_promo_group_mock = AsyncMock(side_effect=Exception('Database error')) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Assertions - assert result["success"] is True # Still succeeds! + assert result['success'] is True # Still succeeds! # Verify promo group assignment was attempted add_promo_group_mock.assert_awaited_once() @@ -384,61 +259,36 @@ async def test_activate_promo_group_assigned_by_value( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Verify assigned_by="promocode" add_promo_group_mock.assert_awaited_once_with( mock_db_session, sample_user.id, sample_promo_group.id, - assigned_by="promocode" # Critical assertion + assigned_by='promocode', # Critical assertion ) @@ -459,57 +309,32 @@ async def test_activate_promo_group_description_includes_group_name( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Verify description includes promo group name - assert "Назначена промогруппа: Test VIP Group" in result["description"] + assert 'Назначена промогруппа: Test VIP Group' in result['description'] async def test_promocode_data_includes_promo_group_id( @@ -529,56 +354,31 @@ async def test_promocode_data_includes_promo_group_id( # Mock CRUD functions get_user_mock = AsyncMock(return_value=sample_user) - monkeypatch.setattr( - 'app.services.promocode_service.get_user_by_id', - get_user_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_user_by_id', get_user_mock) get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promocode_by_code', - get_promocode_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', get_promocode_mock) check_usage_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.check_user_promocode_usage', - check_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', check_usage_mock) get_promo_group_mock = AsyncMock(return_value=sample_promo_group) - monkeypatch.setattr( - 'app.services.promocode_service.get_promo_group_by_id', - get_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.get_promo_group_by_id', get_promo_group_mock) has_promo_group_mock = AsyncMock(return_value=False) - monkeypatch.setattr( - 'app.services.promocode_service.has_user_promo_group', - has_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.has_user_promo_group', has_promo_group_mock) add_promo_group_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.add_user_to_promo_group', - add_promo_group_mock - ) + monkeypatch.setattr('app.services.promocode_service.add_user_to_promo_group', add_promo_group_mock) create_usage_mock = AsyncMock() - monkeypatch.setattr( - 'app.services.promocode_service.create_promocode_use', - create_usage_mock - ) + monkeypatch.setattr('app.services.promocode_service.create_promocode_use', create_usage_mock) # Execute service = PromoCodeService() - result = await service.activate_promocode( - mock_db_session, - sample_user.id, - "VIPGROUP" - ) + result = await service.activate_promocode(mock_db_session, sample_user.id, 'VIPGROUP') # Verify promocode data structure - assert "promocode" in result - assert "promo_group_id" in result["promocode"] - assert result["promocode"]["promo_group_id"] == sample_promo_group.id + assert 'promocode' in result + assert 'promo_group_id' in result['promocode'] + assert result['promocode']['promo_group_id'] == sample_promo_group.id diff --git a/tests/services/test_referral_service.py b/tests/services/test_referral_service.py index 76e709ad..abdb9029 100644 --- a/tests/services/test_referral_service.py +++ b/tests/services/test_referral_service.py @@ -1,29 +1,28 @@ -from pathlib import Path import sys +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock -import pytest ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from app.services import referral_service # noqa: E402 +from app.services import referral_service async def test_commission_accrues_before_minimum_first_topup(monkeypatch): user = SimpleNamespace( id=1, telegram_id=101, - full_name="Test User", + full_name='Test User', referred_by_id=2, has_made_first_topup=False, ) referrer = SimpleNamespace( id=2, telegram_id=202, - full_name="Referrer", + full_name='Referrer', ) db = SimpleNamespace( @@ -32,16 +31,16 @@ async def test_commission_accrues_before_minimum_first_topup(monkeypatch): ) get_user_mock = AsyncMock(side_effect=[user, referrer]) - monkeypatch.setattr(referral_service, "get_user_by_id", get_user_mock) + monkeypatch.setattr(referral_service, 'get_user_by_id', get_user_mock) add_user_balance_mock = AsyncMock() - monkeypatch.setattr(referral_service, "add_user_balance", add_user_balance_mock) + monkeypatch.setattr(referral_service, 'add_user_balance', add_user_balance_mock) create_referral_earning_mock = AsyncMock() - monkeypatch.setattr(referral_service, "create_referral_earning", create_referral_earning_mock) + monkeypatch.setattr(referral_service, 'create_referral_earning', create_referral_earning_mock) - monkeypatch.setattr(referral_service.settings, "REFERRAL_MINIMUM_TOPUP_KOPEKS", 20000) - monkeypatch.setattr(referral_service.settings, "REFERRAL_FIRST_TOPUP_BONUS_KOPEKS", 5000) - monkeypatch.setattr(referral_service.settings, "REFERRAL_INVITER_BONUS_KOPEKS", 10000) - monkeypatch.setattr(referral_service.settings, "REFERRAL_COMMISSION_PERCENT", 25) + monkeypatch.setattr(referral_service.settings, 'REFERRAL_MINIMUM_TOPUP_KOPEKS', 20000) + monkeypatch.setattr(referral_service.settings, 'REFERRAL_FIRST_TOPUP_BONUS_KOPEKS', 5000) + monkeypatch.setattr(referral_service.settings, 'REFERRAL_INVITER_BONUS_KOPEKS', 10000) + monkeypatch.setattr(referral_service.settings, 'REFERRAL_COMMISSION_PERCENT', 25) topup_amount = 15000 @@ -54,13 +53,13 @@ async def test_commission_accrues_before_minimum_first_topup(monkeypatch): add_call = add_user_balance_mock.await_args assert add_call.args[1] is referrer assert add_call.args[2] == 3750 - assert "Комиссия" in add_call.args[3] - assert add_call.kwargs.get("bot") is None + assert 'Комиссия' in add_call.args[3] + assert add_call.kwargs.get('bot') is None create_referral_earning_mock.assert_awaited_once() earning_call = create_referral_earning_mock.await_args - assert earning_call.kwargs["amount_kopeks"] == 3750 - assert earning_call.kwargs["reason"] == "referral_commission_topup" + assert earning_call.kwargs['amount_kopeks'] == 3750 + assert earning_call.kwargs['reason'] == 'referral_commission_topup' db.commit.assert_not_awaited() db.execute.assert_not_awaited() diff --git a/tests/services/test_remnawave_service_sync.py b/tests/services/test_remnawave_service_sync.py index f12ab46f..cf3fb911 100644 --- a/tests/services/test_remnawave_service_sync.py +++ b/tests/services/test_remnawave_service_sync.py @@ -1,12 +1,12 @@ +import sys from datetime import datetime from pathlib import Path -import sys from unittest.mock import AsyncMock - -import pytest -from sqlalchemy.exc import IntegrityError from zoneinfo import ZoneInfo +from sqlalchemy.exc import IntegrityError + + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) @@ -16,16 +16,16 @@ from app.services.remnawave_service import RemnaWaveService def _create_service() -> RemnaWaveService: service = RemnaWaveService.__new__(RemnaWaveService) - service._panel_timezone = ZoneInfo("UTC") - service._utc_timezone = ZoneInfo("UTC") + service._panel_timezone = ZoneInfo('UTC') + service._utc_timezone = ZoneInfo('UTC') return service -def _make_panel_user(telegram_id: int, expire_at: str, status: str = "ACTIVE") -> dict: +def _make_panel_user(telegram_id: int, expire_at: str, status: str = 'ACTIVE') -> dict: return { - "telegramId": telegram_id, - "expireAt": expire_at, - "status": status, + 'telegramId': telegram_id, + 'expireAt': expire_at, + 'status': status, } @@ -46,8 +46,8 @@ def test_deduplicate_prefers_active_status_on_same_expire(): telegram_id = 200 expire = datetime(2025, 1, 1, 0, 0, 0).isoformat() - disabled = _make_panel_user(telegram_id, expire, status="DISABLED") - active = _make_panel_user(telegram_id, expire, status="ACTIVE") + disabled = _make_panel_user(telegram_id, expire, status='DISABLED') + active = _make_panel_user(telegram_id, expire, status='ACTIVE') deduplicated = service._deduplicate_panel_users_by_telegram_id([disabled, active]) @@ -58,7 +58,7 @@ def test_deduplicate_ignores_records_without_expire_date(): service = _create_service() telegram_id = 300 - missing_expire = _make_panel_user(telegram_id, "") + missing_expire = _make_panel_user(telegram_id, '') valid = _make_panel_user(telegram_id, datetime(2025, 3, 1, 0, 0, 0).isoformat()) deduplicated = service._deduplicate_panel_users_by_telegram_id([missing_expire, valid]) @@ -70,20 +70,18 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch): service = _create_service() db = AsyncMock() - panel_user = {"telegramId": 555, "username": "existing"} + panel_user = {'telegramId': 555, 'username': 'existing'} existing_user = object() - create_user_mock = AsyncMock( - side_effect=IntegrityError("stmt", "params", Exception("unique")) - ) + create_user_mock = AsyncMock(side_effect=IntegrityError('stmt', 'params', Exception('unique'))) get_user_mock = AsyncMock(return_value=existing_user) rollback_mock = AsyncMock() db.rollback = rollback_mock - monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) + monkeypatch.setattr('app.services.remnawave_service.create_user_no_commit', create_user_mock) monkeypatch.setattr( - "app.services.remnawave_service.get_user_by_telegram_id", + 'app.services.remnawave_service.get_user_by_telegram_id', get_user_mock, ) @@ -100,12 +98,12 @@ async def test_get_or_create_user_creates_new(monkeypatch): service = _create_service() db = AsyncMock() - panel_user = {"telegramId": 777, "username": "new_user"} + panel_user = {'telegramId': 777, 'username': 'new_user'} new_user = object() create_user_mock = AsyncMock(return_value=new_user) - monkeypatch.setattr("app.services.remnawave_service.create_user_no_commit", create_user_mock) + monkeypatch.setattr('app.services.remnawave_service.create_user_no_commit', create_user_mock) user, created = await service._get_or_create_bot_user_from_panel(db, panel_user) @@ -114,8 +112,8 @@ async def test_get_or_create_user_creates_new(monkeypatch): create_user_mock.assert_awaited_once_with( db=db, telegram_id=777, - username="new_user", - first_name="User 777", + username='new_user', + first_name='User 777', last_name=None, - language="ru", + language='ru', ) diff --git a/tests/services/test_subscription_auto_purchase_service.py b/tests/services/test_subscription_auto_purchase_service.py index da06081c..6684378a 100644 --- a/tests/services/test_subscription_auto_purchase_service.py +++ b/tests/services/test_subscription_auto_purchase_service.py @@ -1,7 +1,8 @@ -import pytest from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock +from sqlalchemy.ext.asyncio import AsyncSession + from app.config import settings from app.database.models import User from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup @@ -14,7 +15,6 @@ from app.services.subscription_purchase_service import ( PurchaseServersConfig, PurchaseTrafficConfig, ) -from sqlalchemy.ext.asyncio import AsyncSession class DummyTexts: @@ -22,30 +22,30 @@ class DummyTexts: return default def format_price(self, value: int) -> str: - return f"{value / 100:.0f} ₽" + return f'{value / 100:.0f} ₽' async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) user = MagicMock(spec=User) user.id = 42 user.telegram_id = 4242 user.balance_kopeks = 200_000 - user.language = "ru" + user.language = 'ru' user.subscription = None user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "period_days": 30, - "countries": ["ru"], - "traffic_gb": 0, - "devices": 1, + 'period_days': 30, + 'countries': ['ru'], + 'traffic_gb': 0, + 'devices': 1, } traffic_config = PurchaseTrafficConfig( selectable=False, - mode="fixed", + mode='fixed', options=[], default_value=0, current_value=0, @@ -54,7 +54,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): options=[], min_selectable=0, max_selectable=0, - default_selection=["ru"], + default_selection=['ru'], ) devices_config = PurchaseDevicesConfig( minimum=1, @@ -63,21 +63,21 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): current=1, price_per_device=0, discounted_price_per_device=0, - price_label="0 ₽", + price_label='0 ₽', ) period_config = PurchasePeriodConfig( - id="days:30", + id='days:30', days=30, months=1, - label="30 дней", + label='30 дней', base_price=100_000, - base_price_label="1000 ₽", + base_price_label='1000 ₽', base_price_original=100_000, base_price_original_label=None, discount_percent=0, per_month_price=100_000, - per_month_price_label="1000 ₽", + per_month_price_label='1000 ₽', traffic=traffic_config, servers=servers_config, devices=devices_config, @@ -86,12 +86,12 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): context = PurchaseOptionsContext( user=user, subscription=None, - currency="RUB", + currency='RUB', balance_kopeks=user.balance_kopeks, periods=[period_config], default_period=period_config, - period_map={"days:30": period_config}, - server_uuid_to_id={"ru": 1}, + period_map={'days:30': period_config}, + server_uuid_to_id={'ru': 1}, payload={}, ) @@ -99,7 +99,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): selection=PurchaseSelection( period=period_config, traffic_value=0, - servers=["ru"], + servers=['ru'], devices=1, ), server_ids=[1], @@ -110,7 +110,7 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): promo_discount_percent=0, final_total=100_000, months=1, - details={"servers_individual_prices": [100_000]}, + details={'servers_individual_prices': [100_000]}, ) class DummyMiniAppService: @@ -133,48 +133,48 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): async def submit_purchase(self, db, prepared_context, pricing): return { - "subscription": MagicMock(), - "transaction": MagicMock(), - "was_trial_conversion": False, - "message": "🎉 Subscription purchased", + 'subscription': MagicMock(), + 'transaction': MagicMock(), + 'was_trial_conversion': False, + 'message': '🎉 Subscription purchased', } monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService", + 'app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService', lambda: DummyMiniAppService(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) delete_cart_mock = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart', delete_cart_mock, ) clear_draft_mock = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + 'app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft', clear_draft_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_purchase_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мокаем get_user_by_id чтобы вернуть того же user monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_user_by_id", + 'app.services.subscription_auto_purchase_service.get_user_by_id', AsyncMock(return_value=user), ) @@ -191,40 +191,40 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) subscription = MagicMock() subscription.id = 99 subscription.is_trial = False - subscription.status = "active" + subscription.status = 'active' subscription.end_date = datetime.utcnow() subscription.device_limit = 1 subscription.traffic_limit_gb = 100 - subscription.connected_squads = ["squad-a"] + subscription.connected_squads = ['squad-a'] user = MagicMock(spec=User) user.id = 7 user.telegram_id = 7007 user.balance_kopeks = 200_000 - user.language = "ru" + user.language = 'ru' user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": 30, - "total_price": 31_000, - "description": "Продление подписки на 30 дней", - "device_limit": 2, - "traffic_limit_gb": 500, - "squad_uuid": "squad-b", - "consume_promo_offer": True, + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': 30, + 'total_price': 31_000, + 'description': 'Продление подписки на 30 дней', + 'device_limit': 2, + 'traffic_limit_gb': 500, + 'squad_uuid': 'squad-b', + 'consume_promo_offer': True, } subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.subtract_user_balance", + 'app.services.subscription_auto_purchase_service.subtract_user_balance', subtract_mock, ) @@ -233,57 +233,57 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): return current_subscription monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.extend_subscription", + 'app.services.subscription_auto_purchase_service.extend_subscription', extend_stub, ) create_transaction_mock = AsyncMock(return_value=MagicMock()) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.create_transaction", + 'app.services.subscription_auto_purchase_service.create_transaction', create_transaction_mock, ) service_mock = MagicMock() service_mock.update_remnawave_user = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.SubscriptionService", + 'app.services.subscription_auto_purchase_service.SubscriptionService', lambda: service_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) delete_cart_mock = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart', delete_cart_mock, ) clear_draft_mock = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + 'app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft', clear_draft_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_extension_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( - "app.database.crud.subscription.get_subscription_by_user_id", + 'app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=subscription), ) @@ -296,13 +296,13 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): subtract_mock.assert_awaited_once_with( db_session, user, - cart_data["total_price"], - cart_data["description"], + cart_data['total_price'], + cart_data['description'], consume_promo_offer=True, ) assert subscription.device_limit == 2 assert subscription.traffic_limit_gb == 500 - assert "squad-b" in subscription.connected_squads + assert 'squad-b' in subscription.connected_squads delete_cart_mock.assert_awaited_once_with(user.id) clear_draft_mock.assert_awaited_once_with(user.id) admin_service_mock.send_subscription_extension_notification.assert_awaited() @@ -313,12 +313,12 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch): async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch): """Тест: триал сохраняется, если не хватает денег для автопокупки""" - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) subscription = MagicMock() subscription.id = 123 subscription.is_trial = True # Триальная подписка! - subscription.status = "active" + subscription.status = 'active' subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня subscription.device_limit = 1 subscription.traffic_limit_gb = 10 @@ -330,57 +330,57 @@ async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch # ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243), # но subtract_user_balance вернёт False (симуляция неудачи списания) user.balance_kopeks = 60_000 - user.language = "ru" + user.language = 'ru' user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": 30, - "total_price": 50_000, - "description": "Продление на 30 дней", - "device_limit": 1, - "traffic_limit_gb": 100, - "squad_uuid": None, - "consume_promo_offer": False, + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': 30, + 'total_price': 50_000, + 'description': 'Продление на 30 дней', + 'device_limit': 1, + 'traffic_limit_gb': 100, + 'squad_uuid': None, + 'consume_promo_offer': False, } # Mock: недостаточно денег, списание не удалось subtract_mock = AsyncMock(return_value=False) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.subtract_user_balance", + 'app.services.subscription_auto_purchase_service.subtract_user_balance', subtract_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_local_datetime", - lambda dt, fmt: dt.strftime(fmt) if dt else "", + 'app.services.subscription_auto_purchase_service.format_local_datetime', + lambda dt, fmt: dt.strftime(fmt) if dt else '', ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_extension_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( - "app.database.crud.subscription.get_subscription_by_user_id", + 'app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=subscription), ) @@ -397,12 +397,12 @@ async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch): """Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления""" - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) subscription = MagicMock() subscription.id = 456 subscription.is_trial = True # Триальная подписка! - subscription.status = "active" + subscription.status = 'active' subscription.end_date = datetime.utcnow() + timedelta(days=1) subscription.device_limit = 1 subscription.traffic_limit_gb = 10 @@ -412,26 +412,26 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa user.id = 88 user.telegram_id = 8888 user.balance_kopeks = 200_000 # Достаточно денег - user.language = "ru" + user.language = 'ru' user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": 30, - "total_price": 100_000, - "description": "Продление на 30 дней", - "device_limit": 2, - "traffic_limit_gb": 500, - "squad_uuid": None, - "consume_promo_offer": False, + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': 30, + 'total_price': 100_000, + 'description': 'Продление на 30 дней', + 'device_limit': 2, + 'traffic_limit_gb': 500, + 'squad_uuid': None, + 'consume_promo_offer': False, } # Mock: деньги списались успешно subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.subtract_user_balance", + 'app.services.subscription_auto_purchase_service.subtract_user_balance', subtract_mock, ) @@ -441,59 +441,59 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa return current_subscription monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.extend_subscription", + 'app.services.subscription_auto_purchase_service.extend_subscription', extend_stub, ) create_transaction_mock = AsyncMock(return_value=MagicMock()) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.create_transaction", + 'app.services.subscription_auto_purchase_service.create_transaction', create_transaction_mock, ) service_mock = MagicMock() service_mock.update_remnawave_user = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.SubscriptionService", + 'app.services.subscription_auto_purchase_service.SubscriptionService', lambda: service_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart', AsyncMock(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + 'app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft', AsyncMock(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) # ИСПРАВЛЕНО: Добавлен мок для format_local_datetime monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_local_datetime", - lambda dt, fmt: dt.strftime(fmt) if dt else "", + 'app.services.subscription_auto_purchase_service.format_local_datetime', + lambda dt, fmt: dt.strftime(fmt) if dt else '', ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_extension_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( - "app.database.crud.subscription.get_subscription_by_user_id", + 'app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=subscription), ) @@ -507,18 +507,18 @@ async def test_auto_purchase_trial_converted_after_successful_extension(monkeypa # Проверки assert result is True # Автопокупка успешна assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН! - assert subscription.status == "active" + assert subscription.status == 'active' db_session.commit.assert_awaited() # Commit был вызван async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): """Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription""" - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) subscription = MagicMock() subscription.id = 789 subscription.is_trial = True # Триальная подписка! - subscription.status = "active" + subscription.status = 'active' subscription.end_date = datetime.utcnow() + timedelta(days=3) subscription.device_limit = 1 subscription.traffic_limit_gb = 10 @@ -528,67 +528,67 @@ async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): user.id = 77 user.telegram_id = 7777 user.balance_kopeks = 200_000 # Достаточно денег - user.language = "ru" + user.language = 'ru' user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": 30, - "total_price": 100_000, - "description": "Продление на 30 дней", - "device_limit": 1, - "traffic_limit_gb": 100, - "squad_uuid": None, - "consume_promo_offer": False, + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': 30, + 'total_price': 100_000, + 'description': 'Продление на 30 дней', + 'device_limit': 1, + 'traffic_limit_gb': 100, + 'squad_uuid': None, + 'consume_promo_offer': False, } # Mock: деньги списались успешно subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.subtract_user_balance", + 'app.services.subscription_auto_purchase_service.subtract_user_balance', subtract_mock, ) # Mock: extend_subscription выбрасывает ошибку! async def extend_error(db, current_subscription, days): - raise Exception("Database connection error") + raise Exception('Database connection error') monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.extend_subscription", + 'app.services.subscription_auto_purchase_service.extend_subscription', extend_error, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) # ИСПРАВЛЕНО: Добавлены недостающие моки monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_local_datetime", - lambda dt, fmt: dt.strftime(fmt) if dt else "", + 'app.services.subscription_auto_purchase_service.format_local_datetime', + lambda dt, fmt: dt.strftime(fmt) if dt else '', ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_extension_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( - "app.database.crud.subscription.get_subscription_by_user_id", + 'app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=subscription), ) @@ -607,8 +607,8 @@ async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): """Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True""" - monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) - monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено! + monkeypatch.setattr(settings, 'AUTO_PURCHASE_AFTER_TOPUP_ENABLED', True) + monkeypatch.setattr(settings, 'TRIAL_ADD_REMAINING_DAYS_TO_PAID', True) # Включено! now = datetime.utcnow() trial_end = now + timedelta(days=2) # Осталось 2 дня триала @@ -616,7 +616,7 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): subscription = MagicMock() subscription.id = 321 subscription.is_trial = True - subscription.status = "active" + subscription.status = 'active' subscription.end_date = trial_end subscription.start_date = now - timedelta(days=1) # Триал начался вчера subscription.device_limit = 1 @@ -627,25 +627,25 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): user.id = 66 user.telegram_id = 6666 user.balance_kopeks = 200_000 - user.language = "ru" + user.language = 'ru' user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { - "cart_mode": "extend", - "subscription_id": subscription.id, - "period_days": 30, # Покупает 30 дней - "total_price": 100_000, - "description": "Продление на 30 дней", - "device_limit": 1, - "traffic_limit_gb": 100, - "squad_uuid": None, - "consume_promo_offer": False, + 'cart_mode': 'extend', + 'subscription_id': subscription.id, + 'period_days': 30, # Покупает 30 дней + 'total_price': 100_000, + 'description': 'Продление на 30 дней', + 'device_limit': 1, + 'traffic_limit_gb': 100, + 'squad_uuid': None, + 'consume_promo_offer': False, } subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.subtract_user_balance", + 'app.services.subscription_auto_purchase_service.subtract_user_balance', subtract_mock, ) @@ -665,58 +665,58 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): return current_subscription monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.extend_subscription", + 'app.services.subscription_auto_purchase_service.extend_subscription', extend_with_bonus, ) create_transaction_mock = AsyncMock(return_value=MagicMock()) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.create_transaction", + 'app.services.subscription_auto_purchase_service.create_transaction', create_transaction_mock, ) service_mock = MagicMock() service_mock.update_remnawave_user = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.SubscriptionService", + 'app.services.subscription_auto_purchase_service.SubscriptionService', lambda: service_mock, ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart', AsyncMock(return_value=cart_data), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", + 'app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart', AsyncMock(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", + 'app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft', AsyncMock(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.get_texts", + 'app.services.subscription_auto_purchase_service.get_texts', lambda lang: DummyTexts(), ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_period_description", - lambda days, lang: f"{days} дней", + 'app.services.subscription_auto_purchase_service.format_period_description', + lambda days, lang: f'{days} дней', ) monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.format_local_datetime", + 'app.services.subscription_auto_purchase_service.format_local_datetime', lambda dt, fmt: dt.strftime(fmt), ) admin_service_mock = MagicMock() admin_service_mock.send_subscription_extension_notification = AsyncMock() monkeypatch.setattr( - "app.services.subscription_auto_purchase_service.AdminNotificationService", + 'app.services.subscription_auto_purchase_service.AdminNotificationService', lambda bot: admin_service_mock, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( - "app.database.crud.subscription.get_subscription_by_user_id", + 'app.database.crud.subscription.get_subscription_by_user_id', AsyncMock(return_value=subscription), ) @@ -733,6 +733,6 @@ async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): # Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных) # end_date должна быть примерно на 32 дня от оригинального trial_end - expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2) + trial_end + timedelta(days=32) # trial_end + (30 + 2) actual_delta = (subscription.end_date - trial_end).days - assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}" + assert actual_delta == 32, f'Expected 32 days extension (30 + 2 bonus), got {actual_delta}' diff --git a/tests/services/test_system_settings_env_priority.py b/tests/services/test_system_settings_env_priority.py index 243013f2..fea2f57c 100644 --- a/tests/services/test_system_settings_env_priority.py +++ b/tests/services/test_system_settings_env_priority.py @@ -1,8 +1,7 @@ -from types import SimpleNamespace -from pathlib import Path import sys +from pathlib import Path +from types import SimpleNamespace -import pytest ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: @@ -15,99 +14,99 @@ from app.services.system_settings_service import bot_configuration_service async def test_env_override_prevents_set_value(monkeypatch): bot_configuration_service.initialize_definitions() - env_value = "env_support" - monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + env_value = 'env_support' + monkeypatch.setattr(settings, 'SUPPORT_USERNAME', env_value) original_values = dict(bot_configuration_service._original_values) - original_values["SUPPORT_USERNAME"] = env_value - monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + original_values['SUPPORT_USERNAME'] = env_value + monkeypatch.setattr(bot_configuration_service, '_original_values', original_values) env_keys = set(bot_configuration_service._env_override_keys) - env_keys.add("SUPPORT_USERNAME") - monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) - monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + env_keys.add('SUPPORT_USERNAME') + monkeypatch.setattr(bot_configuration_service, '_env_override_keys', env_keys) + monkeypatch.setattr(bot_configuration_service, '_overrides_raw', {}) - async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + async def fake_upsert(db, key, value, description=None): return None monkeypatch.setattr( - "app.services.system_settings_service.upsert_system_setting", + 'app.services.system_settings_service.upsert_system_setting', fake_upsert, ) await bot_configuration_service.set_value( object(), - "SUPPORT_USERNAME", - "db_support", + 'SUPPORT_USERNAME', + 'db_support', ) - assert settings.SUPPORT_USERNAME == env_value - assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + assert env_value == settings.SUPPORT_USERNAME + assert not bot_configuration_service.has_override('SUPPORT_USERNAME') async def test_env_override_prevents_reset_value(monkeypatch): bot_configuration_service.initialize_definitions() - env_value = "env_support" - monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + env_value = 'env_support' + monkeypatch.setattr(settings, 'SUPPORT_USERNAME', env_value) original_values = dict(bot_configuration_service._original_values) - original_values["SUPPORT_USERNAME"] = env_value - monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + original_values['SUPPORT_USERNAME'] = env_value + monkeypatch.setattr(bot_configuration_service, '_original_values', original_values) env_keys = set(bot_configuration_service._env_override_keys) - env_keys.add("SUPPORT_USERNAME") - monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) - monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {"SUPPORT_USERNAME": "db"}) + env_keys.add('SUPPORT_USERNAME') + monkeypatch.setattr(bot_configuration_service, '_env_override_keys', env_keys) + monkeypatch.setattr(bot_configuration_service, '_overrides_raw', {'SUPPORT_USERNAME': 'db'}) - async def fake_delete(db, key): # noqa: ANN001 + async def fake_delete(db, key): return None monkeypatch.setattr( - "app.services.system_settings_service.delete_system_setting", + 'app.services.system_settings_service.delete_system_setting', fake_delete, ) await bot_configuration_service.reset_value( object(), - "SUPPORT_USERNAME", + 'SUPPORT_USERNAME', ) - assert settings.SUPPORT_USERNAME == env_value - assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + assert env_value == settings.SUPPORT_USERNAME + assert not bot_configuration_service.has_override('SUPPORT_USERNAME') async def test_initialize_skips_db_value_for_env_override(monkeypatch): bot_configuration_service.initialize_definitions() - env_value = "env_support" - monkeypatch.setattr(settings, "SUPPORT_USERNAME", env_value) + env_value = 'env_support' + monkeypatch.setattr(settings, 'SUPPORT_USERNAME', env_value) original_values = dict(bot_configuration_service._original_values) - original_values["SUPPORT_USERNAME"] = env_value - monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + original_values['SUPPORT_USERNAME'] = env_value + monkeypatch.setattr(bot_configuration_service, '_original_values', original_values) env_keys = set(bot_configuration_service._env_override_keys) - env_keys.add("SUPPORT_USERNAME") - monkeypatch.setattr(bot_configuration_service, "_env_override_keys", env_keys) - monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + env_keys.add('SUPPORT_USERNAME') + monkeypatch.setattr(bot_configuration_service, '_env_override_keys', env_keys) + monkeypatch.setattr(bot_configuration_service, '_overrides_raw', {}) class DummyResult: def scalars(self): return self def all(self): - return [SimpleNamespace(key="SUPPORT_USERNAME", value="db_support")] + return [SimpleNamespace(key='SUPPORT_USERNAME', value='db_support')] class DummySession: async def __aenter__(self): return self - async def __aexit__(self, exc_type, exc, tb): # noqa: ANN001 + async def __aexit__(self, exc_type, exc, tb): return False - async def execute(self, query): # noqa: ANN001 + async def execute(self, query): return DummyResult() monkeypatch.setattr( - "app.services.system_settings_service.AsyncSessionLocal", + 'app.services.system_settings_service.AsyncSessionLocal', lambda: DummySession(), ) @@ -115,45 +114,45 @@ async def test_initialize_skips_db_value_for_env_override(monkeypatch): return True monkeypatch.setattr( - "app.services.system_settings_service.ensure_default_web_api_token", + 'app.services.system_settings_service.ensure_default_web_api_token', fake_sync, raising=False, ) await bot_configuration_service.initialize() - assert settings.SUPPORT_USERNAME == env_value - assert "SUPPORT_USERNAME" not in bot_configuration_service._overrides_raw - assert not bot_configuration_service.has_override("SUPPORT_USERNAME") + assert env_value == settings.SUPPORT_USERNAME + assert 'SUPPORT_USERNAME' not in bot_configuration_service._overrides_raw + assert not bot_configuration_service.has_override('SUPPORT_USERNAME') async def test_set_value_applies_without_env_override(monkeypatch): bot_configuration_service.initialize_definitions() - monkeypatch.setattr(bot_configuration_service, "_env_override_keys", set()) - monkeypatch.setattr(bot_configuration_service, "_overrides_raw", {}) + monkeypatch.setattr(bot_configuration_service, '_env_override_keys', set()) + monkeypatch.setattr(bot_configuration_service, '_overrides_raw', {}) initial_value = True target_value = False - monkeypatch.setattr(settings, "SUPPORT_MENU_ENABLED", initial_value) + monkeypatch.setattr(settings, 'SUPPORT_MENU_ENABLED', initial_value) original_values = dict(bot_configuration_service._original_values) - original_values["SUPPORT_MENU_ENABLED"] = initial_value - monkeypatch.setattr(bot_configuration_service, "_original_values", original_values) + original_values['SUPPORT_MENU_ENABLED'] = initial_value + monkeypatch.setattr(bot_configuration_service, '_original_values', original_values) - async def fake_upsert(db, key, value, description=None): # noqa: ANN001 + async def fake_upsert(db, key, value, description=None): return None monkeypatch.setattr( - "app.services.system_settings_service.upsert_system_setting", + 'app.services.system_settings_service.upsert_system_setting', fake_upsert, ) await bot_configuration_service.set_value( object(), - "SUPPORT_MENU_ENABLED", + 'SUPPORT_MENU_ENABLED', target_value, ) assert settings.SUPPORT_MENU_ENABLED is target_value - assert bot_configuration_service.has_override("SUPPORT_MENU_ENABLED") + assert bot_configuration_service.has_override('SUPPORT_MENU_ENABLED') diff --git a/tests/services/test_traffic_monitoring_redis.py b/tests/services/test_traffic_monitoring_redis.py index 30993e31..3fb9d5af 100644 --- a/tests/services/test_traffic_monitoring_redis.py +++ b/tests/services/test_traffic_monitoring_redis.py @@ -1,15 +1,16 @@ """ Тесты для хранения snapshot трафика в Redis. """ -import pytest + from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from app.services.traffic_monitoring_service import ( - TrafficMonitoringServiceV2, TRAFFIC_SNAPSHOT_KEY, TRAFFIC_SNAPSHOT_TIME_KEY, - TRAFFIC_NOTIFICATION_CACHE_KEY, + TrafficMonitoringServiceV2, ) @@ -32,14 +33,15 @@ def mock_cache(): def sample_snapshot(): """Пример snapshot данных.""" return { - "uuid-1": 1073741824.0, # 1 GB - "uuid-2": 2147483648.0, # 2 GB - "uuid-3": 5368709120.0, # 5 GB + 'uuid-1': 1073741824.0, # 1 GB + 'uuid-2': 2147483648.0, # 2 GB + 'uuid-3': 5368709120.0, # 5 GB } # ============== Тесты сохранения snapshot в Redis ============== + async def test_save_snapshot_to_redis_success(service, mock_cache, sample_snapshot): """Тест успешного сохранения snapshot в Redis.""" mock_cache.set = AsyncMock(return_value=True) @@ -66,7 +68,7 @@ async def test_save_snapshot_to_redis_failure(service, mock_cache, sample_snapsh async def test_save_snapshot_to_redis_exception(service, mock_cache, sample_snapshot): """Тест обработки исключения при сохранении.""" - mock_cache.set = AsyncMock(side_effect=Exception("Redis error")) + mock_cache.set = AsyncMock(side_effect=Exception('Redis error')) result = await service._save_snapshot_to_redis(sample_snapshot) @@ -75,6 +77,7 @@ async def test_save_snapshot_to_redis_exception(service, mock_cache, sample_snap # ============== Тесты загрузки snapshot из Redis ============== + async def test_load_snapshot_from_redis_success(service, mock_cache, sample_snapshot): """Тест успешной загрузки snapshot из Redis.""" mock_cache.get = AsyncMock(return_value=sample_snapshot) @@ -96,7 +99,7 @@ async def test_load_snapshot_from_redis_empty(service, mock_cache): async def test_load_snapshot_from_redis_invalid_data(service, mock_cache): """Тест загрузки невалидных данных.""" - mock_cache.get = AsyncMock(return_value="not a dict") + mock_cache.get = AsyncMock(return_value='not a dict') result = await service._load_snapshot_from_redis() @@ -105,7 +108,7 @@ async def test_load_snapshot_from_redis_invalid_data(service, mock_cache): async def test_load_snapshot_from_redis_exception(service, mock_cache): """Тест обработки исключения при загрузке.""" - mock_cache.get = AsyncMock(side_effect=Exception("Redis error")) + mock_cache.get = AsyncMock(side_effect=Exception('Redis error')) result = await service._load_snapshot_from_redis() @@ -114,6 +117,7 @@ async def test_load_snapshot_from_redis_exception(service, mock_cache): # ============== Тесты времени snapshot ============== + async def test_get_snapshot_time_from_redis_success(service, mock_cache): """Тест получения времени snapshot.""" test_time = datetime(2024, 1, 15, 12, 30, 0) @@ -136,6 +140,7 @@ async def test_get_snapshot_time_from_redis_empty(service, mock_cache): # ============== Тесты has_snapshot ============== + async def test_has_snapshot_redis_exists(service, mock_cache, sample_snapshot): """Тест has_snapshot когда snapshot есть в Redis.""" mock_cache.get = AsyncMock(return_value=sample_snapshot) @@ -150,7 +155,7 @@ async def test_has_snapshot_memory_fallback(service, mock_cache): mock_cache.get = AsyncMock(return_value=None) # Устанавливаем данные в память - service._memory_snapshot = {"uuid-1": 1000.0} + service._memory_snapshot = {'uuid-1': 1000.0} service._memory_snapshot_time = datetime.utcnow() result = await service.has_snapshot() @@ -171,6 +176,7 @@ async def test_has_snapshot_none(service, mock_cache): # ============== Тесты get_snapshot_age_minutes ============== + async def test_get_snapshot_age_minutes_from_redis(service, mock_cache): """Тест возраста snapshot из Redis.""" # Snapshot создан 30 минут назад @@ -204,12 +210,13 @@ async def test_get_snapshot_age_minutes_no_snapshot(service, mock_cache): # ============== Тесты _save_snapshot (с fallback) ============== + async def test_save_snapshot_redis_success(service, mock_cache, sample_snapshot): """Тест сохранения snapshot в Redis успешно.""" mock_cache.set = AsyncMock(return_value=True) # Заполняем память чтобы проверить что она очистится - service._memory_snapshot = {"old": 123.0} + service._memory_snapshot = {'old': 123.0} service._memory_snapshot_time = datetime.utcnow() result = await service._save_snapshot(sample_snapshot) @@ -232,6 +239,7 @@ async def test_save_snapshot_fallback_to_memory(service, mock_cache, sample_snap # ============== Тесты _get_current_snapshot ============== + async def test_get_current_snapshot_from_redis(service, mock_cache, sample_snapshot): """Тест получения snapshot из Redis.""" mock_cache.get = AsyncMock(return_value=sample_snapshot) @@ -253,16 +261,17 @@ async def test_get_current_snapshot_fallback_to_memory(service, mock_cache, samp # ============== Тесты уведомлений ============== + async def test_save_notification_to_redis(service, mock_cache): """Тест сохранения времени уведомления.""" mock_cache.set = AsyncMock(return_value=True) - result = await service._save_notification_to_redis("uuid-123") + result = await service._save_notification_to_redis('uuid-123') assert result is True mock_cache.set.assert_called_once() call_args = mock_cache.set.call_args - assert "traffic:notifications:uuid-123" in call_args[0][0] + assert 'traffic:notifications:uuid-123' in call_args[0][0] async def test_get_notification_time_from_redis(service, mock_cache): @@ -270,7 +279,7 @@ async def test_get_notification_time_from_redis(service, mock_cache): test_time = datetime(2024, 1, 15, 10, 0, 0) mock_cache.get = AsyncMock(return_value=test_time.isoformat()) - result = await service._get_notification_time_from_redis("uuid-123") + result = await service._get_notification_time_from_redis('uuid-123') assert result == test_time @@ -280,7 +289,7 @@ async def test_should_send_notification_no_previous(service, mock_cache): mock_cache.get = AsyncMock(return_value=None) service._memory_notification_cache = {} - result = await service.should_send_notification("uuid-123") + result = await service.should_send_notification('uuid-123') assert result is True @@ -291,7 +300,7 @@ async def test_should_send_notification_cooldown_active(service, mock_cache): recent_time = datetime.utcnow() - timedelta(minutes=5) mock_cache.get = AsyncMock(return_value=recent_time.isoformat()) - result = await service.should_send_notification("uuid-123") + result = await service.should_send_notification('uuid-123') assert result is False @@ -302,7 +311,7 @@ async def test_should_send_notification_cooldown_expired(service, mock_cache): old_time = datetime.utcnow() - timedelta(minutes=120) mock_cache.get = AsyncMock(return_value=old_time.isoformat()) - result = await service.should_send_notification("uuid-123") + result = await service.should_send_notification('uuid-123') assert result is True @@ -311,7 +320,7 @@ async def test_record_notification_redis(service, mock_cache): """Тест record_notification сохраняет в Redis.""" mock_cache.set = AsyncMock(return_value=True) - await service.record_notification("uuid-123") + await service.record_notification('uuid-123') mock_cache.set.assert_called_once() @@ -320,19 +329,22 @@ async def test_record_notification_fallback_to_memory(service, mock_cache): """Тест record_notification с fallback на память.""" mock_cache.set = AsyncMock(return_value=False) - await service.record_notification("uuid-123") + await service.record_notification('uuid-123') - assert "uuid-123" in service._memory_notification_cache + assert 'uuid-123' in service._memory_notification_cache # ============== Тесты create_initial_snapshot ============== + async def test_create_initial_snapshot_uses_existing_redis(service, mock_cache, sample_snapshot): """Тест что create_initial_snapshot использует существующий snapshot из Redis.""" - mock_cache.get = AsyncMock(side_effect=[ - sample_snapshot, # _load_snapshot_from_redis - (datetime.utcnow() - timedelta(minutes=10)).isoformat(), # _get_snapshot_time_from_redis - ]) + mock_cache.get = AsyncMock( + side_effect=[ + sample_snapshot, # _load_snapshot_from_redis + (datetime.utcnow() - timedelta(minutes=10)).isoformat(), # _get_snapshot_time_from_redis + ] + ) with patch.object(service, 'get_all_users_with_traffic', new_callable=AsyncMock) as mock_get_users: result = await service.create_initial_snapshot() @@ -349,7 +361,7 @@ async def test_create_initial_snapshot_creates_new(service, mock_cache): # Мокаем пользователей из API mock_user = MagicMock() - mock_user.uuid = "uuid-1" + mock_user.uuid = 'uuid-1' mock_user.user_traffic = MagicMock() mock_user.user_traffic.used_traffic_bytes = 1073741824 # 1 GB @@ -364,17 +376,18 @@ async def test_create_initial_snapshot_creates_new(service, mock_cache): # ============== Тесты cleanup_notification_cache ============== + async def test_cleanup_notification_cache_removes_old(service, mock_cache): """Тест очистки старых записей из памяти.""" old_time = datetime.utcnow() - timedelta(hours=25) recent_time = datetime.utcnow() - timedelta(hours=1) service._memory_notification_cache = { - "uuid-old": old_time, - "uuid-recent": recent_time, + 'uuid-old': old_time, + 'uuid-recent': recent_time, } await service.cleanup_notification_cache() - assert "uuid-old" not in service._memory_notification_cache - assert "uuid-recent" in service._memory_notification_cache + assert 'uuid-old' not in service._memory_notification_cache + assert 'uuid-recent' in service._memory_notification_cache diff --git a/tests/services/test_yookassa_service_adapter.py b/tests/services/test_yookassa_service_adapter.py index 014dc75d..2d1dca5e 100644 --- a/tests/services/test_yookassa_service_adapter.py +++ b/tests/services/test_yookassa_service_adapter.py @@ -3,25 +3,27 @@ from __future__ import annotations import asyncio +import sys from datetime import datetime from pathlib import Path -import sys from types import SimpleNamespace import pytest + ROOT_DIR = Path(__file__).resolve().parents[2] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) -from yookassa import Configuration, Payment as YooKassaPayment # type: ignore # noqa: E402 -from app.config import settings # noqa: E402 -from app.services.yookassa_service import YooKassaService # noqa: E402 +from yookassa import Configuration, Payment as YooKassaPayment # type: ignore + +from app.config import settings +from app.services.yookassa_service import YooKassaService @pytest.fixture def anyio_backend() -> str: - return "asyncio" + return 'asyncio' class DummyLoop: @@ -30,84 +32,84 @@ class DummyLoop: def _prepare_config(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop123", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "secret123", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_RETURN_URL", "https://example.com/return", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_VAT_CODE", 1, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_MODE", "full_payment", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_SUBJECT", "service", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SHOP_ID', 'shop123', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SECRET_KEY', 'secret123', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_RETURN_URL', 'https://example.com/return', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_VAT_CODE', 1, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service', raising=False) def test_init_without_credentials(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SHOP_ID', '', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SECRET_KEY', '', raising=False) service = YooKassaService() assert service.configured is False - assert service.return_url == "https://t.me/" + assert service.return_url == 'https://t.me/' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: _prepare_config(monkeypatch) - monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False) - monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False) + monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) captured_config: dict[str, tuple[str, str]] = {} def fake_configure(shop_id: str, secret_key: str) -> None: - captured_config["values"] = (shop_id, secret_key) + captured_config['values'] = (shop_id, secret_key) - monkeypatch.setattr(Configuration, "configure", fake_configure, raising=False) + monkeypatch.setattr(Configuration, 'configure', fake_configure, raising=False) response_obj = SimpleNamespace( - id="yk_1", - status="pending", + id='yk_1', + status='pending', paid=False, - confirmation=SimpleNamespace(confirmation_url="https://yk/confirm"), - metadata={"meta": "value"}, - amount=SimpleNamespace(value="140.00", currency="RUB"), + confirmation=SimpleNamespace(confirmation_url='https://yk/confirm'), + metadata={'meta': 'value'}, + amount=SimpleNamespace(value='140.00', currency='RUB'), refundable=True, created_at=datetime(2024, 1, 1, 12, 0, 0), - description="Desc", + description='Desc', test=False, ) monkeypatch.setattr( YooKassaPayment, - "create", + 'create', staticmethod(lambda payload, key: response_obj), raising=False, ) service = YooKassaService() - monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 'fallback@example.com', raising=False) result = await service.create_payment( amount=140.0, - currency="RUB", - description="Пополнение", - metadata={"order": "1"}, - receipt_email="user@example.com", + currency='RUB', + description='Пополнение', + metadata={'order': '1'}, + receipt_email='user@example.com', ) assert service.configured is True - assert captured_config["values"] == ("shop123", "secret123") + assert captured_config['values'] == ('shop123', 'secret123') assert result is not None - assert result["id"] == "yk_1" - assert result["confirmation_url"] == "https://yk/confirm" - assert result["amount_value"] == 140.0 - assert result["status"] == "pending" + assert result['id'] == 'yk_1' + assert result['confirmation_url'] == 'https://yk/confirm' + assert result['amount_value'] == 140.0 + assert result['status'] == 'pending' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch) -> None: _prepare_config(monkeypatch) - monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False) - monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False) - monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', None, raising=False) + monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False) + monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) monkeypatch.setattr( YooKassaPayment, - "create", + 'create', staticmethod(lambda payload, key: SimpleNamespace()), raising=False, ) @@ -115,51 +117,51 @@ async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch) service = YooKassaService() result = await service.create_payment( amount=10, - currency="RUB", - description="desc", + currency='RUB', + description='desc', metadata={}, ) assert result is not None - assert result.get("error") is True + assert result.get('error') is True -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_returns_none_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SHOP_ID', '', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SECRET_KEY', '', raising=False) service = YooKassaService() result = await service.create_payment( amount=10, - currency="RUB", - description="desc", + currency='RUB', + description='desc', metadata={}, ) assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: _prepare_config(monkeypatch) - monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) - monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False) + monkeypatch.setattr(asyncio, 'get_running_loop', lambda: DummyLoop(), raising=False) + monkeypatch.setattr(Configuration, 'configure', lambda *args, **kwargs: None, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL', 'fallback@example.com', raising=False) response_obj = SimpleNamespace( - id="sbp_001", - status="pending", + id='sbp_001', + status='pending', paid=False, - confirmation=SimpleNamespace(confirmation_url="https://sbp/confirm"), - metadata={"meta": "value"}, - amount=SimpleNamespace(value="200.00", currency="RUB"), + confirmation=SimpleNamespace(confirmation_url='https://sbp/confirm'), + metadata={'meta': 'value'}, + amount=SimpleNamespace(value='200.00', currency='RUB'), refundable=False, created_at=datetime(2024, 2, 1, 9, 0, 0), - description="SBP payment", + description='SBP payment', test=True, ) monkeypatch.setattr( YooKassaPayment, - "create", + 'create', staticmethod(lambda payload, key: response_obj), raising=False, ) @@ -167,13 +169,13 @@ async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> No service = YooKassaService() result = await service.create_sbp_payment( amount=200.0, - currency="rub", - description="Оплата", - metadata={"type": "sbp"}, - receipt_phone="+70000000000", + currency='rub', + description='Оплата', + metadata={'type': 'sbp'}, + receipt_phone='+70000000000', ) assert result is not None - assert result["id"] == "sbp_001" - assert result["confirmation_url"] == "https://sbp/confirm" - assert result["status"] == "pending" + assert result['id'] == 'sbp_001' + assert result['confirmation_url'] == 'https://sbp/confirm' + assert result['status'] == 'pending' diff --git a/tests/test_device_limit_resolution.py b/tests/test_device_limit_resolution.py index cb4ccb40..baad07cc 100644 --- a/tests/test_device_limit_resolution.py +++ b/tests/test_device_limit_resolution.py @@ -3,8 +3,8 @@ import pytest from app.utils import subscription_utils from app.utils.subscription_utils import ( resolve_hwid_device_limit, - resolve_simple_subscription_device_limit, resolve_hwid_device_limit_for_payload, + resolve_simple_subscription_device_limit, ) @@ -38,7 +38,7 @@ class StubSettings: @pytest.mark.parametrize( - "forced_amount, expected", + 'forced_amount, expected', [ (None, None), (0, 0), @@ -50,7 +50,7 @@ def test_resolve_hwid_device_limit_disabled_mode(monkeypatch, forced_amount, exp monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings( enabled=False, disabled_amount=forced_amount, @@ -66,7 +66,7 @@ def test_resolve_hwid_device_limit_enabled_mode(monkeypatch): monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=True, disabled_amount=None), ) @@ -78,7 +78,7 @@ def test_resolve_hwid_device_limit_enabled_ignores_non_positive(monkeypatch): monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=True, disabled_amount=None), ) @@ -90,7 +90,7 @@ def test_resolve_hwid_device_limit_for_payload_returns_subscription_limit(monkey monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None), ) @@ -103,7 +103,7 @@ def test_resolve_hwid_device_limit_for_payload_ignores_non_positive(monkeypatch) monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=False, disabled_amount=None, disabled_selection_amount=None), ) @@ -116,7 +116,7 @@ def test_resolve_hwid_device_limit_for_payload_prefers_forced_limit(monkeypatch) monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=False, disabled_amount=7, disabled_selection_amount=7), ) @@ -128,7 +128,7 @@ def test_resolve_hwid_device_limit_for_payload_handles_zero(monkeypatch): monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings(enabled=False, disabled_amount=0, disabled_selection_amount=0), ) @@ -137,7 +137,7 @@ def test_resolve_hwid_device_limit_for_payload_handles_zero(monkeypatch): @pytest.mark.parametrize( - "enabled, simple_limit, disabled_amount, disabled_selection_amount, expected", + 'enabled, simple_limit, disabled_amount, disabled_selection_amount, expected', [ (True, 4, None, None, 4), (False, 4, None, None, 4), @@ -155,7 +155,7 @@ def test_resolve_simple_subscription_device_limit( ): monkeypatch.setattr( subscription_utils, - "settings", + 'settings', StubSettings( enabled=enabled, disabled_amount=disabled_amount, diff --git a/tests/test_menu_subscription_status.py b/tests/test_menu_subscription_status.py index f8832cc3..91217a9a 100644 --- a/tests/test_menu_subscription_status.py +++ b/tests/test_menu_subscription_status.py @@ -22,9 +22,9 @@ def _build_user_with_subscription(actual_status: str, is_trial: bool, days_left: def test_get_subscription_status_marks_trial_as_trial(): texts = DummyTexts() - user = _build_user_with_subscription(actual_status="active", is_trial=True, days_left=5) + user = _build_user_with_subscription(actual_status='active', is_trial=True, days_left=5) status_text = _get_subscription_status(user, texts) - assert "Тестовая подписка" in status_text - assert "Активна" not in status_text + assert 'Тестовая подписка' in status_text + assert 'Активна' not in status_text diff --git a/tests/test_miniapp_payments.py b/tests/test_miniapp_payments.py index ffbffdc4..7188b5c9 100644 --- a/tests/test_miniapp_payments.py +++ b/tests/test_miniapp_payments.py @@ -7,6 +7,7 @@ from typing import Any import pytest + ROOT_DIR = Path(__file__).resolve().parents[1] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) @@ -17,8 +18,8 @@ BACKUP_DIR.mkdir(parents=True, exist_ok=True) os.environ.setdefault('BOT_TOKEN', 'test-token') from app.config import settings -from app.webapi.routes import miniapp from app.database.models import PaymentMethod +from app.services.payment.cryptobot import CryptoBotPaymentMixin from app.services.subscription_renewal_service import ( SubscriptionRenewalPricing, SubscriptionRenewalResult, @@ -26,7 +27,7 @@ from app.services.subscription_renewal_service import ( decode_payment_payload, encode_payment_payload, ) -from app.services.payment.cryptobot import CryptoBotPaymentMixin +from app.webapi.routes import miniapp from app.webapi.schemas.miniapp import ( MiniAppPaymentCreateRequest, MiniAppPaymentIntegrationType, @@ -88,7 +89,7 @@ def test_encode_decode_renewal_payload_preserves_snapshot(): assert decoded.pricing_snapshot.get('server_ids') == [1, 2] -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_submit_subscription_renewal_uses_balance_when_sufficient(monkeypatch): monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False) monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False) @@ -122,18 +123,18 @@ async def test_submit_subscription_renewal_uses_balance_when_sufficient(monkeypa details={}, ) - async def fake_authorize(init_data, db): # noqa: ARG001 + async def fake_authorize(init_data, db): return user - def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001 + def fake_ensure(subscription_user, allowed_statuses=None): return subscription - async def fake_calculate(db, u, sub, period): # noqa: ARG001 + async def fake_calculate(db, u, sub, period): return pricing_model captured: dict[str, Any] = {} - async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001 + async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): charge = charge_balance_amount if charge_balance_amount is not None else pricing.final_total captured['charge'] = charge captured['description'] = description @@ -167,7 +168,7 @@ async def test_submit_subscription_renewal_uses_balance_when_sufficient(monkeypa assert captured['charge'] == 10000 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_submit_subscription_renewal_returns_cryptobot_invoice(monkeypatch): monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False) monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False) @@ -202,13 +203,13 @@ async def test_submit_subscription_renewal_returns_cryptobot_invoice(monkeypatch details={}, ) - async def fake_authorize(init_data, db): # noqa: ARG001 + async def fake_authorize(init_data, db): return user - def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001 + def fake_ensure(subscription_user, allowed_statuses=None): return subscription - async def fake_calculate(db, u, sub, period): # noqa: ARG001 + async def fake_calculate(db, u, sub, period): return pricing_model created_calls: dict[str, Any] = {} @@ -258,7 +259,7 @@ async def test_submit_subscription_renewal_returns_cryptobot_invoice(monkeypatch assert created_calls.get('description') == 'Продление подписки на 30 дней' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_submit_subscription_renewal_rounds_up_cryptobot_amount(monkeypatch): monkeypatch.setattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False, raising=False) monkeypatch.setattr(settings, 'BOT_TOKEN', 'token', raising=False) @@ -293,13 +294,13 @@ async def test_submit_subscription_renewal_rounds_up_cryptobot_amount(monkeypatc details={}, ) - async def fake_authorize(init_data, db): # noqa: ARG001 + async def fake_authorize(init_data, db): return user - def fake_ensure(subscription_user, allowed_statuses=None): # noqa: ARG001 + def fake_ensure(subscription_user, allowed_statuses=None): return subscription - async def fake_calculate(db, u, sub, period): # noqa: ARG001 + async def fake_calculate(db, u, sub, period): return pricing_model captured: dict[str, Any] = {} @@ -342,7 +343,7 @@ async def test_submit_subscription_renewal_rounds_up_cryptobot_amount(monkeypatc assert response.payment_amount_kopeks == 9512 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch): module = sys.modules['app.services.payment.cryptobot'] mixin = CryptoBotPaymentMixin() @@ -376,19 +377,21 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch): payment = types.SimpleNamespace(invoice_id='INV-1', user_id=5) - async def fake_get_user_by_id(db, user_id): # noqa: ARG001 + async def fake_get_user_by_id(db, user_id): return user if user_id == 5 else None - monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id)) + monkeypatch.setitem( + sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id) + ) - async def fail_calculate(*args, **kwargs): # noqa: ARG001 + async def fail_calculate(*args, **kwargs): raise AssertionError('calculate_pricing should not be called when snapshot is present') monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate) captured: dict[str, Any] = {} - async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001 + async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): captured['pricing'] = pricing captured['charge'] = charge_balance_amount captured['description'] = description @@ -403,7 +406,7 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch): monkeypatch.setattr(module.renewal_service, 'finalize', fake_finalize) - async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001 + async def fake_link(db, invoice_id, transaction_id): captured['linked'] = (invoice_id, transaction_id) cryptobot_crud = types.SimpleNamespace(link_cryptobot_payment_to_transaction=fake_link) @@ -423,7 +426,7 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch): assert captured['linked'] == ('INV-1', 999) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkeypatch): module = sys.modules['app.services.payment.cryptobot'] mixin = CryptoBotPaymentMixin() @@ -441,10 +444,12 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey payment = types.SimpleNamespace(invoice_id='INV-2', user_id=8) - async def fake_get_user_by_id(db, user_id): # noqa: ARG001 + async def fake_get_user_by_id(db, user_id): return user if user_id == 8 else None - monkeypatch.setitem(sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id)) + monkeypatch.setitem( + sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id) + ) recalculated_pricing = SubscriptionRenewalPricing( period_days=30, @@ -461,14 +466,14 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey details={}, ) - async def fake_calculate(db, u, sub, period): # noqa: ARG001 + async def fake_calculate(db, u, sub, period): return recalculated_pricing monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate) captured: dict[str, Any] = {} - async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001 + async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): captured['pricing'] = pricing captured['charge'] = charge_balance_amount return SubscriptionRenewalResult( @@ -498,7 +503,7 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey assert captured['charge'] == 4000 -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch): module = sys.modules['app.services.payment.cryptobot'] mixin = CryptoBotPaymentMixin() @@ -545,7 +550,7 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch description='Продление подписки', ) - async def fake_get_user_by_id(db, user_id): # noqa: ARG001 + async def fake_get_user_by_id(db, user_id): return user if user_id == 21 else None monkeypatch.setitem( @@ -554,14 +559,14 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch types.SimpleNamespace(get_user_by_id=fake_get_user_by_id), ) - async def fail_calculate(*args, **kwargs): # noqa: ARG001 + async def fail_calculate(*args, **kwargs): raise AssertionError('calculate_pricing should not be called') monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate) captured: dict[str, Any] = {} - async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): # noqa: ARG001 + async def fake_finalize(db, u, sub, pricing, *, charge_balance_amount=None, description=None, payment_method=None): captured['pricing'] = pricing captured['charge'] = charge_balance_amount captured['description'] = description @@ -578,16 +583,16 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch linked: dict[str, Any] = {} - async def fake_get(db, invoice_id): # noqa: ARG001 + async def fake_get(db, invoice_id): return payment if invoice_id == payment.invoice_id else None - async def fake_update(db, invoice_id, status, paid_at): # noqa: ARG001 + async def fake_update(db, invoice_id, status, paid_at): if invoice_id == payment.invoice_id: payment.status = status payment.paid_at = paid_at return payment - async def fake_link(db, invoice_id, transaction_id): # noqa: ARG001 + async def fake_link(db, invoice_id, transaction_id): linked['value'] = (invoice_id, transaction_id) return payment @@ -619,7 +624,7 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch assert linked['value'] == (payment.invoice_id, 1234) -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_link_pal24_uses_selected_option(monkeypatch): monkeypatch.setattr(settings, 'PAL24_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'PAL24_API_TOKEN', 'token', raising=False) @@ -666,7 +671,7 @@ async def test_create_payment_link_pal24_uses_selected_option(monkeypatch): assert captured_calls and captured_calls[0]['payment_method'] == 'card' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_link_wata_returns_payload(monkeypatch): monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False) @@ -717,7 +722,7 @@ async def test_create_payment_link_wata_returns_payload(monkeypatch): assert captured_call.get('description') -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_resolve_yookassa_status_includes_identifiers(monkeypatch): payment = types.SimpleNamespace( id=55, @@ -764,7 +769,7 @@ async def test_resolve_yookassa_status_includes_identifiers(monkeypatch): assert result.extra['started_at'] == '2024-01-01T00:00:00Z' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_resolve_payment_status_supports_yookassa_sbp(monkeypatch): payment = types.SimpleNamespace( id=77, @@ -780,10 +785,10 @@ async def test_resolve_payment_status_supports_yookassa_sbp(monkeypatch): yookassa_payment_id='yk_sbp_1', ) - async def fake_get_by_local_id(db, local_id): # noqa: ARG001 + async def fake_get_by_local_id(db, local_id): return payment if local_id == 77 else None - async def fake_get_by_id(db, payment_id): # noqa: ARG001 + async def fake_get_by_id(db, payment_id): return None stub_module = types.SimpleNamespace( @@ -816,7 +821,7 @@ async def test_resolve_payment_status_supports_yookassa_sbp(monkeypatch): assert result.extra['started_at'] == '2024-05-01T10:00:00Z' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_resolve_pal24_status_includes_identifiers(monkeypatch): async def fake_get_pal24_payment_by_bill_id(db, bill_id): return None @@ -879,7 +884,7 @@ async def test_resolve_pal24_status_includes_identifiers(monkeypatch): assert result.extra['remote_status'] == 'PAID' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_resolve_wata_payment_status_success(): paid_at = datetime.utcnow() payment = types.SimpleNamespace( @@ -898,7 +903,7 @@ async def test_resolve_wata_payment_status_success(): ) class StubWataService: - async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002 + async def get_wata_payment_status(self, db, local_payment_id): assert local_payment_id == 404 return { 'payment': payment, @@ -931,7 +936,7 @@ async def test_resolve_wata_payment_status_success(): assert result.extra['started_at'] == '2024-06-01T12:00:00Z' -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_resolve_wata_payment_status_uses_payment_link_lookup(monkeypatch): created_at = datetime.utcnow() payment = types.SimpleNamespace( @@ -949,14 +954,14 @@ async def test_resolve_wata_payment_status_uses_payment_link_lookup(monkeypatch) created_at=created_at, ) - async def fake_get_wata_payment_by_link_id(db, link_id): # noqa: ARG001 + async def fake_get_wata_payment_by_link_id(db, link_id): assert link_id == 'wata_lookup' return payment monkeypatch.setattr(miniapp, 'get_wata_payment_by_link_id', fake_get_wata_payment_by_link_id) class StubWataService: - async def get_wata_payment_status(self, db, local_payment_id): # noqa: ARG002 + async def get_wata_payment_status(self, db, local_payment_id): assert local_payment_id == 505 return { 'payment': payment, @@ -985,7 +990,7 @@ async def test_resolve_wata_payment_status_uses_payment_link_lookup(monkeypatch) assert 'transaction' not in result.extra -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_create_payment_link_stars_normalizes_amount(monkeypatch): monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 1000.0, raising=False) @@ -1049,7 +1054,7 @@ async def test_create_payment_link_stars_normalizes_amount(monkeypatch): assert captured.get('session_closed') is True -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch): monkeypatch.setattr(settings, 'TELEGRAM_STARS_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'TELEGRAM_STARS_RATE_RUB', 999.99, raising=False) @@ -1071,7 +1076,7 @@ async def test_get_payment_methods_exposes_stars_min_amount(monkeypatch): assert stars_method.iframe_config is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_payment_methods_includes_wata(monkeypatch): monkeypatch.setattr(settings, 'WATA_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'WATA_ACCESS_TOKEN', 'token', raising=False) @@ -1097,7 +1102,7 @@ async def test_get_payment_methods_includes_wata(monkeypatch): assert wata_method.iframe_config is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_get_payment_methods_marks_mulenpay_iframe(monkeypatch): monkeypatch.setattr(settings, 'MULENPAY_ENABLED', True, raising=False) monkeypatch.setattr(settings, 'MULENPAY_API_KEY', 'api-key', raising=False) @@ -1119,7 +1124,9 @@ async def test_get_payment_methods_marks_mulenpay_iframe(monkeypatch): assert mulenpay_method.integration_type == MiniAppPaymentIntegrationType.IFRAME assert mulenpay_method.iframe_config is not None assert str(mulenpay_method.iframe_config.expected_origin) == 'https://checkout.example' -@pytest.mark.anyio("asyncio") + + +@pytest.mark.anyio('asyncio') async def test_find_recent_deposit_ignores_transactions_before_attempt(): started_at = datetime(2024, 5, 1, 12, 0, 0) @@ -1141,7 +1148,7 @@ async def test_find_recent_deposit_ignores_transactions_before_attempt(): def __init__(self, value): self._value = value - async def execute(self, query): # noqa: ARG002 + async def execute(self, query): return DummyResult(self._value) result = await miniapp._find_recent_deposit( @@ -1155,7 +1162,7 @@ async def test_find_recent_deposit_ignores_transactions_before_attempt(): assert result is None -@pytest.mark.anyio("asyncio") +@pytest.mark.anyio('asyncio') async def test_find_recent_deposit_accepts_recent_transactions(): started_at = datetime(2024, 5, 1, 12, 0, 0) @@ -1177,7 +1184,7 @@ async def test_find_recent_deposit_accepts_recent_transactions(): def __init__(self, value): self._value = value - async def execute(self, query): # noqa: ARG002 + async def execute(self, query): return DummyResult(self._value) result = await miniapp._find_recent_deposit( diff --git a/tests/test_promo_group_base_discounts.py b/tests/test_promo_group_base_discounts.py index 1243b404..bb50f683 100644 --- a/tests/test_promo_group_base_discounts.py +++ b/tests/test_promo_group_base_discounts.py @@ -6,24 +6,21 @@ from app.database.models import PromoGroup @pytest.fixture def base_discount_settings(monkeypatch): - monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED", True) - monkeypatch.setattr(settings, "BASE_PROMO_GROUP_PERIOD_DISCOUNTS", "60:15") - yield + monkeypatch.setattr(settings, 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED', True) + monkeypatch.setattr(settings, 'BASE_PROMO_GROUP_PERIOD_DISCOUNTS', '60:15') def test_base_promo_discount_applies_to_all_categories(base_discount_settings): - promo_group = PromoGroup(name="Default", is_default=True) + promo_group = PromoGroup(name='Default', is_default=True) - assert promo_group.get_discount_percent("period", 60) == 15 - assert promo_group.get_discount_percent("servers", 60) == 15 - assert promo_group.get_discount_percent("traffic", 60) == 15 - assert promo_group.get_discount_percent("devices", 60) == 15 + assert promo_group.get_discount_percent('period', 60) == 15 + assert promo_group.get_discount_percent('servers', 60) == 15 + assert promo_group.get_discount_percent('traffic', 60) == 15 + assert promo_group.get_discount_percent('devices', 60) == 15 def test_specific_category_discount_overrides_base(base_discount_settings): - promo_group = PromoGroup( - name="Default", is_default=True, server_discount_percent=5 - ) + promo_group = PromoGroup(name='Default', is_default=True, server_discount_percent=5) - assert promo_group.get_discount_percent("servers", 60) == 5 - assert promo_group.get_discount_percent("devices", 60) == 15 + assert promo_group.get_discount_percent('servers', 60) == 5 + assert promo_group.get_discount_percent('devices', 60) == 15 diff --git a/tests/test_subscription_cart_integration.py b/tests/test_subscription_cart_integration.py index 7eaf2cd3..1bf1279e 100644 --- a/tests/test_subscription_cart_integration.py +++ b/tests/test_subscription_cart_integration.py @@ -1,17 +1,19 @@ -import pytest from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from aiogram.fsm.context import FSMContext from aiogram.types import ( CallbackQuery, - User as TgUser, - Message, InlineKeyboardButton, InlineKeyboardMarkup, + Message, ) from sqlalchemy.ext.asyncio import AsyncSession -from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart + +from app.database.models import User from app.handlers.subscription.autopay import handle_subscription_cancel -from app.database.models import User, Subscription +from app.handlers.subscription.purchase import clear_saved_cart, return_to_saved_cart, save_cart_and_redirect_to_topup + @pytest.fixture def mock_callback_query(): @@ -19,15 +21,16 @@ def mock_callback_query(): callback.message = AsyncMock(spec=Message) callback.message.edit_text = AsyncMock() callback.answer = AsyncMock() - callback.data = "subscription_confirm" + callback.data = 'subscription_confirm' return callback + @pytest.fixture def mock_user(): user = AsyncMock(spec=User) user.id = 12345 user.telegram_id = 12345 - user.language = "ru" + user.language = 'ru' user.balance_kopeks = 10000 user.subscription = None user.has_had_paid_subscription = False @@ -38,44 +41,44 @@ def mock_user(): user.promo_offer_discount_expires_at = None return user + @pytest.fixture def mock_db(): db = AsyncMock(spec=AsyncSession) return db + @pytest.fixture def mock_state(): state = AsyncMock(spec=FSMContext) - state.get_data = AsyncMock(return_value={ - 'period_days': 30, - 'countries': ['ru'], - 'devices': 2, - 'traffic_gb': 10, - 'total_price': 50000 - }) + state.get_data = AsyncMock( + return_value={'period_days': 30, 'countries': ['ru'], 'devices': 2, 'traffic_gb': 10, 'total_price': 50000} + ) state.set_data = AsyncMock() state.update_data = AsyncMock() state.set_state = AsyncMock() state.clear = AsyncMock() return state + async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db): """Тест сохранения корзины и перенаправления к пополнению""" # Мокаем все зависимости - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, \ - patch('app.localization.texts.get_texts') as mock_get_texts: - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, + patch('app.localization.texts.get_texts') as mock_get_texts, + ): # Подготовим моки mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" + mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts missing_amount = 40000 # 50000 - 10000 = 40000 @@ -106,6 +109,7 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, # mock_callback_query.answer не должен быть вызван mock_callback_query.answer.assert_not_called() + async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с достаточным балансом""" # Подготовим данные корзины @@ -116,31 +120,32 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc 'traffic_gb': 20, 'total_price': 30000, # Меньше, чем баланс пользователя (50000) 'saved_cart': True, - 'user_id': mock_user.id + 'user_id': mock_user.id, } # Мокаем все зависимости - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ - patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ - patch('app.localization.texts.get_texts') as mock_get_texts, \ - patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ - patch('app.handlers.subscription.purchase._prepare_subscription_summary') as mock_prepare_summary: - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, + patch('app.localization.texts.get_texts') as mock_get_texts, + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, + patch('app.handlers.subscription.purchase._prepare_subscription_summary') as mock_prepare_summary, + ): # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock(return_value=True) - mock_prepare_summary.return_value = ("summary", {}) + mock_prepare_summary.return_value = ('summary', {}) mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] - mock_format_period.return_value = "30 дней" + mock_format_period.return_value = '30 дней' mock_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" + mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts # Увеличиваем баланс пользователя, чтобы его хватило @@ -176,30 +181,31 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches( } confirm_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]] + inline_keyboard=[[InlineKeyboardButton(text='Подтвердить', callback_data='confirm')]] ) existing_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="Подтвердить", callback_data="confirm")]] + inline_keyboard=[[InlineKeyboardButton(text='Подтвердить', callback_data='confirm')]] ) - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ - patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ - patch('app.localization.texts.get_texts') as mock_get_texts, \ - patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ - patch('app.handlers.subscription.purchase.settings') as mock_settings: - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, + patch('app.localization.texts.get_texts') as mock_get_texts, + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, + patch('app.handlers.subscription.purchase.settings') as mock_settings, + ): mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock() mock_get_countries.return_value = [ {'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}, ] - mock_format_period.return_value = "60 дней" + mock_format_period.return_value = '60 дней' mock_keyboard_func.return_value = confirm_keyboard mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" + mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts mock_settings.is_devices_selection_enabled.return_value = True @@ -208,13 +214,13 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches( mock_user.balance_kopeks = 50000 summary_text = ( - "🛒 Восстановленная корзина\n\n" - "📅 Период: 60 дней\n" - "📊 Трафик: 40 ГБ\n" - "🌍 Страны: Russia, USA\n" - "📱 Устройства: 3\n\n" - "💎 Общая стоимость: 440 ₽\n\n" - "Подтверждаете покупку?" + '🛒 Восстановленная корзина\n\n' + '📅 Период: 60 дней\n' + '📊 Трафик: 40 ГБ\n' + '🌍 Страны: Russia, USA\n' + '📱 Устройства: 3\n\n' + '💎 Общая стоимость: 440 ₽\n\n' + 'Подтверждаете покупку?' ) mock_callback_query.message.text = summary_text @@ -223,7 +229,7 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches( await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) mock_callback_query.message.edit_text.assert_not_called() - mock_callback_query.answer.assert_called_once_with("✅ Корзина восстановлена!") + mock_callback_query.answer.assert_called_once_with('✅ Корзина восстановлена!') mock_state.set_data.assert_called_once_with(cart_data) mock_state.set_state.assert_called_once() mock_cart_service.save_user_cart.assert_not_called() @@ -255,26 +261,30 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled( 'total_devices_price': 0, } - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, \ - patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, \ - patch('app.localization.texts.get_texts') as mock_get_texts, \ - patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, \ - patch('app.handlers.subscription.purchase.settings') as mock_settings, \ - patch('app.handlers.subscription.pricing._prepare_subscription_summary', new=AsyncMock(return_value=("ignored", sanitized_summary_data))): - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, + patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, + patch('app.localization.texts.get_texts') as mock_get_texts, + patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, + patch('app.handlers.subscription.purchase.settings') as mock_settings, + patch( + 'app.handlers.subscription.pricing._prepare_subscription_summary', + new=AsyncMock(return_value=('ignored', sanitized_summary_data)), + ), + ): mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock() mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] - mock_format_period.return_value = "30 дней" + mock_format_period.return_value = '30 дней' mock_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="✅", callback_data="confirm")]] + inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" - mock_texts.t = lambda key, default=None: default or "" + mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' + mock_texts.t = lambda key, default=None: default or '' mock_get_texts.return_value = mock_texts mock_settings.is_devices_selection_enabled.return_value = False @@ -299,10 +309,11 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled( assert normalized_data['saved_cart'] is True edited_text = mock_callback_query.message.edit_text.call_args[0][0] - assert "📱" not in edited_text + assert '📱' not in edited_text mock_callback_query.answer.assert_called_once() + async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с недостаточным балансом""" # Подготовим данные корзины @@ -313,25 +324,26 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock 'traffic_gb': 20, 'total_price': 50000, # Больше, чем баланс пользователя (10000) 'saved_cart': True, - 'user_id': mock_user.id + 'user_id': mock_user.id, } # Мокаем все зависимости - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.localization.texts.get_texts') as mock_get_texts, \ - patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func: - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.localization.texts.get_texts') as mock_get_texts, + patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func, + ): # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_keyboard = InlineKeyboardMarkup( - inline_keyboard=[[InlineKeyboardButton(text="Пополнить", callback_data="topup")]] + inline_keyboard=[[InlineKeyboardButton(text='Пополнить', callback_data='topup')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() - mock_texts.format_price = lambda x: f"{x/100:.0f} ₽" + mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_texts.t = lambda key, default: default mock_get_texts.return_value = mock_texts @@ -351,12 +363,14 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock # (ответ отправляется через return до вызова callback.answer()) mock_callback_query.answer.assert_not_called() + async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Тест очистки сохраненной корзины""" # Мокаем все зависимости - with patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, \ - patch('app.handlers.menu.show_main_menu') as mock_show_main_menu: - + with ( + patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, + patch('app.handlers.menu.show_main_menu') as mock_show_main_menu, + ): mock_cart_service.delete_user_cart = AsyncMock(return_value=True) mock_show_main_menu.return_value = AsyncMock() @@ -372,16 +386,18 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock # Проверяем, что вызван answer mock_callback_query.answer.assert_called_once() + async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Отмена покупки должна очищать сохраненную корзину""" mock_clear_draft = AsyncMock() mock_show_main_menu = AsyncMock() - with patch('app.handlers.subscription.autopay.user_cart_service') as mock_cart_service, \ - patch('app.handlers.subscription.autopay.clear_subscription_checkout_draft', new=mock_clear_draft), \ - patch('app.localization.texts.get_texts', return_value=MagicMock()) as _, \ - patch('app.handlers.menu.show_main_menu', new=mock_show_main_menu): - + with ( + patch('app.handlers.subscription.autopay.user_cart_service') as mock_cart_service, + patch('app.handlers.subscription.autopay.clear_subscription_checkout_draft', new=mock_clear_draft), + patch('app.localization.texts.get_texts', return_value=MagicMock()) as _, + patch('app.handlers.menu.show_main_menu', new=mock_show_main_menu), + ): mock_cart_service.delete_user_cart = AsyncMock(return_value=True) await handle_subscription_cancel(mock_callback_query, mock_state, mock_user, mock_db) @@ -390,5 +406,4 @@ async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_clear_draft.assert_awaited_once_with(mock_user.id) mock_cart_service.delete_user_cart.assert_awaited_once_with(mock_user.id) mock_show_main_menu.assert_awaited_once_with(mock_callback_query, mock_user, mock_db) - mock_callback_query.answer.assert_called_once_with("❌ Покупка отменена") - + mock_callback_query.answer.assert_called_once_with('❌ Покупка отменена') diff --git a/tests/test_trial_activation_paid.py b/tests/test_trial_activation_paid.py index ba104229..98fc6c37 100644 --- a/tests/test_trial_activation_paid.py +++ b/tests/test_trial_activation_paid.py @@ -23,7 +23,7 @@ def trial_user(): user = MagicMock(spec=User) user.subscription = None user.has_had_paid_subscription = False - user.language = "ru" + user.language = 'ru' return user @@ -44,17 +44,17 @@ async def test_activate_trial_uses_trial_price_for_topup_redirect( with ( patch( - "app.handlers.subscription.purchase.preview_trial_activation_charge", + 'app.handlers.subscription.purchase.preview_trial_activation_charge', side_effect=error, ), patch( - "app.handlers.subscription.purchase.get_texts", + 'app.handlers.subscription.purchase.get_texts', return_value=MagicMock( t=lambda key, default, **kwargs: default, ), ), patch( - "app.handlers.subscription.purchase.get_insufficient_balance_keyboard", + 'app.handlers.subscription.purchase.get_insufficient_balance_keyboard', return_value=mock_keyboard, ) as insufficient_keyboard, ): diff --git a/tests/test_user_cart_service.py b/tests/test_user_cart_service.py index 1531bc8c..90d2bab7 100644 --- a/tests/test_user_cart_service.py +++ b/tests/test_user_cart_service.py @@ -1,33 +1,35 @@ import pytest -from unittest.mock import AsyncMock, MagicMock + from app.services.user_cart_service import UserCartService -from app.config import settings + # Мок для Redis клиента class MockRedis: def __init__(self): self.storage = {} - + async def setex(self, key, ttl, value): self.storage[key] = value return True - + async def get(self, key): return self.storage.get(key) - + async def delete(self, key): if key in self.storage: del self.storage[key] return 1 return 0 - + async def exists(self, key): return 1 if key in self.storage else 0 + @pytest.fixture def mock_redis(): return MockRedis() + @pytest.fixture def user_cart_service(mock_redis): service = UserCartService() @@ -35,106 +37,88 @@ def user_cart_service(mock_redis): service._initialized = True return service + async def test_save_user_cart(user_cart_service, mock_redis): """Тест сохранения корзины пользователя""" user_id = 12345 - cart_data = { - 'period_days': 30, - 'countries': ['ru', 'us'], - 'devices': 3, - 'traffic_gb': 10, - 'total_price': 50000 - } - + cart_data = {'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 10, 'total_price': 50000} + result = await user_cart_service.save_user_cart(user_id, cart_data) - + assert result is True - assert f"user_cart:{user_id}" in mock_redis.storage - assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"]) + assert f'user_cart:{user_id}' in mock_redis.storage + assert cart_data == eval(mock_redis.storage[f'user_cart:{user_id}']) + async def test_get_user_cart(user_cart_service, mock_redis): """Тест получения корзины пользователя""" user_id = 12345 - cart_data = { - 'period_days': 30, - 'countries': ['ru', 'us'], - 'devices': 3, - 'traffic_gb': 10, - 'total_price': 50000 - } - + cart_data = {'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 10, 'total_price': 50000} + # Сохраняем данные await user_cart_service.save_user_cart(user_id, cart_data) - + # Получаем данные result = await user_cart_service.get_user_cart(user_id) - + assert result == cart_data + async def test_get_user_cart_not_found(user_cart_service): """Тест получения несуществующей корзины пользователя""" user_id = 99999 - + result = await user_cart_service.get_user_cart(user_id) - + assert result is None + async def test_delete_user_cart(user_cart_service, mock_redis): """Тест удаления корзины пользователя""" user_id = 12345 - cart_data = { - 'period_days': 30, - 'countries': ['ru', 'us'], - 'devices': 3, - 'traffic_gb': 10, - 'total_price': 50000 - } - + cart_data = {'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 10, 'total_price': 50000} + # Сохраняем данные await user_cart_service.save_user_cart(user_id, cart_data) - assert f"user_cart:{user_id}" in mock_redis.storage - + assert f'user_cart:{user_id}' in mock_redis.storage + # Удаляем данные result = await user_cart_service.delete_user_cart(user_id) - + assert result is True - assert f"user_cart:{user_id}" not in mock_redis.storage + assert f'user_cart:{user_id}' not in mock_redis.storage + async def test_delete_user_cart_not_found(user_cart_service): """Тест удаления несуществующей корзины пользователя""" user_id = 99999 - + result = await user_cart_service.delete_user_cart(user_id) - + assert result is False async def test_has_user_cart(user_cart_service, mock_redis): """Тест проверки наличия корзины пользователя""" user_id = 12345 - cart_data = { - 'period_days': 30, - 'countries': ['ru', 'us'], - 'devices': 3, - 'traffic_gb': 10, - 'total_price': 50000 - } - + cart_data = {'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 10, 'total_price': 50000} + # Проверяем, что корзины нет result = await user_cart_service.has_user_cart(user_id) assert result is False - + # Сохраняем данные await user_cart_service.save_user_cart(user_id, cart_data) - + # Проверяем, что корзина есть result = await user_cart_service.has_user_cart(user_id) assert result is True + async def test_has_user_cart_not_found(user_cart_service): """Тест проверки отсутствия корзины пользователя""" user_id = 99999 - + result = await user_cart_service.has_user_cart(user_id) - - assert result is False \ No newline at end of file + + assert result is False diff --git a/tests/utils/test_formatters_basic.py b/tests/utils/test_formatters_basic.py index 338eb498..cf255b40 100644 --- a/tests/utils/test_formatters_basic.py +++ b/tests/utils/test_formatters_basic.py @@ -8,66 +8,66 @@ from app.utils import formatters def test_format_datetime_handles_iso_strings(fixed_datetime: datetime) -> None: """ISO-строка должна корректно преобразовываться в отформатированный текст.""" iso_value = fixed_datetime.isoformat() - assert formatters.format_datetime(iso_value) == fixed_datetime.strftime("%d.%m.%Y %H:%M") + assert formatters.format_datetime(iso_value) == fixed_datetime.strftime('%d.%m.%Y %H:%M') def test_format_date_uses_custom_format(fixed_datetime: datetime) -> None: """Можно задавать собственный шаблон вывода.""" iso_value = fixed_datetime.isoformat() - assert formatters.format_date(iso_value, format_str="%Y/%m/%d") == fixed_datetime.strftime("%Y/%m/%d") + assert formatters.format_date(iso_value, format_str='%Y/%m/%d') == fixed_datetime.strftime('%Y/%m/%d') def test_format_time_ago_returns_human_readable_text() -> None: """Разница во времени должна переводиться в человеко-понятную строку.""" point_in_time = datetime.utcnow() - timedelta(minutes=5) - assert formatters.format_time_ago(point_in_time, language="ru") == "5 мин. назад" - assert formatters.format_time_ago(point_in_time, language="en") == "5 minutes ago" + assert formatters.format_time_ago(point_in_time, language='ru') == '5 мин. назад' + assert formatters.format_time_ago(point_in_time, language='en') == '5 minutes ago' def test_format_days_declension_handles_russian_rules() -> None: """Склонение дней в русском языке зависит от числа.""" - assert formatters.format_days_declension(1) == "1 день" - assert formatters.format_days_declension(3) == "3 дня" - assert formatters.format_days_declension(10) == "10 дней" + assert formatters.format_days_declension(1) == '1 день' + assert formatters.format_days_declension(3) == '3 дня' + assert formatters.format_days_declension(10) == '10 дней' def test_format_duration_switches_units() -> None: """В зависимости от длины интервала выбирается подходящая единица измерения.""" - assert formatters.format_duration(45) == "45 сек." - assert formatters.format_duration(120) == "2 мин." - assert formatters.format_duration(7200) == "2 ч." - assert formatters.format_duration(172800) == "2 дн." + assert formatters.format_duration(45) == '45 сек.' + assert formatters.format_duration(120) == '2 мин.' + assert formatters.format_duration(7200) == '2 ч.' + assert formatters.format_duration(172800) == '2 дн.' def test_format_bytes_scales_value() -> None: """Размер должен выражаться в наиболее подходящей единице.""" - assert formatters.format_bytes(0) == "0 B" - assert formatters.format_bytes(1024) == "1 KB" - assert formatters.format_bytes(1024 * 1024) == "1 MB" + assert formatters.format_bytes(0) == '0 B' + assert formatters.format_bytes(1024) == '1 KB' + assert formatters.format_bytes(1024 * 1024) == '1 MB' def test_format_percentage_respects_precision() -> None: """Проценты форматируются с нужным количеством знаков.""" - assert formatters.format_percentage(12.3456, decimals=2) == "12.35%" + assert formatters.format_percentage(12.3456, decimals=2) == '12.35%' def test_format_number_inserts_separators() -> None: """Разделители тысяч должны расставляться корректно как для int, так и для float.""" - assert formatters.format_number(1234567) == "1 234 567" - assert formatters.format_number(1234.56) == "1 234.55" + assert formatters.format_number(1234567) == '1 234 567' + assert formatters.format_number(1234.56) == '1 234.55' def test_truncate_text_appends_suffix() -> None: """Строки, превышающие лимит, должны обрезаться и дополняться суффиксом.""" - source = "a" * 10 - assert formatters.truncate_text(source, max_length=5) == "aa..." + source = 'a' * 10 + assert formatters.truncate_text(source, max_length=5) == 'aa...' def test_format_username_prefers_full_name() -> None: """Полное имя имеет приоритет, затем username, затем ID.""" - assert formatters.format_username("nickname", 1, full_name="Имя") == "Имя" - assert formatters.format_username("nickname", 1, full_name=None) == "@nickname" - assert formatters.format_username(None, 42, full_name=None) == "ID42" + assert formatters.format_username('nickname', 1, full_name='Имя') == 'Имя' + assert formatters.format_username('nickname', 1, full_name=None) == '@nickname' + assert formatters.format_username(None, 42, full_name=None) == 'ID42' def test_format_subscription_status_handles_active_and_expired() -> None: @@ -77,28 +77,28 @@ def test_format_subscription_status_handles_active_and_expired() -> None: is_active=True, is_trial=False, end_date=future, - language="ru", + language='ru', ) - assert active.startswith("✅ Активна") - assert "(" in active and ")" in active + assert active.startswith('✅ Активна') + assert '(' in active and ')' in active past = datetime.utcnow() - timedelta(days=1) expired = formatters.format_subscription_status( is_active=True, is_trial=False, end_date=past, - language="ru", + language='ru', ) - assert expired == "⏰ Истекла" + assert expired == '⏰ Истекла' def test_format_traffic_usage_supports_unlimited() -> None: """При безлимитном тарифе в строке должна появляться бесконечность.""" - assert formatters.format_traffic_usage(50.0, 0, language="ru") == "50.0 ГБ / ∞" - assert formatters.format_traffic_usage(10.0, 100, language="ru") == "10.0 ГБ / 100 ГБ (10.0%)" + assert formatters.format_traffic_usage(50.0, 0, language='ru') == '50.0 ГБ / ∞' + assert formatters.format_traffic_usage(10.0, 100, language='ru') == '10.0 ГБ / 100 ГБ (10.0%)' def test_format_boolean_localises_output() -> None: """Булевые значения отображаются локализованными словами.""" - assert formatters.format_boolean(True, language="ru") == "✅ Да" - assert formatters.format_boolean(False, language="en") == "❌ No" + assert formatters.format_boolean(True, language='ru') == '✅ Да' + assert formatters.format_boolean(False, language='en') == '❌ No' diff --git a/tests/utils/test_pricing_utils.py b/tests/utils/test_pricing_utils.py index dde5d021..7ffeabe8 100644 --- a/tests/utils/test_pricing_utils.py +++ b/tests/utils/test_pricing_utils.py @@ -5,9 +5,7 @@ особенно функции отображения цен со скидками на кнопках подписки. """ -import pytest -from unittest.mock import patch, MagicMock -from typing import Dict, Any +from unittest.mock import MagicMock, patch from app.localization.texts import _build_dynamic_values @@ -27,14 +25,14 @@ class TestBuildDynamicValues: @patch('app.localization.texts.settings') def test_returns_empty_dict_for_unknown_language(self, mock_settings: MagicMock) -> None: """Неизвестный язык должен возвращать пустой словарь.""" - result = _build_dynamic_values("fr-FR") # Французский не поддерживается + result = _build_dynamic_values('fr-FR') # Французский не поддерживается assert result == {} @patch('app.localization.texts.settings') def test_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None: """Должны генерироваться ключи трафика и другие динамические значения.""" # Настройка моков для traffic цен - mock_settings.format_price = lambda x: f"{x // 100} ₽" + mock_settings.format_price = lambda x: f'{x // 100} ₽' mock_settings.PRICE_TRAFFIC_5GB = 10000 mock_settings.PRICE_TRAFFIC_10GB = 20000 mock_settings.PRICE_TRAFFIC_25GB = 30000 @@ -43,10 +41,10 @@ class TestBuildDynamicValues: mock_settings.PRICE_TRAFFIC_250GB = 60000 mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000 - result = _build_dynamic_values("ru-RU") + result = _build_dynamic_values('ru-RU') # Проверяем наличие ключей трафика - assert "TRAFFIC_5GB" in result - assert "TRAFFIC_10GB" in result - assert "TRAFFIC_UNLIMITED" in result - assert "SUPPORT_INFO" in result + assert 'TRAFFIC_5GB' in result + assert 'TRAFFIC_10GB' in result + assert 'TRAFFIC_UNLIMITED' in result + assert 'SUPPORT_INFO' in result diff --git a/tests/utils/test_remnawave_auto_sync.py b/tests/utils/test_remnawave_auto_sync.py index 3bbfa6e7..58f19b81 100644 --- a/tests/utils/test_remnawave_auto_sync.py +++ b/tests/utils/test_remnawave_auto_sync.py @@ -7,17 +7,17 @@ from unittest.mock import AsyncMock import pytest from app.config import settings -from app.services.remnawave_sync_service import RemnaWaveAutoSyncService from app.services.remnawave_service import RemnaWaveConfigurationError +from app.services.remnawave_sync_service import RemnaWaveAutoSyncService @pytest.mark.parametrize( - "raw, expected", + 'raw, expected', [ - ("03:00, 15:30 03:00; 07:05", [time_cls(3, 0), time_cls(7, 5), time_cls(15, 30)]), - ("", []), + ('03:00, 15:30 03:00; 07:05', [time_cls(3, 0), time_cls(7, 5), time_cls(15, 30)]), + ('', []), (None, []), - ("25:00, 10:70, test, 09:15", [time_cls(9, 15)]), + ('25:00, 10:70, test, 09:15', [time_cls(9, 15)]), ], ) def test_parse_daily_time_list(raw, expected): @@ -28,7 +28,7 @@ def _patch_datetime(monkeypatch, current): real_datetime = datetime monkeypatch.setattr( - "app.services.remnawave_sync_service.datetime", + 'app.services.remnawave_sync_service.datetime', SimpleNamespace( utcnow=lambda: current, combine=lambda date_obj, time_obj: real_datetime.combine(date_obj, time_obj), @@ -60,8 +60,8 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch): class StubService: def __init__(self, *, configured: bool, user_stats=None, squads=None): self.is_configured = configured - self.configuration_error = None if configured else "missing config" - self._user_stats = user_stats or {"synced": 1} + self.configuration_error = None if configured else 'missing config' + self._user_stats = user_stats or {'synced': 1} self._squads = squads or [] self.sync_calls = 0 self.squad_calls = 0 @@ -80,8 +80,8 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch): StubService(configured=False), StubService( configured=True, - user_stats={"synced": 2}, - squads=[{"id": 1}, {"id": 2}], + user_stats={'synced': 2}, + squads=[{'id': 1}, {'id': 2}], ), ] ) @@ -102,15 +102,15 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch): return False monkeypatch.setattr( - "app.services.remnawave_sync_service.AsyncSessionLocal", + 'app.services.remnawave_sync_service.AsyncSessionLocal', lambda: DummySession(), ) monkeypatch.setattr( - "app.services.remnawave_sync_service.sync_with_remnawave", + 'app.services.remnawave_sync_service.sync_with_remnawave', fake_sync_with_remnawave, ) monkeypatch.setattr( - "app.services.remnawave_sync_service.cache", + 'app.services.remnawave_sync_service.cache', cache_mock, ) @@ -122,10 +122,10 @@ def test_perform_sync_rebuilds_service_on_each_run(monkeypatch): user_stats, server_stats = await service._perform_sync() - assert user_stats == {"synced": 2} - assert server_stats == {"created": 1, "updated": 2, "removed": 3, "total": 2} + assert user_stats == {'synced': 2} + assert server_stats == {'created': 1, 'updated': 2, 'removed': 3, 'total': 2} asyncio.run(runner()) assert not services - cache_mock.delete_pattern.assert_awaited_once_with("available_countries*") + cache_mock.delete_pattern.assert_awaited_once_with('available_countries*') diff --git a/tests/utils/test_security.py b/tests/utils/test_security.py index 77fe5bad..7a1ca2dd 100644 --- a/tests/utils/test_security.py +++ b/tests/utils/test_security.py @@ -9,35 +9,35 @@ from app.utils.security import generate_api_token, hash_api_token def test_hash_api_token_default_algorithm_matches_hashlib() -> None: """Проверяем, что алгоритм по умолчанию совпадает с hashlib.sha256.""" - sample = "secret-token" + sample = 'secret-token' # Самостоятельно считаем эталонное значение. - expected = hashlib.sha256(sample.encode("utf-8")).hexdigest() + expected = hashlib.sha256(sample.encode('utf-8')).hexdigest() # Сравниваем с функцией проекта. assert hash_api_token(sample) == expected @pytest.mark.parametrize( - "algorithm,hash_factory", + 'algorithm,hash_factory', [ - ("sha256", hashlib.sha256), - ("sha384", hashlib.sha384), - ("sha512", hashlib.sha512), + ('sha256', hashlib.sha256), + ('sha384', hashlib.sha384), + ('sha512', hashlib.sha512), ], ) def test_hash_api_token_accepts_supported_algorithms(algorithm, hash_factory) -> None: """Каждый поддерживаемый алгоритм должен выдавать корректный результат.""" - sample = "token-value" - expected = hash_factory(sample.encode("utf-8")).hexdigest() + sample = 'token-value' + expected = hash_factory(sample.encode('utf-8')).hexdigest() assert hash_api_token(sample, algorithm=algorithm) == expected def test_hash_api_token_rejects_unknown_algorithm() -> None: """Некорректное имя алгоритма должно приводить к ValueError.""" with pytest.raises(ValueError): - hash_api_token("value", algorithm="md5") # type: ignore[arg-type] + hash_api_token('value', algorithm='md5') # type: ignore[arg-type] -@pytest.mark.parametrize("length", [8, 24, 48, 256]) +@pytest.mark.parametrize('length', [8, 24, 48, 256]) def test_generate_api_token_respects_length_bounds(length: int) -> None: """Функция должна ограничивать длину токена безопасным диапазоном.""" token = generate_api_token(length) diff --git a/tests/utils/test_validators_basic.py b/tests/utils/test_validators_basic.py index 3f81f5b7..cfd1a384 100644 --- a/tests/utils/test_validators_basic.py +++ b/tests/utils/test_validators_basic.py @@ -6,13 +6,13 @@ from app.utils import validators @pytest.mark.parametrize( - "email,is_valid", + 'email,is_valid', [ - ("user@example.com", True), - ("user.name+tag@sub.domain.ru", True), - ("plain-address", False), - ("missing-at.example.com", False), - ("user@invalid", False), + ('user@example.com', True), + ('user.name+tag@sub.domain.ru', True), + ('plain-address', False), + ('missing-at.example.com', False), + ('user@invalid', False), ], ) def test_validate_email_handles_expected_patterns(email: str, is_valid: bool) -> None: @@ -21,13 +21,13 @@ def test_validate_email_handles_expected_patterns(email: str, is_valid: bool) -> @pytest.mark.parametrize( - "phone,is_valid", + 'phone,is_valid', [ - ("+71234567890", True), - ("+1 (202) 555-0101", True), - ("12345", True), - ("+0 123456789", False), - ("abc", False), + ('+71234567890', True), + ('+1 (202) 555-0101', True), + ('12345', True), + ('+0 123456789', False), + ('abc', False), ], ) def test_validate_phone_strips_formatting_and_checks_pattern(phone: str, is_valid: bool) -> None: @@ -36,13 +36,13 @@ def test_validate_phone_strips_formatting_and_checks_pattern(phone: str, is_vali @pytest.mark.parametrize( - "username,is_valid", + 'username,is_valid', [ - ("@valid_name", True), - ("simpleUser", True), - ("bad", False), - ("toolongusername_more_than32_chars", False), - ("", False), + ('@valid_name', True), + ('simpleUser', True), + ('bad', False), + ('toolongusername_more_than32_chars', False), + ('', False), ], ) def test_validate_telegram_username_enforces_length(username: str, is_valid: bool) -> None: @@ -52,27 +52,27 @@ def test_validate_telegram_username_enforces_length(username: str, is_valid: boo def test_validate_amount_returns_float_within_bounds() -> None: """Числа должны конвертироваться с уважением к диапазону.""" - assert validators.validate_amount("10.5", min_amount=5, max_amount=20) == pytest.approx(10.5) - assert validators.validate_amount("2", min_amount=5, max_amount=20) is None - assert validators.validate_amount("abc", min_amount=0, max_amount=10) is None + assert validators.validate_amount('10.5', min_amount=5, max_amount=20) == pytest.approx(10.5) + assert validators.validate_amount('2', min_amount=5, max_amount=20) is None + assert validators.validate_amount('abc', min_amount=0, max_amount=10) is None def test_validate_positive_integer_enforces_upper_bound() -> None: """Положительное целое число выходит за пределы — возвращаем None.""" - assert validators.validate_positive_integer("12", max_value=20) == 12 - assert validators.validate_positive_integer("0", max_value=20) is None - assert validators.validate_positive_integer("50", max_value=20) is None - assert validators.validate_positive_integer("NaN") is None + assert validators.validate_positive_integer('12', max_value=20) == 12 + assert validators.validate_positive_integer('0', max_value=20) is None + assert validators.validate_positive_integer('50', max_value=20) is None + assert validators.validate_positive_integer('NaN') is None @pytest.mark.parametrize( - "value,expected", + 'value,expected', [ - ("500", 500), - ("10gb", 10240), - ("2 TB", 2097152), - ("безлимит", 0), - ("invalid", None), + ('500', 500), + ('10gb', 10240), + ('2 TB', 2097152), + ('безлимит', 0), + ('invalid', None), ], ) def test_validate_traffic_amount_supports_units(value: str, expected: int | None) -> None: @@ -82,40 +82,40 @@ def test_validate_traffic_amount_supports_units(value: str, expected: int | None def test_validate_subscription_period_accepts_reasonable_range() -> None: """Диапазон допустимой длительности от 1 до 3650 дней.""" - assert validators.validate_subscription_period("30") == 30 + assert validators.validate_subscription_period('30') == 30 assert validators.validate_subscription_period(0) is None assert validators.validate_subscription_period(4000) is None def test_validate_uuid_detects_standard_format() -> None: """UUID должен соответствовать HEX шаблону версии 4/5.""" - sample = "123e4567-e89b-12d3-a456-426614174000" + sample = '123e4567-e89b-12d3-a456-426614174000' assert validators.validate_uuid(sample) is True - assert validators.validate_uuid("not-a-uuid") is False + assert validators.validate_uuid('not-a-uuid') is False def test_validate_url_recognises_https_links() -> None: """Валидатор URL допускает http/https ссылки и отклоняет произвольные строки.""" - assert validators.validate_url("https://example.com/path?query=1") - assert not validators.validate_url("ftp://example.com") + assert validators.validate_url('https://example.com/path?query=1') + assert not validators.validate_url('ftp://example.com') def test_validate_html_tags_rejects_unknown_tags() -> None: """Неизвестные HTML теги должны приводить к отказу.""" - ok, message = validators.validate_html_tags("bold") + ok, message = validators.validate_html_tags('bold') assert ok is True - bad, error = validators.validate_html_tags("run") + bad, error = validators.validate_html_tags('run') assert bad is False - assert "Неподдерживаемый тег" in error + assert 'Неподдерживаемый тег' in error def test_validate_html_structure_detects_wrong_nesting() -> None: """Неправильная вложенность тегов должна сообщаться пользователю.""" - ok, message = validators.validate_html_structure("text") + ok, message = validators.validate_html_structure('text') assert ok is True - bad, error = validators.validate_html_structure("text") + bad, error = validators.validate_html_structure('text') assert bad is False - assert "Неправильная вложенность" in error + assert 'Неправильная вложенность' in error def test_fix_html_tags_repairs_missing_quotes() -> None: @@ -127,15 +127,15 @@ def test_fix_html_tags_repairs_missing_quotes() -> None: def test_validate_rules_content_detects_structure_error() -> None: """При нарушении структуры должны вернуться сообщение и отсутствие подсказки.""" - is_valid, message, suggestion = validators.validate_rules_content("text") + is_valid, message, suggestion = validators.validate_rules_content('text') assert is_valid is False - assert "Неправильная вложенность" in message + assert 'Неправильная вложенность' in message assert suggestion is None def test_validate_rules_content_accepts_supported_markup() -> None: """Корректный HTML должен проходить проверку без сообщений.""" - is_valid, message, suggestion = validators.validate_rules_content("Добро пожаловать!") + is_valid, message, suggestion = validators.validate_rules_content('Добро пожаловать!') assert is_valid is True - assert message == "" + assert message == '' assert suggestion is None diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py index fb66fcff..8d81e02b 100644 --- a/tests/webserver/test_payments.py +++ b/tests/webserver/test_payments.py @@ -1,4 +1,3 @@ -import base64 import json from types import SimpleNamespace from unittest.mock import AsyncMock @@ -12,59 +11,61 @@ from app.webserver.payments import create_payment_router class DummyBot: pass + + @pytest.fixture(autouse=True) def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "TRIBUTE_API_KEY", None, raising=False) - monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False) - monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", None, raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False) - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', False, raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_API_KEY', None, raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_WEBHOOK_PATH', '/tribute', raising=False) + monkeypatch.setattr(settings, 'MULENPAY_WEBHOOK_PATH', '/mulen', raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', False, raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', None, raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_PATH', '/cryptobot', raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', None, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', False, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_WEBHOOK_PATH', '/yookassa', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SHOP_ID', 'shop', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_SECRET_KEY', 'key', raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', 'http://test', raising=False) -def _get_route(router, path: str, method: str = "POST"): +def _get_route(router, path: str, method: str = 'POST'): for route in router.routes: - if getattr(route, "path", "") == path and method in getattr(route, "methods", set()): + if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()): return route - raise AssertionError(f"Route {path} with method {method} not found") + raise AssertionError(f'Route {path} with method {method} not found') def _build_request( path: str, body: bytes, headers: dict[str, str], - client_ip: str | None = "185.71.76.1", + client_ip: str | None = '185.71.76.1', ) -> Request: scope = { - "type": "http", - "asgi": {"version": "3.0"}, - "method": "POST", - "path": path, - "headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()], + 'type': 'http', + 'asgi': {'version': '3.0'}, + 'method': 'POST', + 'path': path, + 'headers': [(k.lower().encode('latin-1'), v.encode('latin-1')) for k, v in headers.items()], } if client_ip is not None: - scope["client"] = (client_ip, 12345) + scope['client'] = (client_ip, 12345) async def receive() -> dict: - return {"type": "http.request", "body": body, "more_body": False} + return {'type': 'http.request', 'body': body, 'more_body': False} return Request(scope, receive) @pytest.mark.anyio async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', True, raising=False) - process_mock = AsyncMock(return_value={"status": "ok"}) + process_mock = AsyncMock(return_value={'status': 'ok'}) class StubTributeService: def __init__(self, *_args, **_kwargs): @@ -75,11 +76,11 @@ async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: class StubTributeAPI: @staticmethod - def verify_webhook_signature(payload: str, signature: str) -> bool: # noqa: D401 - test stub + def verify_webhook_signature(payload: str, signature: str) -> bool: return True - monkeypatch.setattr("app.webserver.payments.TributeService", StubTributeService) - monkeypatch.setattr("app.webserver.payments.TributeAPI", StubTributeAPI) + monkeypatch.setattr('app.webserver.payments.TributeService', StubTributeService) + monkeypatch.setattr('app.webserver.payments.TributeAPI', StubTributeAPI) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None @@ -87,20 +88,20 @@ async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: route = _get_route(router, settings.TRIBUTE_WEBHOOK_PATH) request = _build_request( settings.TRIBUTE_WEBHOOK_PATH, - body=json.dumps({"event": "payment"}).encode("utf-8"), - headers={"trbt-signature": "sig"}, + body=json.dumps({'event': 'payment'}).encode('utf-8'), + headers={'trbt-signature': 'sig'}, ) response = await route.endpoint(request) assert response.status_code == 200 - assert json.loads(response.body.decode("utf-8"))["status"] == "ok" + assert json.loads(response.body.decode('utf-8'))['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) @@ -110,7 +111,7 @@ async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None: route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), headers={}, client_ip=None, ) @@ -118,14 +119,14 @@ async def test_yookassa_unknown_ip(monkeypatch: pytest.MonkeyPatch) -> None: response = await route.endpoint(request) assert response.status_code == 403 - payload = json.loads(response.body.decode("utf-8")) - assert payload["reason"] == "unknown_ip" + payload = json.loads(response.body.decode('utf-8')) + assert payload['reason'] == 'unknown_ip' service.process_yookassa_webhook.assert_not_awaited() @pytest.mark.anyio async def test_yookassa_forbidden_ip(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) @@ -135,23 +136,23 @@ async def test_yookassa_forbidden_ip(monkeypatch: pytest.MonkeyPatch) -> None: route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), headers={}, - client_ip="8.8.8.8", + client_ip='8.8.8.8', ) response = await route.endpoint(request) assert response.status_code == 403 - payload = json.loads(response.body.decode("utf-8")) - assert payload["reason"] == "forbidden_ip" - assert payload["ip"] == "8.8.8.8" + payload = json.loads(response.body.decode('utf-8')) + assert payload['reason'] == 'forbidden_ip' + assert payload['ip'] == '8.8.8.8' service.process_yookassa_webhook.assert_not_awaited() @pytest.mark.anyio async def test_yookassa_forbidden_ip_ignores_spoofed_header(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) @@ -161,23 +162,23 @@ async def test_yookassa_forbidden_ip_ignores_spoofed_header(monkeypatch: pytest. route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"X-Forwarded-For": "185.71.76.10"}, - client_ip="8.8.8.8", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'X-Forwarded-For': '185.71.76.10'}, + client_ip='8.8.8.8', ) response = await route.endpoint(request) assert response.status_code == 403 - payload = json.loads(response.body.decode("utf-8")) - assert payload["reason"] == "forbidden_ip" - assert payload["ip"] == "8.8.8.8" + payload = json.loads(response.body.decode('utf-8')) + assert payload['reason'] == 'forbidden_ip' + assert payload['ip'] == '8.8.8.8' service.process_yookassa_webhook.assert_not_awaited() @pytest.mark.anyio async def test_yookassa_forbidden_ip_ignores_spoofed_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) service = SimpleNamespace(process_yookassa_webhook=AsyncMock()) @@ -187,28 +188,28 @@ async def test_yookassa_forbidden_ip_ignores_spoofed_forwarded_chain(monkeypatch route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"X-Forwarded-For": "185.71.76.10, 8.8.8.8"}, - client_ip="10.0.0.5", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'X-Forwarded-For': '185.71.76.10, 8.8.8.8'}, + client_ip='10.0.0.5', ) response = await route.endpoint(request) assert response.status_code == 403 - payload = json.loads(response.body.decode("utf-8")) - assert payload["reason"] == "forbidden_ip" - assert payload["ip"] == "8.8.8.8" + payload = json.loads(response.body.decode('utf-8')) + assert payload['reason'] == 'forbidden_ip' + assert payload['ip'] == '8.8.8.8' service.process_yookassa_webhook.assert_not_awaited() @pytest.mark.anyio async def test_yookassa_allowed_ip(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -219,27 +220,27 @@ async def test_yookassa_allowed_ip(monkeypatch: pytest.MonkeyPatch) -> None: route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), headers={}, - client_ip="185.71.76.10", + client_ip='185.71.76.10', ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -250,27 +251,27 @@ async def test_yookassa_allowed_via_forwarded_header_when_proxy(monkeypatch: pyt route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"X-Forwarded-For": "185.71.76.10"}, - client_ip="10.0.0.5", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'X-Forwarded-For': '185.71.76.10'}, + client_ip='10.0.0.5', ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -281,28 +282,28 @@ async def test_yookassa_allowed_via_cf_connecting_ip(monkeypatch: pytest.MonkeyP route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"Cf-Connecting-Ip": "185.71.76.10"}, - client_ip="172.64.223.133", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'Cf-Connecting-Ip': '185.71.76.10'}, + client_ip='172.64.223.133', ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "203.0.113.0/24", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '203.0.113.0/24', raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -313,28 +314,28 @@ async def test_yookassa_allowed_via_trusted_forwarded_chain(monkeypatch: pytest. route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"X-Forwarded-For": "185.71.76.10, 203.0.113.10"}, - client_ip="10.0.0.5", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'X-Forwarded-For': '185.71.76.10, 203.0.113.10'}, + client_ip='10.0.0.5', ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "198.51.100.0/24", raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_TRUSTED_PROXY_NETWORKS', '198.51.100.0/24', raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -345,27 +346,27 @@ async def test_yookassa_allowed_via_trusted_public_proxy(monkeypatch: pytest.Mon route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, - body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"), - headers={"X-Forwarded-For": "185.71.76.10, 198.51.100.10"}, - client_ip="198.51.100.20", + body=json.dumps({'event': 'payment.succeeded'}).encode('utf-8'), + headers={'X-Forwarded-For': '185.71.76.10, 198.51.100.10'}, + client_ip='198.51.100.20', ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -374,8 +375,8 @@ async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None assert router is not None route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) - payload = {"event": "payment.succeeded"} - body = json.dumps(payload).encode("utf-8") + payload = {'event': 'payment.succeeded'} + body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, @@ -385,19 +386,19 @@ async def test_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -406,8 +407,8 @@ async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> assert router is not None route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) - payload = {"event": "payment.canceled"} - body = json.dumps(payload).encode("utf-8") + payload = {'event': 'payment.canceled'} + body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, @@ -417,19 +418,19 @@ async def test_yookassa_webhook_cancellation(monkeypatch: pytest.MonkeyPatch) -> response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_yookassa_webhook_with_signature(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False) + monkeypatch.setattr(settings, 'YOOKASSA_ENABLED', True, raising=False) async def fake_get_db(): yield SimpleNamespace() - monkeypatch.setattr("app.webserver.payments.get_db", fake_get_db) + monkeypatch.setattr('app.webserver.payments.get_db', fake_get_db) process_mock = AsyncMock(return_value=True) service = SimpleNamespace(process_yookassa_webhook=process_mock) @@ -438,27 +439,27 @@ async def test_yookassa_webhook_with_signature(monkeypatch: pytest.MonkeyPatch) assert router is not None route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH) - payload = {"event": "payment.succeeded"} - body = json.dumps(payload).encode("utf-8") + payload = {'event': 'payment.succeeded'} + body = json.dumps(payload).encode('utf-8') request = _build_request( settings.YOOKASSA_WEBHOOK_PATH, body=body, - headers={"Signature": "dummy"}, + headers={'Signature': 'dummy'}, ) response = await route.endpoint(request) assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' process_mock.assert_awaited_once() @pytest.mark.anyio async def test_cryptobot_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None @@ -466,29 +467,29 @@ async def test_cryptobot_missing_signature(monkeypatch: pytest.MonkeyPatch) -> N route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH) request = _build_request( settings.CRYPTOBOT_WEBHOOK_PATH, - body=json.dumps({"test": "value"}).encode("utf-8"), + body=json.dumps({'test': 'value'}).encode('utf-8'), headers={}, ) response = await route.endpoint(request) assert response.status_code == 401 - payload = json.loads(response.body.decode("utf-8")) - assert payload["reason"] == "missing_signature" + payload = json.loads(response.body.decode('utf-8')) + assert payload['reason'] == 'missing_signature' @pytest.mark.anyio async def test_cryptobot_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False) - monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_API_TOKEN', 'token', raising=False) + monkeypatch.setattr(settings, 'CRYPTOBOT_WEBHOOK_SECRET', 'secret', raising=False) class StubCryptoBotService: @staticmethod - def verify_webhook_signature(body: str, signature: str) -> bool: # noqa: D401 - test stub + def verify_webhook_signature(body: str, signature: str) -> bool: return False - monkeypatch.setattr("app.external.cryptobot.CryptoBotService", StubCryptoBotService) + monkeypatch.setattr('app.external.cryptobot.CryptoBotService', StubCryptoBotService) router = create_payment_router(DummyBot(), SimpleNamespace()) assert router is not None @@ -496,8 +497,8 @@ async def test_cryptobot_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> N route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH) request = _build_request( settings.CRYPTOBOT_WEBHOOK_PATH, - body=json.dumps({"test": "value"}).encode("utf-8"), - headers={"Crypto-Pay-API-Signature": "sig"}, + body=json.dumps({'test': 'value'}).encode('utf-8'), + headers={'Crypto-Pay-API-Signature': 'sig'}, ) response = await route.endpoint(request) diff --git a/tests/webserver/test_telegram.py b/tests/webserver/test_telegram.py index 958eb401..03d9f078 100644 --- a/tests/webserver/test_telegram.py +++ b/tests/webserver/test_telegram.py @@ -15,36 +15,34 @@ from app.webserver.telegram import ( @pytest.fixture(autouse=True) def reset_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_URL", None, raising=False) - monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_PATH', '/telegram-webhook', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_SECRET_TOKEN', '', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', None, raising=False) + monkeypatch.setattr(settings, 'BOT_RUN_MODE', 'webhook', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_MAX_QUEUE_SIZE', 8, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_WORKERS', 1, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_ENQUEUE_TIMEOUT', 0.0, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_WORKER_SHUTDOWN_TIMEOUT', 1.0, raising=False) -def _get_route(router, path: str, method: str = "POST"): +def _get_route(router, path: str, method: str = 'POST'): for route in router.routes: - if getattr(route, "path", "") == path and method in getattr(route, "methods", set()): + if getattr(route, 'path', '') == path and method in getattr(route, 'methods', set()): return route - raise AssertionError(f"Route {path} with method {method} not found") + raise AssertionError(f'Route {path} with method {method} not found') def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request: scope = { - "type": "http", - "asgi": {"version": "3.0"}, - "method": "POST", - "path": path, - "headers": [ - (k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in (headers or {}).items() - ], + 'type': 'http', + 'asgi': {'version': '3.0'}, + 'method': 'POST', + 'path': path, + 'headers': [(k.lower().encode('latin-1'), v.encode('latin-1')) for k, v in (headers or {}).items()], } async def receive() -> dict[str, Any]: - return {"type": "http.request", "body": body, "more_body": False} + return {'type': 'http.request', 'body': body, 'more_body': False} return Request(scope, receive) @@ -60,19 +58,19 @@ async def test_webhook_without_secret() -> None: dispatcher.feed_update = AsyncMock() sample_update = { - "update_id": 123, - "message": { - "message_id": 10, - "date": 1715700000, - "chat": {"id": 456, "type": "private"}, - "text": "ping", + 'update_id': 123, + 'message': { + 'message_id': 10, + 'date': 1715700000, + 'chat': {'id': 456, 'type': 'private'}, + 'text': 'ping', }, } router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) - request = _build_request(path, json.dumps(sample_update).encode("utf-8")) + request = _build_request(path, json.dumps(sample_update).encode('utf-8')) response = await route.endpoint(request) @@ -88,15 +86,15 @@ async def test_webhook_with_secret(monkeypatch: pytest.MonkeyPatch) -> None: dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() - monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_SECRET_TOKEN', 'super-secret', raising=False) sample_update = { - "update_id": 321, - "message": { - "message_id": 20, - "date": 1715700000, - "chat": {"id": 789, "type": "private"}, - "text": "pong", + 'update_id': 321, + 'message': { + 'message_id': 20, + 'date': 1715700000, + 'chat': {'id': 789, 'type': 'private'}, + 'text': 'pong', }, } @@ -105,8 +103,8 @@ async def test_webhook_with_secret(monkeypatch: pytest.MonkeyPatch) -> None: route = _get_route(router, path) request = _build_request( path, - json.dumps(sample_update).encode("utf-8"), - headers={"X-Telegram-Bot-Api-Secret-Token": "super-secret"}, + json.dumps(sample_update).encode('utf-8'), + headers={'X-Telegram-Bot-Api-Secret-Token': 'super-secret'}, ) response = await route.endpoint(request) @@ -121,15 +119,15 @@ async def test_webhook_secret_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() - monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "expected", raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_SECRET_TOKEN', 'expected', raising=False) router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) request = _build_request( path, - json.dumps({"update_id": 1}).encode("utf-8"), - headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"}, + json.dumps({'update_id': 1}).encode('utf-8'), + headers={'X-Telegram-Bot-Api-Secret-Token': 'wrong'}, ) with pytest.raises(HTTPException) as exc: @@ -148,7 +146,7 @@ async def test_webhook_invalid_payload() -> None: router = create_telegram_router(bot, dispatcher) path = _webhook_path() route = _get_route(router, path) - request = _build_request(path, b"not-json") + request = _build_request(path, b'not-json') with pytest.raises(HTTPException) as exc: await route.endpoint(request) @@ -164,12 +162,12 @@ async def test_webhook_invalid_content_type() -> None: dispatcher.feed_update = AsyncMock() sample_update = { - "update_id": 123, - "message": { - "message_id": 10, - "date": 1715700000, - "chat": {"id": 456, "type": "private"}, - "text": "ping", + 'update_id': 123, + 'message': { + 'message_id': 10, + 'date': 1715700000, + 'chat': {'id': 456, 'type': 'private'}, + 'text': 'ping', }, } @@ -178,8 +176,8 @@ async def test_webhook_invalid_content_type() -> None: route = _get_route(router, path) request = _build_request( path, - json.dumps(sample_update).encode("utf-8"), - headers={"Content-Type": "text/plain"}, + json.dumps(sample_update).encode('utf-8'), + headers={'Content-Type': 'text/plain'}, ) with pytest.raises(HTTPException) as exc: @@ -206,19 +204,19 @@ async def test_webhook_uses_processor() -> None: await processor.start() sample_update = { - "update_id": 999, - "message": { - "message_id": 77, - "date": 1715700000, - "chat": {"id": 111, "type": "private"}, - "text": "processor", + 'update_id': 999, + 'message': { + 'message_id': 77, + 'date': 1715700000, + 'chat': {'id': 111, 'type': 'private'}, + 'text': 'processor', }, } router = create_telegram_router(bot, dispatcher, processor=processor) path = _webhook_path() route = _get_route(router, path) - request = _build_request(path, json.dumps(sample_update).encode("utf-8")) + request = _build_request(path, json.dumps(sample_update).encode('utf-8')) response = await route.endpoint(request) @@ -249,7 +247,7 @@ async def test_webhook_processor_overloaded() -> None: path = _webhook_path() route = _get_route(router, path) - request_payload = json.dumps({"update_id": 1}).encode("utf-8") + request_payload = json.dumps({'update_id': 1}).encode('utf-8') request = _build_request(path, request_payload) await route.endpoint(request) @@ -257,7 +255,7 @@ async def test_webhook_processor_overloaded() -> None: await route.endpoint(request) assert exc.value.status_code == 503 - assert exc.value.detail == "webhook_queue_full" + assert exc.value.detail == 'webhook_queue_full' dispatcher.feed_update.assert_not_called() await processor.stop() @@ -281,13 +279,13 @@ async def test_webhook_processor_not_running() -> None: router = create_telegram_router(bot, dispatcher, processor=processor) path = _webhook_path() route = _get_route(router, path) - request = _build_request(path, json.dumps({"update_id": 5}).encode("utf-8")) + request = _build_request(path, json.dumps({'update_id': 5}).encode('utf-8')) with pytest.raises(HTTPException) as exc: await route.endpoint(request) assert exc.value.status_code == 503 - assert exc.value.detail == "webhook_processor_unavailable" + assert exc.value.detail == 'webhook_processor_unavailable' dispatcher.feed_update.assert_not_called() @@ -297,15 +295,15 @@ async def test_webhook_path_normalization(monkeypatch: pytest.MonkeyPatch) -> No dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() - monkeypatch.setattr(settings, "WEBHOOK_PATH", " telegram/webhook ", raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_PATH', ' telegram/webhook ', raising=False) router = create_telegram_router(bot, dispatcher) normalized_path = settings.get_telegram_webhook_path() - assert normalized_path == "/telegram/webhook" + assert normalized_path == '/telegram/webhook' route = _get_route(router, normalized_path) - request = _build_request(normalized_path, json.dumps({"update_id": 7}).encode("utf-8")) + request = _build_request(normalized_path, json.dumps({'update_id': 7}).encode('utf-8')) response = await route.endpoint(request) assert response.status_code == 200 @@ -318,21 +316,21 @@ async def test_health_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: dispatcher = AsyncMock() dispatcher.feed_update = AsyncMock() - monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_PATH", "/custom", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 42, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 2, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', 'https://example.com', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_PATH', '/custom', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_MAX_QUEUE_SIZE', 42, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_WORKERS', 2, raising=False) router = create_telegram_router(bot, dispatcher) - route = _get_route(router, "/health/telegram-webhook", method="GET") + route = _get_route(router, '/health/telegram-webhook', method='GET') response = await route.endpoint() assert response.status_code == 200 - payload = json.loads(response.body.decode("utf-8")) - assert payload["status"] == "ok" - assert payload["mode"] == settings.get_bot_run_mode() - assert payload["path"] == "/custom" - assert payload["webhook_configured"] is True - assert payload["queue_maxsize"] == 42 - assert payload["workers"] == 2 + payload = json.loads(response.body.decode('utf-8')) + assert payload['status'] == 'ok' + assert payload['mode'] == settings.get_bot_run_mode() + assert payload['path'] == '/custom' + assert payload['webhook_configured'] is True + assert payload['queue_maxsize'] == 42 + assert payload['workers'] == 2 diff --git a/tests/webserver/test_unified_app.py b/tests/webserver/test_unified_app.py index 26b0a10c..af097f45 100644 --- a/tests/webserver/test_unified_app.py +++ b/tests/webserver/test_unified_app.py @@ -9,35 +9,34 @@ from fastapi import FastAPI, status from app.config import settings from app.services.payment_service import PaymentService + # ensure backup directory exists before importing the unified app to avoid side effects during module import -_backup_dir = Path("data/backups") +_backup_dir = Path('data/backups') _backup_dir.mkdir(parents=True, exist_ok=True) from app.webserver.unified_app import create_unified_app @pytest.mark.anyio -async def test_unified_app_health_reports_features( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: +async def test_unified_app_health_reports_features(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: bot = AsyncMock() dispatcher = SimpleNamespace(feed_update=AsyncMock()) payment_service = AsyncMock(spec=PaymentService) - miniapp_static_dir = tmp_path / "miniapp" + miniapp_static_dir = tmp_path / 'miniapp' miniapp_static_dir.mkdir() - monkeypatch.setattr(settings, "WEB_API_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_URL", "https://hooks.example.com", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False) - monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False) - monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False) - monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False) - monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", str(miniapp_static_dir), raising=False) + monkeypatch.setattr(settings, 'WEB_API_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'TRIBUTE_ENABLED', True, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_URL', 'https://hooks.example.com', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_PATH', '/telegram-webhook', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_SECRET_TOKEN', 'super-secret', raising=False) + monkeypatch.setattr(settings, 'BOT_RUN_MODE', 'webhook', raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_MAX_QUEUE_SIZE', 8, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_WORKERS', 1, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_ENQUEUE_TIMEOUT', 0.0, raising=False) + monkeypatch.setattr(settings, 'WEBHOOK_WORKER_SHUTDOWN_TIMEOUT', 1.0, raising=False) + monkeypatch.setattr(settings, 'MINIAPP_STATIC_PATH', str(miniapp_static_dir), raising=False) app = create_unified_app( bot, @@ -49,10 +48,10 @@ async def test_unified_app_health_reports_features( health_route = next( route for route in app.routes - if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health" + if getattr(route, 'endpoint', None) and getattr(route.endpoint, '__name__', '') == 'unified_health' ) - assert getattr(health_route, "path", None) == "/health/unified" + assert getattr(health_route, 'path', None) == '/health/unified' await app.router.startup() try: @@ -60,19 +59,19 @@ async def test_unified_app_health_reports_features( finally: await app.router.shutdown() - payload = json.loads(response.body.decode("utf-8")) # type: ignore[attr-defined] + payload = json.loads(response.body.decode('utf-8')) # type: ignore[attr-defined] - assert payload["status"] == "ok" - assert payload["web_api_enabled"] is True - assert payload["bot_run_mode"] == "webhook" - assert payload["telegram_webhook"]["enabled"] is True - assert payload["telegram_webhook"]["running"] is True - assert payload["telegram_webhook"]["path"] == "/telegram-webhook" - assert payload["telegram_webhook"]["secret_configured"] is True - assert payload["payment_webhooks"]["enabled"] is True - assert payload["payment_webhooks"]["providers"]["tribute"] is True - assert payload["miniapp_static"]["mounted"] is True - assert payload["miniapp_static"]["path"].endswith("miniapp") + assert payload['status'] == 'ok' + assert payload['web_api_enabled'] is True + assert payload['bot_run_mode'] == 'webhook' + assert payload['telegram_webhook']['enabled'] is True + assert payload['telegram_webhook']['running'] is True + assert payload['telegram_webhook']['path'] == '/telegram-webhook' + assert payload['telegram_webhook']['secret_configured'] is True + assert payload['payment_webhooks']['enabled'] is True + assert payload['payment_webhooks']['providers']['tribute'] is True + assert payload['miniapp_static']['mounted'] is True + assert payload['miniapp_static']['path'].endswith('miniapp') def _build_unified_app(monkeypatch: pytest.MonkeyPatch, docs_enabled: bool) -> FastAPI: @@ -80,9 +79,9 @@ def _build_unified_app(monkeypatch: pytest.MonkeyPatch, docs_enabled: bool) -> F dispatcher = SimpleNamespace(feed_update=AsyncMock()) payment_service = AsyncMock(spec=PaymentService) - monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "WEB_API_DOCS_ENABLED", docs_enabled, raising=False) - monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False) + monkeypatch.setattr(settings, 'WEB_API_ENABLED', False, raising=False) + monkeypatch.setattr(settings, 'WEB_API_DOCS_ENABLED', docs_enabled, raising=False) + monkeypatch.setattr(settings, 'MINIAPP_STATIC_PATH', 'miniapp', raising=False) return create_unified_app( bot, @@ -98,8 +97,8 @@ async def test_unified_app_health_path_without_admin(monkeypatch: pytest.MonkeyP dispatcher = SimpleNamespace(feed_update=AsyncMock()) payment_service = AsyncMock(spec=PaymentService) - monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False) - monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False) + monkeypatch.setattr(settings, 'WEB_API_ENABLED', False, raising=False) + monkeypatch.setattr(settings, 'MINIAPP_STATIC_PATH', 'miniapp', raising=False) app = create_unified_app( bot, @@ -111,10 +110,10 @@ async def test_unified_app_health_path_without_admin(monkeypatch: pytest.MonkeyP health_route = next( route for route in app.routes - if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health" + if getattr(route, 'endpoint', None) and getattr(route.endpoint, '__name__', '') == 'unified_health' ) - assert getattr(health_route, "path", None) == "/health" + assert getattr(health_route, 'path', None) == '/health' def test_unified_app_docs_disabled(monkeypatch: pytest.MonkeyPatch) -> None: @@ -124,34 +123,34 @@ def test_unified_app_docs_disabled(monkeypatch: pytest.MonkeyPatch) -> None: assert app.redoc_url is None assert app.openapi_url is None - registered_paths = {getattr(route, "path", None) for route in app.routes} - assert "/doc" not in registered_paths + registered_paths = {getattr(route, 'path', None) for route in app.routes} + assert '/doc' not in registered_paths @pytest.mark.anyio async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPatch) -> None: app = _build_unified_app(monkeypatch, docs_enabled=True) - assert app.docs_url == "/docs" - assert app.openapi_url == "/openapi.json" + assert app.docs_url == '/docs' + assert app.openapi_url == '/openapi.json' alias_route = next( - (route for route in app.routes if getattr(route, "path", None) == "/doc"), + (route for route in app.routes if getattr(route, 'path', None) == '/doc'), None, ) assert alias_route is not None - assert getattr(alias_route, "include_in_schema", True) is False + assert getattr(alias_route, 'include_in_schema', True) is False redoc_route = next( - (route for route in app.routes if getattr(route, "path", None) == "/redoc"), + (route for route in app.routes if getattr(route, 'path', None) == '/redoc'), None, ) assert redoc_route is not None - assert getattr(redoc_route, "include_in_schema", True) is False + assert getattr(redoc_route, 'include_in_schema', True) is False response = await alias_route.endpoint() # type: ignore[func-returns-value] assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT - assert response.headers["location"] == "/docs" + assert response.headers['location'] == '/docs' redoc_response = await redoc_route.endpoint() # type: ignore[func-returns-value] - assert b"ReDoc" in redoc_response.body # type: ignore[attr-defined] + assert b'ReDoc' in redoc_response.body # type: ignore[attr-defined] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..d44827ba --- /dev/null +++ b/uv.lock @@ -0,0 +1,1607 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiogram" +version = "3.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "magic-filter" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/15/6c3d85d63964340fde6f36cc80f3f365d35f371e6a918d68ff3a3d588ef2/fastapi_cloud_cli-0.11.0.tar.gz", hash = "sha256:ecc83a5db106be35af528eccb01aa9bced1d29783efd48c8c1c831cf111eea99", size = 36170, upload-time = "2026-01-15T09:51:33.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/07/60f79270a3320780be7e2ae8a1740cb98a692920b569ba420b97bcc6e175/fastapi_cloud_cli-0.11.0-py3-none-any.whl", hash = "sha256:76857b0f09d918acfcb50ade34682ba3b2079ca0c43fda10215de301f185a7f8", size = 26884, upload-time = "2026-01-15T09:51:34.471Z" }, +] + +[[package]] +name = "fastar" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, + { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, + { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "magic-filter" +version = "1.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload-time = "2023-10-01T12:33:19.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload-time = "2023-10-01T12:33:17.711Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "netaddr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" }, + { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "pyzipper" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodomex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/97/2f03c67b40e531b30f0e1357476b4db989097a92cd30c6d2389cfa12db49/pyzipper-0.3.6.tar.gz", hash = "sha256:0adca90a00c36a93fbe49bfa8c5add452bfe4ef85a1b8e3638739dd1c7b26bfc", size = 31377, upload-time = "2022-07-31T09:58:34.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/b8/9d5d7cf4d96db8efa39f232fb152e87231fdaa5072229e6517f77a18d9c7/pyzipper-0.3.6-py2.py3-none-any.whl", hash = "sha256:6d097f465bfa47796b1494e12ea65d1478107d38e13bc56f6e58eedc4f6c1a87", size = 67652, upload-time = "2022-07-31T09:58:31.945Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[package.optional-dependencies] +pil = [ + { name = "pillow" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "remnawave-bedolaga-telegram-bot" +version = "3.1.3" +source = { virtual = "." } +dependencies = [ + { name = "aiogram" }, + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "fastapi", extra = ["standard"] }, + { name = "flask" }, + { name = "packaging" }, + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "pyzipper" }, + { name = "qrcode", extra = ["pil"] }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "yookassa" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiogram", specifier = ">=3.22.0" }, + { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "alembic", specifier = ">=1.16.5" }, + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "bcrypt", specifier = ">=4.2.0" }, + { name = "cryptography", specifier = ">=41.0.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" }, + { name = "flask", specifier = ">=3.1.0" }, + { name = "packaging", specifier = ">=23.2" }, + { name = "pyjwt", specifier = ">=2.8.0" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "pyzipper", specifier = ">=0.3.6" }, + { name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" }, + { name = "redis", specifier = ">=5.0.1" }, + { name = "sqlalchemy", specifier = ">=2.0.43" }, + { name = "yookassa", specifier = ">=3.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/9c/137848452e130e71f3ca9a9876751ddcac99e4b1f248ed297996c8c2d728/rich-14.3.0.tar.gz", hash = "sha256:b75e54d3abbcc49137e83e4db54dc86c5e47687eebc95aa0305363231a36e699", size = 230113, upload-time = "2026-01-24T12:25:46.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/e0/83cbdcb81b5cbbbe355648dd402b410437806544f48ee218a2354798f012/rich-14.3.0-py3-none-any.whl", hash = "sha256:0b8c1e368c1125b9e993c2d2f1342802525f4853fc6dac2e8e9e88bac0f45bce", size = 309950, upload-time = "2026-01-24T12:25:44.679Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/09/3f9b8d9daaf235195c626f21e03604c05b987404ee3bcacee0c1f67f2a8e/rich_toolkit-0.17.1.tar.gz", hash = "sha256:5af54df8d1dd9c8530e462e1bdcaed625c9b49f5a55b035aa0ba1c17bdb87c9a", size = 187925, upload-time = "2025-12-17T10:49:22.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/7b/15e55fa8a76d0d41bf34d965af78acdaf80a315907adb30de8b63c272694/rich_toolkit-0.17.1-py3-none-any.whl", hash = "sha256:96d24bb921ecd225ffce7c526a9149e74006410c05e6d405bd74ffd54d5631ed", size = 31412, upload-time = "2025-12-17T10:49:21.793Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8a/3c4f53d32c21012e9870913544e56bfa9e931aede080779a0f177513f534/sentry_sdk-2.50.0.tar.gz", hash = "sha256:873437a989ee1b8b25579847bae8384515bf18cfed231b06c591b735c1781fe3", size = 401233, upload-time = "2026-01-20T12:53:16.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5b/cbc2bb9569f03c8e15d928357e7e6179e5cfab45544a3bbac8aec4caf9be/sentry_sdk-2.50.0-py2.py3-none-any.whl", hash = "sha256:0ef0ed7168657ceb5a0be081f4102d92042a125462d1d1a29277992e344e749e", size = 424961, upload-time = "2026-01-20T12:53:14.826Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "yookassa" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "distro" }, + { name = "netaddr" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/05/cbacdfd7d5478956e184cb9e1e99321e1ae14f325cb8eadaf6e7a64206ab/yookassa-3.9.0.tar.gz", hash = "sha256:e8a78fcd96543a5700a4ae9e1411b54f729998f4c051c06c182b9946926e67f5", size = 139247, upload-time = "2025-12-17T09:11:55.657Z" }